# v0.5 Retrospective — Capability gate trio

*Period: 2026-04-20 (same-day continuation of v0.4).*
*Commits: `ff7ca27` (v0.5-A) → `e8803da` (v0.5-B) → `e20fb36` (v0.5-C). None are git tags yet; this retro proposes tagging the trio as `v0.5.0`.*
*Tests: 153 → **225** (+72, +47%). All green throughout.*

## Scope at a glance

| Sub-release | Theme | Primary deliverable | Tests Δ |
|---|---|---|---|
| v0.5-A | thinking | Capability gate module + chain reorder + strip + log. Pinning `claude-sonnet-4-5-20250929` no longer 400s on adaptive thinking. | +36 |
| v0.5-B | cache_control | Observability-only gate (no reorder, no strip). `capability-degraded` / `reason: "translation-lossy"` fires when a cache_control request hits `openai_compat`. | +21 |
| v0.5-C | reasoning | Adapter-boundary passive strip of the non-standard `reasoning` field (OpenRouter free-tier quirk). One-shot log per stream. | +15 |

The v0.4 retrospective's "Follow-ons" section listed three capability-gate-shaped items in order of urgency (thinking → cache_control → reasoning). v0.5 worked them in that exact order; each one required a different mechanism, and keeping them as distinct sub-releases preserved the reasoning trail.

## What happened, in order

1. **v0.5-A** was the item that motivated the gate as a concept. The v0.4-D footnote "we had to change `claude-sonnet-4-5-20250929` to `claude-sonnet-4-6` by hand" promoted model selection from a purely economic decision to a correctness-affecting one. v0.5-A reverses that. A new `coderouter/routing/capability.py` module holds three pure functions (`provider_supports_thinking`, `anthropic_request_requires_thinking`, `strip_thinking`), the fallback engine resolves the Anthropic chain via `_resolve_anthropic_chain` (capable providers stable-sorted to the front), and non-capable providers get the block stripped plus a `capability-degraded` log before the call lands. Two design decisions worth noting: (a) the heuristic pattern table (`^claude-(opus|haiku)-4-`, `^claude-sonnet-4-6`, `^claude-sonnet-4-7`) is an allow-list, so stale entries don't harm — only missing entries do; (b) the YAML `capabilities.thinking: true` flag overrides the heuristic, giving users an escape hatch before the heuristic table catches up to new families.

2. **v0.5-B** looked like a copy-paste from v0.5-A at first glance but turned out to need a different design. `cache_control` is not rejected on non-supporting providers — it's silently dropped during the Anthropic → OpenAI translation because the OpenAI wire has no equivalent marker. That shifted the problem from "prevent a 400" to "expose the lossiness". The rest of the design followed: no chain reorder (the user's provider order encodes latency / cost intent, which outweighs cache-hit savings), no strip (translation already drops it), observability-only. The same `capability-degraded` message name with a distinct `reason: "translation-lossy"` keeps both logs greppable. YAML escape hatch reused the existing `capabilities.prompt_cache: true`.

3. **v0.5-C** was the smallest and cleanest. v0.4 real-machine verification noticed that `openai/gpt-oss-120b:free` returns a non-standard `reasoning` field alongside `content` on each choice. Spec-strict downstreams can reject the unknown key, so v0.5-C strips it at the `OpenAICompatAdapter` boundary on both `generate()` (non-streaming) and `stream()` (per chunk, log once per stream to avoid duplicate lines on long reasoning tracks). Opt-out via `capabilities.reasoning_passthrough: true`. A seventh `capability-degraded` log `reason` (`non-standard-field`) joined the family.

## The gate design, now that three pieces exist

The three sub-releases settled into a consistent shape worth documenting here so v0.6+ can follow (or deliberately deviate from) it:

| Dimension | v0.5-A (thinking) | v0.5-B (cache_control) | v0.5-C (reasoning) |
|---|---|---|---|
| Failure mode upstream | 400 on non-capable | silent drop | client-side parse error (potential) |
| Detection location | request-side (`anthropic_request_requires_thinking`) | request-side (`anthropic_request_has_cache_control`) | response-side (adapter exit) |
| Action taken | strip + chain reorder | log only | strip |
| Escape hatch (YAML) | `capabilities.thinking: true` | `capabilities.prompt_cache: true` | `capabilities.reasoning_passthrough: true` |
| Log message | `capability-degraded` | `capability-degraded` | `capability-degraded` |
| Log `reason` | `provider-does-not-support` | `translation-lossy` | `non-standard-field` |

Three things held across all three:

- **Unified log message name.** `capability-degraded` with a varying `reason` makes the whole family greppable as one class while still being discriminable. Adding a fourth piece in v0.6+ should reuse the pattern.
- **YAML escape hatch always honored first.** Heuristic / kind-based defaults only decide when the flag is unset. This keeps user intent ahead of our inference.
- **No SDK dependency.** The gate is pure functions + dict walks. It reads shapes that Pydantic carries via `extra="allow"`; it does not re-parse wire bytes.

The most important differentiator is the detection side: request-side vs response-side. request-side gates live in the fallback engine (before the adapter call), response-side gates live in the adapter (after the call). That split turned out to matter because it determines whether the feature is subject to chain reordering (only request-side can reorder).

## What worked

- **Reusing v0.5-A's pattern for v0.5-B, then breaking from it when the problem shape differed.** Starting from the same mental model saved design time, but recognizing that cache_control's silent drop vs. thinking's 400 meant the _engagement_ was different (observe vs. prevent) kept the implementation honest. The temptation to "just reorder for cache_control too" was real and would have silently overridden user ordering.
- **Consistent `capability-degraded` / `reason` contract.** Adding a fourth reason in v0.5-C was a one-liner because the shape was already set.
- **Test count more than doubled the delta from feature count.** +72 tests against 3 features (+24 per feature on average), heavily weighted toward integration tests that exercise the engine loop end-to-end. That deliberate bias traded unit-test surface area for regression-proofing against the next fallback-loop refactor.
- **Pure-function gates, engine stays thin.** The engine gained one new function (`_resolve_anthropic_chain`) in v0.5-A and two log emission sites in v0.5-B. Everything else lives in `capability.py` or the adapter. Easy to unit-test without mocking HTTP.
- **`extra="allow"` on the Pydantic request model kept paying off.** `thinking`, `cache_control`, `reasoning` — none of these are on the declared schema, but the gate reads them via `.model_extra` or a direct dict walk and that's been sufficient.

## What was sharp

- **"Reuse v0.5-A for v0.5-B" was nearly a trap.** The first draft for v0.5-B reordered the chain to prefer `anthropic` providers when cache_control was present. That would have silently violated user ordering, and the symptom would have been hard to spot (the faster local model gets skipped on cache_control requests). The fix was to re-read v0.4 retro's "cache_control is lossy, not rejected" sentence and stop. Takeaway: when two features look similar, list their failure modes before picking a design.
- **Heuristic allow-lists drift against the Anthropic release cadence.** `claude-sonnet-4-7` is already in the pattern as forward-compat, but there's no signal to tell us when Anthropic ships `4-8` or `5-0`. The escape hatch works (user sets `thinking: true`), but passive drift is invisible until someone reports "my model that I thought was capable isn't being preferred".
- **Streaming log dedup is stateful and easy to get wrong.** v0.5-C's "log once per stream" flag lives inside the `stream()` coroutine. A different stream shape (say, nested iterators) could bypass it. The current tests cover the happy path; the dedup is not independently tested as a contract.
- **`reasoning` strip is tightly coupled to the OpenAI wire.** If OpenRouter adds another non-standard field (say, `safety_metadata`), we'll extend `_strip_reasoning_field` — or rename it. A parametric "extra-field strip list" from YAML would be more general but nobody needs that yet; YAGNI held.
- **`Capabilities` is becoming a kitchen sink.** `thinking`, `reasoning_passthrough`, `prompt_cache`, `reasoning_control` (v1.0+ abstraction), `mcp`, `openai_compatible`, `tools`, `vision`, `chat`, `streaming` — 10 flags. v1.0+ is supposed to collapse some of these into the `reasoning_control` / `mcp` Literal spaces, but in v0.5 they simply accumulated. Worth noting as a future pressure point.

