Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
04a84df
fix Duration2 modulo so multi-day uptime no longer divides by zero
thushan Jun 11, 2026
9e456f1
harden olla service: guard UpdateConfig nil load and keep cleanup loo…
thushan Jun 11, 2026
550c8fe
wire proxy engine Cleanup into the service wrapper shutdown
thushan Jun 11, 2026
08f183d
return errors instead of panicking when the anthropic translator buff…
thushan Jun 11, 2026
6d3bc0a
bind the http listener synchronously so a port-in-use failure surface…
thushan Jun 11, 2026
f096dac
stop closing the worker pool channel on shutdown to avoid a send-on-c…
thushan Jun 11, 2026
0d84abe
recover health check panics per tick so the loop keeps running
thushan Jun 11, 2026
960a815
stop treating client context timeouts as connection errors that mark …
thushan Jun 11, 2026
a49d407
remove the unused stateTransitions channel from the lifecycle unifier
thushan Jun 11, 2026
9b3ee20
cap rate-limited IP tracking and move the eviction scan off the hot path
thushan Jun 11, 2026
dfe4d4b
return a 502 instead of re-panicking when a streaming translation panics
thushan Jun 11, 2026
337f069
skill fixes post review
thushan Jun 11, 2026
2b30fc9
preserve 429 and 413 status codes on proxy routes instead of flatteni…
thushan Jun 12, 2026
7a08cf7
emit proper Anthropic wire format from /olla/anthropic/v1/models
thushan Jun 12, 2026
1d4bfba
align Anthropic count_tokens, streaming usage and error mapping with …
thushan Jun 12, 2026
4bd2e48
stop every tool block before the next one starts, fix delta index for…
thushan Jun 12, 2026
64c0bbd
put tool results before user text so they sit directly after assistan…
thushan Jun 12, 2026
0ca7e26
map 413 to request_too_large in the Anthropic error envelope
thushan Jun 12, 2026
2eb3065
fix mid-stream panic errors, non-json backend errors and mixed-fleet …
thushan Jun 12, 2026
9e62b5e
test mixed-fleet passthrough path filtering and uniform DMR fleet beh…
thushan Jun 12, 2026
7dbafd1
fix resolvePassthroughTargetPath tie-break to use first-seen endpoint…
thushan Jun 12, 2026
5c4a044
tidy gofmt spacing and struct field alignment from make ready
thushan Jun 12, 2026
4224686
fix inverted liveness probe in cleanup loop panic test
thushan Jun 13, 2026
c91d06f
count tool definitions in the count_tokens estimate
thushan Jun 13, 2026
73d413a
preserve tool_result is_error as an Error prefix when translating to …
thushan Jun 13, 2026
bfe4040
add request_id to the anthropic error envelope and mirror the request…
thushan Jun 13, 2026
c8df834
complete message_start fields, flush pre-init tool args and abort the…
thushan Jun 13, 2026
597e301
make stream-started flag atomic, filter passthrough endpoints by decl…
thushan Jun 13, 2026
420d6af
map openai reasoning to anthropic thinking blocks so translated think…
thushan Jun 13, 2026
cee6114
add claude-code-e2e skill to prove Claude Code works through Olla's a…
thushan Jun 13, 2026
ab9c5f2
gate logging field construction behind level checks so the proxy hot …
thushan Jun 13, 2026
63fe2d8
skip client-ip parse when no trusted proxy cidrs are configured, drop…
thushan Jun 13, 2026
12f4261
cache the anthropic models response keyed on the healthy model set to…
thushan Jun 13, 2026
91479b3
profile olla's hot path in claude-code-e2e with micro and streaming l…
thushan Jun 13, 2026
a1cba86
pool the sse encode buffer and use typed delta structs to cut per-chu…
thushan Jun 13, 2026
e0f9fc5
diff heap snapshots in claude-code-e2e so the alloc profile shows bur…
thushan Jun 13, 2026
cb82d73
decode streaming chunks into typed structs instead of a map to cut in…
thushan Jun 13, 2026
9cb274d
extend streaming benchmarks to cover the typed-decode parse path
thushan Jun 13, 2026
30373a4
merge typed-decode streaming parse and per-chunk alloc tuning from an…
thushan Jun 13, 2026
f1a753c
doc updates
thushan Jun 13, 2026
e07d0a6
keep same-chunk content and tool_calls after reasoning, and stop reop…
thushan Jun 13, 2026
8c6ff72
include the emitted model id in the anthropic models cache fingerprin…
thushan Jun 13, 2026
198339b
harden the models cache separator against id collisions and cover the…
thushan Jun 13, 2026
dac7e29
merge anthropic improvements: reasoning-to-thinking translation, clau…
thushan Jun 13, 2026
e0f1f83
capture streaming usage before the empty-choices return so choices:[]…
thushan Jun 13, 2026
87d4934
track real client commit for streaming panic recovery so an uncommitt…
thushan Jun 13, 2026
92f3bb7
litepool avoids a panic again
thushan Jun 13, 2026
db4a397
make litepool Get return an error and have callers propagate it, with…
thushan Jun 13, 2026
2fe2801
implement Flush and Unwrap on the committed response writer so transl…
thushan Jun 13, 2026
dcf07db
synthesise streaming output_tokens from content when the backend stre…
thushan Jun 13, 2026
de5c61c
make ollamock emit a terminal usage chunk in its openai stream like a…
thushan Jun 13, 2026
09da739
serialise count_tokens before committing 200 so a serialise failure r…
thushan Jun 13, 2026
1c272d8
derive the health-check per-tick context from the loop context so shu…
thushan Jun 13, 2026
dc670f3
extract bindListener and test the bind path directly
thushan Jun 13, 2026
39d469f
fix t.Fatalf inside a goroutine in the models-cache test and isolate …
thushan Jun 13, 2026
d716e83
record a failure when the stream buffer pool is exhausted, matching t…
thushan Jun 13, 2026
295fa37
docs and comments update
thushan Jun 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
743 changes: 743 additions & 0 deletions .claude/skills/claude-code-e2e/SKILL.md

