Three fixes bundled; all in admin / cache-hit paths where tests didn't
catch the regression.
## (A) 13 RequestOutcome sites missing tags=
An AST audit found that 13 of 21 ``RequestOutcome(...)`` construction
sites across the four handler files emitted outcomes without threading
``tags=``. Affected paths:
* ``handle_anthropic_messages`` — the ``from_response_cache=True``
early-return outcome (Claude Code cache-hit turns dashboard-blind)
* ``handle_openai_chat`` — same cache-hit early-return (Codex +
Cursor + Continue cache-hit turns dashboard-blind)
* ``handle_openai_responses_ws`` — the per-turn outcome inside the
Codex WS session. The stale comment that said "ws_session_tags is
not yet bound" was wrong — ``ws_tags`` was already extracted at
handler entry
* ``handle_anthropic_batch_create / batch_passthrough / batch_results``
* ``handle_passthrough`` (OpenAI Models / Files / List-Batches)
* ``handle_google_batch_create / batch_passthrough / batch_results``
* ``_google_batch_passthrough`` (internal helper)
* ``handle_batch_create`` (OpenAI batch entry)
* ``handle_gemini_count_tokens`` (also fixed in #479; identical)
Pattern of the fix is uniform: pull tags from headers and thread
them into the ``RequestOutcome`` construction.
New contract test ``test_handler_outcome_tag_invariant.py`` walks each
handler file's AST and asserts every ``RequestOutcome`` site inside any
``handle_*`` or ``*_passthrough`` method passes both ``tags=`` and
``client=``. Future handlers get a clear test failure with file +
line + method name if they regress.
## (B) Issue #478 — /v1/models 403 under Codex ChatGPT auth
Codex Desktop with ChatGPT-subscription OAuth polls ``/v1/models`` to
populate its model picker. Forwarding to ``chatgpt.com/backend-api/
models`` returned 403 to OAuth tokens. Fix: synthesize an OpenAI-
compatible payload locally from a known-supported model set
(``gpt-5.5`` through ``gpt-5``). All other ChatGPT-auth paths still
forward as before — only model-metadata gets the local response.
## (C) Move _extract_tags to free function (mixin-isolation test compat)
Handlers called ``self._extract_tags(headers)``. That worked in
production where ``HeadroomProxy`` composes every mixin and defines
the method, but broke tests that instantiate a single mixin via
``object.__new__(OpenAIHandlerMixin)``. The free-function form
removes that coupling — handlers import ``extract_tags`` from
``headroom.proxy.helpers`` and call directly. ``HeadroomProxy.
_extract_tags`` is kept as a thin wrapper for any external caller
still using the method form. 17 call sites migrated.
## Zero behavior change for existing users
Claude Code, Codex, Cursor, Continue, Aider, Gemini-routed harnesses
all hit handlers that already extracted tags. Their wire bytes to
upstream LLMs are byte-identical. Only the dashboard view gains tags
on previously-blind paths.
Closes#478.
Full regression sweep found 7 failures in test dummies (out of 4242
tests) that didn't have the production handler interface my refactor
now requires. All same root cause: the dummies need
``_record_request_outcome`` to delegate to the funnel; the
copilot-auth passthrough dummy also needs ``_next_request_id``
because the migrated passthrough handler now allocates an ID at
record-time.
Failures:
test_proxy_handlers_batch.py (6 sites — all DummyBatchHandler)
test_proxy_copilot_auth_hooks.py (1 site — Dummy in passthrough test)
Fix is the same pattern used in the earlier dummy fixes
(test_anthropic_pre_upstream_backpressure, test_openai_codex_routing,
test_openai_codex_ws_lifecycle):
async def _record_request_outcome(self, outcome):
from headroom.proxy.outcome import emit_request_outcome
await emit_request_outcome(self, outcome)
After the fix: 22 / 22 in the previously-failing tests; full
regression sweep 4242 / 4242 with zero failures (179 skipped, all
opt-in real-API).
Unrelated env issues observed in the same sweep but skipped:
* tests/test_memory/* — huggingface-hub<2.0 / transformers version
drift in local venv. Pre-existing, not caused by this refactor.
* tests/integrations/* — same env class.
* tests/test_realignment_live_multi_turn.py — opt-in live tests
needing API keys.
Eliminates P0-2 universally. Every Python forwarder (server.py
`_retry_request`, handlers/streaming.py `_stream_response`,
handlers/openai.py `_ws_http_fallback`, handlers/batch.py `_batch_passthrough`
+ batch-create + Google batch passthrough, handlers/anthropic.py CCR
continuation + batch endpoint) now switches from `httpx ... json=body` to
`httpx ... content=raw_bytes`. The default httpx JSON encoder was
re-serializing every request with `, `/`: ` separators and `\\uXXXX` ASCII
escapes — collapsing Anthropic prompt-cache hit-rate.
Forwarder strategy:
- unmutated body → forward `await request.body()` verbatim;
- mutated body → re-serialize once via the new
`serialize_body_canonical(body) -> bytes` helper (compact separators,
`ensure_ascii=False`, dict insertion order preserved).
`HEADROOM_PROXY_PYTHON_FORWARDER_MODE` env var configures the mode:
- `byte_faithful` (default) — the new behavior;
- `legacy_json_kwarg` — explicit operator opt-in for emergency rollback.
Documented in `docs/content/docs/configuration.mdx`. NOT a fallback —
unknown values raise loudly per build constraint #4.
`BodyMutationTracker` accompanies each request through the handler so
transform sites mark the tracker (`memory_injection`,
`image_compression`, `compression_*`, `batch_compression`,
`ccr_continuation`, etc.). At forwarder dispatch we additionally compare
the final body dict against the parsed original bytes as a structural
safety net — any silent mutation we missed still triggers canonical
re-serialization.
A2 follow-up: `handlers/openai.py:534-540` (Chat Completions memory
injection) was prepending a system message; replaced with
`append_text_to_latest_user_chat_message`, the OpenAI Chat Completions
analog of `_append_context_to_latest_non_frozen_user_turn`. The cache
hot zone (system messages) is now sacrosanct on /v1/chat/completions
too. Honors `HEADROOM_MEMORY_INJECTION_MODE=disabled`.
Structured logging: every forwarder emits an `event=outbound_request`
log line with `forwarder`, `path`, `body_bytes`, `body_mutated`,
`mutation_reasons`, `source` (passthrough|canonical|legacy),
`request_id`. Never logs Authorization or full body.
`_read_request_json` factored to share `_read_request_body_bytes` with
new `read_request_json_with_bytes` so the anthropic handler can capture
both the parsed dict and the original (decompressed) bytes.
Tests:
- `tests/test_proxy_byte_faithful_forwarding.py` (28 tests):
SHA-256 byte-equality on /v1/messages and streaming, unicode
preservation, numeric precision, mutation-tracker invariants,
canonical-serializer properties, legacy-mode rollback, OpenAI
Chat memory routing.
- Existing test mocks updated to accept the new `**kwargs` on
`_retry_request` (no behavior change).
- `tests/test_proxy_handlers_batch.py` updated to read the captured
`content=` bytes (formerly `json=`).
- One A2 test corrected (`test_anthropic_tool_sort_and_context_append_helpers`)
to match the live-zone-tail semantics introduced by A2.
Constraints satisfied: configurable env var; no new regex / hardcodes;
no silent fallback (`legacy_json_kwarg` is operator opt-in);
performant (`prepare_outbound_body_bytes` is O(1) for passthrough);
elegant single-responsibility helpers; structured tracing logs.