# v0.4 Retrospective — Symmetric OpenAI ⇄ Anthropic routing

*Period: 2026-04-20 (single day).*
*Commits: `e566bce` (v0.4-A) → `9c75327` (v0.4-A docs) → `e1d7108` (v0.4-B roster) → `baa88f1` (実機疎通) → `7fe3027` (v0.4-D). Only `v0.4-A` exists as a git tag; v0.4-B/D are commit-level sub-releases.*
*Tests: 110 → **153** (+43, +39%). All green throughout.*

## Scope at a glance

| Sub-release | Theme | Primary deliverable | Tests Δ |
|---|---|---|---|
| v0.4-A | symmetry | `ChatRequest → AnthropicRequest` reverse translation; `AnthropicAdapter.generate` / `.stream` now works from `/v1/chat/completions` | +37 |
| v0.4-B | hygiene | OpenRouter free-tier roster audit (`deepseek-r1:free` gone → `openai/gpt-oss-120b:free`) | 0 |
| v0.4 実機 | verification | Real-machine spot-check of all three paths (OpenAI → OpenRouter gpt-oss, OpenAI → Anthropic reverse, Anthropic → Anthropic native). Cache_control lossless passthrough proven with `cache_creation_input_tokens: 1321` → `cache_read_input_tokens: 1321`. | 0 |
| v0.4-D | Claude Code unblock | `anthropic-beta` HTTP header passthrough; structured upstream-error logging | +6 |

No formal `v0.4-C` tag — the verification block filled that slot and was commit-only.

## What happened, in order

1. **v0.4-A** closed the v0.3.x-1 footnote: "OpenAI client → `kind: anthropic` provider is out of scope". Adding `to_anthropic_request` + `to_chat_response` + `stream_anthropic_to_chat_chunks` (~300 lines total in `translation/convert.py`) made it work without touching the engine, because the existing polymorphic dispatch was already kind-agnostic. The one remaining limitation is `cache_control` / `thinking` — those have no OpenAI-shape equivalent, so they remain the Anthropic ingress's domain.

2. **v0.4-B** was defensive maintenance. `deepseek/deepseek-r1:free` silently vanished from OpenRouter's free roster; a live `/api/v1/models` audit picked the replacement using three criteria: different vendor family (so a rate-limit on one doesn't cascade), tool support, and production-grade positioning. `openai/gpt-oss-120b:free` won.