Large diffs are not rendered by default.

108 changes: 108 additions & 0 deletions .claude/skills/claude-code-e2e/config.regression.yaml.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Olla config template for the /claude-code-e2e skill.
#
# Rendered at runtime into test/regression/tmp/ with these placeholders
# substituted (the skill does the substitution, never edit the rendered copy):
# __OLLA_PORT__ - loopback port Olla listens on
# __OLLAMA_URL__ - the real Ollama endpoint (--ollama-endpoint)
# __PASSTHROUGH__ - true (native Anthropic passthrough) | false (forced translation)
#
# One real Ollama endpoint, one Olla instance. The skill renders this twice -
# once with __PASSTHROUGH__=true, once =false - and restarts Olla between the
# two legs so both the passthrough and the OpenAI<->Anthropic translation paths
# are exercised against the same backend and model.

server:
host: "127.0.0.1"
port: __OLLA_PORT__
read_timeout: 300s # gemma-class models on CPU can be slow to first token
read_header_timeout: 10s
write_timeout: 0s # 0 = no write deadline, required for SSE streaming
shutdown_timeout: 5s
request_logging: true
request_limits:
# Claude Code system prompts + tool schemas are large; keep this generous.
max_body_size: 10485760
max_header_size: 524288
rate_limits:
# Wide open: this harness is a correctness gate, not a rate-limit test.
global_requests_per_minute: 100000
per_ip_requests_per_minute: 50000
health_requests_per_minute: 100000
burst_size: 1000
cleanup_interval: 5m
trust_proxy_headers: false
trusted_proxy_cidrs: ["127.0.0.0/8"]

