Commit graph

9 commits

Author SHA1 Message Date
chopratejas
3ec549288a fix(proxy): thread tags into 13 outcome sites + synth /v1/models + free-fn _extract_tags
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.
2026-05-15 19:15:48 -07:00
chopratejas
299d995066 test: wire test dummies to RequestOutcome funnel for batch + copilot-auth paths
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.
2026-05-14 19:39:15 -07:00
chopratejas
f0dcc02775 fix: A3 — byte-faithful Python forwarders; serialize canonical only when mutated
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.
2026-05-02 09:02:10 -07:00
Garm
efd2ac1ca4 chore: renormalize line endings to LF
`.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>
2026-04-24 15:33:30 +02:00
JerrettDavis
3f30474fb7 Merge upstream/main into fix/copilot-oauth-runtime
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 12:43:51 -05:00
Garm
1788d907f0 fix(tests): scope sys.modules mutation and unpin hardcoded version
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.
2026-04-22 12:37:22 +02:00
JerrettDavis
f5b959a470 test: isolate copilot oauth suites
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 23:01:31 -05:00
JerrettDavis
d60cf7914c fix: support live copilot oauth runtime
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 22:48:57 -05:00
JerrettDavis
7989581350 fix: support copilot oauth sessions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 22:11:30 -05:00