3. **Real-machine疎通** was the most important step without a version number. Four scenarios, all four passed:
   - `/v1/chat/completions` → `openrouter-gpt-oss-free`: 200 OK + discovered an undocumented `reasoning` field in the response (filed for a future `reasoning`-strip layer).
   - `/v1/chat/completions` → `anthropic-direct` with `tool_choice=auto`: confirmed reverse-translation with a tell-tale `tool_calls[0].id = "toolu_..."` id leaking through (Anthropic's id format preserved, which is fine).
   - Streaming version: OpenAI-shape delta sequence + trailing usage chunk + `[DONE]` reconstructed without Anthropic event leakage.
   - `/v1/messages` → `anthropic-direct` (native passthrough): sent an 1200+ token system prompt with `cache_control: {type: "ephemeral"}`. Call 1 reported `cache_creation_input_tokens: 1321`; call 2 reported `cache_read_input_tokens: 1321`. Lossless passthrough is now a number, not a claim.

4. **v0.4-D** is where real-machine testing paid off. Pointing actual Claude Code at the `claude-code-direct` profile produced a 400 Bad Gateway. The diagnostic flow that followed turned out to be the retrospective's big lesson.

## The v0.4-D diagnostic chain — the meta-story

The initial log line was useless:

```
{"msg": "provider-failed", "provider": "anthropic-direct", "status": 400, "retryable": false}
```

Step 1 was to make the log line useful. Adding `"error": str(exc)[:500]` to six log sites in `fallback.py` surfaced the exact upstream body:

```
400 from upstream: {"type":"error","error":{"type":"invalid_request_error",
"message":"context_management: Extra inputs are not permitted"}}
```

That identified the root cause (body field gated behind a beta header that CodeRouter wasn't forwarding). The fix was small (three files, ~15 lines), but the log change was arguably more valuable long-term. Two rounds later, a completely different 400 surfaced the *same way* — `"adaptive thinking is not supported on this model"` — and the diagnosis took minutes instead of hours. The log change paid for itself twice on the same day.

The 429 rate_limit_error that followed the model bump also came through cleanly as structured log. No ambiguity about whether CodeRouter or Anthropic was to blame.

## What worked

- **Feature → real-machine verify → next bug → fix → repeat.** Four passes through this loop in a day. Each pass produced either a commit or a test; nothing got punted to "tomorrow me".
- **Adapter layer is the right boundary for translation.** v0.4-A literally did not touch the engine — `AnthropicAdapter.generate/stream` wrapped reverse translation internally and the polymorphic loop kept working. No `isinstance` switches leaked into higher layers.
- **`Field(exclude=True)` for header-hop data.** `anthropic_beta` lives on `AnthropicRequest` as a stash, but `model_dump()` won't serialize it into the wire body. Clean boundary, tested with an outbound-body assertion instead of a mock.
- **Tests caught nothing in v0.4-D** — they existed to prove the fix was correct and to protect future refactors. The bug was found by *running the server against Claude Code*. That's the right arrangement: unit tests for correctness, live traffic for completeness.
- **Structured logging as a compounding investment.** Every upgrade to the log payload pays off on every future incident, forever. Worth prioritizing over most "nice-to-have" features.

## What was sharp

- **Claude Code's surface area is larger than the Messages API spec suggests.** Body fields like `context_management` and `thinking.type: "enabled"` with dynamic budget aren't called out as "beta-gated" anywhere obvious — they're discoverable only by sending a real request and reading the 400. Without the log enrichment we would've been stuck.
- **`anthropic-beta` is a comma-delimited list.** The value `context-management-2025-06-27,fake-beta,another-beta` is legal and CodeRouter must not parse, filter, or reorder it. Verbatim forward is the only correct behavior, but it was tempting to get clever.
- **Model capabilities drift.** `claude-sonnet-4-5-20250929` is fine for most requests but 400s on adaptive thinking; `claude-sonnet-4-6` accepts it. Users pinning model strings will hit this. The fix is manual today; the v0.5 capability gate should normalize at the adapter layer so the user's choice of model stops being a correctness-affecting decision.
- **Cache_control's 1024-token minimum is a footgun in test harnesses.** The first cache_control verify used a 5-token system prompt, got 0 cached tokens, and nearly filed as a bug. Reading the docs saved the day; the retest used 1200 tokens and worked.

## Follow-ons worth remembering

- **v0.5 capability gate.** Parametric block normalization at the adapter layer — strip `thinking` when the upstream model doesn't support it, downgrade `cache_control` variants, etc. This promotes "which model you pick" from a correctness concern to a purely economic one. Tracked in plan.md's 低優先 section.
- **OpenRouter `reasoning` field.** Some free models emit a non-standard `reasoning` key in responses. Not a bug but worth a future passive strip + log.
- **Live OpenRouter roster monitoring.** Free-tier churn is real; v0.4-B was reactive. A weekly cron hitting `/api/v1/models` and diffing against a committed snapshot would surface regressions before users hit them.
- **Claude Code has many small side requests.** Topic naming, auto-compact summaries, etc. These use different shapes from the main inference turn and will continue to surface edge cases. Keeping the structured log stream trimmed and searchable is the cheapest defense.

## Numbers

```
Tests:        110  →  153              (+43, +39%)
LOC (src):    2984 →  3596             (+612,  +21%, translation helpers + beta-header plumbing)
LOC (tests):  3662 →  4949             (+1287, +35%, reverse-translation + ingress header + log-enrichment suites)
Commits:      5 on 2026-04-20          (e566bce v0.4-A / 9c75327 v0.4-A docs / e1d7108 v0.4-B roster /
                                        baa88f1 実機疎通 / 7fe3027 v0.4-D)
Runtime deps: 5    →  5                (unchanged — still no SDK dependency)
```

Baselines are measured against tag `v0.3.x-1` (commit `4390e8b`, last commit before v0.4-A started).

The 5-dependency bound held. That was a tempting pressure point during v0.4-A (the Anthropic SDK would've saved half the reverse-translation work), but every API hit in `anthropic_native.py` is a plain `httpx.post` / `httpx.stream`. The bet is that the translation layer can stay smaller than an SDK's surface area, and through v0.4 that's true.

## How to read this

This retro is a primary source for v0.5 planning. If you're starting v0.5, the "Follow-ons" section has three concrete entry points and one observational note; the "What was sharp" section has constraints that should be respected, not re-litigated. The full sub-release detail lives in `CHANGELOG.md` — this document is the narrative layer on top.