proxy:
engine: "olla"
profile: "auto"
load_balancer: "priority"
stream_buffer_size: 8192
connection_timeout: 10s
response_timeout: 300s # long generations through a local model
read_timeout: 300s
response_header_timeout: 30s
retry:
enabled: true
on_connection_failure: true
max_attempts: 0
sticky_sessions:
# Off on purpose: Claude Code resends full history each turn, so pinning
# adds a variable without adding coverage for this gate.
enabled: false

discovery:
type: "static"
refresh_interval: 10s
health_check:
initial_delay: 1s
static:
endpoints:
- url: "__OLLAMA_URL__"
name: "ollama-regression"
type: "ollama"
priority: 100
model_url: "/api/tags"
health_check_url: "/"
check_interval: 5s
check_timeout: 3s

model_discovery:
# Fast interval so the model is discoverable within the skill's 60s
# readiness window on a cold boot (matches check_interval).
enabled: true
interval: 5s
timeout: 10s
concurrent_workers: 2
retry_attempts: 2
retry_backoff: 1s

model_registry:
type: "memory"
enable_unifier: true
unification:
enabled: true
stale_threshold: 24h
cleanup_interval: 10m
routing_strategy:
type: "optimistic"
options:
fallback_behavior: "all"
discovery_timeout: 5s
discovery_refresh_on_miss: false

translators:
anthropic:
enabled: true
passthrough_enabled: __PASSTHROUGH__
max_message_size: 10485760
inspector:
enabled: false

logging:
level: "info"
format: "text"
output: "stdout"

engineering:
show_nerdstats: false
46 changes: 46 additions & 0 deletions .claude/skills/claude-code-e2e/tasks/totalsize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Tier-B task: Summary.TotalSize()

Fixed feature task run by the child Claude Code instance against a fresh clone
of `thushan/smash`. Chosen because it is one self-contained file plus its test
(`pkg/analysis/summary.go` + `summary_test.go`), depends only on stdlib, and
needs only those two files in context - so it stays inside a small local
model's window while still forcing a real multi-turn tool loop
(read file -> read test -> edit both -> run test).

## Prompt

The canonical prompt is the single source of truth in `totalsize.prompt.txt`
(this directory). The skill reads it with `cat` and passes it verbatim to
`claude -p` - do not duplicate the wording here or inline in the skill, so the
two cannot drift. Current content:

> Add a method `func (t *Summary) TotalSize() uint64` to pkg/analysis/summary.go
> that returns the sum of the Size field of every item the Summary holds. Then
> add a test in pkg/analysis/summary_test.go, following the existing style, that
> verifies the returned total. Run `go test ./pkg/analysis/...` and make sure it
> passes before you finish.

## Assertion (run by the skill after the child exits, in the clone dir)

All of these must hold for Tier-B PASS on a leg:

1. The child reported success - the final `stream-json` event is
`{"type":"result", ... "is_error": false}`.
2. `go test ./pkg/analysis/...` exits 0 in the clone.
3. `pkg/analysis/summary.go` shows as modified in `git status --porcelain` (the
method was actually added, not a no-op run).
4. `pkg/analysis/summary_test.go` shows as modified AND contains `TotalSize` -
otherwise a model could add the method, skip the test, and still pass 2 and 3
on the strength of the pre-existing suite.

A leg that fails any of the three is a Tier-B FAIL for that leg, but Tier-B is
non-gating: it is reported and influences the headline, while the run verdict is
set by Tier-A (the protocol gate) and the fitness preflight. Record which of the
three conditions failed so a model-capability failure (condition 1/2) is
distinguishable from an Olla wire failure (which Tier-A would also catch).

## Reset between legs