## Follow-ons worth remembering

- **v0.5-D: Live OpenRouter roster diff cron.** v0.4-B was reactive (we noticed `deepseek/deepseek-r1:free` was gone after a test failure); v0.5-D would schedule a weekly hit to `/api/v1/models`, diff against a committed snapshot, and file a PR-like artifact when rosters move. Not urgent but the half-life of the current `examples/providers.yaml` is measured in months.
- **Real-machine re-verify with v0.5 active.** v0.4's real-machine day was the single most valuable hour of the whole v0.4 release. v0.5's changes should get the same treatment — specifically: point actual Claude Code at `claude-code-direct` with a capable model pinned, then at an intentionally incapable one, and verify `capability-degraded` fires with the right shape. Not a blocker for `v0.5.0` but worth scheduling before v0.6.
- **Heuristic table maintenance signal.** Nothing automatic flags when the Anthropic model family advances past the heuristic. Two cheap options: (a) a weekly `/v1/models` hit against Anthropic (symmetric with the OpenRouter cron from v0.5-D), or (b) a warning log when a request's model (received but not used for routing) doesn't match any capability pattern and the heuristic returned False — i.e. "this might be a new family". Option (b) is one function call from what the gate already computes.
- **Tests as retro evidence.** The `capability-degraded` log format is exercised in ~20 test cases but isn't pinned as a schema anywhere. If the field names ever change, a consumer (e.g. future metrics dashboard) would break silently. Promoting the log payload to a dataclass or TypedDict in v0.6 would catch that.

## Numbers

```
Tests:        153  →  225              (+72, +47%)
LOC (src):    3596 →  4000             (+404,  +11%, capability.py + fallback.py gate wiring + adapter strip)
LOC (tests):  4949 →  6503             (+1554, +31%, 4 new test files weighted toward engine-level integration)
Commits:      3 on 2026-04-20          (ff7ca27 v0.5-A / e8803da v0.5-B / e20fb36 v0.5-C)
Runtime deps: 5    →  5                (unchanged — still no SDK dependency)
```

Baselines are measured against commit `fe6e4a5` (the v0.4 retro / docs pass, last commit before v0.5-A started).

The LOC delta is small relative to v0.4 (+404 src vs v0.4's +612) because v0.5 was structurally narrow: a new module, one new engine method, three adapter-boundary hooks, and one schema field. Most of the line count went into tests — the gate design is simple enough that the interesting surface area is _which traffic patterns it handles_, not _how it handles them_.

The 5-dependency bound held again. No SDK, no prompt-cache library, no reasoning-parser. The bet from v0.4 that "the translation layer can stay smaller than an SDK's surface area" extends cleanly: the capability layer is smaller than any SDK's capability model would be.

## How to read this

This retro is the primary source for v0.6+ planning. If v0.6 adds a fourth capability piece, the "gate design" table above is the target shape — follow it unless the problem's failure mode differs (and if it does, document that explicitly, per the v0.5-A → v0.5-B lesson). The "What was sharp" section flags three constraints worth respecting:

- Don't reorder the chain for silent-drop features.
- Don't assume the heuristic table is self-maintaining.
- Don't add a flag to `Capabilities` without considering the v1.0 `reasoning_control` / `mcp` Literal abstraction.

The full sub-release detail lives in `CHANGELOG.md` (sections `[v0.5-A]` / `[v0.5-B]` / `[v0.5-C]`). This document is the narrative layer on top.
