Commit graph

11 commits

Author SHA1 Message Date
Abhay Singh
0951663562
fix(proxy): close the upstream stream when a streaming body is never consumed
Close unconsumed upstream streaming bodies.
2026-08-11 18:16:04 -07:00
Rod Boev
5af5e22862
fix(copilot): route mixed-model requests per model (#1785)
## 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>
2026-07-08 09:42:37 -05:00
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