The clone is thrown away and re-cloned (or `git reset --hard <SHA> && git clean
-xfd`) before the second leg, so the passthrough and translation legs both start
from the identical pinned baseline.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a method `func (t *Summary) TotalSize() uint64` to pkg/analysis/summary.go that returns the sum of the Size field of every item the Summary holds. Then add a test in pkg/analysis/summary_test.go, following the existing style in that file, that verifies the returned total. Run `go test ./pkg/analysis/...` and make sure it passes before you finish.
10 changes: 8 additions & 2 deletions .claude/skills/olla-validate/areas/anthropic.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,14 @@ Headers: `Content-Type: application/json`, `x-api-key: validate`,
valid order: `message_start` → `content_block_start` →
`content_block_delta`(×N) → `content_block_stop` → `message_delta` →
`message_stop`; deltas assemble to non-empty text.
4. `GET /olla/anthropic/v1/models` → 200, Anthropic-format model list
(non-empty `data[]`).
4. `GET /olla/anthropic/v1/models` → 200, real Anthropic wire format:
- Top-level envelope has `has_more` (boolean) and `first_id`/`last_id`.
- `data[]` is non-empty.
- Each entry has `type: "model"` (not `"chat"` — the official Anthropic SDK
uses this as a deserialise discriminator; a wrong value breaks client
compatibility), a non-empty `display_name`, and `created_at` as an ISO
8601 string.
FAIL if any of the above are missing or wrong.
5. `POST /olla/anthropic/v1/messages/count_tokens` with the standard body →
200 with `input_tokens > 0`.
6. Invalid body (`{"model":"test-model"}` - no messages/max_tokens) → 4xx
Expand Down
25 changes: 15 additions & 10 deletions .claude/skills/olla-validate/areas/limits-failures.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,24 @@ long" on Git Bash/Windows.
1. Baseline: `POST http://127.0.0.1:41142/olla/proxy/v1/chat/completions`
with the standard small body → 200.
2. Body cap, declared length: same route with a ~300KB body (pad one message
with filler) → rejected. Current contract: the security chain rejects a
declared oversized Content-Length with a blanket **403** ("Security
validation failed", `internal/app/handlers/application.go`); 413 is also
acceptable. Any 2xx or 5xx = FAIL.
with filler) → rejected with **413** (Content Too Large, RFC 9110 §15.5.14).
A 403 is a FAIL (it was the pre-fix bug). Any 2xx or 5xx = FAIL.
3. Just-under cap (~200KB) → 200.
4. 429 rate limit: fire 45 rapid sequential requests → at least one 429;
record at which request it first appears (expect after roughly
burst+window allowance) and whether a Retry-After or rate-limit header is
present (note, don't fail on header absence).
4. 429 rate limit: fire 45 rapid sequential requests → at least one 429
(RFC 6585 §4); record at which request it first appears (expect after roughly
burst+window allowance). The 429 response must carry at least one of
`Retry-After` or an `X-RateLimit-*` header — FAIL if neither is present.
A 403 in place of 429 is a FAIL (it was the pre-fix masking bug).
5. Health exemption: while rate-limited, `GET /internal/health` on 41142
still 200 (health has its own generous limit).
6. Malformed JSON (`{"model":`) → 4xx, not 5xx.
7. Empty body POST → 4xx, not 5xx.
6. Malformed JSON (`{"model":`) → must not 5xx and must not hang; record the
observed status (2xx or 4xx are both acceptable). Olla is a transparent
proxy and delegates body validation to the backend by design — it
opportunistically extracts the model field and, on failure, forwards to a
fallback backend. Only a 5xx or a hang is a FAIL.
7. Empty body POST → must not 5xx and must not hang; record the observed
status (2xx or 4xx are both acceptable, same delegation rationale as
check 6). Only a 5xx or a hang is a FAIL.
8. Wrong method: `DELETE /olla/proxy/v1/chat/completions` → 404/405, no 5xx.

### Nightly additions
Expand Down
7 changes: 5 additions & 2 deletions .claude/skills/olla-validate/areas/openai-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ a check without mutating mock state, mark it skip with a note).
5. Ollama native non-stream: `POST /olla/ollama/api/chat`
(`"stream":false`) → 200, `done: true`, `message.content` non-empty,
`prompt_eval_count`/`eval_count` > 0.
6. Malformed body to `/olla/proxy/v1/chat/completions` (`{"model":`) → 4xx
(FAIL if 5xx or hang).
6. Malformed body to `/olla/proxy/v1/chat/completions` (`{"model":`) → must
not 5xx and must not hang; record the observed status (2xx or 4xx are both
acceptable). Olla is a transparent proxy and delegates body validation to
the backend by design — it opportunistically extracts the model field and,
on failure, forwards to a fallback backend. Only a 5xx or a hang is a FAIL.

