## Description
Copilot subscription sessions can mix a chat-completions main model with
a Responses-only internal bootstrap model. The wrapper still seeds one
`COPILOT_PROVIDER_WIRE_API` value for launch-time compatibility, but the
proxy now chooses the Copilot upstream path per request model inside
OpenAI chat dispatch. That keeps `gpt-5.4-mini` on `/responses` while
`claude-sonnet-5` stays on `/chat/completions`.
Closes#1745
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Added a narrow OpenAI chat-handler path resolver that reuses the
existing Copilot model heuristic and only switches Copilot-hosted
requests to `/responses` when the model already prefers Responses.
- Threaded that resolved path through the OpenAI chat handler so request
logs, cache hits, and upstream dispatch all reflect the actual
per-request route.
- Added a regression test that captures the upstream URL for
`gpt-5.4-mini`, preserves the `claude-sonnet-5` control case, and keeps
the non-Copilot control case on chat completions through the same path
resolver composition used by the handler.
- Left the Copilot launch wrapper behavior intact, so the existing
subscription env defaults still serialize the same way at launch.
- Preserved the existing invalid/custom upstream base URL fallback
behavior while applying the Copilot-only per-model route switch.
## Testing
- [x] Unit tests pass (`uv run pytest
tests/test_proxy_copilot_auth_hooks.py
tests/test_cli/test_wrap_copilot.py
tests/test_proxy/test_openai_transport_path_prefix.py
tests/test_proxy/test_openai_upstream_header.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/openai.py headroom/cli/wrap.py
tests/test_proxy_copilot_auth_hooks.py
tests/test_cli/test_wrap_copilot.py
tests/test_proxy/test_openai_transport_path_prefix.py
tests/test_proxy/test_openai_upstream_header.py`; `uv run ruff format
--check headroom/proxy/handlers/openai.py
tests/test_proxy_copilot_auth_hooks.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
collected 44 items
tests\test_proxy_copilot_auth_hooks.py ... [ 6%]
tests\test_cli\test_wrap_copilot.py .............................. [ 75%]
tests\test_proxy\test_openai_transport_path_prefix.py ....... [ 90%]
tests\test_proxy\test_openai_upstream_header.py .... [100%]
======================== 44 passed, 1 warning in 1.38s ========================
All checks passed!
2 files already formatted
```
## Real Behavior Proof
- Environment: Windows, Python 3.12.13, focused local proxy tests.
- Exact command / steps: `uv run pytest
tests/test_proxy_copilot_auth_hooks.py
tests/test_cli/test_wrap_copilot.py
tests/test_proxy/test_openai_transport_path_prefix.py
tests/test_proxy/test_openai_upstream_header.py -q`, then `uv run ruff
check headroom/proxy/handlers/openai.py headroom/cli/wrap.py
tests/test_proxy_copilot_auth_hooks.py
tests/test_cli/test_wrap_copilot.py
tests/test_proxy/test_openai_transport_path_prefix.py
tests/test_proxy/test_openai_upstream_header.py`, then `uv run ruff
format --check headroom/proxy/handlers/openai.py
tests/test_proxy_copilot_auth_hooks.py`.
- Observed result: the new proxy regression test saw
`https://api.githubcopilot.com/responses` for `gpt-5.4-mini` and
`https://api.githubcopilot.com/chat/completions` for `claude-sonnet-5`;
the non-Copilot control stayed on `/v1/chat/completions`, invalid base
URL fallbacks kept the configured OpenAI `/v1` route, and the wrap
regression tests still passed unchanged.
- Not tested: live GitHub Copilot subscription traffic and the rest of
the suite.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
`CHANGELOG.md` stays unchecked because Headroom generates release notes
from conventional commits, not manual edits, and this patch preserves
the existing launch flags while changing only the runtime route
decision.
Co-authored-by: JD Davis <mxjerrett@gmail.com>
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.
`.gitattributes` declares `*.py text eol=lf` and `*.sh text eol=lf`, but
74 files (73 .py, 1 .sh) are stored in the index with CRLF line endings,
violating that contract. Every macOS/Linux clone reports these files as
"modified" on fresh checkout because git's diff engine sees the stored
bytes don't match the attribute contract, even though the working tree
and index match byte-for-byte.
Running `git add --renormalize .` rewrites each affected blob so the
stored form matches the attribute declaration. No semantic changes —
every affected file's diff is "N insertions, N deletions" with inserts
and deletes being the same lines modulo line endings.
Follow-up commit adds `.git-blame-ignore-revs` so `git blame` / GitHub
blame skip this mechanical commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independent pre-existing test-hygiene regressions on main, all
surfaced as cascading CI failures:
1. tests/test_cli/test_wrap_copilot.py (from #229) mutated
sys.modules["headroom.cli.main"] with a fake click.Group() at
module-import time and never restored it. Any later test that did
`from headroom.cli.main import main` got an empty group with no
version option and no registered subcommands, breaking ~20
test_cli/* and test_cli_proxy_env.py tests. Rewrite to import the
real `main` directly — the fake-group indirection served no
purpose.
2. tests/test_proxy_copilot_auth_hooks.py (from #229) installed fake
httpx / fastapi.responses / headroom.proxy.* modules into
sys.modules inside a helper called from test functions, never
cleaned up. Later tests that imported ASGITransport or JSONResponse
hit the fakes and failed with ImportError. Switch the helper to
monkeypatch.setitem so the fakes are scoped to the owning test.
3. tests/test_release_version.py hardcoded canonical=0.5.25 in the
subprocess-output assertion; the project version in pyproject.toml
has since bumped to 0.9.1. Compute the expected value dynamically
via get_canonical_version(ROOT) so the test tracks pyproject.