Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
225634d
fix(server): cache-stable OpenAI wire form for llama-server prefix ca…
lydiym Aug 18, 2026
8b1cdc8
test: replace Cyrillic fixture with CJK in unicode test
lydiym Aug 18, 2026
ffc4836
Merge branch 'main' into fix/bugs
lydiym Aug 19, 2026
2aa8a98
fix(server): pick tool_use stop_reason when upstream omits finish_reason
lydiym Aug 19, 2026
955806a
feat(prompt_remap): cover task-tools reminder variant
lydiym Aug 20, 2026
c136b7b
style: fix pre-existing ruff errors
lydiym Aug 20, 2026
17f0507
feat(debug): PROXY_DEBUG_CACHE_DUMP=1 writes prompt diffs to cwd
lydiym Aug 20, 2026
92d2a28
fix(debug): _strip_ids no longer mutates the caller's payload
lydiym Aug 20, 2026
a9f1479
fix(debug): match newest prior and disambiguate same-second stamps
lydiym Aug 20, 2026
5bd8011
fix(debug): scrub tool_call ids in disk artifacts too
lydiym Aug 20, 2026
ba44803
feat(server): WARNING log when prompt_remap strips something
lydiym Aug 20, 2026
515a498
refactor(debug): drop cache debug -diff.diff artifact
lydiym Aug 20, 2026
de79904
feat(server): show match count in prompt_remap log
lydiym Aug 20, 2026
9757f67
fix(server): don't crash when message content is a list
lydiym Aug 20, 2026
7bb774e
fix(server): preserve image content in tool_result blocks
lydiym Aug 20, 2026
3ebc967
test(streaming): prove proxy faithfully forwards partial tool arguments
lydiym Aug 20, 2026
0f85411
feat(debug): add inbound Anthropic request dump
lydiym Aug 25, 2026
d98f531
fix(server): hoist top-level system field before in-band reminders
lydiym Aug 25, 2026
d8f953e
feat(server): translate output_config.effort + pass thinking through
lydiym Aug 25, 2026
493a903
fix(server): round-trip reasoning_content on assistant turns
lydiym Aug 25, 2026
492d293
fix(debug): wrap inbound body dump in try/except as docstring promises
lydiym Aug 26, 2026
07fb595
test(server): output_config.effort="" is treated as absent
lydiym Aug 26, 2026
dc4ab80
docs: codify post-/code-review findings triage in CLAUDE.md
lydiym Aug 26, 2026
da915b9
fix(server): collapse null text in tool_result content instead of cra…
lydiym Aug 26, 2026
e255233
test(server): cover tool_result text=null + inbound dump seq counter
lydiym Aug 26, 2026
3b9ae47
docs(bugtracker): extra_body spread doesn't filter protected keys
lydiym Aug 26, 2026
debf51a
fix(server): annotate CONFIG so ty can resolve downstream indexing
lydiym Aug 26, 2026
855a6ea
test(server): suppress remaining ty false positives after CONFIG anno…
lydiym Aug 26, 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ OPENAI_API_KEY="sk-..."
# LOG_LEVEL="INFO" # DEBUG dumps effective upstream sampling params + extra_body per request

# LITELLM_DEBUG_HTTP="false" # verbose: dump full litellm kwargs/response, enable litellm.set_verbose + httpx/httpcore DEBUG. Use with LOG_LEVEL=DEBUG.

# PROXY_DEBUG_CACHE_DUMP="false" # debug: write outgoing payloads to $cwd/.claude-code-proxy/prompts/ when an outgoing isn't a prefix extension of a prior one (cache-busting hot spot finder). Default: false.

# PROXY_DEBUG_INBOUND_DUMP="false" # debug: write raw Anthropic request (pre-conversion) to $cwd/.claude-code-proxy/anthropic-prompts/ on every request. Default: false.
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ Logs go to stderr with a single timestamped format. `log_request` prints one lin

Follow the convention: add a unit test by writing a `test_foo` function; it'll be picked up automatically. Use `assert` statements — failures are caught and printed with the function name.

## Code review

After every `/code-review` invocation on a feature branch, run a **findings triage** before acting on fixes:

- **Scope check** — for each finding, determine whether it belongs to the current branch's commits (`fix/...`, `feat/...`) or was already present on `main` before the branch diverged. Use `git log main..HEAD --stat` and `git merge-base HEAD main` to draw the boundary.
- **Relevance check** — if a finding predates the branch, it is out of scope for *this* review. Note it in the triage summary so it isn't forgotten, but do not bundle the fix into the branch's commits (keep diffs reviewable; queue the finding separately).
- **Verification** — re-read the cited file:line against the current `HEAD`, since file:line anchors drift across commits. Findings citing a line that no longer contains the alleged code are stale and should be discarded.
- **Outcome** — report each finding as one of: `fix in branch`, `pre-existing → file separate ticket`, `stale → discard`. Only act on the first category.

The triage happens once per `/code-review` invocation, before any code changes — not on every finding individually.

## Files to know

- `server.py` — everything (proxy, models, translation, streaming, config loader)
Expand Down
60 changes: 58 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,22 @@ Merge chain is `[global] → [bucket] → [tier]` (later wins per leaf). Samplin

Pick the knobs your backend actually understands — don't mix `reasoning_effort` (OpenAI o-series), `chat_template_kwargs` (llama.cpp), or Anthropic-native `thinking` in one section. They belong to different backends.

### Anthropic-shaped field translations

Claude Code sends a few Anthropic-native fields that don't map 1:1 to OpenAI Chat Completions. The proxy translates them so you don't have to round-trip via config:

| Anthropic field | Where it lands on the wire | Notes |
|---------------------------|-------------------------------------|-------|
| `thinking` | `extra_body.thinking` (body-only) | Anthropic-compatible backends like MiniMax-M3 read this verbatim. Body-only because the openai SDK rejects unknown top-level kwargs; the key still reaches the JSON body via litellm's `extra_body` lift (see `openai_like/chat/handler.py:241,258`). |
| `output_config.effort` | top-level `reasoning_effort` | OpenAI's reasoning-effort axis (o-series, etc.). Only translated when `effort` is set; other `output_config` keys are ignored. |
| `metadata`, `context_management` | dropped | Anthropic-only; not forwarded. |

Three more rules apply when these fields overlap with `[tier].extra_body`:

- `thinking`: deep-merged with `[tier].extra_body.thinking` — config wins per leaf (e.g. config can flip `type` from `adaptive` to `disabled` while the client's `budget_tokens` survives).
- `output_config.effort`: translated first, then `[tier].extra_body.reasoning_effort` (if any) overrides it.
- The merged key list is published as `allowed_openai_params` at both top-level and inside `extra_body` so the litellm hop and any cascade proxy forward these vendor keys verbatim instead of filtering them.

### Lookup order

For each setting, the proxy uses the first non-empty value from this list:
Expand All @@ -131,7 +147,7 @@ Env wins so `docker run -e KEY=VAL` and `docker-compose.yml: environment:` overr
### Per-tier merge semantics

- **Model selection**: per tier, the resolver walks `{TIER}_MODEL` env → `{BIG|SMALL}_MODEL` env → `[tier].model` → `[bucket].model` → `[global].model` → built-in default. First non-empty wins. `[global].model` is the catch-all for any model — including unmapped ones (tier=None).
- **extra_body merge chain**: `[global] → [bucket] → [tier]` (haiku → `small`, others → `big`). Each layer deep-merges; later wins per leaf. Keys are lifted to top-level kwargs on the upstream call. The merged key list is published as `allowed_openai_params` (top-level + inside `extra_body`) so the litellm hop and any cascade proxy forward vendor keys (`chat_template_kwargs`, `cache_prompt`, `n_predict`, `reasoning_effort`, …) instead of dropping them.
- **extra_body merge chain**: `[global] → [bucket] → [tier]` (haiku → `small`, others → `big`). Each layer deep-merges; later wins per leaf. Keys are lifted to top-level kwargs on the upstream call, except `thinking` and any other Anthropic-only fields that the openai SDK rejects at signature time — those stay in `extra_body` and reach the JSON body via litellm's cascade lift. The merged key list is published as `allowed_openai_params` (top-level + inside `extra_body`) so the litellm hop and any cascade proxy forward vendor keys (`chat_template_kwargs`, `cache_prompt`, `n_predict`, `reasoning_effort`, …) instead of dropping them.
- **Sampling / reasoning / vendor fields** all live inside `[tier].extra_body` (and `[global].extra_body` / `[bucket].extra_body`). There is no per-key whitelist — pass any top-level key the upstream OpenAI Chat Completions API (or your compatible backend) accepts: `temperature`, `top_p`, `top_k`, `stop`, `seed`, `max_completion_tokens`, `reasoning_effort`, `chat_template_kwargs`, `cache_prompt`, `n_predict`, …
- **Conflict resolution**: when both a config layer (`[global]` / `[bucket]` / `[tier]`) and the client request set the same key (whether via Pydantic sampling fields or a request-level `extra_body`), **config wins** per leaf.
- **No defaults applied**: when neither config nor the request sets a key, it is **omitted from the upstream call** (we don't auto-apply Anthropic defaults like `temperature=1.0`).
Expand All @@ -158,6 +174,46 @@ extra_body = { temperature = 0.3, cache_prompt = true, n_predict = 4096, chat_te

Inspect upstream logs (or use `mitmproxy`) to confirm `cache_prompt`, `chat_template_kwargs`, etc. land in the body. For offline checks, set `LOG_LEVEL=DEBUG` — the proxy logs the effective `extra_body` per request (sourced from request or `[tier]` config).

### Cache-busting diagnostics (`PROXY_DEBUG_CACHE_DUMP=true`)

Off by default. When enabled, every outgoing payload is matched against a rolling window of prior ones — a request whose outgoing starts with a prior one is a `prefix_hit` (the desired case); anything that crosses the 0.6 fuzzy threshold but isn't a structural prefix is `fuzzy_match` (suspect a cache-busting edit).

Artifacts land in `$cwd/.claude-code-proxy/prompts/`:

- `<ts>-<pid>-<kind>-<score>-new.json` — the current outgoing payload
- `<ts>-<pid>-<kind>-<score>-old.json` — the prior payload it matched against

Compare them with your diff tool of choice (the system prompt is one long
string, so `diff -u` won't be useful — a side-by-side viewer or a script
that splits on `\n\n` works better). Adjust `[[prompt_remap]]` to canonicalise
whichever side drifted.

```bash
PROXY_DEBUG_CACHE_DUMP=true uv run uvicorn server:app
```

### Inbound request dumps (`PROXY_DEBUG_INBOUND_DUMP=true`)

Off by default. When enabled, every Anthropic request Claude Code sends is
written verbatim (before conversion to OpenAI shape) to
`$cwd/.claude-code-proxy/anthropic-prompts/<ts>-<pid>-inbound.json`.

Use this when you need to see the raw Anthropic payload — in particular:

- Where Claude Code places its `<system-reminder>` injections across turns
(top-level `system` field vs in-band `role="system"` messages vs user content)
- The exact shape of the `system` field (`str` vs `list[ContentBlock]`)
- Whether the conversation history is growing or staying stable

This complements `PROXY_DEBUG_CACHE_DUMP` (which captures post-conversion
outgoing payloads to `.claude-code-proxy/prompts/`). Inbound dumps let you
inspect what the *client* sends; cache dumps let you inspect what
*upstream* receives.

```bash
PROXY_DEBUG_INBOUND_DUMP=true uv run uvicorn server:app
```

### System-prompt rewrites (`[[prompt_remap]]`)

Two things at once:
Expand All @@ -167,7 +223,7 @@ Two things at once:

```toml
[[prompt_remap]]
match = "The TodoWrite tool hasn't been used recently.*?ignore if not applicable\\.\\n+"
match = "The (?:TodoWrite tool hasn't|task tools haven't) been used recently.*?ignore if not applicable\\.\\n+"
replacement = ""
```

Expand Down
54 changes: 38 additions & 16 deletions bugtracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,45 @@ surrounding code changes.

## Active

### Streaming resilience
### Security / proxy invariants

#### `extra_body` spread doesn't filter protected keys — `server.py:1478`

- **Severity**: medium — operator or client can override the proxy's own
`model`, `messages`, `stream`, `tools` keys via `request.extra_body` or
`[tier].extra_body`. The WARNING log at server.py:1464 reads "ignoring
protected key" but the value still reaches the JSON body upstream via
litellm's `openai_like/chat/handler.py:241,258` spread path.
- **Where**: `_apply_merged_extra_body` does
`litellm_request["extra_body"] = {"allowed_openai_params": keys, **merged_extra}`.
The loop at server.py:1462-1470 correctly skips protected keys for the
top-level kwarg lift, but the `**merged_extra` spread below re-includes
them in `extra_body`, which is forwarded verbatim into the wire body.
- **Repro**: `[sonnet].extra_body.model = "evil-model"` →
`WARNING ignoring protected key in extra_body: model` is logged, but
`out["extra_body"]["model"] == "evil-model"` and upstream receives a
second `model` field alongside the legitimate `out["model"]`. Same
hazard for `stream=true` (silently toggles streaming against the
route's expectation) and `messages=...` (replaces the proxy-built
messages list).
- **Why caught now**: existing test `test_extra_body_protected_keys_blocked`
only asserts on top-level kwargs (`out["model"]`, `out["messages"]`,
`out["stream"]`); it doesn't check `out["extra_body"]` so the bypass
slipped through. Reviewer-verified via runtime execution of
`_prepare_litellm_request` against a malicious config.
- **Suggested fix**: filter `merged_extra` before the spread —
`safe_extra = {k: v for k, v in merged_extra.items() if k not in _PROTECTED_KEYS}`
— and use `safe_extra` (not `merged_extra`) in the `extra_body` dict.
Extend `test_extra_body_protected_keys_blocked` to assert
`"model" not in out["extra_body"]`, etc.
- **Source**: introduced on `fix/bugs` by commit `d8f953e feat(server):
translate output_config.effort + pass thinking through` (the spread
was added so body-only keys like `thinking` could reach the JSON body
— but the spread didn't filter `_PROTECTED_KEYS`). Pre-merge-base, on
`main`, `extra_body` only held `{"allowed_openai_params": keys}` — no
spread, no bypass.

#### `end_turn` hardcoded when upstream omits finish_reason — `server.py:1635`

- **Severity**: high — silently drops tool_use when upstream closes the
stream before sending `finish_reason`.
- **Where**: `_stream_epilogue` always calls `_SseFormatter.finish("end_turn", …)`,
even when a `tool_use` block is mid-emission. The Anthropic SDK treats
`end_turn` as "no pending work" and never asks the user for tool results.
- **Repro**: tool_use stream where upstream emits two valid tool_use blocks,
then closes without `finish_reason`.
- **Suggested fix**: track whether any `tool_use` block was emitted in
`_StreamState`; pick `_to_anthropic_stop_reason("tool_use")` when true,
`end_turn` otherwise. Requires the in-flight tool_use block to remain
open through the finish event (Anthropic SSE expects
`content_block_stop` before `message_delta`).
- **Source**: pre-existing in `main`.
### Streaming resilience

#### `_log_request` emits STATUS_OK before upstream call — `server.py:1907`

Expand Down
7 changes: 4 additions & 3 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@
# rewritten text.

[[prompt_remap]]
# Claude Code periodically injects a TodoWrite reminder into the system
# prompt; without stripping it, every flip state is a cache miss and the
# Claude Code periodically injects a reminder into the system prompt (one
# variant mentions TodoWrite, another mentions task tools / TaskCreate /
# TaskUpdate). Without stripping, every flip state is a cache miss and the
# whole prompt is re-processed.
match = "The TodoWrite tool hasn't been used recently.*?ignore if not applicable\\.\\n+"
match = "The (?:TodoWrite tool hasn't|task tools haven't) been used recently.*?ignore if not applicable\\.\\n+"
replacement = ""

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ known-first-party = ["server", "tests"]
"magic-value-comparison", # tests assert against literal sampling temps/byte sizes/HTTP codes — naming each one adds ceremony without clarity
"D", # tests.py has its own module docstring already; per-test docstrings are noise
"T20", # integration runner prints scenarios + pass/fail lines
"S108", # /tmp/... in test fixture strings isn't a filesystem op
]

[tool.ty.environment]
Expand Down
Loading