## Nightly additions

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ config.yaml
*.local.json
*.log
/test/reports/

# /claude-code-e2e throwaway clones + rendered configs
test/regression/tmp/
51 changes: 49 additions & 2 deletions docs/content/api-reference/anthropic.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,52 @@ curl -X POST http://localhost:40114/olla/anthropic/v1/messages \

**Note**: Tool use requires a model that supports function calling. Not all local models support this feature.

### Example: Reasoning (Thinking Blocks)

Reasoning models (DeepSeek-R1, Qwen3, gpt-oss) emit a chain-of-thought before their answer. Olla surfaces it as a `thinking` block ahead of the text. Passthrough forwards the native block as-is; translation builds one from the backend's `reasoning`/`reasoning_content` field.

**Non-streaming response**:

```json
{
"id": "msg_reason123",
"type": "message",
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "The user wants the capital of France. That is Paris."
},
{
"type": "text",
"text": "The capital of France is Paris."
}
],
"model": "deepseek-r1:latest",
"stop_reason": "end_turn",
"usage": { "input_tokens": 12, "output_tokens": 34 }
}
```

**Streaming**: the thinking block streams first, before the text block:

```
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"The user wants"}}

event: content_block_stop
data: {"type":"content_block_stop","index":0}
```

**Notes**:

- Local models don't produce Anthropic's `signature`, so thinking blocks come through without one.
- Olla reads both field names: `reasoning` (Ollama, LM Studio, Lemonade) and `reasoning_content` (vLLM, SGLang, DeepSeek).
- Reasoning tokens count against `max_tokens`. Set it too low and the model spends the lot thinking, returning `stop_reason: "max_tokens"` with little or no text, so give reasoning models headroom.

### Example: Vision (Image Input)

**Request**:
Expand Down Expand Up @@ -587,7 +633,7 @@ Streaming uses Server-Sent Events (SSE) with typed events.
|-------|-------------|--------------|
| `message_start` | Initial message metadata | `{"type":"message_start","message":{...}}` |
| `content_block_start` | Start of text or tool_use block | `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` |
| `content_block_delta` | Text chunks (`text_delta`) or tool JSON chunks (`input_json_delta`) | `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"..."}}` |
| `content_block_delta` | Text chunks (`text_delta`), reasoning chunks (`thinking_delta`), or tool JSON chunks (`input_json_delta`) | `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"..."}}` |
| `content_block_stop` | End of content block | `{"type":"content_block_stop","index":0}` |
| `message_delta` | Stop reason and final usage statistics | `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":9}}` |
| `message_stop` | End of stream | `{"type":"message_stop"}` |
Expand Down Expand Up @@ -712,6 +758,7 @@ Errors follow Anthropic API format:
- System messages (string or content blocks)
- Tool use (definitions, tool_choice, tool_use, tool_result)
- Tool streaming with `input_json_delta` events
- **Reasoning output** surfaced as `thinking` blocks (passthrough keeps the native block; translation builds one from `reasoning`/`reasoning_content`, streamed as `thinking_delta`)
- Token counting via `/count_tokens` endpoint
- Stop sequences
- Temperature, top_p, top_k parameters
Expand All @@ -733,7 +780,7 @@ Errors follow Anthropic API format:

### ❌ Not Supported

- **Extended Thinking**: Field accepted but not processed
- **Extended Thinking control**: the request-side `thinking`/`budget_tokens` parameter is accepted but ignored. (Reasoning *output* is still surfaced as `thinking` blocks, see above.)
- **Prompt Caching**: Not implemented
- **Batches API**: Not implemented
- **Message Editing**: Not supported
Expand Down
2 changes: 1 addition & 1 deletion docs/content/concepts/api-translation.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ Not all features translate perfectly:

**Anthropic → OpenAI Limitations**:

- Extended thinking: Not supported (Anthropic-specific feature)
- Extended thinking control: the request-side `thinking`/`budget_tokens` is ignored. Reasoning *output* is preserved though, surfaced as `thinking` blocks from the backend's `reasoning`/`reasoning_content` field.
- Prompt caching: Not supported (Anthropic-specific feature)
- Some advanced parameters may not have OpenAI equivalents

Expand Down
2 changes: 1 addition & 1 deletion docs/content/integrations/api-translation/anthropic.md
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ See [Crush CLI Integration](../frontend/crush-cli.md) for complete setup.

### Not Supported

- ❌ **Extended Thinking** - Advanced reasoning mode
- ❌ **Extended Thinking control** - request-side `thinking`/`budget_tokens` is ignored. (Reasoning *output* is supported, surfaced as `thinking` blocks in both modes.)
- ❌ **Prompt Caching** - Response caching
- ❌ **Batches API** - Batch processing
- ❌ **Usage Tracking** - Account-level usage tracking
Expand Down
30 changes: 22 additions & 8 deletions internal/adapter/health/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,15 @@ func (c *HTTPHealthChecker) StopChecking(ctx context.Context) error {
}

func (c *HTTPHealthChecker) healthCheckLoop(ctx context.Context) {
// Function-level safety net: if something truly unrecoverable escapes the
// per-tick inner recover, mark isRunning false so StartChecking can restart.
defer func() {
if c.ticker != nil {
c.ticker.Stop()
}
// Panic recovery for health check loop
if r := recover(); r != nil {
c.logger.Error("Health check loop panic recovered", "panic", r)
// Could restart the loop here if needed
c.logger.Error("Health check loop exited unexpectedly", "panic", r)
c.isRunning.Store(false)
}
}()

Expand All @@ -148,10 +149,23 @@ func (c *HTTPHealthChecker) healthCheckLoop(ctx context.Context) {
c.logger.Debug("Health check loop stopping due to stop signal")
return
case <-c.ticker.C:
// Use a separate context for health checks to avoid cancelling mid-check
checkCtx, cancel := context.WithTimeout(context.Background(), DefaultHealthCheckInterval/2)
c.performHealthChecks(checkCtx)
cancel()
// Wrap per-tick work in its own recover so a panic in
// performHealthChecks does not kill the goroutine - the loop
// continues and the next tick fires normally.
func() {
defer func() {
if r := recover(); r != nil {
c.logger.Error("Health check tick panic recovered, loop continues",
"panic", r)
}
}()
// Derive from the loop's own context so in-flight checks are
// cancelled when the checker shuts down, rather than running
// to their full timeout against an already-stopped service.
checkCtx, cancel := context.WithTimeout(ctx, DefaultHealthCheckInterval/2)
defer cancel()
c.performHealthChecks(checkCtx)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}()
}
}
}
Expand Down Expand Up @@ -305,7 +319,7 @@ func (c *HTTPHealthChecker) checkEndpoint(ctx context.Context, endpoint *domain.

// Trigger unhealthy callback only when an endpoint becomes non-routable from a
// routable state. Busy and Warming are still routable, so a Healthy→Busy transition
// must not evict sticky sessions that would defeat KV-cache affinity entirely.
// must not evict sticky sessions - that would defeat KV-cache affinity entirely.
// Unknown→Unhealthy is intentionally excluded: nothing could have been pinned to an
// endpoint that was never routable, so there is nothing to purge.
if statusChanged && !newStatus.IsRoutable() && oldStatus.IsRoutable() {
Expand Down
Loading
Loading