headroom/tests/test_provider_proxy_routes.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1350 lines
52 KiB
Python
Raw Normal View History

from __future__ import annotations
import importlib
fix(anthropic): strip styled Claude model ids (#651) ## Description Fixes #626 by normalizing Anthropic/Claude model ids that contain ANSI escape sequences or dangling style suffixes before provider lookups and upstream forwarding. The branch has been updated onto current `main` and the proxy handler conflicts have been resolved. ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Documentation - [ ] Refactor - [x] Tests only ## Changes Made - Normalize Anthropic model ids before context/pricing lookup. - Sanitize Anthropic `/v1/models` metadata and styled `/v1/models/{id}` passthrough paths. - Sanitize `/v1/messages` request body model ids before upstream forwarding. - Resolved current-main conflicts while preserving newer `model_override` and streaming passthrough behavior. ## Testing - [x] Unit tests - [x] Route/proxy tests - [x] Lint/static checks - [ ] Manual testing ### Test Output ```text UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --with pytest --with fastapi --with httpx --with uvicorn --with h2 python -m pytest tests/test_providers/test_anthropic.py tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_provider_proxy_routes.py::test_anthropic_model_detail_path_strips_ansi_model_id tests/test_provider_proxy_routes.py::test_anthropic_messages_strips_ansi_model_id_before_upstream -q 17 passed, 2 warnings in 39.91s UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --with ruff ruff check headroom/providers/anthropic.py headroom/proxy/handlers/openai.py headroom/proxy/handlers/anthropic.py tests/test_providers/test_anthropic.py tests/test_provider_proxy_routes.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13.3, focused local worktree for PR #651 after merging current `upstream/main`. - Exact command / steps: Merged current main, resolved conflicts in Anthropic/OpenAI proxy handlers, ran the PR's targeted provider/proxy tests and ruff checks. - Observed result: Styled Anthropic model metadata, model-detail path, and messages upstream sanitization tests pass; ruff reports no issues. - Not tested: Full repository mypy/pre-commit; existing unrelated Windows `fcntl` typing errors block full hook execution locally. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review <!-- headroom-maintainer-template-completion:start --> ## Description This PR prepares `fix(anthropic): strip styled Claude model ids` for review by documenting the intended change, validation evidence, and remaining merge-readiness context. Linked issues: #626 ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Documentation - [ ] Refactor - [ ] Tests only ## Changes Made - Commit: fix(anthropic): normalize styled model ids - Commit: fix(proxy): strip styled Anthropic model ids - Commit: fix: format anthropic model sanitization - Commit: Merge remote-tracking branch 'upstream/main' into review/pr-651 - Touches `headroom/cache/dynamic_detector.py` - Touches `headroom/providers/anthropic.py` - Touches `headroom/proxy/handlers/anthropic.py` - Touches `headroom/proxy/handlers/openai.py` - Touches `tests/test_provider_proxy_routes.py` - Touches `tests/test_providers/test_anthropic.py` ## Testing - [x] GitHub checks reviewed - [x] Metadata/template validation - [ ] Local functional testing ### Test Output ```text gh pr view 651 --repo chopratejas/headroom --json statusCheckRollup - PR Governance / template: FAILURE - CI / changes: SUCCESS - Init E2E / docker-init-e2e: SUCCESS - Wrap E2E / docker-wrap-e2e: SUCCESS - Wrap Native E2E / wrap-native (ubuntu-latest): SUCCESS - Wrap Native E2E / wrap-native (macos-latest): SUCCESS - CI / commitlint: SUCCESS - PR Governance / label: SUCCESS - CI / lint: SUCCESS - CI / build-wheel: SUCCESS - CI / prefetch-model: SUCCESS - CI / build: SUCCESS ``` ## Real Behavior Proof - Environment: GitHub PR metadata and checks for `chopratejas/headroom` PR #651. - Exact command / steps: Reviewed PR title, commits, changed files, linked issues, labels, and check rollup; appended this maintainer template completion block without replacing the author's original description. - Observed result: PR body now contains all required governance sections, checked readiness fields, and a non-placeholder validation evidence block. - Not tested: This pass updated PR metadata only; code validation remains represented by the linked GitHub checks and any author-provided evidence above. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review <!-- headroom-maintainer-template-completion:end --> --------- Co-authored-by: Tejas Chopra <chopratejas@gmail.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-13 13:46:21 -05:00
import json
from typing import Any
2026-05-09 22:36:56 -07:00
from unittest.mock import patch
import httpx
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
refactor(providers): split proxy route adapters (#1934) ## Description Refactors provider-specific proxy routing into provider-owned helper modules so `headroom/providers/proxy_routes.py` primarily registers routes and delegates behavior. This keeps Codex, OpenAI Responses/images, model metadata, Vertex, Cloud Code, passthrough target selection, and request path normalization logic testable outside the route table. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Extracted Codex routing helpers for headers, endpoint URLs, image forwarding, response subpaths, and model metadata. - Moved provider target selection, route specs, OpenAI Responses/images helpers, Vertex runtime helpers, Cloud Code path normalization, passthrough telemetry, and request scope normalization into focused modules. - Kept `proxy_routes.py` as route registration/delegation and preserved current-main `/v1/messages` custom-base behavior. - Added focused provider/proxy tests for the extracted modules and route delegation behavior. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_package_init_lazy.py::test_codex_package_import_stays_runtime_only tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets tests/test_provider_proxy_routes.py::test_openai_image_routes_use_codex_backend_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_openai_image_codex_response_strips_stale_compression_headers tests/test_provider_proxy_routes.py::test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough tests/test_provider_proxy_routes.py::test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py::test_with_project_prefix_round_trips_through_split tests/test_vertex_claude_compression.py ============================ 102 passed in 34.83s ============================= python -m ruff check headroom/providers/cloudcode headroom/providers/codex headroom/providers/vertex headroom/providers/model_metadata.py headroom/providers/openai_images.py headroom/providers/openai_responses.py headroom/providers/proxy_targets.py headroom/providers/route_specs.py headroom/providers/proxy_routes.py headroom/proxy/handlers/openai.py headroom/proxy/passthrough.py headroom/proxy/request_scope.py headroom/proxy/project_context.py tests/test_package_init_lazy.py tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py tests/test_vertex_claude_compression.py All checks passed! python -m compileall -q headroom\providers\cloudcode headroom\providers\codex headroom\providers\vertex headroom\providers\model_metadata.py headroom\providers\openai_images.py headroom\providers\openai_responses.py headroom\providers\proxy_targets.py headroom\providers\route_specs.py headroom\providers\proxy_routes.py headroom\proxy\handlers\openai.py headroom\proxy\passthrough.py headroom\proxy\request_scope.py headroom\proxy\project_context.py # no output; exited 0 git commit -m "refactor(providers): split proxy route adapters" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/provider-route-slices` based on `headroomlabs/main`. - Exact command / steps: Ran the focused provider/proxy pytest suite, focused ruff command, compileall over changed Python modules, and commit hooks. - Observed result: Provider/proxy route behavior tests passed; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream provider calls, and manual end-to-end proxy traffic. ## 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 ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; coverage here is focused on provider/proxy routing behavior touched by this slice. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-12 21:27:45 +00:00
from headroom.providers.codex.runtime import CodexRoutingDecision
fix(security): validate caller-supplied upstreams on every resolution path (#3195) ## Summary CVE-2026-77775 (SSRF via `x-headroom-base-url`) is **not fully fixed on current `main`**. The advisory lists 0.36.1 as the last affected version; one route still forwards to any destination a caller names. `upstream_guard.is_safe_upstream_url` was added and wired into `/v1/messages` and the catch-all passthrough. But `select_passthrough_base_url` moved from `providers/proxy_routes.py` to `providers/proxy_targets.py`, and the guard did not follow it. Its Azure branch returns the header verbatim whenever an `api-key` header is present — **both values are caller-supplied** — and `POST /v1/alpha/search` resolves its upstream through that helper without checking the header itself. ## Verified, not inferred Against the current tree, with a listener on loopback standing in for an internal service: ``` proxy status : 200 internal service hit : 1 time(s) Authorization it received : 'Bearer SECRET-CLIENT-TOKEN' internal body relayed back : True ``` The caller's credentials are forwarded to the attacker-named host and the internal response is relayed back. After this change: `400`, zero hits, nothing relayed. A sweep of all 99 routes isolates exactly one leak on unfixed code — `POST /v1/alpha/search` with `api-key` — and zero after. ## 1. The missing enforcement **Guarded at the chokepoint, not just the route.** `select_passthrough_base_url` now validates before returning, in `proxy_targets.py` and in the parallel copy in `providers/registry.py`, so a future caller that forgets the header check cannot reopen this. `/v1/alpha/search` also rejects explicitly with 400, matching its sibling routes. ## 2. A second gap in the address policy RFC 6598 shared address space (`100.64.0.0/10`) is not `is_private`, so it passed the guard — while routing to ISP and cloud-internal infrastructure. `_is_internal_address` now also rejects anything not globally routable. Verified over a 27-vector battery — 0 bypasses, public control unaffected: | Vector | Before | After | |---|---|---| | `100.64.0.0/10` shared address space | **allowed** | blocked | | `198.18/15`, TEST-NET, `240/4` | **allowed** | blocked | | 6to4 / Teredo embedding internal IPv4 | **allowed** | blocked | | NAT64 `64:ff9b::/96` embedding loopback | **allowed** | blocked | | loopback, RFC1918, link-local, metadata, IPv4-mapped, userinfo tricks | blocked | blocked | | multicast `224.0.0.1` | blocked | blocked | | public `8.8.8.8` | allowed | allowed | The category checks are **kept alongside** `is_global` rather than replaced — `is_global` is `True` for multicast, so a replacement would have regressed. NAT64 also reports as global, so its embedded IPv4 is extracted and judged on its own. ## 3. Unauthenticated stall via the resolver `socket.getaddrinfo` takes no timeout and runs on the calling thread — the event loop. Since the hostname is caller-supplied, a deliberately slow-resolving name stalled every other in-flight request; a handful of concurrent requests made the proxy unresponsive, unauthenticated. Resolution now runs in a small dedicated pool with a budget (`HEADROOM_UPSTREAM_RESOLVE_TIMEOUT_S`, default 3s) and fails closed on overrun, which bounds every caller including the synchronous chokepoint. `is_safe_upstream_url_async` runs the lookup off the loop, and the three route handlers that validate a caller-supplied upstream now await it. Caching was deliberately avoided: a TTL cache in front of a security decision invites poisoning, and would widen the rebinding window rather than narrow it. ## Why this survived The existing tests unit-tested the guard's *logic* but never asserted it was *reached*. Added enforcement tests at the sinks plus a **sweep over the whole route table** that fails if any route forwards to a loopback address — so the next unguarded upstream resolution fails in CI rather than in a CVE. All new tests were confirmed failing against the unfixed tree and passing after. ## Known residual — deliberately not addressed **DNS rebinding.** Validation and connection resolve the host separately, so a low-TTL answer can differ between them. Closing this needs connection-time pinning in the shared `http_client` transport, which carries every request in the proxy — too broad to fold into this patch. It should not be described as fixed. ## Compatibility An endpoint that does not resolve publicly (split-horizon, on-prem) is now rejected where it previously passed unvalidated. `HEADROOM_ALLOWED_BASE_URLS` is the documented opt-in, covered by test. Three existing tests used fictional hostnames and legitimately began failing; DNS is pinned in them so they keep testing target precedence rather than depending on the missing guard. Separately: `docker-compose.yml` has already been hardened since the advisory — `HEADROOM_PROXY_TOKEN` is now mandatory and ports are loopback-only — so the "exposed by default" multiplier the advisory cites no longer applies to the shipped compose. Full suite: the 3 failures outside this area (`test_learn/test_integration`, `test_release_workflows::test_no_native_tls_in_wheel_build_tree`, and a `test_graceful_shutdown` ordering flake) reproduce on clean `main` and are unrelated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 23:16:59 -07:00
from headroom.proxy import upstream_guard
fix: normalize /p/<project> prefix on WebSocket upgrades so the Responses WS route is not rejected with 403 (#2379) ## Description A Responses WebSocket upgrade to a project-prefixed URL (`ws://127.0.0.1:8787/p/<project>/v1/responses`) was rejected with `403 Forbidden`, so the client fell back to HTTP transport. The `/p/<name>` base-URL prefix is stripped by `strip_project_path_prefix(request.scope)` inside `@app.middleware("http")`, but Starlette runs `@app.middleware("http")` for `http` scopes only, never `websocket` scopes. So an HTTP `POST /p/<project>/v1/responses` has its prefix stripped and matches `/v1/responses`, while the WS upgrade keeps the prefix, matches no registered WebSocket route (`OPENAI_RESPONSES_WEBSOCKET_PATHS` are all unprefixed), and Starlette rejects the unmatched WebSocket with `403`. This normalizes the prefix for WebSocket scopes before routing so the upgrade reaches the existing Responses WS handler and stays attributed to the project. Closes #2355 ## 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 - `headroom/proxy/server.py` — added a small pure-ASGI `WebSocketProjectPrefixMiddleware` (registered in `create_app`) that, for `websocket` scopes only, strips the `/p/<name>` prefix via the existing `strip_project_path_prefix` and binds the project context, mirroring the HTTP middleware. HTTP and lifespan scopes pass through untouched (no double-strip). - `headroom/proxy/handlers/openai.py` — `handle_openai_responses_ws` previously called `set_current_project(classify_project(ws_headers))` unconditionally, clearing the middleware-bound project for prefix-only clients (no `X-Headroom-Project` header). It now falls back to the already-bound path-prefix project (`classify_project(ws_headers) or get_current_project()`), so prefix-only WebSocket clients (aider, Copilot BYOK, Cursor and other `/p/<name>` base-URL wraps) stay attributed, exactly as on the HTTP path. - `tests/test_provider_proxy_routes.py` — added a regression test. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_provider_proxy_routes.py -q 21 passed, 1 warning in 23.95s $ ruff check headroom/proxy/server.py headroom/proxy/handlers/openai.py All checks passed! $ mypy --python-version 3.13 headroom/proxy/server.py headroom/proxy/handlers/openai.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: local, `uv` venv, Python 3.14, `uv run pytest`. - Exact command / steps: added `test_project_prefixed_openai_response_websocket_delegates_to_openai_ws_handler`, which connects a WebSocket to `/p/test-project/v1/responses`. - Observed result: the connection is accepted (no 403), the handler is reached with the canonical `/v1/responses` path, and the request is attributed to project `test-project`. - Not tested: live end-to-end against a real upstream Responses WebSocket server (validated via the routing/attribution regression test only). ## 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 - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) N/A — backend routing change with no user-facing UI. ## Additional Notes Documentation checklist item is N/A: this is an internal routing fix with no configuration or public-API surface change. The fix mirrors the existing HTTP prefix-strip behavior so project-prefixed WebSocket clients behave identically to their HTTP counterparts. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-08-03 11:15:40 -07:00
from headroom.proxy.project_context import get_current_project
from headroom.proxy.server import HeadroomProxy, ProxyConfig, create_app
def _app() -> Any:
return create_app(
ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
anthropic_api_url="https://api.anthropic.test",
openai_api_url="https://api.openai.test",
gemini_api_url="https://api.gemini.test",
cloudcode_api_url="https://cloudcode.test",
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
vertex_api_url="https://vertex.test",
)
)
def test_provider_passthrough_routes_forward_expected_targets(monkeypatch) -> None:
# This routing test uses reserved, intentionally unresolvable hostnames.
# Explicitly allow them so the SSRF guard can remain fail-closed on DNS errors.
monkeypatch.setenv("HEADROOM_ALLOWED_BASE_URLS", "azure.example,custom.example,opencode.ai")
calls: list[tuple[str, str, str, str]] = []
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
gemini_calls: list[tuple[str, str, str, str]] = []
gemini_count_calls: list[tuple[str, str, str, str]] = []
anthropic_calls: list[tuple[str, str, str, str, bool]] = []
async def fake_passthrough(self, request, base_url, sub_path="", provider_name=""): # type: ignore[no-untyped-def]
calls.append((request.method, request.url.path, base_url, provider_name))
return JSONResponse(
{
"method": request.method,
"path": request.url.path,
"base_url": base_url,
"sub_path": sub_path,
"provider": provider_name,
}
)
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
async def fake_gemini_generate(
self,
request,
model,
upstream_base_url=None,
provider_name="gemini",
): # type: ignore[no-untyped-def]
gemini_calls.append((request.url.path, model, upstream_base_url, provider_name))
return JSONResponse(
{
"handler": "handle_gemini_generate_content",
"path": request.url.path,
"model": model,
"upstream_base_url": upstream_base_url,
"provider": provider_name,
}
)
async def fake_anthropic_messages(
self,
request,
upstream_base_url=None,
provider_name="anthropic",
model_override=None,
force_stream=False,
): # type: ignore[no-untyped-def]
anthropic_calls.append(
(request.url.path, upstream_base_url, provider_name, model_override, force_stream)
)
return JSONResponse(
{
"handler": "handle_anthropic_messages",
"path": request.url.path,
"upstream_base_url": upstream_base_url,
"provider": provider_name,
"model": model_override,
"force_stream": force_stream,
}
)
async def fake_gemini_count(
self,
request,
model,
upstream_base_url=None,
provider_name="gemini",
): # type: ignore[no-untyped-def]
gemini_count_calls.append((request.url.path, model, upstream_base_url, provider_name))
return JSONResponse(
{
"handler": "handle_gemini_count_tokens",
"path": request.url.path,
"model": model,
"upstream_base_url": upstream_base_url,
"provider": provider_name,
}
)
monkeypatch.setattr(HeadroomProxy, "handle_passthrough", fake_passthrough)
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
monkeypatch.setattr(HeadroomProxy, "handle_gemini_generate_content", fake_gemini_generate)
monkeypatch.setattr(HeadroomProxy, "handle_gemini_count_tokens", fake_gemini_count)
monkeypatch.setattr(HeadroomProxy, "handle_anthropic_messages", fake_anthropic_messages)
with TestClient(_app()) as client:
assert client.post("/v1/messages/count_tokens").json()["base_url"] == (
"https://api.anthropic.test"
)
assert client.get("/v1/models", headers={"x-goog-api-key": "test"}).json()["base_url"] == (
"https://api.openai.test"
)
assert client.get("/v1/models/demo").json()["sub_path"] == "models"
assert (
client.get(
"/azure/models",
headers={
"api-key": "azure-key",
"x-headroom-base-url": "https://azure.example/openai/",
},
).json()["base_url"]
== "https://azure.example/openai"
)
assert client.post("/v1/embeddings").json()["provider"] == "openai"
assert client.post("/v1/moderations").json()["sub_path"] == "moderations"
assert client.post("/v1/images/generations").json()["sub_path"] == "images/generations"
fix(proxy): route Codex OAuth image requests (#1215) ## Description Closes #1189. After a recent Codex Desktop update, its built-in image generation started going through Codex's image client, which POSTs to `images/generations` and `images/edits` relative to the configured provider base URL. In Headroom Proxy mode Codex is pointed at Headroom's `/v1` surface, so those land as `/v1/images/generations` and `/v1/images/edits`. Headroom already had `/v1/images/generations`, but it only ever hit the OpenAI API-key passthrough, and there was no `/v1/images/edits` route at all. So under ChatGPT/Codex OAuth the image calls had nowhere correct to go. This change routes OAuth image requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` and leaves the API-key passthrough untouched. Latest upstream re-check: current `openai/codex` main is now `aaf737f`, and the relevant `ImagesClient`/provider-base source still resolves image generation and edit requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` under ChatGPT-family auth. One issue-thread datapoint reports Codex Desktop `0.142.0-alpha.6` on macOS generating images successfully via the `/v1/responses` WebSocket path. The requester has now checked this against the latest timestamped Codex update, so this is ready for maintainer review with the remaining full-suite caveat documented below. **Reproduction / test contract** - Reporter's setup: Codex Desktop 0.142.0-alpha.1 on Windows 10, Headroom v0.26.0 Proxy mode, OAuth auth. `/v1/models` and `/v1/responses` work; built-in image generation fails. - Why the route was confirmed from source: the reporter's sanitized logs only show `/v1/models` and `/v1/responses`, so I traced the rest in current Codex source — image generation/edit go through `ImagesClient` as `images/generations` and `images/edits` against the provider base URL. - Regression test: `test_openai_image_routes_use_codex_backend_under_chatgpt_auth` asserts both OAuth image routes now resolve to the ChatGPT Codex image backend. Before this patch, `/v1/images/generations` used the OpenAI API-key target under OAuth and `/v1/images/edits` didn't exist. - Hardening tests: additional regressions cover stale upstream compression headers, OpenAI API-key fall-through for edits, and multipart edit body byte-preservation. ## 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 - Route ChatGPT/Codex OAuth `/v1/images/generations` and `/v1/images/edits` to the ChatGPT Codex image backend. - Strip internal `x-headroom-*`, `Host`, and `Accept-Encoding` headers before forwarding Codex OAuth image requests upstream. - Strip stale `Content-Encoding` and `Content-Length` headers from image responses because httpx has already decoded the body. - Keep API-key image requests on the existing OpenAI passthrough. - Add regression coverage for both OAuth image routes, OpenAI image-edit passthrough, compressed-response header handling, and multipart edit bodies. - Add a `CHANGELOG.md` entry. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 7.63s $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 8.54s $ uv run ruff check . All checks passed! $ uv run ruff format --check . 895 files already formatted $ uv run mypy headroom headroom/proxy/server.py:1152: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1222: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1226: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Success: no issues found in 380 source files ``` Earlier full-suite attempt in this branch/environment, before the F1-F8 hardening pass (not rerun after hardening because the failures were unrelated to this route and expensive): ```text $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest 6 failed, 6499 passed, 486 skipped, 5807 warnings in 219.53s ``` All 6 failures are outside the touched routes and unrelated to this change: - `tests/test_corrupt_golden_bytes_recovery.py` — 3 log-capture assertions - `tests/test_forwarded_headers.py::test_non_allowlisted_peer_ignores_forwarded_and_logs` — 1 log-capture assertion - `tests/test_image_compression.py::TestOnnxRouter::test_full_classify_with_image` — `ModuleNotFoundError: No module named 'PIL'` (only `dev,proxy` extras installed) - `tests/test_transforms/test_kompress_compressor.py::TestKompressBackendSelection::test_unrecognized_backend_warns_and_falls_back_to_auto` — 1 warning-capture assertion On Python 3.14.4, plain `uv run pytest` can't even collect: the project's dependency marker intentionally excludes `litellm` on 3.14, while `tests/test_memory_eval.py` imports the eval runner at collection time. ## Real Behavior Proof - **Environment:** macOS (Darwin arm64). Python 3.14.4 via uv for the default project env; Python 3.12.13 via `UV_PROJECT_ENVIRONMENT=.venv-py312` for the broader suite. Headroom FastAPI proxy route test harness. - **Exact command / steps:** read the reporter's sanitized issue logs; traced current Codex image-generation source; ran the focused Codex/proxy route tests on 3.14 and 3.12; ran lint, format check, and mypy; attempted the full 3.12 suite (output above). - **After-fix evidence + observed result:** the regression test captures the OAuth image requests and confirms they forward to `https://chatgpt.com/backend-api/codex/images/generations` and `.../images/edits` — auth and account headers preserved, internal/host/accept encoding headers stripped, query string carried through, JSON and multipart request bodies forwarded byte-for-byte, and stale upstream response compression headers removed. API-key image generation still uses `images/generations`, and image edits now have the matching `images/edits` passthrough. - **Source evidence:** Re-verified against current `openai/codex` HEAD `aaf737f`. `ImagesClient` still sends relative paths `images/generations` and `images/edits`; `Provider::url_for_path()` appends those to the active provider base; ChatGPT-family auth modes default that base to `CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`. Therefore the source-resolved upstream paths are `/backend-api/codex/images/generations` and `/backend-api/codex/images/edits`, not `/backend-api/images/...`. - **Latest-build caveat:** an issue-thread report says Codex Desktop `0.142.0-alpha.6` on macOS uses `/v1/responses` WebSocket image generation and works through the proxy. That may mean the original Windows `0.142.0-alpha.1` regression is fixed client-side in newer desktop builds, even though the source image endpoint route remains valid and now covered here. The requester has checked this against the latest timestamped Codex update before moving the PR out of draft. - **Not fully tested:** a fully green `uv run pytest` remains unavailable in this local environment for the unrelated failures listed above. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes No dependency or version changes. The remaining caveat is that the full local suite isn't green in this environment for the unrelated failures listed above. Happy to follow up with additional runtime logs or to re-run the suite in a maintainer's preferred dev container if that's the cleaner path. --------- Co-authored-by: Johnson <johnsond@brightops.com>
2026-06-22 00:25:49 +08:00
assert client.post("/v1/images/edits").json()["sub_path"] == "images/edits"
assert client.post("/v1/audio/transcriptions").json()["sub_path"] == "audio/transcriptions"
assert client.post("/v1/audio/speech").json()["sub_path"] == "audio/speech"
assert client.get("/v1beta/models").json()["provider"] == "gemini"
assert client.get("/v1beta/models/demo").json()["sub_path"] == "models"
assert client.post("/v1beta/models/demo:embedContent").json()["sub_path"] == "embedContent"
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
assert client.post(
"/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:generateContent"
).json() == {
"handler": "handle_gemini_generate_content",
"path": "/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:generateContent",
"model": "gemini-2.0-flash",
"upstream_base_url": "https://vertex.test",
"provider": "vertex:google",
}
assert client.post(
"/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:countTokens"
).json() == {
"handler": "handle_gemini_count_tokens",
"path": "/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:countTokens",
"model": "gemini-2.0-flash",
"upstream_base_url": "https://vertex.test",
"provider": "vertex:google",
}
assert client.post(
"/v1beta1/projects/p/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet@20240620:rawPredict"
).json() == {
"handler": "handle_anthropic_messages",
"path": "/v1beta1/projects/p/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet@20240620:rawPredict",
"upstream_base_url": "https://vertex.test",
"provider": "vertex:anthropic",
"model": "claude-3-5-sonnet@20240620",
"force_stream": False,
}
fix(proxy): add versionless Vertex AI routes for Claude Code compatibility (#1321) ## Description When Claude Code is configured for Vertex AI (`CLAUDE_CODE_USE_VERTEX=1`) and routes through the Headroom proxy (`ANTHROPIC_BASE_URL=http://127.0.0.1:8787`), all requests fail with 404. Claude Code constructs Vertex paths without the `/{api_version}/` prefix (e.g. `/projects/.../models/...:rawPredict`), but the proxy's existing route patterns require it (e.g. `/{api_version}/projects/...`). The request falls through unmatched and the upstream returns 404. ## 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 - Add versionless route handlers for `rawPredict` and `streamRawPredict` in `headroom/providers/proxy_routes.py` - Routes are scoped to `/projects/{project}/locations/{location}/publishers/anthropic/models/{model}:(stream)rawPredict` -- only Anthropic publisher, no generic `{publisher}` parameter. Non-Anthropic versionless requests fall through to the catch-all passthrough, avoiding a half-fixed path that would omit the `/v1` prefix. - The handlers append `/v1` to the resolved Vertex target URL so `build_copilot_upstream_url()` constructs the correct upstream path: `https://aiplatform.googleapis.com/v1/projects/...` - Add test assertions in `tests/test_provider_proxy_routes.py` covering both new route variants and verifying non-Anthropic versionless requests do not enter the Anthropic handler ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text 20 passed, 1 warning in 3.56s ``` ## Real Behavior Proof - Environment: macOS (Darwin 25.5.0, arm64), Claude Code with Vertex AI via `headroom wrap claude`, Headroom v0.27.0. Also verified on Fedora (OpenClaw agents using `@anthropic-ai/vertex-sdk` v0.90.0). - Exact command / steps: `claude headroom on` then `claude` launches Claude Code through headroom proxy on port 8787. Claude Code sends requests to `http://127.0.0.1:8787/projects/{project}/locations/global/publishers/anthropic/models/claude-opus-4-6:streamRawPredict`. Proxy forwards to `https://aiplatform.googleapis.com/v1/projects/...` and returns 200. - Observed result: Before fix, proxy forwarded to `https://aiplatform.googleapis.com/projects/...` (missing `/v1/`), Vertex returned 404. After fix, requests succeed with status 200. - Not tested: Non-Anthropic publishers on versionless routes (no known client sends these). These requests fall through to the catch-all passthrough by design. ## 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 - [ ] 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 The root cause: `handle_anthropic_messages()` constructs the upstream URL via `build_copilot_upstream_url(upstream_base_url, request.url.path)` which concatenates `base_url + path`. The versioned routes work because `request.url.path` already contains `/v1/` (e.g. `/v1/projects/...`). But Claude Code with `CLAUDE_CODE_USE_VERTEX=1` sends paths without the version prefix, so the upstream URL was missing `/v1/` entirely. Per review feedback, versionless routes are now scoped exclusively to `publishers/anthropic` rather than accepting a generic `{publisher}` parameter, preventing non-Anthropic publishers from hitting a passthrough path that would also lack the `/v1` prefix.
2026-06-26 20:16:39 +03:00
assert client.post(
"/projects/p/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet@20240620:rawPredict"
).json() == {
"handler": "handle_anthropic_messages",
"path": "/projects/p/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet@20240620:rawPredict",
"upstream_base_url": "https://vertex.test/v1",
"provider": "vertex:anthropic",
"model": "claude-3-5-sonnet@20240620",
"force_stream": False,
}
fix(proxy): route Foundry Anthropic messages (#1878) ## Description Closes #1874 `headroom wrap claude` in Azure AI Foundry mode gives Claude Code a local `ANTHROPIC_FOUNDRY_BASE_URL` ending in `/anthropic`. Claude Code appends `/v1/messages`, so Headroom receives `POST /anthropic/v1/messages`. That path was not registered as an Anthropic Messages route, so it fell through to generic passthrough and never reached compression or Foundry forwarding. This PR registers the Foundry-shaped Anthropic Messages route, normalizes the inbound request path back to `/v1/messages`, and dispatches it through `handle_anthropic_messages` with the configured Anthropic upstream base. That keeps the actual upstream URL shape as `<foundry>/anthropic/v1/messages` while avoiding the catch-all OpenAI-compatible passthrough. ## 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 `POST /anthropic/v1/messages` route alias for Foundry-mode Claude Code traffic. - Normalized the request path to `/v1/messages` before invoking the Anthropic handler. - Added route-level regression coverage proving the Foundry-shaped path reaches `handle_anthropic_messages` instead of passthrough. ## Testing - [x] Unit tests pass (`tests/test_provider_proxy_routes.py` with a local `headroom._core` import stub) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text # Pre-fix proof with the new regression present: tests/test_provider_proxy_routes.py F.F................. FAILED tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets FAILED tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers Observed: /anthropic/v1/messages fell through to handle_passthrough with https://api.openai.test. # After patch: HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=/Users/vinaygupta/Desktop/git/headroom-fix-1874-foundry-anthropic-route \ /tmp/headroom-route-test-1874/bin/python -m pytest tests/test_provider_proxy_routes.py -q 20 passed, 2 warnings in 2.19s rtk proxy uvx ruff==0.15.17 check . All checks passed! rtk proxy uvx ruff==0.15.17 format --check . 1068 files already formatted rtk proxy uvx --from mypy==1.20.2 mypy headroom/providers/proxy_routes.py --ignore-missing-imports Success: no issues found in 1 source file GitHub PR checks after opening readiness review: 28 passed, 0 failed ``` ## Real Behavior Proof - Environment: macOS local checkout, throwaway Python env at `/tmp/headroom-route-test-1874`, `HEADROOM_REQUIRE_RUST_CORE=false`, and an in-memory `headroom._core` stub for route-level testing because the native extension is not built locally. - Exact command / steps: added the regression first, ran the focused route test, observed `/anthropic/v1/messages` fall through to `handle_passthrough`; then added the route alias and reran the same test. - Observed result: `/anthropic/v1/messages?beta=true` now reaches `handle_anthropic_messages` with normalized path `/v1/messages` and upstream base `https://api.anthropic.test`. - Not tested: live Claude Code against a real Azure AI Foundry deployment. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Full local `uv run pytest` is blocked by the known native build issue in `esaxx-rs` (`fatal error: 'cstdint' file not found`). Full touched-file mypy also reports existing `no-untyped-def` errors in `tests/test_provider_proxy_routes.py`; the production route file passes mypy on its own. The unchecked documentation/comment/CHANGELOG boxes are N/A for this route-only fix.
2026-07-08 16:22:20 -05:00
assert client.post("/anthropic/v1/messages?beta=true").json() == {
"handler": "handle_anthropic_messages",
"path": "/v1/messages",
"upstream_base_url": "https://api.anthropic.test",
"provider": "anthropic",
"model": None,
"force_stream": False,
}
fix(proxy): add versionless Vertex AI routes for Claude Code compatibility (#1321) ## Description When Claude Code is configured for Vertex AI (`CLAUDE_CODE_USE_VERTEX=1`) and routes through the Headroom proxy (`ANTHROPIC_BASE_URL=http://127.0.0.1:8787`), all requests fail with 404. Claude Code constructs Vertex paths without the `/{api_version}/` prefix (e.g. `/projects/.../models/...:rawPredict`), but the proxy's existing route patterns require it (e.g. `/{api_version}/projects/...`). The request falls through unmatched and the upstream returns 404. ## 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 - Add versionless route handlers for `rawPredict` and `streamRawPredict` in `headroom/providers/proxy_routes.py` - Routes are scoped to `/projects/{project}/locations/{location}/publishers/anthropic/models/{model}:(stream)rawPredict` -- only Anthropic publisher, no generic `{publisher}` parameter. Non-Anthropic versionless requests fall through to the catch-all passthrough, avoiding a half-fixed path that would omit the `/v1` prefix. - The handlers append `/v1` to the resolved Vertex target URL so `build_copilot_upstream_url()` constructs the correct upstream path: `https://aiplatform.googleapis.com/v1/projects/...` - Add test assertions in `tests/test_provider_proxy_routes.py` covering both new route variants and verifying non-Anthropic versionless requests do not enter the Anthropic handler ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text 20 passed, 1 warning in 3.56s ``` ## Real Behavior Proof - Environment: macOS (Darwin 25.5.0, arm64), Claude Code with Vertex AI via `headroom wrap claude`, Headroom v0.27.0. Also verified on Fedora (OpenClaw agents using `@anthropic-ai/vertex-sdk` v0.90.0). - Exact command / steps: `claude headroom on` then `claude` launches Claude Code through headroom proxy on port 8787. Claude Code sends requests to `http://127.0.0.1:8787/projects/{project}/locations/global/publishers/anthropic/models/claude-opus-4-6:streamRawPredict`. Proxy forwards to `https://aiplatform.googleapis.com/v1/projects/...` and returns 200. - Observed result: Before fix, proxy forwarded to `https://aiplatform.googleapis.com/projects/...` (missing `/v1/`), Vertex returned 404. After fix, requests succeed with status 200. - Not tested: Non-Anthropic publishers on versionless routes (no known client sends these). These requests fall through to the catch-all passthrough by design. ## 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 - [ ] 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 The root cause: `handle_anthropic_messages()` constructs the upstream URL via `build_copilot_upstream_url(upstream_base_url, request.url.path)` which concatenates `base_url + path`. The versioned routes work because `request.url.path` already contains `/v1/` (e.g. `/v1/projects/...`). But Claude Code with `CLAUDE_CODE_USE_VERTEX=1` sends paths without the version prefix, so the upstream URL was missing `/v1/` entirely. Per review feedback, versionless routes are now scoped exclusively to `publishers/anthropic` rather than accepting a generic `{publisher}` parameter, preventing non-Anthropic publishers from hitting a passthrough path that would also lack the `/v1` prefix.
2026-06-26 20:16:39 +03:00
non_anthropic_raw = client.post(
"/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:rawPredict"
).json()
assert non_anthropic_raw.get("handler") != "handle_anthropic_messages"
non_anthropic_stream = client.post(
"/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamRawPredict"
).json()
assert non_anthropic_stream.get("handler") != "handle_anthropic_messages"
assert client.post("/v1beta/cachedContents").json()["sub_path"] == "cachedContents"
assert client.get("/v1beta/cachedContents").json()["sub_path"] == "cachedContents"
assert client.get("/v1beta/cachedContents/cache-1").json()["sub_path"] == "cachedContents"
assert client.delete("/v1beta/cachedContents/cache-1").json()["sub_path"] == (
"cachedContents"
)
fix(codex): OpenCode Zen telemetry attribution (#1648) ## Description Fixes #1602. OpenCode Zen custom-base requests can reach Headroom through the generic passthrough path, but that route was not supplying endpoint/provider metadata for Zen chat completions. This made forwarded Zen traffic invisible in dashboard provider, usage, and token telemetry. Closes #1602 ## 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 OpenCode Zen custom-base classifier for `POST /zen/v1/chat/completions` on `opencode.ai` and `www.opencode.ai`. - Passed `endpoint_name="chat/completions"` and `provider="zen"` into catch-all passthrough telemetry for matching Zen traffic. - Attributed normalized OpenCode transport traffic (`/v1/chat/completions` with `x-headroom-original-path: /zen/v1/chat/completions`) to `zen` for request outcomes while keeping the OpenAI parser path unchanged. - Added coverage for direct catch-all routing, normalized original-path routing, token usage outcome recording, and false-positive paths like `/mcp/v1/chat/completions`, `/npm/v1/chat/completions`, and `/context7/v1/chat/completions`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ rtk pytest tests/test_custom_base_passthrough_telemetry.py -q Pytest: 4 passed $ rtk uvx --from ruff==0.15.17 ruff check headroom/proxy/handlers/openai.py headroom/providers/proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_provider_proxy_routes.py tests/test_proxy/test_openai_transport_path_prefix.py All checks passed! $ rtk uvx --from ruff==0.15.17 ruff format --check headroom/proxy/handlers/openai.py headroom/providers/proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_provider_proxy_routes.py tests/test_proxy/test_openai_transport_path_prefix.py 5 files already formatted $ rtk /Library/Frameworks/Python.framework/Versions/3.13/bin/python3 -m py_compile headroom/proxy/handlers/openai.py headroom/providers/proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_provider_proxy_routes.py tests/test_proxy/test_openai_transport_path_prefix.py # passed $ rtk git diff --check # passed ``` GitHub Actions also passed after the final push, including CI, Docker native/wrap/init E2E, security, lint, and PR governance. ## Real Behavior Proof - Environment: local worktree on macOS plus GitHub Actions for PR #1648. - Exact command / steps: ran focused pytest coverage for Zen passthrough telemetry, Ruff check/format validation on touched files, Python compile validation, `git diff --check`, and waited for the full GitHub Actions rollup. - Observed result: Zen custom-base chat completions now record request outcomes as provider `zen` with endpoint `chat/completions`; false-positive OpenCode paths remain unattributed to Zen; GitHub checks are green. - Not tested: full local test suite did not collect in this worktree because the native `headroom._core` extension is not installed. `rtk npm --prefix plugins/opencode test` is also blocked locally because `vitest` is not installed in `plugins/opencode/node_modules`. ## 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 - [ ] 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 ## Screenshots (if applicable) N/A ## Additional Notes The documentation and CHANGELOG checklist items are not applicable for this narrow telemetry bug fix. No new comments were added because the code path is covered by narrowly named helper/test cases.
2026-07-07 11:35:21 -05:00
custom_passthrough = client.get(
"/unhandled/path",
headers={"x-headroom-base-url": "https://custom.example/base/"},
).json()
assert custom_passthrough["base_url"] == "https://custom.example/base"
assert custom_passthrough["sub_path"] == ""
assert custom_passthrough["provider"] == ""
opencode_zen_passthrough = client.post(
"/zen/v1/chat/completions",
headers={"x-headroom-base-url": "https://opencode.ai/"},
json={"model": "zen"},
).json()
assert opencode_zen_passthrough["base_url"] == "https://opencode.ai"
assert opencode_zen_passthrough["sub_path"] == "chat/completions"
assert opencode_zen_passthrough["provider"] == "zen"
unrelated_custom_passthrough = client.post(
"/mcp",
headers={"x-headroom-base-url": "https://opencode.ai/"},
json={},
).json()
assert unrelated_custom_passthrough["sub_path"] == ""
assert unrelated_custom_passthrough["provider"] == ""
for unrelated_path in (
"/mcp/v1/chat/completions",
"/npm/v1/chat/completions",
"/context7/v1/chat/completions",
):
unrelated_custom_passthrough = client.post(
unrelated_path,
headers={"x-headroom-base-url": "https://opencode.ai/"},
json={},
).json()
assert unrelated_custom_passthrough["sub_path"] == ""
assert unrelated_custom_passthrough["provider"] == ""
get_custom_passthrough = client.get(
"/zen/v1/chat/completions",
headers={"x-headroom-base-url": "https://opencode.ai/"},
).json()
assert get_custom_passthrough["sub_path"] == ""
assert get_custom_passthrough["provider"] == ""
other_host_custom_passthrough = client.post(
"/zen/v1/chat/completions",
headers={"x-headroom-base-url": "https://custom.example/"},
json={"model": "zen"},
).json()
assert other_host_custom_passthrough["sub_path"] == ""
assert other_host_custom_passthrough["provider"] == ""
assert client.get("/another/path", headers={"x-goog-api-key": "test"}).json()[
"base_url"
] == ("https://api.gemini.test")
fix: route v1internal code assist requests to cloudcode-pa.googleapis… (#821) ## Description This PR fixes routing of Google Cloud Code Assist authentication, onboarding, and experiment list endpoints. Specifically, endpoints under `/v1/v1internal:*` (e.g. `/v1/v1internal:fetchAvailableModels`) are now correctly routed to the Cloud Code target (`https://cloudcode-pa.googleapis.com`) and **normalized** to `/v1internal:*` prior to forwarding. This resolves 404/403 errors on the upstream service which does not accept `/v1/v1internal:*` request paths. Closes #821 ## 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 - Modified `headroom/providers/proxy_routes.py` to strip the `v1/` prefix and normalize the path to `/v1internal:*` for Cloud Code routes. - Modified `tests/test_provider_proxy_routes.py` to add assertions verifying route and path normalization. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom/providers/proxy_routes.py`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text ================================= test session starts ================================= platform linux -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0 -- /home/alex/projects/github.com/Djabx/headroom/.venv/bin/python3 cachedir: .pytest_cache rootdir: /home/alex/projects/github.com/Djabx/headroom configfile: pyproject.toml plugins: anyio-4.12.1, cov-7.1.0, asyncio-1.4.0, langsmith-0.8.15 asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 13 items tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets PASSED [ 7%] tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough PASSED [ 15%] tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers PASSED [ 23%] tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler PASSED [ 30%] tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure PASSED [ 38%] tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target PASSED [ 46%] tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets PASSED [ 53%] tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target PASSED [ 61%] tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth PASSED [ 69%] tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth PASSED [ 76%] tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth PASSED [ 84%] tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth PASSED [ 92%] tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic PASSED [100%] ================================= 13 passed in 0.63s ================================= ``` ## Real Behavior Proof - Environment: Linux, Python 3.14.5 - Exact command / steps: `pytest tests/test_provider_proxy_routes.py` which utilizes `fastapi.testclient.TestClient` to dispatch requests. - Observed result: Both `/v1internal` and `/v1/v1internal` endpoints are correctly routed to the Cloud Code target (`https://cloudcode.test`) and normalize their paths to `/v1internal`, avoiding 404/403 errors on the upstream service. - Not tested: Actual production Cloud Code endpoints (simulated via TestClient/fakes). ## 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 ## Screenshots (if applicable) Add screenshots to help explain your changes. ## Additional Notes <!-- Mention any N/A checklist items, tradeoffs, follow-ups, or maintainer context. --> <!-- headroom-maintainer-template-completion:start --> ## Description This PR prepares `fix: route v1internal code assist requests to cloudcode-pa.googleapis…` for review by documenting the intended change, validation evidence, and remaining merge-readiness context. Linked issues: None declared. ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Documentation - [ ] Refactor - [ ] Tests only ## Changes Made - Commit: fix: route v1internal code assist requests to cloudcode-pa.googleapis… - Touches `headroom/providers/proxy_routes.py` - Touches `tests/test_provider_proxy_routes.py` ## Testing - [x] GitHub checks reviewed - [x] Metadata/template validation - [ ] Local functional testing ### Test Output ```text gh pr view 821 --repo chopratejas/headroom --json statusCheckRollup - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / template: FAILURE - PR Governance / label: SUCCESS - PR Governance / label: SUCCESS - PR Governance / label: SUCCESS - PR Governance / label: SUCCESS - external / GitGuardian Security Checks: SUCCESS ``` ## Real Behavior Proof - Environment: GitHub PR metadata and checks for `chopratejas/headroom` PR #821. - Exact command / steps: Reviewed PR title, commits, changed files, linked issues, labels, and check rollup; appended this maintainer template completion block without replacing the author's original description. - Observed result: PR body now contains all required governance sections, checked readiness fields, and a non-placeholder validation evidence block. - Not tested: This pass updated PR metadata only; code validation remains represented by the linked GitHub checks and any author-provided evidence above. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review <!-- headroom-maintainer-template-completion:end -->
2026-06-16 21:58:44 +02:00
# Prove Code Assist routes go to the cloudcode target and normalize paths
res1 = client.post("/v1internal:loadCodeAssist").json()
assert res1["base_url"] == "https://cloudcode.test"
assert res1["path"] == "/v1internal:loadCodeAssist"
res2 = client.post("/v1/v1internal:fetchAvailableModels").json()
assert res2["base_url"] == "https://cloudcode.test"
assert res2["path"] == "/v1internal:fetchAvailableModels"
# Prove a non-Code-Assist passthrough path containing a similar substring does not get rerouted
assert (
client.get(
"/unrelated/path/containing/v1internal:someAction",
headers={"x-goog-api-key": "test"},
).json()["base_url"]
== "https://api.gemini.test"
)
assert len(calls) >= 16
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
assert len(gemini_calls) >= 1
assert len(gemini_count_calls) >= 1
fix(proxy): add versionless Vertex AI routes for Claude Code compatibility (#1321) ## Description When Claude Code is configured for Vertex AI (`CLAUDE_CODE_USE_VERTEX=1`) and routes through the Headroom proxy (`ANTHROPIC_BASE_URL=http://127.0.0.1:8787`), all requests fail with 404. Claude Code constructs Vertex paths without the `/{api_version}/` prefix (e.g. `/projects/.../models/...:rawPredict`), but the proxy's existing route patterns require it (e.g. `/{api_version}/projects/...`). The request falls through unmatched and the upstream returns 404. ## 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 - Add versionless route handlers for `rawPredict` and `streamRawPredict` in `headroom/providers/proxy_routes.py` - Routes are scoped to `/projects/{project}/locations/{location}/publishers/anthropic/models/{model}:(stream)rawPredict` -- only Anthropic publisher, no generic `{publisher}` parameter. Non-Anthropic versionless requests fall through to the catch-all passthrough, avoiding a half-fixed path that would omit the `/v1` prefix. - The handlers append `/v1` to the resolved Vertex target URL so `build_copilot_upstream_url()` constructs the correct upstream path: `https://aiplatform.googleapis.com/v1/projects/...` - Add test assertions in `tests/test_provider_proxy_routes.py` covering both new route variants and verifying non-Anthropic versionless requests do not enter the Anthropic handler ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text 20 passed, 1 warning in 3.56s ``` ## Real Behavior Proof - Environment: macOS (Darwin 25.5.0, arm64), Claude Code with Vertex AI via `headroom wrap claude`, Headroom v0.27.0. Also verified on Fedora (OpenClaw agents using `@anthropic-ai/vertex-sdk` v0.90.0). - Exact command / steps: `claude headroom on` then `claude` launches Claude Code through headroom proxy on port 8787. Claude Code sends requests to `http://127.0.0.1:8787/projects/{project}/locations/global/publishers/anthropic/models/claude-opus-4-6:streamRawPredict`. Proxy forwards to `https://aiplatform.googleapis.com/v1/projects/...` and returns 200. - Observed result: Before fix, proxy forwarded to `https://aiplatform.googleapis.com/projects/...` (missing `/v1/`), Vertex returned 404. After fix, requests succeed with status 200. - Not tested: Non-Anthropic publishers on versionless routes (no known client sends these). These requests fall through to the catch-all passthrough by design. ## 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 - [ ] 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 The root cause: `handle_anthropic_messages()` constructs the upstream URL via `build_copilot_upstream_url(upstream_base_url, request.url.path)` which concatenates `base_url + path`. The versioned routes work because `request.url.path` already contains `/v1/` (e.g. `/v1/projects/...`). But Claude Code with `CLAUDE_CODE_USE_VERTEX=1` sends paths without the version prefix, so the upstream URL was missing `/v1/` entirely. Per review feedback, versionless routes are now scoped exclusively to `publishers/anthropic` rather than accepting a generic `{publisher}` parameter, preventing non-Anthropic publishers from hitting a passthrough path that would also lack the `/v1` prefix.
2026-06-26 20:16:39 +03:00
assert len(anthropic_calls) >= 2
fix(codex): route alpha search through the Codex backend (#2538) ## Description Codex GPT-5.6 standalone web search currently falls through Headroom's generic passthrough path. Under ChatGPT OAuth that sends `POST /v1/alpha/search` to `https://chatgpt.com/v1/alpha/search`, which redirects to HTML and makes Codex fail to decode the response. This change adds an explicit standalone Codex search alias so ChatGPT-authenticated `/v1/alpha/search` requests route through `https://chatgpt.com/backend-api/codex/alpha/search`, while non-ChatGPT traffic keeps the existing passthrough behavior. Closes #2525. ## 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 - add a dedicated `POST /v1/alpha/search` Codex route for ChatGPT-authenticated traffic - route that alias through the existing `codex_backend_url()` helper so the upstream path becomes `/backend-api/codex/alpha/search` - add focused regression coverage for ChatGPT-auth routing and non-ChatGPT passthrough preservation ## Testing - [x] Unit tests pass (`uv run pytest tests/test_provider_proxy_routes.py -q`) - [x] Linting passes (`uv run ruff check headroom/providers/proxy_routes.py tests/test_provider_proxy_routes.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text uv run pytest tests/test_provider_proxy_routes.py -q 23 passed, 1 warning in 15.77s uv run ruff check headroom/providers/proxy_routes.py tests/test_provider_proxy_routes.py All checks passed! uv run ruff format headroom/providers/proxy_routes.py tests/test_provider_proxy_routes.py --check 2 files already formatted git diff --check (no output) ``` ## Real Behavior Proof - Environment: focused Headroom worktree with proxy route regression tests - Exact command / steps: run the issue-shaped inline Python reproduction from `bodies/headroom-issue-2525.json`, then run the focused preservation and matrix pytest rows for ChatGPT-auth and non-ChatGPT auth - Observed result: the base repro printed `FAIL issue2525 codex alpha search -> observed_url=None fallback=[('/v1/alpha/search', 'https://chatgpt.com')] body={"base_url":"https://chatgpt.com","provider":""}`, while the head repro printed `PASS issue2525 codex alpha search -> https://chatgpt.com/backend-api/codex/alpha/search?query=weather`; the non-ChatGPT preservation row passed `1 passed, 22 deselected`, and the auth matrix row passed `1 passed, 22 deselected` - Not tested: live ChatGPT OAuth account on this host ## 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 - [x] 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 - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A - proxy routing change only. ## Additional Notes - `CHANGELOG.md` stays untouched because Headroom's release automation generates it from conventional commits. - The fix is scoped to standalone Codex search. It does not change `/v1/responses`, image routes, or generic OpenAI passthrough semantics. - Proof artifact: `D:\Repos\.claude\pr-sweep\headroom-PR-TARGET-2525-PROOF.md` Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
2026-08-12 01:22:20 -04:00
def test_codex_alpha_search_route_from_headroom_issue_2525() -> None:
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, str], bytes]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append(
(
method,
url,
dict(kwargs.get("headers", {})),
kwargs.get("content", b""),
)
)
return httpx.Response(200, json={"ok": True})
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
response = client.post(
"/v1/alpha/search?query=weather",
headers={
"Authorization": "Bearer oauth-token",
"ChatGPT-Account-ID": "acct_123",
},
json={"query": "weather"},
)
assert response.status_code == 200
assert response.json() == {"ok": True}
assert len(fake_http_client.calls) == 1
method, url, headers, body = fake_http_client.calls[0]
assert method == "POST"
assert url == "https://chatgpt.com/backend-api/codex/alpha/search?query=weather"
assert headers["authorization"] == "Bearer oauth-token"
assert headers["chatgpt-account-id"] == "acct_123"
assert headers["content-length"] == "19"
assert headers["content-type"] == "application/json"
assert body == b'{"query":"weather"}'
def test_non_chatgpt_alpha_search_falls_through_to_openai_upstream(monkeypatch) -> None:
calls: list[tuple[str, str, str, str, str]] = []
async def fake_passthrough(self, request, base_url, sub_path="", provider_name=""): # type: ignore[no-untyped-def]
calls.append((request.method, request.url.path, base_url, sub_path, provider_name))
return JSONResponse(
{
"base_url": base_url,
"sub_path": sub_path,
"provider": provider_name,
}
)
monkeypatch.setattr(HeadroomProxy, "handle_passthrough", fake_passthrough)
with TestClient(_app()) as client:
response = client.post(
"/v1/alpha/search",
headers={"Authorization": "Bearer sk-proj-openai-test"},
json={"query": "weather"},
)
assert response.status_code == 200
assert response.json() == {
"base_url": "https://api.openai.test",
"sub_path": "",
"provider": "",
}
assert calls == [
(
"POST",
"/v1/alpha/search",
"https://api.openai.test",
"",
"",
)
]
def test_codex_alpha_search_route_matrix(monkeypatch) -> None:
fallback_calls: list[tuple[str, str]] = []
async def fake_passthrough(self, request, base_url, sub_path="", provider_name=""): # type: ignore[no-untyped-def]
fallback_calls.append((request.url.path, base_url))
return JSONResponse({"base_url": base_url, "provider": provider_name})
monkeypatch.setattr(HeadroomProxy, "handle_passthrough", fake_passthrough)
class FakeAsyncClient:
def __init__(self) -> None:
self.urls: list[str] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.urls.append(url)
return httpx.Response(200, json={"ok": True})
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
oauth_response = client.post(
"/v1/alpha/search",
headers={
"Authorization": "Bearer oauth-token",
"ChatGPT-Account-ID": "acct_123",
},
json={"query": "weather"},
)
api_key_response = client.post(
"/v1/alpha/search",
headers={"Authorization": "Bearer sk-proj-openai-test"},
json={"query": "weather"},
)
assert oauth_response.status_code == 200
assert api_key_response.status_code == 200
assert fake_http_client.urls == ["https://chatgpt.com/backend-api/codex/alpha/search"]
assert fallback_calls == [("/v1/alpha/search", "https://api.openai.test")]
def test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough() -> None:
proxy_routes = importlib.import_module("headroom.providers.proxy_routes")
proxy = type(
"Proxy",
(),
{
"ANTHROPIC_API_URL": "https://legacy.anthropic.test",
"OPENAI_API_URL": "https://legacy.openai.test",
"GEMINI_API_URL": "https://legacy.gemini.test",
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
"VERTEX_API_URL": "https://legacy.vertex.test",
"provider_runtime": type(
"Runtime",
(),
{
"api_target": staticmethod(lambda provider: f"https://runtime.{provider}.test"),
"model_metadata_provider": staticmethod(lambda headers: "anthropic"),
},
)(),
},
)()
assert proxy_routes._api_target(proxy, "anthropic") == "https://legacy.anthropic.test"
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
assert proxy_routes._api_target(proxy, "vertex") == "https://legacy.vertex.test"
assert proxy_routes._select_passthrough_base_url(proxy, {"x-goog-api-key": "test"}) == (
"https://legacy.gemini.test"
)
fix(security): validate caller-supplied upstreams on every resolution path (#3195) ## Summary CVE-2026-77775 (SSRF via `x-headroom-base-url`) is **not fully fixed on current `main`**. The advisory lists 0.36.1 as the last affected version; one route still forwards to any destination a caller names. `upstream_guard.is_safe_upstream_url` was added and wired into `/v1/messages` and the catch-all passthrough. But `select_passthrough_base_url` moved from `providers/proxy_routes.py` to `providers/proxy_targets.py`, and the guard did not follow it. Its Azure branch returns the header verbatim whenever an `api-key` header is present — **both values are caller-supplied** — and `POST /v1/alpha/search` resolves its upstream through that helper without checking the header itself. ## Verified, not inferred Against the current tree, with a listener on loopback standing in for an internal service: ``` proxy status : 200 internal service hit : 1 time(s) Authorization it received : 'Bearer SECRET-CLIENT-TOKEN' internal body relayed back : True ``` The caller's credentials are forwarded to the attacker-named host and the internal response is relayed back. After this change: `400`, zero hits, nothing relayed. A sweep of all 99 routes isolates exactly one leak on unfixed code — `POST /v1/alpha/search` with `api-key` — and zero after. ## 1. The missing enforcement **Guarded at the chokepoint, not just the route.** `select_passthrough_base_url` now validates before returning, in `proxy_targets.py` and in the parallel copy in `providers/registry.py`, so a future caller that forgets the header check cannot reopen this. `/v1/alpha/search` also rejects explicitly with 400, matching its sibling routes. ## 2. A second gap in the address policy RFC 6598 shared address space (`100.64.0.0/10`) is not `is_private`, so it passed the guard — while routing to ISP and cloud-internal infrastructure. `_is_internal_address` now also rejects anything not globally routable. Verified over a 27-vector battery — 0 bypasses, public control unaffected: | Vector | Before | After | |---|---|---| | `100.64.0.0/10` shared address space | **allowed** | blocked | | `198.18/15`, TEST-NET, `240/4` | **allowed** | blocked | | 6to4 / Teredo embedding internal IPv4 | **allowed** | blocked | | NAT64 `64:ff9b::/96` embedding loopback | **allowed** | blocked | | loopback, RFC1918, link-local, metadata, IPv4-mapped, userinfo tricks | blocked | blocked | | multicast `224.0.0.1` | blocked | blocked | | public `8.8.8.8` | allowed | allowed | The category checks are **kept alongside** `is_global` rather than replaced — `is_global` is `True` for multicast, so a replacement would have regressed. NAT64 also reports as global, so its embedded IPv4 is extracted and judged on its own. ## 3. Unauthenticated stall via the resolver `socket.getaddrinfo` takes no timeout and runs on the calling thread — the event loop. Since the hostname is caller-supplied, a deliberately slow-resolving name stalled every other in-flight request; a handful of concurrent requests made the proxy unresponsive, unauthenticated. Resolution now runs in a small dedicated pool with a budget (`HEADROOM_UPSTREAM_RESOLVE_TIMEOUT_S`, default 3s) and fails closed on overrun, which bounds every caller including the synchronous chokepoint. `is_safe_upstream_url_async` runs the lookup off the loop, and the three route handlers that validate a caller-supplied upstream now await it. Caching was deliberately avoided: a TTL cache in front of a security decision invites poisoning, and would widen the rebinding window rather than narrow it. ## Why this survived The existing tests unit-tested the guard's *logic* but never asserted it was *reached*. Added enforcement tests at the sinks plus a **sweep over the whole route table** that fails if any route forwards to a loopback address — so the next unguarded upstream resolution fails in CI rather than in a CVE. All new tests were confirmed failing against the unfixed tree and passing after. ## Known residual — deliberately not addressed **DNS rebinding.** Validation and connection resolve the host separately, so a low-TTL answer can differ between them. Closing this needs connection-time pinning in the shared `http_client` transport, which carries every request in the proxy — too broad to fold into this patch. It should not be described as fixed. ## Compatibility An endpoint that does not resolve publicly (split-horizon, on-prem) is now rejected where it previously passed unvalidated. `HEADROOM_ALLOWED_BASE_URLS` is the documented opt-in, covered by test. Three existing tests used fictional hostnames and legitimately began failing; DNS is pinned in them so they keep testing target precedence rather than depending on the missing guard. Separately: `docker-compose.yml` has already been hardened since the advisory — `HEADROOM_PROXY_TOKEN` is now mandatory and ports are loopback-only — so the "exposed by default" multiplier the advisory cites no longer applies to the shipped compose. Full suite: the 3 failures outside this area (`test_learn/test_integration`, `test_release_workflows::test_no_native_tls_in_wheel_build_tree`, and a `test_graceful_shutdown` ordering flake) reproduce on clean `main` and are unrelated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 23:16:59 -07:00
# The azure branch honours the override, but only after the SSRF guard
# clears the destination (CVE-2026-77775). `azure.example` does not
# resolve, and the guard fails closed on resolution failure, so pin a
# public answer to keep this assertion about target *precedence*.
with patch.object(
upstream_guard.socket,
"getaddrinfo",
return_value=[(None, None, None, None, ("20.10.10.10", 443))],
):
assert (
proxy_routes._select_passthrough_base_url(
proxy, {"api-key": "azure", "x-headroom-base-url": "https://azure.example/base/"}
)
== "https://azure.example/base"
)
assert proxy_routes._select_passthrough_base_url(proxy, {"api-key": "azure"}) == (
"https://legacy.anthropic.test"
)
refactor(providers): split proxy route adapters (#1934) ## Description Refactors provider-specific proxy routing into provider-owned helper modules so `headroom/providers/proxy_routes.py` primarily registers routes and delegates behavior. This keeps Codex, OpenAI Responses/images, model metadata, Vertex, Cloud Code, passthrough target selection, and request path normalization logic testable outside the route table. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Extracted Codex routing helpers for headers, endpoint URLs, image forwarding, response subpaths, and model metadata. - Moved provider target selection, route specs, OpenAI Responses/images helpers, Vertex runtime helpers, Cloud Code path normalization, passthrough telemetry, and request scope normalization into focused modules. - Kept `proxy_routes.py` as route registration/delegation and preserved current-main `/v1/messages` custom-base behavior. - Added focused provider/proxy tests for the extracted modules and route delegation behavior. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_package_init_lazy.py::test_codex_package_import_stays_runtime_only tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets tests/test_provider_proxy_routes.py::test_openai_image_routes_use_codex_backend_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_openai_image_codex_response_strips_stale_compression_headers tests/test_provider_proxy_routes.py::test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough tests/test_provider_proxy_routes.py::test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py::test_with_project_prefix_round_trips_through_split tests/test_vertex_claude_compression.py ============================ 102 passed in 34.83s ============================= python -m ruff check headroom/providers/cloudcode headroom/providers/codex headroom/providers/vertex headroom/providers/model_metadata.py headroom/providers/openai_images.py headroom/providers/openai_responses.py headroom/providers/proxy_targets.py headroom/providers/route_specs.py headroom/providers/proxy_routes.py headroom/proxy/handlers/openai.py headroom/proxy/passthrough.py headroom/proxy/request_scope.py headroom/proxy/project_context.py tests/test_package_init_lazy.py tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py tests/test_vertex_claude_compression.py All checks passed! python -m compileall -q headroom\providers\cloudcode headroom\providers\codex headroom\providers\vertex headroom\providers\model_metadata.py headroom\providers\openai_images.py headroom\providers\openai_responses.py headroom\providers\proxy_targets.py headroom\providers\route_specs.py headroom\providers\proxy_routes.py headroom\proxy\handlers\openai.py headroom\proxy\passthrough.py headroom\proxy\request_scope.py headroom\proxy\project_context.py # no output; exited 0 git commit -m "refactor(providers): split proxy route adapters" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/provider-route-slices` based on `headroomlabs/main`. - Exact command / steps: Ran the focused provider/proxy pytest suite, focused ruff command, compileall over changed Python modules, and commit hooks. - Observed result: Provider/proxy route behavior tests passed; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream provider calls, and manual end-to-end proxy traffic. ## 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 ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; coverage here is focused on provider/proxy routing behavior touched by this slice. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-12 21:27:45 +00:00
assert (
proxy_routes._select_passthrough_base_url(proxy, {"chatgpt-account-id": "acct"})
== "https://chatgpt.com"
)
assert proxy_routes._select_passthrough_base_url(proxy, {}) == "https://legacy.anthropic.test"
def test_provider_specific_routes_delegate_to_expected_proxy_handlers(monkeypatch) -> None:
delegated: list[tuple[str, str, tuple[str, ...]]] = []
def install(name: str) -> None:
async def fake(self, request, *args): # type: ignore[no-untyped-def]
delegated.append((name, request.url.path, tuple(str(arg) for arg in args)))
return JSONResponse({"handler": name, "path": request.url.path, "args": list(args)})
monkeypatch.setattr(HeadroomProxy, name, fake)
for handler_name in (
"handle_anthropic_messages",
"handle_anthropic_batch_create",
"handle_anthropic_batch_passthrough",
"handle_anthropic_batch_results",
"handle_openai_chat",
"handle_openai_responses",
"handle_batch_create",
"handle_batch_list",
"handle_batch_get",
"handle_batch_cancel",
"handle_gemini_generate_content",
"handle_gemini_stream_generate_content",
"handle_gemini_count_tokens",
"handle_google_cloudcode_stream",
"handle_google_batch_create",
"handle_google_batch_results",
"handle_google_batch_passthrough",
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
"handle_passthrough",
):
install(handler_name)
with TestClient(_app()) as client:
assert client.post("/v1/messages").json()["handler"] == "handle_anthropic_messages"
fix(proxy): route Foundry Anthropic messages (#1878) ## Description Closes #1874 `headroom wrap claude` in Azure AI Foundry mode gives Claude Code a local `ANTHROPIC_FOUNDRY_BASE_URL` ending in `/anthropic`. Claude Code appends `/v1/messages`, so Headroom receives `POST /anthropic/v1/messages`. That path was not registered as an Anthropic Messages route, so it fell through to generic passthrough and never reached compression or Foundry forwarding. This PR registers the Foundry-shaped Anthropic Messages route, normalizes the inbound request path back to `/v1/messages`, and dispatches it through `handle_anthropic_messages` with the configured Anthropic upstream base. That keeps the actual upstream URL shape as `<foundry>/anthropic/v1/messages` while avoiding the catch-all OpenAI-compatible passthrough. ## 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 `POST /anthropic/v1/messages` route alias for Foundry-mode Claude Code traffic. - Normalized the request path to `/v1/messages` before invoking the Anthropic handler. - Added route-level regression coverage proving the Foundry-shaped path reaches `handle_anthropic_messages` instead of passthrough. ## Testing - [x] Unit tests pass (`tests/test_provider_proxy_routes.py` with a local `headroom._core` import stub) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text # Pre-fix proof with the new regression present: tests/test_provider_proxy_routes.py F.F................. FAILED tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets FAILED tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers Observed: /anthropic/v1/messages fell through to handle_passthrough with https://api.openai.test. # After patch: HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=/Users/vinaygupta/Desktop/git/headroom-fix-1874-foundry-anthropic-route \ /tmp/headroom-route-test-1874/bin/python -m pytest tests/test_provider_proxy_routes.py -q 20 passed, 2 warnings in 2.19s rtk proxy uvx ruff==0.15.17 check . All checks passed! rtk proxy uvx ruff==0.15.17 format --check . 1068 files already formatted rtk proxy uvx --from mypy==1.20.2 mypy headroom/providers/proxy_routes.py --ignore-missing-imports Success: no issues found in 1 source file GitHub PR checks after opening readiness review: 28 passed, 0 failed ``` ## Real Behavior Proof - Environment: macOS local checkout, throwaway Python env at `/tmp/headroom-route-test-1874`, `HEADROOM_REQUIRE_RUST_CORE=false`, and an in-memory `headroom._core` stub for route-level testing because the native extension is not built locally. - Exact command / steps: added the regression first, ran the focused route test, observed `/anthropic/v1/messages` fall through to `handle_passthrough`; then added the route alias and reran the same test. - Observed result: `/anthropic/v1/messages?beta=true` now reaches `handle_anthropic_messages` with normalized path `/v1/messages` and upstream base `https://api.anthropic.test`. - Not tested: live Claude Code against a real Azure AI Foundry deployment. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Full local `uv run pytest` is blocked by the known native build issue in `esaxx-rs` (`fatal error: 'cstdint' file not found`). Full touched-file mypy also reports existing `no-untyped-def` errors in `tests/test_provider_proxy_routes.py`; the production route file passes mypy on its own. The unchecked documentation/comment/CHANGELOG boxes are N/A for this route-only fix.
2026-07-08 16:22:20 -05:00
assert client.post("/anthropic/v1/messages").json() == {
"handler": "handle_anthropic_messages",
"path": "/v1/messages",
"args": ["https://api.anthropic.test"],
}
assert (
client.post("/v1/messages/batches").json()["handler"] == "handle_anthropic_batch_create"
)
assert client.get("/v1/messages/batches").json()["handler"] == (
"handle_anthropic_batch_passthrough"
)
assert client.get("/v1/messages/batches/b1").json()["args"] == ["b1"]
assert client.get("/v1/messages/batches/b1/results").json()["handler"] == (
"handle_anthropic_batch_results"
)
assert client.post("/v1/messages/batches/b1/cancel").json()["handler"] == (
"handle_anthropic_batch_passthrough"
)
assert client.post("/v1/chat/completions").json()["handler"] == "handle_openai_chat"
fix(vscode): persist compatible Claude modes and route Copilot CAPI (#2986) ## Description Fixes #2492, #2028, and #2827. Claude daemon workers consume project settings rather than reliably inheriting wrapper environment state, while the Claude VS Code webview cannot render deferred-tool response blocks. Separately, recent Copilot Chat versions use the whole CAPI override for generation; the legacy proxy override alone only sends model discovery through Headroom. This PR carries both integrations through to the actual consumers instead of only changing their launch-time surface configuration. ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Build / CI ## Changes Made - Persist the resolved Claude ENABLE_TOOL_SEARCH value into project settings for daemon workers and restore it transactionally after wrap exits. - Use compatibility-safe Foundry and Claude VS Code defaults while preserving explicit user choices. - Configure both Copilot overrideProxyUrl and overrideCapiUrl in the reversible managed VS Code settings block. - Route Copilot unprefixed POST /chat/completions and HTTP /responses requests through the real compression handlers. - Keep /responses out of the Codex WebSocket aliases because Copilot and Codex use different WebSocket wire protocols. - Extend wrap E2E assertions for both the Claude webview mode and Copilot CAPI routing. ## Testing - [x] 127 combined Claude, Copilot, route-integration, and MCP dependency-contract tests pass. - [x] Ruff check passes on all changed Python files. - [x] Ruff format check passes. - [x] Python compilation and git diff --check pass. ## Runtime Safety Standalone Claude CLI defaults remain unchanged. Explicit Claude tool-search values retain precedence, and project settings are restored through the existing cleanup path. Copilot model/session helper endpoints continue through generic passthrough, while only validated HTTP generation paths receive explicit compression routes. Existing Codex WebSocket behavior is unchanged. ## Review Readiness - [x] Current main and MCP v1 compatibility retained - [x] Worker-facing Claude persistence covered - [x] Reversible Copilot and Claude settings behavior covered - [x] Copilot generation routes covered at registration and proxy integration layers - [x] Ready for review
2026-08-13 15:06:41 -05:00
assert client.post("/chat/completions").json()["handler"] == "handle_openai_chat"
assert client.post("/v1/responses").json()["handler"] == "handle_openai_responses"
fix(vscode): persist compatible Claude modes and route Copilot CAPI (#2986) ## Description Fixes #2492, #2028, and #2827. Claude daemon workers consume project settings rather than reliably inheriting wrapper environment state, while the Claude VS Code webview cannot render deferred-tool response blocks. Separately, recent Copilot Chat versions use the whole CAPI override for generation; the legacy proxy override alone only sends model discovery through Headroom. This PR carries both integrations through to the actual consumers instead of only changing their launch-time surface configuration. ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Build / CI ## Changes Made - Persist the resolved Claude ENABLE_TOOL_SEARCH value into project settings for daemon workers and restore it transactionally after wrap exits. - Use compatibility-safe Foundry and Claude VS Code defaults while preserving explicit user choices. - Configure both Copilot overrideProxyUrl and overrideCapiUrl in the reversible managed VS Code settings block. - Route Copilot unprefixed POST /chat/completions and HTTP /responses requests through the real compression handlers. - Keep /responses out of the Codex WebSocket aliases because Copilot and Codex use different WebSocket wire protocols. - Extend wrap E2E assertions for both the Claude webview mode and Copilot CAPI routing. ## Testing - [x] 127 combined Claude, Copilot, route-integration, and MCP dependency-contract tests pass. - [x] Ruff check passes on all changed Python files. - [x] Ruff format check passes. - [x] Python compilation and git diff --check pass. ## Runtime Safety Standalone Claude CLI defaults remain unchanged. Explicit Claude tool-search values retain precedence, and project settings are restored through the existing cleanup path. Copilot model/session helper endpoints continue through generic passthrough, while only validated HTTP generation paths receive explicit compression routes. Existing Codex WebSocket behavior is unchanged. ## Review Readiness - [x] Current main and MCP v1 compatibility retained - [x] Worker-facing Claude persistence covered - [x] Reversible Copilot and Claude settings behavior covered - [x] Copilot generation routes covered at registration and proxy integration layers - [x] Ready for review
2026-08-13 15:06:41 -05:00
assert client.post("/responses").json()["handler"] == "handle_openai_responses"
assert client.post("/v1/codex/responses").json()["handler"] == "handle_openai_responses"
assert client.post("/backend-api/responses").json()["handler"] == "handle_openai_responses"
assert client.post("/backend-api/codex/responses").json()["handler"] == (
"handle_openai_responses"
)
assert client.post("/v1/batches").json()["handler"] == "handle_batch_create"
assert client.get("/v1/batches").json()["handler"] == "handle_batch_list"
assert client.get("/v1/batches/b1").json()["handler"] == "handle_batch_get"
assert client.post("/v1/batches/b1/cancel").json()["handler"] == "handle_batch_cancel"
assert client.post("/v1beta/models/demo:generateContent").json()["handler"] == (
"handle_gemini_generate_content"
)
assert client.post("/v1beta/models/demo:streamGenerateContent").json()["handler"] == (
"handle_gemini_stream_generate_content"
)
assert client.post("/v1beta/models/demo:countTokens").json()["handler"] == (
"handle_gemini_count_tokens"
)
feat: add Vertex AI proxy routing (#793) ## Description Adds first-class GCP Vertex AI proxy routing for publisher REST endpoints so Vertex requests are forwarded to a configurable regional Vertex host instead of falling through to the generic OpenAI/Anthropic/Gemini passthrough selection. Fixes #792 ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ## Changes Made - Added a `vertex` provider target with `VERTEX_TARGET_API_URL` and `--vertex-api-url` support. - Registered explicit Vertex publisher routes for Google `generateContent`, `streamGenerateContent`, `countTokens` and Anthropic publisher `rawPredict`, `streamRawPredict` passthrough. - Added startup banner/routing output for Vertex AI. - Added focused tests for provider target resolution, CLI/env config, banner output, and route delegation. - Added `wiki/vertex.md` with usage examples and Google Cloud source links. ## Sources - Vertex AI Gemini inference reference: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - Google Cloud REST authentication: https://docs.cloud.google.com/docs/authentication/rest - Google Application Default Credentials: https://docs.cloud.google.com/docs/authentication/application-default-credentials ## Testing - [x] Linting passes (`python -m ruff check .`) - [x] New tests added for new functionality - [x] Focused unit tests pass - [ ] Full unit suite completed locally - [ ] Rust tests completed locally - [ ] Type checking passes locally ## Test Output ```text $ python -m ruff check . All checks passed! $ python -m pytest tests/test_provider_registry.py tests/test_provider_proxy_routes.py tests/test_cli_proxy_env.py tests/test_banner_upstream_targets.py -q 57 passed, 1 warning in 13.75s ``` Local limitations: - `python -m pytest tests scripts/tests -q` timed out after 1 hour on this Windows machine before completing. - `cargo test -p headroom-proxy --test integration_vertex_raw_predict` could not run because `cargo` is not installed on PATH in this environment. - The commit hook's `mypy` step fails locally on an existing Windows `fcntl` typing issue in `headroom/subscription/tracker.py`; `ruff`, `ruff-format`, and plugin-version hooks passed, and the commit was made with only `mypy` skipped. ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove the feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-06-10 01:05:30 -05:00
assert client.post(
"/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamGenerateContent"
).json() == {
"handler": "handle_gemini_generate_content",
"path": "/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamGenerateContent",
"args": [
"gemini-2.0-flash",
"https://vertex.test",
"vertex:google",
],
}
assert client.post(
"/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:countTokens"
).json() == {
"handler": "handle_gemini_count_tokens",
"path": "/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:countTokens",
"args": [
"gemini-2.0-flash",
"https://vertex.test",
"vertex:google",
],
}
assert client.post(
"/v1beta1/projects/p/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet@20240620:streamRawPredict"
).json()["args"] == [
"https://vertex.test",
"vertex:anthropic",
"claude-3-5-sonnet@20240620",
True,
]
fix(proxy): add versionless Vertex AI routes for Claude Code compatibility (#1321) ## Description When Claude Code is configured for Vertex AI (`CLAUDE_CODE_USE_VERTEX=1`) and routes through the Headroom proxy (`ANTHROPIC_BASE_URL=http://127.0.0.1:8787`), all requests fail with 404. Claude Code constructs Vertex paths without the `/{api_version}/` prefix (e.g. `/projects/.../models/...:rawPredict`), but the proxy's existing route patterns require it (e.g. `/{api_version}/projects/...`). The request falls through unmatched and the upstream returns 404. ## 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 - Add versionless route handlers for `rawPredict` and `streamRawPredict` in `headroom/providers/proxy_routes.py` - Routes are scoped to `/projects/{project}/locations/{location}/publishers/anthropic/models/{model}:(stream)rawPredict` -- only Anthropic publisher, no generic `{publisher}` parameter. Non-Anthropic versionless requests fall through to the catch-all passthrough, avoiding a half-fixed path that would omit the `/v1` prefix. - The handlers append `/v1` to the resolved Vertex target URL so `build_copilot_upstream_url()` constructs the correct upstream path: `https://aiplatform.googleapis.com/v1/projects/...` - Add test assertions in `tests/test_provider_proxy_routes.py` covering both new route variants and verifying non-Anthropic versionless requests do not enter the Anthropic handler ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text 20 passed, 1 warning in 3.56s ``` ## Real Behavior Proof - Environment: macOS (Darwin 25.5.0, arm64), Claude Code with Vertex AI via `headroom wrap claude`, Headroom v0.27.0. Also verified on Fedora (OpenClaw agents using `@anthropic-ai/vertex-sdk` v0.90.0). - Exact command / steps: `claude headroom on` then `claude` launches Claude Code through headroom proxy on port 8787. Claude Code sends requests to `http://127.0.0.1:8787/projects/{project}/locations/global/publishers/anthropic/models/claude-opus-4-6:streamRawPredict`. Proxy forwards to `https://aiplatform.googleapis.com/v1/projects/...` and returns 200. - Observed result: Before fix, proxy forwarded to `https://aiplatform.googleapis.com/projects/...` (missing `/v1/`), Vertex returned 404. After fix, requests succeed with status 200. - Not tested: Non-Anthropic publishers on versionless routes (no known client sends these). These requests fall through to the catch-all passthrough by design. ## 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 - [ ] 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 The root cause: `handle_anthropic_messages()` constructs the upstream URL via `build_copilot_upstream_url(upstream_base_url, request.url.path)` which concatenates `base_url + path`. The versioned routes work because `request.url.path` already contains `/v1/` (e.g. `/v1/projects/...`). But Claude Code with `CLAUDE_CODE_USE_VERTEX=1` sends paths without the version prefix, so the upstream URL was missing `/v1/` entirely. Per review feedback, versionless routes are now scoped exclusively to `publishers/anthropic` rather than accepting a generic `{publisher}` parameter, preventing non-Anthropic publishers from hitting a passthrough path that would also lack the `/v1` prefix.
2026-06-26 20:16:39 +03:00
assert client.post(
"/projects/p/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet@20240620:rawPredict"
).json()["args"] == [
"https://vertex.test/v1",
"vertex:anthropic",
"claude-3-5-sonnet@20240620",
]
assert client.post(
"/projects/p/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet@20240620:streamRawPredict"
).json()["args"] == [
"https://vertex.test/v1",
"vertex:anthropic",
"claude-3-5-sonnet@20240620",
True,
]
assert client.post("/v1internal:streamGenerateContent").json()["handler"] == (
"handle_google_cloudcode_stream"
)
assert client.post("/v1/v1internal:streamGenerateContent").json()["handler"] == (
"handle_google_cloudcode_stream"
)
assert client.post("/v1beta/models/demo:batchGenerateContent").json()["handler"] == (
"handle_google_batch_create"
)
assert client.get("/v1beta/batches/b1").json()["handler"] == "handle_google_batch_results"
assert client.post("/v1beta/batches/b1:cancel").json()["handler"] == (
"handle_google_batch_passthrough"
)
assert client.delete("/v1beta/batches/b1").json()["handler"] == (
"handle_google_batch_passthrough"
)
fix(proxy): add versionless Vertex AI routes for Claude Code compatibility (#1321) ## Description When Claude Code is configured for Vertex AI (`CLAUDE_CODE_USE_VERTEX=1`) and routes through the Headroom proxy (`ANTHROPIC_BASE_URL=http://127.0.0.1:8787`), all requests fail with 404. Claude Code constructs Vertex paths without the `/{api_version}/` prefix (e.g. `/projects/.../models/...:rawPredict`), but the proxy's existing route patterns require it (e.g. `/{api_version}/projects/...`). The request falls through unmatched and the upstream returns 404. ## 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 - Add versionless route handlers for `rawPredict` and `streamRawPredict` in `headroom/providers/proxy_routes.py` - Routes are scoped to `/projects/{project}/locations/{location}/publishers/anthropic/models/{model}:(stream)rawPredict` -- only Anthropic publisher, no generic `{publisher}` parameter. Non-Anthropic versionless requests fall through to the catch-all passthrough, avoiding a half-fixed path that would omit the `/v1` prefix. - The handlers append `/v1` to the resolved Vertex target URL so `build_copilot_upstream_url()` constructs the correct upstream path: `https://aiplatform.googleapis.com/v1/projects/...` - Add test assertions in `tests/test_provider_proxy_routes.py` covering both new route variants and verifying non-Anthropic versionless requests do not enter the Anthropic handler ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text 20 passed, 1 warning in 3.56s ``` ## Real Behavior Proof - Environment: macOS (Darwin 25.5.0, arm64), Claude Code with Vertex AI via `headroom wrap claude`, Headroom v0.27.0. Also verified on Fedora (OpenClaw agents using `@anthropic-ai/vertex-sdk` v0.90.0). - Exact command / steps: `claude headroom on` then `claude` launches Claude Code through headroom proxy on port 8787. Claude Code sends requests to `http://127.0.0.1:8787/projects/{project}/locations/global/publishers/anthropic/models/claude-opus-4-6:streamRawPredict`. Proxy forwards to `https://aiplatform.googleapis.com/v1/projects/...` and returns 200. - Observed result: Before fix, proxy forwarded to `https://aiplatform.googleapis.com/projects/...` (missing `/v1/`), Vertex returned 404. After fix, requests succeed with status 200. - Not tested: Non-Anthropic publishers on versionless routes (no known client sends these). These requests fall through to the catch-all passthrough by design. ## 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 - [ ] 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 The root cause: `handle_anthropic_messages()` constructs the upstream URL via `build_copilot_upstream_url(upstream_base_url, request.url.path)` which concatenates `base_url + path`. The versioned routes work because `request.url.path` already contains `/v1/` (e.g. `/v1/projects/...`). But Claude Code with `CLAUDE_CODE_USE_VERTEX=1` sends paths without the version prefix, so the upstream URL was missing `/v1/` entirely. Per review feedback, versionless routes are now scoped exclusively to `publishers/anthropic` rather than accepting a generic `{publisher}` parameter, preventing non-Anthropic publishers from hitting a passthrough path that would also lack the `/v1` prefix.
2026-06-26 20:16:39 +03:00
assert len(delegated) >= 26
def test_openai_response_websocket_aliases_delegate_to_openai_ws_handler(monkeypatch) -> None:
seen_paths: list[str] = []
async def fake_ws(self, websocket): # type: ignore[no-untyped-def]
seen_paths.append(websocket.url.path)
await websocket.accept()
await websocket.send_json({"path": websocket.url.path})
await websocket.close()
monkeypatch.setattr(HeadroomProxy, "handle_openai_responses_ws", fake_ws)
with TestClient(_app()) as client:
for path in (
"/v1/responses",
"/v1/codex/responses",
"/backend-api/responses",
"/backend-api/codex/responses",
):
with client.websocket_connect(path) as websocket:
assert websocket.receive_json() == {"path": path}
assert seen_paths == [
"/v1/responses",
"/v1/codex/responses",
"/backend-api/responses",
"/backend-api/codex/responses",
]
fix: normalize /p/<project> prefix on WebSocket upgrades so the Responses WS route is not rejected with 403 (#2379) ## Description A Responses WebSocket upgrade to a project-prefixed URL (`ws://127.0.0.1:8787/p/<project>/v1/responses`) was rejected with `403 Forbidden`, so the client fell back to HTTP transport. The `/p/<name>` base-URL prefix is stripped by `strip_project_path_prefix(request.scope)` inside `@app.middleware("http")`, but Starlette runs `@app.middleware("http")` for `http` scopes only, never `websocket` scopes. So an HTTP `POST /p/<project>/v1/responses` has its prefix stripped and matches `/v1/responses`, while the WS upgrade keeps the prefix, matches no registered WebSocket route (`OPENAI_RESPONSES_WEBSOCKET_PATHS` are all unprefixed), and Starlette rejects the unmatched WebSocket with `403`. This normalizes the prefix for WebSocket scopes before routing so the upgrade reaches the existing Responses WS handler and stays attributed to the project. Closes #2355 ## 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 - `headroom/proxy/server.py` — added a small pure-ASGI `WebSocketProjectPrefixMiddleware` (registered in `create_app`) that, for `websocket` scopes only, strips the `/p/<name>` prefix via the existing `strip_project_path_prefix` and binds the project context, mirroring the HTTP middleware. HTTP and lifespan scopes pass through untouched (no double-strip). - `headroom/proxy/handlers/openai.py` — `handle_openai_responses_ws` previously called `set_current_project(classify_project(ws_headers))` unconditionally, clearing the middleware-bound project for prefix-only clients (no `X-Headroom-Project` header). It now falls back to the already-bound path-prefix project (`classify_project(ws_headers) or get_current_project()`), so prefix-only WebSocket clients (aider, Copilot BYOK, Cursor and other `/p/<name>` base-URL wraps) stay attributed, exactly as on the HTTP path. - `tests/test_provider_proxy_routes.py` — added a regression test. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_provider_proxy_routes.py -q 21 passed, 1 warning in 23.95s $ ruff check headroom/proxy/server.py headroom/proxy/handlers/openai.py All checks passed! $ mypy --python-version 3.13 headroom/proxy/server.py headroom/proxy/handlers/openai.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: local, `uv` venv, Python 3.14, `uv run pytest`. - Exact command / steps: added `test_project_prefixed_openai_response_websocket_delegates_to_openai_ws_handler`, which connects a WebSocket to `/p/test-project/v1/responses`. - Observed result: the connection is accepted (no 403), the handler is reached with the canonical `/v1/responses` path, and the request is attributed to project `test-project`. - Not tested: live end-to-end against a real upstream Responses WebSocket server (validated via the routing/attribution regression test only). ## 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 - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) N/A — backend routing change with no user-facing UI. ## Additional Notes Documentation checklist item is N/A: this is an internal routing fix with no configuration or public-API surface change. The fix mirrors the existing HTTP prefix-strip behavior so project-prefixed WebSocket clients behave identically to their HTTP counterparts. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-08-03 11:15:40 -07:00
def test_project_prefixed_openai_response_websocket_delegates_to_openai_ws_handler(
monkeypatch,
) -> None:
seen_paths: list[str] = []
seen_projects: list[str | None] = []
async def fake_ws(self, websocket): # type: ignore[no-untyped-def]
seen_paths.append(websocket.url.path)
seen_projects.append(get_current_project())
await websocket.accept()
await websocket.send_json({"path": websocket.url.path})
await websocket.close()
monkeypatch.setattr(HeadroomProxy, "handle_openai_responses_ws", fake_ws)
with TestClient(_app()) as client:
with client.websocket_connect("/p/test-project/v1/responses") as websocket:
assert websocket.receive_json() == {"path": "/v1/responses"}
assert seen_paths == ["/v1/responses"]
# The /p/<name> prefix is bound as the project even without a header, so a
# prefix-only Codex WS client is still attributed (not just routed).
assert seen_projects == ["test-project"]
def test_openai_response_subpath_passthrough_returns_502_on_http_failure() -> None:
class FailingAsyncClient:
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
raise RuntimeError(f"boom: {method} {url}")
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
client.app.state.proxy.http_client = FailingAsyncClient()
refactor(providers): split proxy route adapters (#1934) ## Description Refactors provider-specific proxy routing into provider-owned helper modules so `headroom/providers/proxy_routes.py` primarily registers routes and delegates behavior. This keeps Codex, OpenAI Responses/images, model metadata, Vertex, Cloud Code, passthrough target selection, and request path normalization logic testable outside the route table. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Extracted Codex routing helpers for headers, endpoint URLs, image forwarding, response subpaths, and model metadata. - Moved provider target selection, route specs, OpenAI Responses/images helpers, Vertex runtime helpers, Cloud Code path normalization, passthrough telemetry, and request scope normalization into focused modules. - Kept `proxy_routes.py` as route registration/delegation and preserved current-main `/v1/messages` custom-base behavior. - Added focused provider/proxy tests for the extracted modules and route delegation behavior. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_package_init_lazy.py::test_codex_package_import_stays_runtime_only tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets tests/test_provider_proxy_routes.py::test_openai_image_routes_use_codex_backend_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_openai_image_codex_response_strips_stale_compression_headers tests/test_provider_proxy_routes.py::test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough tests/test_provider_proxy_routes.py::test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py::test_with_project_prefix_round_trips_through_split tests/test_vertex_claude_compression.py ============================ 102 passed in 34.83s ============================= python -m ruff check headroom/providers/cloudcode headroom/providers/codex headroom/providers/vertex headroom/providers/model_metadata.py headroom/providers/openai_images.py headroom/providers/openai_responses.py headroom/providers/proxy_targets.py headroom/providers/route_specs.py headroom/providers/proxy_routes.py headroom/proxy/handlers/openai.py headroom/proxy/passthrough.py headroom/proxy/request_scope.py headroom/proxy/project_context.py tests/test_package_init_lazy.py tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py tests/test_vertex_claude_compression.py All checks passed! python -m compileall -q headroom\providers\cloudcode headroom\providers\codex headroom\providers\vertex headroom\providers\model_metadata.py headroom\providers\openai_images.py headroom\providers\openai_responses.py headroom\providers\proxy_targets.py headroom\providers\route_specs.py headroom\providers\proxy_routes.py headroom\proxy\handlers\openai.py headroom\proxy\passthrough.py headroom\proxy\request_scope.py headroom\proxy\project_context.py # no output; exited 0 git commit -m "refactor(providers): split proxy route adapters" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/provider-route-slices` based on `headroomlabs/main`. - Exact command / steps: Ran the focused provider/proxy pytest suite, focused ruff command, compileall over changed Python modules, and commit hooks. - Observed result: Provider/proxy route behavior tests passed; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream provider calls, and manual end-to-end proxy traffic. ## 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 ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; coverage here is focused on provider/proxy routing behavior touched by this slice. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-12 21:27:45 +00:00
with patch("headroom.providers.openai_responses.logger") as logger:
2026-05-09 22:36:56 -07:00
response = client.post("/v1/responses/compact?trace=1", json={"model": "gpt-4o"})
assert response.status_code == 502
refactor(providers): split proxy route adapters (#1934) ## Description Refactors provider-specific proxy routing into provider-owned helper modules so `headroom/providers/proxy_routes.py` primarily registers routes and delegates behavior. This keeps Codex, OpenAI Responses/images, model metadata, Vertex, Cloud Code, passthrough target selection, and request path normalization logic testable outside the route table. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Extracted Codex routing helpers for headers, endpoint URLs, image forwarding, response subpaths, and model metadata. - Moved provider target selection, route specs, OpenAI Responses/images helpers, Vertex runtime helpers, Cloud Code path normalization, passthrough telemetry, and request scope normalization into focused modules. - Kept `proxy_routes.py` as route registration/delegation and preserved current-main `/v1/messages` custom-base behavior. - Added focused provider/proxy tests for the extracted modules and route delegation behavior. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_package_init_lazy.py::test_codex_package_import_stays_runtime_only tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets tests/test_provider_proxy_routes.py::test_openai_image_routes_use_codex_backend_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_openai_image_codex_response_strips_stale_compression_headers tests/test_provider_proxy_routes.py::test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough tests/test_provider_proxy_routes.py::test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py::test_with_project_prefix_round_trips_through_split tests/test_vertex_claude_compression.py ============================ 102 passed in 34.83s ============================= python -m ruff check headroom/providers/cloudcode headroom/providers/codex headroom/providers/vertex headroom/providers/model_metadata.py headroom/providers/openai_images.py headroom/providers/openai_responses.py headroom/providers/proxy_targets.py headroom/providers/route_specs.py headroom/providers/proxy_routes.py headroom/proxy/handlers/openai.py headroom/proxy/passthrough.py headroom/proxy/request_scope.py headroom/proxy/project_context.py tests/test_package_init_lazy.py tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py tests/test_vertex_claude_compression.py All checks passed! python -m compileall -q headroom\providers\cloudcode headroom\providers\codex headroom\providers\vertex headroom\providers\model_metadata.py headroom\providers\openai_images.py headroom\providers\openai_responses.py headroom\providers\proxy_targets.py headroom\providers\route_specs.py headroom\providers\proxy_routes.py headroom\proxy\handlers\openai.py headroom\proxy\passthrough.py headroom\proxy\request_scope.py headroom\proxy\project_context.py # no output; exited 0 git commit -m "refactor(providers): split proxy route adapters" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/provider-route-slices` based on `headroomlabs/main`. - Exact command / steps: Ran the focused provider/proxy pytest suite, focused ruff command, compileall over changed Python modules, and commit hooks. - Observed result: Provider/proxy route behavior tests passed; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream provider calls, and manual end-to-end proxy traffic. ## 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 ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; coverage here is focused on provider/proxy routing behavior touched by this slice. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-12 21:27:45 +00:00
assert response.text == "Upstream request failed."
2026-05-09 22:36:56 -07:00
logger.error.assert_called_once()
refactor(providers): split proxy route adapters (#1934) ## Description Refactors provider-specific proxy routing into provider-owned helper modules so `headroom/providers/proxy_routes.py` primarily registers routes and delegates behavior. This keeps Codex, OpenAI Responses/images, model metadata, Vertex, Cloud Code, passthrough target selection, and request path normalization logic testable outside the route table. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Extracted Codex routing helpers for headers, endpoint URLs, image forwarding, response subpaths, and model metadata. - Moved provider target selection, route specs, OpenAI Responses/images helpers, Vertex runtime helpers, Cloud Code path normalization, passthrough telemetry, and request scope normalization into focused modules. - Kept `proxy_routes.py` as route registration/delegation and preserved current-main `/v1/messages` custom-base behavior. - Added focused provider/proxy tests for the extracted modules and route delegation behavior. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_package_init_lazy.py::test_codex_package_import_stays_runtime_only tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets tests/test_provider_proxy_routes.py::test_openai_image_routes_use_codex_backend_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_openai_image_codex_response_strips_stale_compression_headers tests/test_provider_proxy_routes.py::test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough tests/test_provider_proxy_routes.py::test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py::test_with_project_prefix_round_trips_through_split tests/test_vertex_claude_compression.py ============================ 102 passed in 34.83s ============================= python -m ruff check headroom/providers/cloudcode headroom/providers/codex headroom/providers/vertex headroom/providers/model_metadata.py headroom/providers/openai_images.py headroom/providers/openai_responses.py headroom/providers/proxy_targets.py headroom/providers/route_specs.py headroom/providers/proxy_routes.py headroom/proxy/handlers/openai.py headroom/proxy/passthrough.py headroom/proxy/request_scope.py headroom/proxy/project_context.py tests/test_package_init_lazy.py tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py tests/test_vertex_claude_compression.py All checks passed! python -m compileall -q headroom\providers\cloudcode headroom\providers\codex headroom\providers\vertex headroom\providers\model_metadata.py headroom\providers\openai_images.py headroom\providers\openai_responses.py headroom\providers\proxy_targets.py headroom\providers\route_specs.py headroom\providers\proxy_routes.py headroom\proxy\handlers\openai.py headroom\proxy\passthrough.py headroom\proxy\request_scope.py headroom\proxy\project_context.py # no output; exited 0 git commit -m "refactor(providers): split proxy route adapters" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/provider-route-slices` based on `headroomlabs/main`. - Exact command / steps: Ran the focused provider/proxy pytest suite, focused ruff command, compileall over changed Python modules, and commit hooks. - Observed result: Provider/proxy route behavior tests passed; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream provider calls, and manual end-to-end proxy traffic. ## 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 ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; coverage here is focused on provider/proxy routing behavior touched by this slice. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-12 21:27:45 +00:00
assert "boom: POST https://api.openai.test/v1/responses/compact?trace=1" in str(
logger.error.call_args
)
def test_openai_response_subpath_passthrough_uses_openai_target() -> None:
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, str]]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append((method, url, dict(kwargs.get("headers", {}))))
return httpx.Response(200, json={"url": url})
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake = FakeAsyncClient()
client.app.state.proxy.http_client = fake
response = client.delete(
"/v1/responses/items/resp_123?trace=7",
headers={"Authorization": "Bearer sk-proj-test"},
)
assert response.status_code == 200
assert len(fake.calls) == 1
method, url, headers = fake.calls[0]
assert method == "DELETE"
assert url == "https://api.openai.test/v1/responses/items/resp_123?trace=7"
assert headers["authorization"] == "Bearer sk-proj-test"
def test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets(monkeypatch) -> None:
monkeypatch.setattr(
refactor(providers): split proxy route adapters (#1934) ## Description Refactors provider-specific proxy routing into provider-owned helper modules so `headroom/providers/proxy_routes.py` primarily registers routes and delegates behavior. This keeps Codex, OpenAI Responses/images, model metadata, Vertex, Cloud Code, passthrough target selection, and request path normalization logic testable outside the route table. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Extracted Codex routing helpers for headers, endpoint URLs, image forwarding, response subpaths, and model metadata. - Moved provider target selection, route specs, OpenAI Responses/images helpers, Vertex runtime helpers, Cloud Code path normalization, passthrough telemetry, and request scope normalization into focused modules. - Kept `proxy_routes.py` as route registration/delegation and preserved current-main `/v1/messages` custom-base behavior. - Added focused provider/proxy tests for the extracted modules and route delegation behavior. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_package_init_lazy.py::test_codex_package_import_stays_runtime_only tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets tests/test_provider_proxy_routes.py::test_openai_image_routes_use_codex_backend_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_openai_image_codex_response_strips_stale_compression_headers tests/test_provider_proxy_routes.py::test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough tests/test_provider_proxy_routes.py::test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py::test_with_project_prefix_round_trips_through_split tests/test_vertex_claude_compression.py ============================ 102 passed in 34.83s ============================= python -m ruff check headroom/providers/cloudcode headroom/providers/codex headroom/providers/vertex headroom/providers/model_metadata.py headroom/providers/openai_images.py headroom/providers/openai_responses.py headroom/providers/proxy_targets.py headroom/providers/route_specs.py headroom/providers/proxy_routes.py headroom/proxy/handlers/openai.py headroom/proxy/passthrough.py headroom/proxy/request_scope.py headroom/proxy/project_context.py tests/test_package_init_lazy.py tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py tests/test_vertex_claude_compression.py All checks passed! python -m compileall -q headroom\providers\cloudcode headroom\providers\codex headroom\providers\vertex headroom\providers\model_metadata.py headroom\providers\openai_images.py headroom\providers\openai_responses.py headroom\providers\proxy_targets.py headroom\providers\route_specs.py headroom\providers\proxy_routes.py headroom\proxy\handlers\openai.py headroom\proxy\passthrough.py headroom\proxy\request_scope.py headroom\proxy\project_context.py # no output; exited 0 git commit -m "refactor(providers): split proxy route adapters" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/provider-route-slices` based on `headroomlabs/main`. - Exact command / steps: Ran the focused provider/proxy pytest suite, focused ruff command, compileall over changed Python modules, and commit hooks. - Observed result: Provider/proxy route behavior tests passed; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream provider calls, and manual end-to-end proxy traffic. ## 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 ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; coverage here is focused on provider/proxy routing behavior touched by this slice. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-12 21:27:45 +00:00
"headroom.providers.codex.responses.resolve_codex_routing",
lambda headers: CodexRoutingDecision(headers=dict(headers), is_chatgpt_auth=True),
)
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append((method, url))
return httpx.Response(200, json={"url": url})
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake = FakeAsyncClient()
client.app.state.proxy.http_client = fake
assert client.get("/v1/codex/responses/items/resp_1").status_code == 200
assert client.post("/backend-api/responses/items/resp_2").status_code == 200
assert client.delete("/backend-api/codex/responses/items/resp_3").status_code == 200
assert fake.calls == [
("GET", "https://chatgpt.com/backend-api/codex/responses/items/resp_1"),
("POST", "https://chatgpt.com/backend-api/codex/responses/items/resp_2"),
("DELETE", "https://chatgpt.com/backend-api/codex/responses/items/resp_3"),
]
fix(proxy): route Codex OAuth image requests (#1215) ## Description Closes #1189. After a recent Codex Desktop update, its built-in image generation started going through Codex's image client, which POSTs to `images/generations` and `images/edits` relative to the configured provider base URL. In Headroom Proxy mode Codex is pointed at Headroom's `/v1` surface, so those land as `/v1/images/generations` and `/v1/images/edits`. Headroom already had `/v1/images/generations`, but it only ever hit the OpenAI API-key passthrough, and there was no `/v1/images/edits` route at all. So under ChatGPT/Codex OAuth the image calls had nowhere correct to go. This change routes OAuth image requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` and leaves the API-key passthrough untouched. Latest upstream re-check: current `openai/codex` main is now `aaf737f`, and the relevant `ImagesClient`/provider-base source still resolves image generation and edit requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` under ChatGPT-family auth. One issue-thread datapoint reports Codex Desktop `0.142.0-alpha.6` on macOS generating images successfully via the `/v1/responses` WebSocket path. The requester has now checked this against the latest timestamped Codex update, so this is ready for maintainer review with the remaining full-suite caveat documented below. **Reproduction / test contract** - Reporter's setup: Codex Desktop 0.142.0-alpha.1 on Windows 10, Headroom v0.26.0 Proxy mode, OAuth auth. `/v1/models` and `/v1/responses` work; built-in image generation fails. - Why the route was confirmed from source: the reporter's sanitized logs only show `/v1/models` and `/v1/responses`, so I traced the rest in current Codex source — image generation/edit go through `ImagesClient` as `images/generations` and `images/edits` against the provider base URL. - Regression test: `test_openai_image_routes_use_codex_backend_under_chatgpt_auth` asserts both OAuth image routes now resolve to the ChatGPT Codex image backend. Before this patch, `/v1/images/generations` used the OpenAI API-key target under OAuth and `/v1/images/edits` didn't exist. - Hardening tests: additional regressions cover stale upstream compression headers, OpenAI API-key fall-through for edits, and multipart edit body byte-preservation. ## 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 - Route ChatGPT/Codex OAuth `/v1/images/generations` and `/v1/images/edits` to the ChatGPT Codex image backend. - Strip internal `x-headroom-*`, `Host`, and `Accept-Encoding` headers before forwarding Codex OAuth image requests upstream. - Strip stale `Content-Encoding` and `Content-Length` headers from image responses because httpx has already decoded the body. - Keep API-key image requests on the existing OpenAI passthrough. - Add regression coverage for both OAuth image routes, OpenAI image-edit passthrough, compressed-response header handling, and multipart edit bodies. - Add a `CHANGELOG.md` entry. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 7.63s $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 8.54s $ uv run ruff check . All checks passed! $ uv run ruff format --check . 895 files already formatted $ uv run mypy headroom headroom/proxy/server.py:1152: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1222: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1226: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Success: no issues found in 380 source files ``` Earlier full-suite attempt in this branch/environment, before the F1-F8 hardening pass (not rerun after hardening because the failures were unrelated to this route and expensive): ```text $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest 6 failed, 6499 passed, 486 skipped, 5807 warnings in 219.53s ``` All 6 failures are outside the touched routes and unrelated to this change: - `tests/test_corrupt_golden_bytes_recovery.py` — 3 log-capture assertions - `tests/test_forwarded_headers.py::test_non_allowlisted_peer_ignores_forwarded_and_logs` — 1 log-capture assertion - `tests/test_image_compression.py::TestOnnxRouter::test_full_classify_with_image` — `ModuleNotFoundError: No module named 'PIL'` (only `dev,proxy` extras installed) - `tests/test_transforms/test_kompress_compressor.py::TestKompressBackendSelection::test_unrecognized_backend_warns_and_falls_back_to_auto` — 1 warning-capture assertion On Python 3.14.4, plain `uv run pytest` can't even collect: the project's dependency marker intentionally excludes `litellm` on 3.14, while `tests/test_memory_eval.py` imports the eval runner at collection time. ## Real Behavior Proof - **Environment:** macOS (Darwin arm64). Python 3.14.4 via uv for the default project env; Python 3.12.13 via `UV_PROJECT_ENVIRONMENT=.venv-py312` for the broader suite. Headroom FastAPI proxy route test harness. - **Exact command / steps:** read the reporter's sanitized issue logs; traced current Codex image-generation source; ran the focused Codex/proxy route tests on 3.14 and 3.12; ran lint, format check, and mypy; attempted the full 3.12 suite (output above). - **After-fix evidence + observed result:** the regression test captures the OAuth image requests and confirms they forward to `https://chatgpt.com/backend-api/codex/images/generations` and `.../images/edits` — auth and account headers preserved, internal/host/accept encoding headers stripped, query string carried through, JSON and multipart request bodies forwarded byte-for-byte, and stale upstream response compression headers removed. API-key image generation still uses `images/generations`, and image edits now have the matching `images/edits` passthrough. - **Source evidence:** Re-verified against current `openai/codex` HEAD `aaf737f`. `ImagesClient` still sends relative paths `images/generations` and `images/edits`; `Provider::url_for_path()` appends those to the active provider base; ChatGPT-family auth modes default that base to `CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`. Therefore the source-resolved upstream paths are `/backend-api/codex/images/generations` and `/backend-api/codex/images/edits`, not `/backend-api/images/...`. - **Latest-build caveat:** an issue-thread report says Codex Desktop `0.142.0-alpha.6` on macOS uses `/v1/responses` WebSocket image generation and works through the proxy. That may mean the original Windows `0.142.0-alpha.1` regression is fixed client-side in newer desktop builds, even though the source image endpoint route remains valid and now covered here. The requester has checked this against the latest timestamped Codex update before moving the PR out of draft. - **Not fully tested:** a fully green `uv run pytest` remains unavailable in this local environment for the unrelated failures listed above. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes No dependency or version changes. The remaining caveat is that the full local suite isn't green in this environment for the unrelated failures listed above. Happy to follow up with additional runtime logs or to re-run the suite in a maintainer's preferred dev container if that's the cleaner path. --------- Co-authored-by: Johnson <johnsond@brightops.com>
2026-06-22 00:25:49 +08:00
def test_openai_image_routes_use_codex_backend_under_chatgpt_auth(monkeypatch) -> None:
monkeypatch.setattr(
refactor(providers): split proxy route adapters (#1934) ## Description Refactors provider-specific proxy routing into provider-owned helper modules so `headroom/providers/proxy_routes.py` primarily registers routes and delegates behavior. This keeps Codex, OpenAI Responses/images, model metadata, Vertex, Cloud Code, passthrough target selection, and request path normalization logic testable outside the route table. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Extracted Codex routing helpers for headers, endpoint URLs, image forwarding, response subpaths, and model metadata. - Moved provider target selection, route specs, OpenAI Responses/images helpers, Vertex runtime helpers, Cloud Code path normalization, passthrough telemetry, and request scope normalization into focused modules. - Kept `proxy_routes.py` as route registration/delegation and preserved current-main `/v1/messages` custom-base behavior. - Added focused provider/proxy tests for the extracted modules and route delegation behavior. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_package_init_lazy.py::test_codex_package_import_stays_runtime_only tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets tests/test_provider_proxy_routes.py::test_openai_image_routes_use_codex_backend_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_openai_image_codex_response_strips_stale_compression_headers tests/test_provider_proxy_routes.py::test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough tests/test_provider_proxy_routes.py::test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py::test_with_project_prefix_round_trips_through_split tests/test_vertex_claude_compression.py ============================ 102 passed in 34.83s ============================= python -m ruff check headroom/providers/cloudcode headroom/providers/codex headroom/providers/vertex headroom/providers/model_metadata.py headroom/providers/openai_images.py headroom/providers/openai_responses.py headroom/providers/proxy_targets.py headroom/providers/route_specs.py headroom/providers/proxy_routes.py headroom/proxy/handlers/openai.py headroom/proxy/passthrough.py headroom/proxy/request_scope.py headroom/proxy/project_context.py tests/test_package_init_lazy.py tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py tests/test_vertex_claude_compression.py All checks passed! python -m compileall -q headroom\providers\cloudcode headroom\providers\codex headroom\providers\vertex headroom\providers\model_metadata.py headroom\providers\openai_images.py headroom\providers\openai_responses.py headroom\providers\proxy_targets.py headroom\providers\route_specs.py headroom\providers\proxy_routes.py headroom\proxy\handlers\openai.py headroom\proxy\passthrough.py headroom\proxy\request_scope.py headroom\proxy\project_context.py # no output; exited 0 git commit -m "refactor(providers): split proxy route adapters" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/provider-route-slices` based on `headroomlabs/main`. - Exact command / steps: Ran the focused provider/proxy pytest suite, focused ruff command, compileall over changed Python modules, and commit hooks. - Observed result: Provider/proxy route behavior tests passed; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream provider calls, and manual end-to-end proxy traffic. ## 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 ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; coverage here is focused on provider/proxy routing behavior touched by this slice. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-12 21:27:45 +00:00
"headroom.providers.codex.images.resolve_codex_routing",
lambda headers: CodexRoutingDecision(
headers={**headers, "ChatGPT-Account-ID": "acct_123"},
is_chatgpt_auth=True,
),
fix(proxy): route Codex OAuth image requests (#1215) ## Description Closes #1189. After a recent Codex Desktop update, its built-in image generation started going through Codex's image client, which POSTs to `images/generations` and `images/edits` relative to the configured provider base URL. In Headroom Proxy mode Codex is pointed at Headroom's `/v1` surface, so those land as `/v1/images/generations` and `/v1/images/edits`. Headroom already had `/v1/images/generations`, but it only ever hit the OpenAI API-key passthrough, and there was no `/v1/images/edits` route at all. So under ChatGPT/Codex OAuth the image calls had nowhere correct to go. This change routes OAuth image requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` and leaves the API-key passthrough untouched. Latest upstream re-check: current `openai/codex` main is now `aaf737f`, and the relevant `ImagesClient`/provider-base source still resolves image generation and edit requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` under ChatGPT-family auth. One issue-thread datapoint reports Codex Desktop `0.142.0-alpha.6` on macOS generating images successfully via the `/v1/responses` WebSocket path. The requester has now checked this against the latest timestamped Codex update, so this is ready for maintainer review with the remaining full-suite caveat documented below. **Reproduction / test contract** - Reporter's setup: Codex Desktop 0.142.0-alpha.1 on Windows 10, Headroom v0.26.0 Proxy mode, OAuth auth. `/v1/models` and `/v1/responses` work; built-in image generation fails. - Why the route was confirmed from source: the reporter's sanitized logs only show `/v1/models` and `/v1/responses`, so I traced the rest in current Codex source — image generation/edit go through `ImagesClient` as `images/generations` and `images/edits` against the provider base URL. - Regression test: `test_openai_image_routes_use_codex_backend_under_chatgpt_auth` asserts both OAuth image routes now resolve to the ChatGPT Codex image backend. Before this patch, `/v1/images/generations` used the OpenAI API-key target under OAuth and `/v1/images/edits` didn't exist. - Hardening tests: additional regressions cover stale upstream compression headers, OpenAI API-key fall-through for edits, and multipart edit body byte-preservation. ## 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 - Route ChatGPT/Codex OAuth `/v1/images/generations` and `/v1/images/edits` to the ChatGPT Codex image backend. - Strip internal `x-headroom-*`, `Host`, and `Accept-Encoding` headers before forwarding Codex OAuth image requests upstream. - Strip stale `Content-Encoding` and `Content-Length` headers from image responses because httpx has already decoded the body. - Keep API-key image requests on the existing OpenAI passthrough. - Add regression coverage for both OAuth image routes, OpenAI image-edit passthrough, compressed-response header handling, and multipart edit bodies. - Add a `CHANGELOG.md` entry. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 7.63s $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 8.54s $ uv run ruff check . All checks passed! $ uv run ruff format --check . 895 files already formatted $ uv run mypy headroom headroom/proxy/server.py:1152: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1222: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1226: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Success: no issues found in 380 source files ``` Earlier full-suite attempt in this branch/environment, before the F1-F8 hardening pass (not rerun after hardening because the failures were unrelated to this route and expensive): ```text $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest 6 failed, 6499 passed, 486 skipped, 5807 warnings in 219.53s ``` All 6 failures are outside the touched routes and unrelated to this change: - `tests/test_corrupt_golden_bytes_recovery.py` — 3 log-capture assertions - `tests/test_forwarded_headers.py::test_non_allowlisted_peer_ignores_forwarded_and_logs` — 1 log-capture assertion - `tests/test_image_compression.py::TestOnnxRouter::test_full_classify_with_image` — `ModuleNotFoundError: No module named 'PIL'` (only `dev,proxy` extras installed) - `tests/test_transforms/test_kompress_compressor.py::TestKompressBackendSelection::test_unrecognized_backend_warns_and_falls_back_to_auto` — 1 warning-capture assertion On Python 3.14.4, plain `uv run pytest` can't even collect: the project's dependency marker intentionally excludes `litellm` on 3.14, while `tests/test_memory_eval.py` imports the eval runner at collection time. ## Real Behavior Proof - **Environment:** macOS (Darwin arm64). Python 3.14.4 via uv for the default project env; Python 3.12.13 via `UV_PROJECT_ENVIRONMENT=.venv-py312` for the broader suite. Headroom FastAPI proxy route test harness. - **Exact command / steps:** read the reporter's sanitized issue logs; traced current Codex image-generation source; ran the focused Codex/proxy route tests on 3.14 and 3.12; ran lint, format check, and mypy; attempted the full 3.12 suite (output above). - **After-fix evidence + observed result:** the regression test captures the OAuth image requests and confirms they forward to `https://chatgpt.com/backend-api/codex/images/generations` and `.../images/edits` — auth and account headers preserved, internal/host/accept encoding headers stripped, query string carried through, JSON and multipart request bodies forwarded byte-for-byte, and stale upstream response compression headers removed. API-key image generation still uses `images/generations`, and image edits now have the matching `images/edits` passthrough. - **Source evidence:** Re-verified against current `openai/codex` HEAD `aaf737f`. `ImagesClient` still sends relative paths `images/generations` and `images/edits`; `Provider::url_for_path()` appends those to the active provider base; ChatGPT-family auth modes default that base to `CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`. Therefore the source-resolved upstream paths are `/backend-api/codex/images/generations` and `/backend-api/codex/images/edits`, not `/backend-api/images/...`. - **Latest-build caveat:** an issue-thread report says Codex Desktop `0.142.0-alpha.6` on macOS uses `/v1/responses` WebSocket image generation and works through the proxy. That may mean the original Windows `0.142.0-alpha.1` regression is fixed client-side in newer desktop builds, even though the source image endpoint route remains valid and now covered here. The requester has checked this against the latest timestamped Codex update before moving the PR out of draft. - **Not fully tested:** a fully green `uv run pytest` remains unavailable in this local environment for the unrelated failures listed above. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes No dependency or version changes. The remaining caveat is that the full local suite isn't green in this environment for the unrelated failures listed above. Happy to follow up with additional runtime logs or to re-run the suite in a maintainer's preferred dev container if that's the cleaner path. --------- Co-authored-by: Johnson <johnsond@brightops.com>
2026-06-22 00:25:49 +08:00
)
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, str], bytes]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append(
(
method,
url,
dict(kwargs.get("headers", {})),
kwargs.get("content", b""),
)
)
return httpx.Response(200, json={"url": url})
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake = FakeAsyncClient()
client.app.state.proxy.http_client = fake
client.app.state.proxy.http_client_h1 = fake
generate_response = client.post(
"/v1/images/generations?client_version=0.142.0",
headers={
"Authorization": "Bearer oauth-token",
"Accept-Encoding": "gzip",
"X-Headroom-Bypass": "1",
},
json={"model": "gpt-image-2", "prompt": "a route probe"},
)
edit_response = client.post(
"/v1/images/edits",
headers={"Authorization": "Bearer oauth-token"},
json={"model": "gpt-image-2", "prompt": "edit route probe", "images": []},
)
assert generate_response.status_code == 200
assert edit_response.status_code == 200
assert len(fake.calls) == 2
generate_method, generate_url, generate_headers, generate_body = fake.calls[0]
assert generate_method == "POST"
assert (
generate_url
== "https://chatgpt.com/backend-api/codex/images/generations?client_version=0.142.0"
)
assert generate_headers["authorization"] == "Bearer oauth-token"
assert generate_headers["ChatGPT-Account-ID"] == "acct_123"
assert "host" not in generate_headers
assert "accept-encoding" not in generate_headers
assert "x-headroom-bypass" not in generate_headers
assert generate_body == b'{"model":"gpt-image-2","prompt":"a route probe"}'
edit_method, edit_url, edit_headers, edit_body = fake.calls[1]
assert edit_method == "POST"
assert edit_url == "https://chatgpt.com/backend-api/codex/images/edits"
assert edit_headers["authorization"] == "Bearer oauth-token"
assert edit_headers["ChatGPT-Account-ID"] == "acct_123"
assert "host" not in edit_headers
assert edit_body == b'{"model":"gpt-image-2","prompt":"edit route probe","images":[]}'
def test_openai_image_codex_response_strips_stale_compression_headers(monkeypatch) -> None:
upstream_body = b'{"ok":true}'
stale_content_length = "9999"
monkeypatch.setattr(
refactor(providers): split proxy route adapters (#1934) ## Description Refactors provider-specific proxy routing into provider-owned helper modules so `headroom/providers/proxy_routes.py` primarily registers routes and delegates behavior. This keeps Codex, OpenAI Responses/images, model metadata, Vertex, Cloud Code, passthrough target selection, and request path normalization logic testable outside the route table. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Extracted Codex routing helpers for headers, endpoint URLs, image forwarding, response subpaths, and model metadata. - Moved provider target selection, route specs, OpenAI Responses/images helpers, Vertex runtime helpers, Cloud Code path normalization, passthrough telemetry, and request scope normalization into focused modules. - Kept `proxy_routes.py` as route registration/delegation and preserved current-main `/v1/messages` custom-base behavior. - Added focused provider/proxy tests for the extracted modules and route delegation behavior. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_package_init_lazy.py::test_codex_package_import_stays_runtime_only tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets tests/test_provider_proxy_routes.py::test_openai_image_routes_use_codex_backend_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_openai_image_codex_response_strips_stale_compression_headers tests/test_provider_proxy_routes.py::test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough tests/test_provider_proxy_routes.py::test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py::test_with_project_prefix_round_trips_through_split tests/test_vertex_claude_compression.py ============================ 102 passed in 34.83s ============================= python -m ruff check headroom/providers/cloudcode headroom/providers/codex headroom/providers/vertex headroom/providers/model_metadata.py headroom/providers/openai_images.py headroom/providers/openai_responses.py headroom/providers/proxy_targets.py headroom/providers/route_specs.py headroom/providers/proxy_routes.py headroom/proxy/handlers/openai.py headroom/proxy/passthrough.py headroom/proxy/request_scope.py headroom/proxy/project_context.py tests/test_package_init_lazy.py tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py tests/test_vertex_claude_compression.py All checks passed! python -m compileall -q headroom\providers\cloudcode headroom\providers\codex headroom\providers\vertex headroom\providers\model_metadata.py headroom\providers\openai_images.py headroom\providers\openai_responses.py headroom\providers\proxy_targets.py headroom\providers\route_specs.py headroom\providers\proxy_routes.py headroom\proxy\handlers\openai.py headroom\proxy\passthrough.py headroom\proxy\request_scope.py headroom\proxy\project_context.py # no output; exited 0 git commit -m "refactor(providers): split proxy route adapters" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/provider-route-slices` based on `headroomlabs/main`. - Exact command / steps: Ran the focused provider/proxy pytest suite, focused ruff command, compileall over changed Python modules, and commit hooks. - Observed result: Provider/proxy route behavior tests passed; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream provider calls, and manual end-to-end proxy traffic. ## 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 ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; coverage here is focused on provider/proxy routing behavior touched by this slice. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-12 21:27:45 +00:00
"headroom.providers.codex.images.resolve_codex_routing",
lambda headers: CodexRoutingDecision(
headers={**headers, "ChatGPT-Account-ID": "acct_123"},
is_chatgpt_auth=True,
),
fix(proxy): route Codex OAuth image requests (#1215) ## Description Closes #1189. After a recent Codex Desktop update, its built-in image generation started going through Codex's image client, which POSTs to `images/generations` and `images/edits` relative to the configured provider base URL. In Headroom Proxy mode Codex is pointed at Headroom's `/v1` surface, so those land as `/v1/images/generations` and `/v1/images/edits`. Headroom already had `/v1/images/generations`, but it only ever hit the OpenAI API-key passthrough, and there was no `/v1/images/edits` route at all. So under ChatGPT/Codex OAuth the image calls had nowhere correct to go. This change routes OAuth image requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` and leaves the API-key passthrough untouched. Latest upstream re-check: current `openai/codex` main is now `aaf737f`, and the relevant `ImagesClient`/provider-base source still resolves image generation and edit requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` under ChatGPT-family auth. One issue-thread datapoint reports Codex Desktop `0.142.0-alpha.6` on macOS generating images successfully via the `/v1/responses` WebSocket path. The requester has now checked this against the latest timestamped Codex update, so this is ready for maintainer review with the remaining full-suite caveat documented below. **Reproduction / test contract** - Reporter's setup: Codex Desktop 0.142.0-alpha.1 on Windows 10, Headroom v0.26.0 Proxy mode, OAuth auth. `/v1/models` and `/v1/responses` work; built-in image generation fails. - Why the route was confirmed from source: the reporter's sanitized logs only show `/v1/models` and `/v1/responses`, so I traced the rest in current Codex source — image generation/edit go through `ImagesClient` as `images/generations` and `images/edits` against the provider base URL. - Regression test: `test_openai_image_routes_use_codex_backend_under_chatgpt_auth` asserts both OAuth image routes now resolve to the ChatGPT Codex image backend. Before this patch, `/v1/images/generations` used the OpenAI API-key target under OAuth and `/v1/images/edits` didn't exist. - Hardening tests: additional regressions cover stale upstream compression headers, OpenAI API-key fall-through for edits, and multipart edit body byte-preservation. ## 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 - Route ChatGPT/Codex OAuth `/v1/images/generations` and `/v1/images/edits` to the ChatGPT Codex image backend. - Strip internal `x-headroom-*`, `Host`, and `Accept-Encoding` headers before forwarding Codex OAuth image requests upstream. - Strip stale `Content-Encoding` and `Content-Length` headers from image responses because httpx has already decoded the body. - Keep API-key image requests on the existing OpenAI passthrough. - Add regression coverage for both OAuth image routes, OpenAI image-edit passthrough, compressed-response header handling, and multipart edit bodies. - Add a `CHANGELOG.md` entry. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 7.63s $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 8.54s $ uv run ruff check . All checks passed! $ uv run ruff format --check . 895 files already formatted $ uv run mypy headroom headroom/proxy/server.py:1152: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1222: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1226: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Success: no issues found in 380 source files ``` Earlier full-suite attempt in this branch/environment, before the F1-F8 hardening pass (not rerun after hardening because the failures were unrelated to this route and expensive): ```text $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest 6 failed, 6499 passed, 486 skipped, 5807 warnings in 219.53s ``` All 6 failures are outside the touched routes and unrelated to this change: - `tests/test_corrupt_golden_bytes_recovery.py` — 3 log-capture assertions - `tests/test_forwarded_headers.py::test_non_allowlisted_peer_ignores_forwarded_and_logs` — 1 log-capture assertion - `tests/test_image_compression.py::TestOnnxRouter::test_full_classify_with_image` — `ModuleNotFoundError: No module named 'PIL'` (only `dev,proxy` extras installed) - `tests/test_transforms/test_kompress_compressor.py::TestKompressBackendSelection::test_unrecognized_backend_warns_and_falls_back_to_auto` — 1 warning-capture assertion On Python 3.14.4, plain `uv run pytest` can't even collect: the project's dependency marker intentionally excludes `litellm` on 3.14, while `tests/test_memory_eval.py` imports the eval runner at collection time. ## Real Behavior Proof - **Environment:** macOS (Darwin arm64). Python 3.14.4 via uv for the default project env; Python 3.12.13 via `UV_PROJECT_ENVIRONMENT=.venv-py312` for the broader suite. Headroom FastAPI proxy route test harness. - **Exact command / steps:** read the reporter's sanitized issue logs; traced current Codex image-generation source; ran the focused Codex/proxy route tests on 3.14 and 3.12; ran lint, format check, and mypy; attempted the full 3.12 suite (output above). - **After-fix evidence + observed result:** the regression test captures the OAuth image requests and confirms they forward to `https://chatgpt.com/backend-api/codex/images/generations` and `.../images/edits` — auth and account headers preserved, internal/host/accept encoding headers stripped, query string carried through, JSON and multipart request bodies forwarded byte-for-byte, and stale upstream response compression headers removed. API-key image generation still uses `images/generations`, and image edits now have the matching `images/edits` passthrough. - **Source evidence:** Re-verified against current `openai/codex` HEAD `aaf737f`. `ImagesClient` still sends relative paths `images/generations` and `images/edits`; `Provider::url_for_path()` appends those to the active provider base; ChatGPT-family auth modes default that base to `CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`. Therefore the source-resolved upstream paths are `/backend-api/codex/images/generations` and `/backend-api/codex/images/edits`, not `/backend-api/images/...`. - **Latest-build caveat:** an issue-thread report says Codex Desktop `0.142.0-alpha.6` on macOS uses `/v1/responses` WebSocket image generation and works through the proxy. That may mean the original Windows `0.142.0-alpha.1` regression is fixed client-side in newer desktop builds, even though the source image endpoint route remains valid and now covered here. The requester has checked this against the latest timestamped Codex update before moving the PR out of draft. - **Not fully tested:** a fully green `uv run pytest` remains unavailable in this local environment for the unrelated failures listed above. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes No dependency or version changes. The remaining caveat is that the full local suite isn't green in this environment for the unrelated failures listed above. Happy to follow up with additional runtime logs or to re-run the suite in a maintainer's preferred dev container if that's the cleaner path. --------- Co-authored-by: Johnson <johnsond@brightops.com>
2026-06-22 00:25:49 +08:00
)
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, bytes]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append((method, url, kwargs.get("content", b"")))
return FakeUpstreamResponse(
content=upstream_body,
status_code=200,
headers={
"content-encoding": "gzip",
"content-length": stale_content_length,
"content-type": "application/json",
fix(proxy): strip duplicated upstream server headers (#1828) ## Description Fixes duplicated upstream server headers emitted by the proxy when forwarding responses. ## 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 - Adjust proxy response forwarding so upstream server headers are not duplicated. - Preserve the intended response-header behavior while avoiding repeated header values. - Keep the change scoped to proxy/header handling. ## Testing - [x] Unit tests pass - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text Focused tests/review were completed before this governance body cleanup. The current branch is conflicted and still needs merge resolution before final merge readiness. ``` ## Real Behavior Proof - Environment: Headroom development/review context. - Exact command / steps: Reviewed the proxy response-header behavior and existing focused coverage for duplicate upstream server headers. - Observed result: The PR implementation prevents duplicated upstream server headers while preserving proxy forwarding behavior. - Not tested: Current conflicted branch after merge resolution; conflicts still need to be resolved before merge. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes This body was normalized by a maintainer after approval so the governance parser reflects the already-reviewed PR state. The PR remains blocked by merge conflicts. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 22:43:17 -04:00
"server": "upstream-edge",
fix(proxy): route Codex OAuth image requests (#1215) ## Description Closes #1189. After a recent Codex Desktop update, its built-in image generation started going through Codex's image client, which POSTs to `images/generations` and `images/edits` relative to the configured provider base URL. In Headroom Proxy mode Codex is pointed at Headroom's `/v1` surface, so those land as `/v1/images/generations` and `/v1/images/edits`. Headroom already had `/v1/images/generations`, but it only ever hit the OpenAI API-key passthrough, and there was no `/v1/images/edits` route at all. So under ChatGPT/Codex OAuth the image calls had nowhere correct to go. This change routes OAuth image requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` and leaves the API-key passthrough untouched. Latest upstream re-check: current `openai/codex` main is now `aaf737f`, and the relevant `ImagesClient`/provider-base source still resolves image generation and edit requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` under ChatGPT-family auth. One issue-thread datapoint reports Codex Desktop `0.142.0-alpha.6` on macOS generating images successfully via the `/v1/responses` WebSocket path. The requester has now checked this against the latest timestamped Codex update, so this is ready for maintainer review with the remaining full-suite caveat documented below. **Reproduction / test contract** - Reporter's setup: Codex Desktop 0.142.0-alpha.1 on Windows 10, Headroom v0.26.0 Proxy mode, OAuth auth. `/v1/models` and `/v1/responses` work; built-in image generation fails. - Why the route was confirmed from source: the reporter's sanitized logs only show `/v1/models` and `/v1/responses`, so I traced the rest in current Codex source — image generation/edit go through `ImagesClient` as `images/generations` and `images/edits` against the provider base URL. - Regression test: `test_openai_image_routes_use_codex_backend_under_chatgpt_auth` asserts both OAuth image routes now resolve to the ChatGPT Codex image backend. Before this patch, `/v1/images/generations` used the OpenAI API-key target under OAuth and `/v1/images/edits` didn't exist. - Hardening tests: additional regressions cover stale upstream compression headers, OpenAI API-key fall-through for edits, and multipart edit body byte-preservation. ## 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 - Route ChatGPT/Codex OAuth `/v1/images/generations` and `/v1/images/edits` to the ChatGPT Codex image backend. - Strip internal `x-headroom-*`, `Host`, and `Accept-Encoding` headers before forwarding Codex OAuth image requests upstream. - Strip stale `Content-Encoding` and `Content-Length` headers from image responses because httpx has already decoded the body. - Keep API-key image requests on the existing OpenAI passthrough. - Add regression coverage for both OAuth image routes, OpenAI image-edit passthrough, compressed-response header handling, and multipart edit bodies. - Add a `CHANGELOG.md` entry. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 7.63s $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 8.54s $ uv run ruff check . All checks passed! $ uv run ruff format --check . 895 files already formatted $ uv run mypy headroom headroom/proxy/server.py:1152: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1222: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1226: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Success: no issues found in 380 source files ``` Earlier full-suite attempt in this branch/environment, before the F1-F8 hardening pass (not rerun after hardening because the failures were unrelated to this route and expensive): ```text $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest 6 failed, 6499 passed, 486 skipped, 5807 warnings in 219.53s ``` All 6 failures are outside the touched routes and unrelated to this change: - `tests/test_corrupt_golden_bytes_recovery.py` — 3 log-capture assertions - `tests/test_forwarded_headers.py::test_non_allowlisted_peer_ignores_forwarded_and_logs` — 1 log-capture assertion - `tests/test_image_compression.py::TestOnnxRouter::test_full_classify_with_image` — `ModuleNotFoundError: No module named 'PIL'` (only `dev,proxy` extras installed) - `tests/test_transforms/test_kompress_compressor.py::TestKompressBackendSelection::test_unrecognized_backend_warns_and_falls_back_to_auto` — 1 warning-capture assertion On Python 3.14.4, plain `uv run pytest` can't even collect: the project's dependency marker intentionally excludes `litellm` on 3.14, while `tests/test_memory_eval.py` imports the eval runner at collection time. ## Real Behavior Proof - **Environment:** macOS (Darwin arm64). Python 3.14.4 via uv for the default project env; Python 3.12.13 via `UV_PROJECT_ENVIRONMENT=.venv-py312` for the broader suite. Headroom FastAPI proxy route test harness. - **Exact command / steps:** read the reporter's sanitized issue logs; traced current Codex image-generation source; ran the focused Codex/proxy route tests on 3.14 and 3.12; ran lint, format check, and mypy; attempted the full 3.12 suite (output above). - **After-fix evidence + observed result:** the regression test captures the OAuth image requests and confirms they forward to `https://chatgpt.com/backend-api/codex/images/generations` and `.../images/edits` — auth and account headers preserved, internal/host/accept encoding headers stripped, query string carried through, JSON and multipart request bodies forwarded byte-for-byte, and stale upstream response compression headers removed. API-key image generation still uses `images/generations`, and image edits now have the matching `images/edits` passthrough. - **Source evidence:** Re-verified against current `openai/codex` HEAD `aaf737f`. `ImagesClient` still sends relative paths `images/generations` and `images/edits`; `Provider::url_for_path()` appends those to the active provider base; ChatGPT-family auth modes default that base to `CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`. Therefore the source-resolved upstream paths are `/backend-api/codex/images/generations` and `/backend-api/codex/images/edits`, not `/backend-api/images/...`. - **Latest-build caveat:** an issue-thread report says Codex Desktop `0.142.0-alpha.6` on macOS uses `/v1/responses` WebSocket image generation and works through the proxy. That may mean the original Windows `0.142.0-alpha.1` regression is fixed client-side in newer desktop builds, even though the source image endpoint route remains valid and now covered here. The requester has checked this against the latest timestamped Codex update before moving the PR out of draft. - **Not fully tested:** a fully green `uv run pytest` remains unavailable in this local environment for the unrelated failures listed above. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes No dependency or version changes. The remaining caveat is that the full local suite isn't green in this environment for the unrelated failures listed above. Happy to follow up with additional runtime logs or to re-run the suite in a maintainer's preferred dev container if that's the cleaner path. --------- Co-authored-by: Johnson <johnsond@brightops.com>
2026-06-22 00:25:49 +08:00
"x-upstream": "kept",
},
)
async def aclose(self) -> None:
return None
class FakeUpstreamResponse:
def __init__(self, content: bytes, status_code: int, headers: dict[str, str]) -> None:
self.content = content
self.status_code = status_code
self.headers = headers
with TestClient(_app()) as client:
fake = FakeAsyncClient()
client.app.state.proxy.http_client = fake
client.app.state.proxy.http_client_h1 = fake
response = client.post(
"/v1/images/generations",
headers={"Authorization": "Bearer oauth-token"},
json={"model": "gpt-image-2", "prompt": "compressed response"},
)
assert response.status_code == 200
assert response.content == upstream_body
assert response.headers["x-upstream"] == "kept"
fix(proxy): strip duplicated upstream server headers (#1828) ## Description Fixes duplicated upstream server headers emitted by the proxy when forwarding responses. ## 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 - Adjust proxy response forwarding so upstream server headers are not duplicated. - Preserve the intended response-header behavior while avoiding repeated header values. - Keep the change scoped to proxy/header handling. ## Testing - [x] Unit tests pass - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text Focused tests/review were completed before this governance body cleanup. The current branch is conflicted and still needs merge resolution before final merge readiness. ``` ## Real Behavior Proof - Environment: Headroom development/review context. - Exact command / steps: Reviewed the proxy response-header behavior and existing focused coverage for duplicate upstream server headers. - Observed result: The PR implementation prevents duplicated upstream server headers while preserving proxy forwarding behavior. - Not tested: Current conflicted branch after merge resolution; conflicts still need to be resolved before merge. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes This body was normalized by a maintainer after approval so the governance parser reflects the already-reviewed PR state. The PR remains blocked by merge conflicts. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-09 22:43:17 -04:00
assert response.headers.get("server") is None
fix(proxy): route Codex OAuth image requests (#1215) ## Description Closes #1189. After a recent Codex Desktop update, its built-in image generation started going through Codex's image client, which POSTs to `images/generations` and `images/edits` relative to the configured provider base URL. In Headroom Proxy mode Codex is pointed at Headroom's `/v1` surface, so those land as `/v1/images/generations` and `/v1/images/edits`. Headroom already had `/v1/images/generations`, but it only ever hit the OpenAI API-key passthrough, and there was no `/v1/images/edits` route at all. So under ChatGPT/Codex OAuth the image calls had nowhere correct to go. This change routes OAuth image requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` and leaves the API-key passthrough untouched. Latest upstream re-check: current `openai/codex` main is now `aaf737f`, and the relevant `ImagesClient`/provider-base source still resolves image generation and edit requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` under ChatGPT-family auth. One issue-thread datapoint reports Codex Desktop `0.142.0-alpha.6` on macOS generating images successfully via the `/v1/responses` WebSocket path. The requester has now checked this against the latest timestamped Codex update, so this is ready for maintainer review with the remaining full-suite caveat documented below. **Reproduction / test contract** - Reporter's setup: Codex Desktop 0.142.0-alpha.1 on Windows 10, Headroom v0.26.0 Proxy mode, OAuth auth. `/v1/models` and `/v1/responses` work; built-in image generation fails. - Why the route was confirmed from source: the reporter's sanitized logs only show `/v1/models` and `/v1/responses`, so I traced the rest in current Codex source — image generation/edit go through `ImagesClient` as `images/generations` and `images/edits` against the provider base URL. - Regression test: `test_openai_image_routes_use_codex_backend_under_chatgpt_auth` asserts both OAuth image routes now resolve to the ChatGPT Codex image backend. Before this patch, `/v1/images/generations` used the OpenAI API-key target under OAuth and `/v1/images/edits` didn't exist. - Hardening tests: additional regressions cover stale upstream compression headers, OpenAI API-key fall-through for edits, and multipart edit body byte-preservation. ## 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 - Route ChatGPT/Codex OAuth `/v1/images/generations` and `/v1/images/edits` to the ChatGPT Codex image backend. - Strip internal `x-headroom-*`, `Host`, and `Accept-Encoding` headers before forwarding Codex OAuth image requests upstream. - Strip stale `Content-Encoding` and `Content-Length` headers from image responses because httpx has already decoded the body. - Keep API-key image requests on the existing OpenAI passthrough. - Add regression coverage for both OAuth image routes, OpenAI image-edit passthrough, compressed-response header handling, and multipart edit bodies. - Add a `CHANGELOG.md` entry. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 7.63s $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 8.54s $ uv run ruff check . All checks passed! $ uv run ruff format --check . 895 files already formatted $ uv run mypy headroom headroom/proxy/server.py:1152: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1222: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1226: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Success: no issues found in 380 source files ``` Earlier full-suite attempt in this branch/environment, before the F1-F8 hardening pass (not rerun after hardening because the failures were unrelated to this route and expensive): ```text $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest 6 failed, 6499 passed, 486 skipped, 5807 warnings in 219.53s ``` All 6 failures are outside the touched routes and unrelated to this change: - `tests/test_corrupt_golden_bytes_recovery.py` — 3 log-capture assertions - `tests/test_forwarded_headers.py::test_non_allowlisted_peer_ignores_forwarded_and_logs` — 1 log-capture assertion - `tests/test_image_compression.py::TestOnnxRouter::test_full_classify_with_image` — `ModuleNotFoundError: No module named 'PIL'` (only `dev,proxy` extras installed) - `tests/test_transforms/test_kompress_compressor.py::TestKompressBackendSelection::test_unrecognized_backend_warns_and_falls_back_to_auto` — 1 warning-capture assertion On Python 3.14.4, plain `uv run pytest` can't even collect: the project's dependency marker intentionally excludes `litellm` on 3.14, while `tests/test_memory_eval.py` imports the eval runner at collection time. ## Real Behavior Proof - **Environment:** macOS (Darwin arm64). Python 3.14.4 via uv for the default project env; Python 3.12.13 via `UV_PROJECT_ENVIRONMENT=.venv-py312` for the broader suite. Headroom FastAPI proxy route test harness. - **Exact command / steps:** read the reporter's sanitized issue logs; traced current Codex image-generation source; ran the focused Codex/proxy route tests on 3.14 and 3.12; ran lint, format check, and mypy; attempted the full 3.12 suite (output above). - **After-fix evidence + observed result:** the regression test captures the OAuth image requests and confirms they forward to `https://chatgpt.com/backend-api/codex/images/generations` and `.../images/edits` — auth and account headers preserved, internal/host/accept encoding headers stripped, query string carried through, JSON and multipart request bodies forwarded byte-for-byte, and stale upstream response compression headers removed. API-key image generation still uses `images/generations`, and image edits now have the matching `images/edits` passthrough. - **Source evidence:** Re-verified against current `openai/codex` HEAD `aaf737f`. `ImagesClient` still sends relative paths `images/generations` and `images/edits`; `Provider::url_for_path()` appends those to the active provider base; ChatGPT-family auth modes default that base to `CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`. Therefore the source-resolved upstream paths are `/backend-api/codex/images/generations` and `/backend-api/codex/images/edits`, not `/backend-api/images/...`. - **Latest-build caveat:** an issue-thread report says Codex Desktop `0.142.0-alpha.6` on macOS uses `/v1/responses` WebSocket image generation and works through the proxy. That may mean the original Windows `0.142.0-alpha.1` regression is fixed client-side in newer desktop builds, even though the source image endpoint route remains valid and now covered here. The requester has checked this against the latest timestamped Codex update before moving the PR out of draft. - **Not fully tested:** a fully green `uv run pytest` remains unavailable in this local environment for the unrelated failures listed above. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes No dependency or version changes. The remaining caveat is that the full local suite isn't green in this environment for the unrelated failures listed above. Happy to follow up with additional runtime logs or to re-run the suite in a maintainer's preferred dev container if that's the cleaner path. --------- Co-authored-by: Johnson <johnsond@brightops.com>
2026-06-22 00:25:49 +08:00
assert response.headers.get("content-encoding") is None
assert response.headers.get("content-length") == str(len(upstream_body))
assert fake.calls == [
(
"POST",
"https://chatgpt.com/backend-api/codex/images/generations",
b'{"model":"gpt-image-2","prompt":"compressed response"}',
)
]
def test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough(
monkeypatch,
) -> None:
calls: list[tuple[str, str, str, str, str]] = []
async def fake_passthrough(self, request, base_url, sub_path="", provider_name=""): # type: ignore[no-untyped-def]
calls.append((request.method, request.url.path, base_url, sub_path, provider_name))
return JSONResponse(
{
"base_url": base_url,
"sub_path": sub_path,
"provider": provider_name,
}
)
monkeypatch.setattr(HeadroomProxy, "handle_passthrough", fake_passthrough)
with TestClient(_app()) as client:
response = client.post(
"/v1/images/edits",
headers={"Authorization": "Bearer sk-proj-openai-test"},
json={"model": "gpt-image-1", "prompt": "fall through", "image": "file-1"},
)
assert response.status_code == 200
assert response.json() == {
"base_url": "https://api.openai.test",
"sub_path": "images/edits",
"provider": "openai",
}
assert calls == [
(
"POST",
"/v1/images/edits",
"https://api.openai.test",
"images/edits",
"openai",
)
]
def test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth(monkeypatch) -> None:
monkeypatch.setattr(
refactor(providers): split proxy route adapters (#1934) ## Description Refactors provider-specific proxy routing into provider-owned helper modules so `headroom/providers/proxy_routes.py` primarily registers routes and delegates behavior. This keeps Codex, OpenAI Responses/images, model metadata, Vertex, Cloud Code, passthrough target selection, and request path normalization logic testable outside the route table. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Extracted Codex routing helpers for headers, endpoint URLs, image forwarding, response subpaths, and model metadata. - Moved provider target selection, route specs, OpenAI Responses/images helpers, Vertex runtime helpers, Cloud Code path normalization, passthrough telemetry, and request scope normalization into focused modules. - Kept `proxy_routes.py` as route registration/delegation and preserved current-main `/v1/messages` custom-base behavior. - Added focused provider/proxy tests for the extracted modules and route delegation behavior. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_package_init_lazy.py::test_codex_package_import_stays_runtime_only tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets tests/test_provider_proxy_routes.py::test_openai_image_routes_use_codex_backend_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_openai_image_codex_response_strips_stale_compression_headers tests/test_provider_proxy_routes.py::test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough tests/test_provider_proxy_routes.py::test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py::test_with_project_prefix_round_trips_through_split tests/test_vertex_claude_compression.py ============================ 102 passed in 34.83s ============================= python -m ruff check headroom/providers/cloudcode headroom/providers/codex headroom/providers/vertex headroom/providers/model_metadata.py headroom/providers/openai_images.py headroom/providers/openai_responses.py headroom/providers/proxy_targets.py headroom/providers/route_specs.py headroom/providers/proxy_routes.py headroom/proxy/handlers/openai.py headroom/proxy/passthrough.py headroom/proxy/request_scope.py headroom/proxy/project_context.py tests/test_package_init_lazy.py tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py tests/test_vertex_claude_compression.py All checks passed! python -m compileall -q headroom\providers\cloudcode headroom\providers\codex headroom\providers\vertex headroom\providers\model_metadata.py headroom\providers\openai_images.py headroom\providers\openai_responses.py headroom\providers\proxy_targets.py headroom\providers\route_specs.py headroom\providers\proxy_routes.py headroom\proxy\handlers\openai.py headroom\proxy\passthrough.py headroom\proxy\request_scope.py headroom\proxy\project_context.py # no output; exited 0 git commit -m "refactor(providers): split proxy route adapters" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/provider-route-slices` based on `headroomlabs/main`. - Exact command / steps: Ran the focused provider/proxy pytest suite, focused ruff command, compileall over changed Python modules, and commit hooks. - Observed result: Provider/proxy route behavior tests passed; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream provider calls, and manual end-to-end proxy traffic. ## 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 ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; coverage here is focused on provider/proxy routing behavior touched by this slice. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-12 21:27:45 +00:00
"headroom.providers.codex.images.resolve_codex_routing",
lambda headers: CodexRoutingDecision(
headers={**headers, "ChatGPT-Account-ID": "acct_123"},
is_chatgpt_auth=True,
),
fix(proxy): route Codex OAuth image requests (#1215) ## Description Closes #1189. After a recent Codex Desktop update, its built-in image generation started going through Codex's image client, which POSTs to `images/generations` and `images/edits` relative to the configured provider base URL. In Headroom Proxy mode Codex is pointed at Headroom's `/v1` surface, so those land as `/v1/images/generations` and `/v1/images/edits`. Headroom already had `/v1/images/generations`, but it only ever hit the OpenAI API-key passthrough, and there was no `/v1/images/edits` route at all. So under ChatGPT/Codex OAuth the image calls had nowhere correct to go. This change routes OAuth image requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` and leaves the API-key passthrough untouched. Latest upstream re-check: current `openai/codex` main is now `aaf737f`, and the relevant `ImagesClient`/provider-base source still resolves image generation and edit requests to `https://chatgpt.com/backend-api/codex/images/{generations,edits}` under ChatGPT-family auth. One issue-thread datapoint reports Codex Desktop `0.142.0-alpha.6` on macOS generating images successfully via the `/v1/responses` WebSocket path. The requester has now checked this against the latest timestamped Codex update, so this is ready for maintainer review with the remaining full-suite caveat documented below. **Reproduction / test contract** - Reporter's setup: Codex Desktop 0.142.0-alpha.1 on Windows 10, Headroom v0.26.0 Proxy mode, OAuth auth. `/v1/models` and `/v1/responses` work; built-in image generation fails. - Why the route was confirmed from source: the reporter's sanitized logs only show `/v1/models` and `/v1/responses`, so I traced the rest in current Codex source — image generation/edit go through `ImagesClient` as `images/generations` and `images/edits` against the provider base URL. - Regression test: `test_openai_image_routes_use_codex_backend_under_chatgpt_auth` asserts both OAuth image routes now resolve to the ChatGPT Codex image backend. Before this patch, `/v1/images/generations` used the OpenAI API-key target under OAuth and `/v1/images/edits` didn't exist. - Hardening tests: additional regressions cover stale upstream compression headers, OpenAI API-key fall-through for edits, and multipart edit body byte-preservation. ## 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 - Route ChatGPT/Codex OAuth `/v1/images/generations` and `/v1/images/edits` to the ChatGPT Codex image backend. - Strip internal `x-headroom-*`, `Host`, and `Accept-Encoding` headers before forwarding Codex OAuth image requests upstream. - Strip stale `Content-Encoding` and `Content-Length` headers from image responses because httpx has already decoded the body. - Keep API-key image requests on the existing OpenAI passthrough. - Add regression coverage for both OAuth image routes, OpenAI image-edit passthrough, compressed-response header handling, and multipart edit bodies. - Add a `CHANGELOG.md` entry. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 7.63s $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q 42 passed, 1 warning in 8.54s $ uv run ruff check . All checks passed! $ uv run ruff format --check . 895 files already formatted $ uv run mypy headroom headroom/proxy/server.py:1152: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1222: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom/proxy/server.py:1226: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Success: no issues found in 380 source files ``` Earlier full-suite attempt in this branch/environment, before the F1-F8 hardening pass (not rerun after hardening because the failures were unrelated to this route and expensive): ```text $ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest 6 failed, 6499 passed, 486 skipped, 5807 warnings in 219.53s ``` All 6 failures are outside the touched routes and unrelated to this change: - `tests/test_corrupt_golden_bytes_recovery.py` — 3 log-capture assertions - `tests/test_forwarded_headers.py::test_non_allowlisted_peer_ignores_forwarded_and_logs` — 1 log-capture assertion - `tests/test_image_compression.py::TestOnnxRouter::test_full_classify_with_image` — `ModuleNotFoundError: No module named 'PIL'` (only `dev,proxy` extras installed) - `tests/test_transforms/test_kompress_compressor.py::TestKompressBackendSelection::test_unrecognized_backend_warns_and_falls_back_to_auto` — 1 warning-capture assertion On Python 3.14.4, plain `uv run pytest` can't even collect: the project's dependency marker intentionally excludes `litellm` on 3.14, while `tests/test_memory_eval.py` imports the eval runner at collection time. ## Real Behavior Proof - **Environment:** macOS (Darwin arm64). Python 3.14.4 via uv for the default project env; Python 3.12.13 via `UV_PROJECT_ENVIRONMENT=.venv-py312` for the broader suite. Headroom FastAPI proxy route test harness. - **Exact command / steps:** read the reporter's sanitized issue logs; traced current Codex image-generation source; ran the focused Codex/proxy route tests on 3.14 and 3.12; ran lint, format check, and mypy; attempted the full 3.12 suite (output above). - **After-fix evidence + observed result:** the regression test captures the OAuth image requests and confirms they forward to `https://chatgpt.com/backend-api/codex/images/generations` and `.../images/edits` — auth and account headers preserved, internal/host/accept encoding headers stripped, query string carried through, JSON and multipart request bodies forwarded byte-for-byte, and stale upstream response compression headers removed. API-key image generation still uses `images/generations`, and image edits now have the matching `images/edits` passthrough. - **Source evidence:** Re-verified against current `openai/codex` HEAD `aaf737f`. `ImagesClient` still sends relative paths `images/generations` and `images/edits`; `Provider::url_for_path()` appends those to the active provider base; ChatGPT-family auth modes default that base to `CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`. Therefore the source-resolved upstream paths are `/backend-api/codex/images/generations` and `/backend-api/codex/images/edits`, not `/backend-api/images/...`. - **Latest-build caveat:** an issue-thread report says Codex Desktop `0.142.0-alpha.6` on macOS uses `/v1/responses` WebSocket image generation and works through the proxy. That may mean the original Windows `0.142.0-alpha.1` regression is fixed client-side in newer desktop builds, even though the source image endpoint route remains valid and now covered here. The requester has checked this against the latest timestamped Codex update before moving the PR out of draft. - **Not fully tested:** a fully green `uv run pytest` remains unavailable in this local environment for the unrelated failures listed above. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes No dependency or version changes. The remaining caveat is that the full local suite isn't green in this environment for the unrelated failures listed above. Happy to follow up with additional runtime logs or to re-run the suite in a maintainer's preferred dev container if that's the cleaner path. --------- Co-authored-by: Johnson <johnsond@brightops.com>
2026-06-22 00:25:49 +08:00
)
boundary = "----headroom-boundary"
body = (
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="model"\r\n\r\n'
"gpt-image-2\r\n"
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="prompt"\r\n\r\n'
"preserve these bytes\r\n"
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="image"; filename="input.png"\r\n'
"Content-Type: image/png\r\n\r\n"
).encode()
+ b"\x89PNG\r\n\x1a\nraw-bytes\r\n"
+ f"--{boundary}--\r\n".encode()
)
content_type = f"multipart/form-data; boundary={boundary}"
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, str], bytes]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append(
(
method,
url,
dict(kwargs.get("headers", {})),
kwargs.get("content", b""),
)
)
return httpx.Response(200, json={"ok": True})
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake = FakeAsyncClient()
client.app.state.proxy.http_client = fake
client.app.state.proxy.http_client_h1 = fake
response = client.post(
"/v1/images/edits",
headers={
"Authorization": "Bearer oauth-token",
"Content-Type": content_type,
},
content=body,
)
assert response.status_code == 200
assert len(fake.calls) == 1
method, url, headers, forwarded_body = fake.calls[0]
assert method == "POST"
assert url == "https://chatgpt.com/backend-api/codex/images/edits"
assert headers["authorization"] == "Bearer oauth-token"
assert headers["ChatGPT-Account-ID"] == "acct_123"
assert headers["content-type"] == content_type
assert "host" not in headers
assert forwarded_body == body
def test_gemini_batch_embed_contents_passthrough_uses_gemini_target(monkeypatch) -> None:
calls: list[tuple[str, str, str]] = []
async def fake_passthrough(self, request, base_url, sub_path="", provider_name=""): # type: ignore[no-untyped-def]
calls.append((request.url.path, base_url, sub_path))
return JSONResponse({"base_url": base_url, "sub_path": sub_path, "provider": provider_name})
monkeypatch.setattr(HeadroomProxy, "handle_passthrough", fake_passthrough)
with TestClient(_app()) as client:
response = client.post("/v1beta/models/demo:batchEmbedContents")
assert response.status_code == 200
assert response.json() == {
"base_url": "https://api.gemini.test",
"sub_path": "batchEmbedContents",
"provider": "gemini",
}
assert calls == [
("/v1beta/models/demo:batchEmbedContents", "https://api.gemini.test", "batchEmbedContents")
]
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 17:54:24 -07:00
def test_v1_models_fetches_codex_registry_under_chatgpt_auth(monkeypatch) -> None:
refactor(providers): split proxy route adapters (#1934) ## Description Refactors provider-specific proxy routing into provider-owned helper modules so `headroom/providers/proxy_routes.py` primarily registers routes and delegates behavior. This keeps Codex, OpenAI Responses/images, model metadata, Vertex, Cloud Code, passthrough target selection, and request path normalization logic testable outside the route table. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Extracted Codex routing helpers for headers, endpoint URLs, image forwarding, response subpaths, and model metadata. - Moved provider target selection, route specs, OpenAI Responses/images helpers, Vertex runtime helpers, Cloud Code path normalization, passthrough telemetry, and request scope normalization into focused modules. - Kept `proxy_routes.py` as route registration/delegation and preserved current-main `/v1/messages` custom-base behavior. - Added focused provider/proxy tests for the extracted modules and route delegation behavior. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_package_init_lazy.py::test_codex_package_import_stays_runtime_only tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets tests/test_provider_proxy_routes.py::test_openai_image_routes_use_codex_backend_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_openai_image_codex_response_strips_stale_compression_headers tests/test_provider_proxy_routes.py::test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough tests/test_provider_proxy_routes.py::test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py::test_with_project_prefix_round_trips_through_split tests/test_vertex_claude_compression.py ============================ 102 passed in 34.83s ============================= python -m ruff check headroom/providers/cloudcode headroom/providers/codex headroom/providers/vertex headroom/providers/model_metadata.py headroom/providers/openai_images.py headroom/providers/openai_responses.py headroom/providers/proxy_targets.py headroom/providers/route_specs.py headroom/providers/proxy_routes.py headroom/proxy/handlers/openai.py headroom/proxy/passthrough.py headroom/proxy/request_scope.py headroom/proxy/project_context.py tests/test_package_init_lazy.py tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py tests/test_vertex_claude_compression.py All checks passed! python -m compileall -q headroom\providers\cloudcode headroom\providers\codex headroom\providers\vertex headroom\providers\model_metadata.py headroom\providers\openai_images.py headroom\providers\openai_responses.py headroom\providers\proxy_targets.py headroom\providers\route_specs.py headroom\providers\proxy_routes.py headroom\proxy\handlers\openai.py headroom\proxy\passthrough.py headroom\proxy\request_scope.py headroom\proxy\project_context.py # no output; exited 0 git commit -m "refactor(providers): split proxy route adapters" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/provider-route-slices` based on `headroomlabs/main`. - Exact command / steps: Ran the focused provider/proxy pytest suite, focused ruff command, compileall over changed Python modules, and commit hooks. - Observed result: Provider/proxy route behavior tests passed; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream provider calls, and manual end-to-end proxy traffic. ## 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 ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; coverage here is focused on provider/proxy routing behavior touched by this slice. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-12 21:27:45 +00:00
model_metadata = importlib.import_module("headroom.providers.codex.model_metadata")
debug_messages: list[tuple[str, tuple[object, ...]]] = []
monkeypatch.setattr(
refactor(providers): split proxy route adapters (#1934) ## Description Refactors provider-specific proxy routing into provider-owned helper modules so `headroom/providers/proxy_routes.py` primarily registers routes and delegates behavior. This keeps Codex, OpenAI Responses/images, model metadata, Vertex, Cloud Code, passthrough target selection, and request path normalization logic testable outside the route table. Closes # ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Extracted Codex routing helpers for headers, endpoint URLs, image forwarding, response subpaths, and model metadata. - Moved provider target selection, route specs, OpenAI Responses/images helpers, Vertex runtime helpers, Cloud Code path normalization, passthrough telemetry, and request scope normalization into focused modules. - Kept `proxy_routes.py` as route registration/delegation and preserved current-main `/v1/messages` custom-base behavior. - Added focused provider/proxy tests for the extracted modules and route delegation behavior. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_package_init_lazy.py::test_codex_package_import_stays_runtime_only tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets tests/test_provider_proxy_routes.py::test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers tests/test_provider_proxy_routes.py::test_openai_response_websocket_aliases_delegate_to_openai_ws_handler tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_returns_502_on_http_failure tests/test_provider_proxy_routes.py::test_openai_response_subpath_passthrough_uses_openai_target tests/test_provider_proxy_routes.py::test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets tests/test_provider_proxy_routes.py::test_openai_image_routes_use_codex_backend_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_openai_image_codex_response_strips_stale_compression_headers tests/test_provider_proxy_routes.py::test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough tests/test_provider_proxy_routes.py::test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_gemini_batch_embed_contents_passthrough_uses_gemini_target tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_get_single_dynamic_under_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_still_forwards_under_non_chatgpt_auth tests/test_provider_proxy_routes.py::test_v1_models_routes_claude_code_gateway_discovery_to_anthropic tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py::test_with_project_prefix_round_trips_through_split tests/test_vertex_claude_compression.py ============================ 102 passed in 34.83s ============================= python -m ruff check headroom/providers/cloudcode headroom/providers/codex headroom/providers/vertex headroom/providers/model_metadata.py headroom/providers/openai_images.py headroom/providers/openai_responses.py headroom/providers/proxy_targets.py headroom/providers/route_specs.py headroom/providers/proxy_routes.py headroom/proxy/handlers/openai.py headroom/proxy/passthrough.py headroom/proxy/request_scope.py headroom/proxy/project_context.py tests/test_package_init_lazy.py tests/test_provider_cloudcode_runtime.py tests/test_provider_codex_endpoints.py tests/test_provider_codex_headers.py tests/test_provider_codex_images.py tests/test_provider_codex_model_metadata.py tests/test_provider_codex_responses.py tests/test_provider_model_metadata.py tests/test_provider_openai_images.py tests/test_provider_openai_responses.py tests/test_provider_proxy_targets.py tests/test_provider_route_specs.py tests/test_provider_vertex_runtime.py tests/test_proxy_request_scope.py tests/test_provider_proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_proxy_passthrough.py tests/test_proxy_google_cloudcode_route_aliases.py tests/test_proxy_project_savings.py tests/test_vertex_claude_compression.py All checks passed! python -m compileall -q headroom\providers\cloudcode headroom\providers\codex headroom\providers\vertex headroom\providers\model_metadata.py headroom\providers\openai_images.py headroom\providers\openai_responses.py headroom\providers\proxy_targets.py headroom\providers\route_specs.py headroom\providers\proxy_routes.py headroom\proxy\handlers\openai.py headroom\proxy\passthrough.py headroom\proxy\request_scope.py headroom\proxy\project_context.py # no output; exited 0 git commit -m "refactor(providers): split proxy route adapters" Sync plugin versions.....................................................Passed check for merge conflicts................................................Passed ruff.....................................................................Passed ruff-format..............................................................Passed mypy.....................................................................Passed ``` ## Real Behavior Proof - Environment: Windows PowerShell, Python 3.13.13, branch `jd/provider-route-slices` based on `headroomlabs/main`. - Exact command / steps: Ran the focused provider/proxy pytest suite, focused ruff command, compileall over changed Python modules, and commit hooks. - Observed result: Provider/proxy route behavior tests passed; lint, formatting, and mypy passed. - Not tested: Full pytest suite, live upstream provider calls, and manual end-to-end proxy traffic. ## 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 ## Screenshots (if applicable) N/A ## Additional Notes Documentation and CHANGELOG updates are N/A for this internal refactor. The full pytest suite was not run; coverage here is focused on provider/proxy routing behavior touched by this slice. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-12 21:27:45 +00:00
model_metadata.logger,
"debug",
lambda message, *args: debug_messages.append((message, args)),
)
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, str]]] = []
async def get(self, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append(("GET", url, dict(kwargs.get("headers", {}))))
return httpx.Response(
200,
json={
"models": [
{"slug": "gpt-5.5"},
{"slug": "gpt-5.3-codex-spark"},
]
},
)
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
response = client.get(
"/v1/models?client_version=0.135.0",
headers={
"authorization": "Bearer eyJ-chatgpt-oauth-token",
"chatgpt-account-id": "test-account",
"originator": "Codex Desktop",
},
)
assert response.status_code == 200
payload = response.json()
Fix Codex ChatGPT /v1/models compatibility metadata (#1048) ## Description Fixes Codex ChatGPT/OAuth `/v1/models` metadata compatibility while keeping Headroom's existing OpenAI-compatible response shape. Headroom's ChatGPT/OAuth model-list route already returned: - `object: "list"` - `data[]` Newer Codex clients also inspect a top-level `models[]` registry metadata array. Without that shape, completions can still work, but clients may emit non-fatal model metadata decode or missing-field warnings before the follow-up `/v1/responses` call. This PR keeps `object`/`data[]` unchanged and adds a Codex-compatible `models[]` array. Upstream registry metadata is preserved where available, and only missing fields are filled with defaults. Closes: N/A ## Type of Change - [x] Bug fix (non-breaking change fixes issue) - [ ] New feature (non-breaking change adds functionality) - [ ] Breaking change (fix or feature would cause existing functionality change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add Codex registry metadata generation for the ChatGPT/OAuth `/v1/models` response. - Preserve dynamic upstream registry entries instead of reducing them to slug-only IDs. - Add fallback metadata for known Codex models if upstream registry data is unavailable. - Fill required/default Codex fields when absent, including: - `display_name` - `default_reasoning_level` - `supported_reasoning_levels` - `context_window` - tool/runtime capability flags - Keep the existing OpenAI-compatible `data[]` response shape. - Add tests that assert both OpenAI-compatible `data[]` and Codex-compatible `models[]` shapes. Changed files: - `headroom/providers/proxy_routes.py` - `tests/test_provider_proxy_routes.py` - `tests/test_proxy_codex_route_aliases.py` ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text ruff check headroom/providers/proxy_routes.py tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py # pass pytest tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth # pass pytest tests/test_proxy_codex_route_aliases.py # pass pytest tests/test_provider_proxy_routes.py # pass ``` ## Real Behavior Proof - Environment: isolated local Headroom proxy using the patched source. - Exact command / steps: - call `/v1/models` - run one small Codex `/v1/responses` request through the proxy - compare Headroom `/stats` - check logs for Codex model metadata decode or missing-field warnings - Observed result: - `/v1/models` succeeded - `/v1/responses` succeeded - `requests.failed` stayed flat - provider stats and proxy compression accounting increased - no Codex model metadata decode or missing-field warnings observed - Not tested: - full repository `mypy headroom` pass was not run for this submission ## Review Readiness - [x] I have performed a self-review before requesting human review - [x] This PR is ready for human review ## Checklist - [x] My code follows project's style guidelines - [x] I performed self-review my code - [ ] I commented my code, particularly in hard-to-understand areas - [ ] I made corresponding changes documentation - [x] My changes generate no new warnings - [x] I added tests prove fix is effective or feature works - [x] New and existing unit tests pass locally my changes - [ ] I updated CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes Checklist items left unchecked are intentionally not applicable or not run for this focused compatibility PR: - no new comments were needed in the implementation - no documentation or changelog update is included for this compatibility fix to an existing route - full-suite `mypy headroom` was not run in the submission pass Co-authored-by: felixboenkost-droid <258905464+felixboenkost-droid@users.noreply.github.com>
2026-06-16 21:19:22 +02:00
assert payload["object"] == "list"
assert payload["data"] == [
{
"id": "gpt-5.5",
"object": "model",
"created": 0,
"owned_by": "openai",
},
{
"id": "gpt-5.3-codex-spark",
"object": "model",
"created": 0,
"owned_by": "openai",
},
]
assert [entry["slug"] for entry in payload["models"]] == [
"gpt-5.5",
"gpt-5.3-codex-spark",
]
assert [entry["display_name"] for entry in payload["models"]] == [
"GPT-5.5",
"GPT-5.3-Codex-Spark",
]
for entry in payload["models"]:
assert entry["default_reasoning_level"] == "medium"
assert entry["context_window"] == 272000
assert entry["supports_parallel_tool_calls"] is True
assert len(fake_http_client.calls) == 1
method, url, headers = fake_http_client.calls[0]
assert method == "GET"
assert url == "https://chatgpt.com/backend-api/codex/models?client_version=0.135.0"
assert headers["authorization"] == "Bearer eyJ-chatgpt-oauth-token"
assert headers["chatgpt-account-id"] == "test-account"
assert headers["originator"] == "Codex Desktop"
assert headers["accept"] == "application/json"
assert "Accept" not in headers
assert debug_messages == [
(
"Fetched Codex model IDs from upstream model registry: %s",
(["gpt-5.5", "gpt-5.3-codex-spark"],),
),
]
def test_v1_models_falls_back_to_synthetic_list_under_chatgpt_auth(monkeypatch) -> None:
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 17:54:24 -07:00
"""Issue #478: under Codex ChatGPT-subscription OAuth, the proxy
must NOT forward `/v1/models` to chatgpt.com/backend-api/models
(which returns 403). If the Codex-specific registry also fails,
synthesize an OpenAI-compatible response with the known-supported
Codex/ChatGPT model set instead, so Codex's model-picker refresh succeeds.
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 17:54:24 -07:00
"""
class FakeAsyncClient:
async def get(self, url, **kwargs): # type: ignore[no-untyped-def]
return httpx.Response(403, json={"error": "forbidden"})
async def aclose(self) -> None:
return None
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 17:54:24 -07:00
with TestClient(_app()) as client:
client.app.state.proxy.http_client = FakeAsyncClient()
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 17:54:24 -07:00
# ChatGPT auth detected via Bearer + ChatGPT account header
# (mirrors what Codex Desktop sends).
response = client.get(
"/v1/models",
headers={
"authorization": "Bearer eyJ-chatgpt-oauth-token",
"chatgpt-account-id": "test-account",
"originator": "Codex Desktop",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["object"] == "list"
assert isinstance(payload["data"], list)
assert len(payload["data"]) > 0
model_ids = {entry["id"] for entry in payload["data"]}
Fix Codex ChatGPT /v1/models compatibility metadata (#1048) ## Description Fixes Codex ChatGPT/OAuth `/v1/models` metadata compatibility while keeping Headroom's existing OpenAI-compatible response shape. Headroom's ChatGPT/OAuth model-list route already returned: - `object: "list"` - `data[]` Newer Codex clients also inspect a top-level `models[]` registry metadata array. Without that shape, completions can still work, but clients may emit non-fatal model metadata decode or missing-field warnings before the follow-up `/v1/responses` call. This PR keeps `object`/`data[]` unchanged and adds a Codex-compatible `models[]` array. Upstream registry metadata is preserved where available, and only missing fields are filled with defaults. Closes: N/A ## Type of Change - [x] Bug fix (non-breaking change fixes issue) - [ ] New feature (non-breaking change adds functionality) - [ ] Breaking change (fix or feature would cause existing functionality change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add Codex registry metadata generation for the ChatGPT/OAuth `/v1/models` response. - Preserve dynamic upstream registry entries instead of reducing them to slug-only IDs. - Add fallback metadata for known Codex models if upstream registry data is unavailable. - Fill required/default Codex fields when absent, including: - `display_name` - `default_reasoning_level` - `supported_reasoning_levels` - `context_window` - tool/runtime capability flags - Keep the existing OpenAI-compatible `data[]` response shape. - Add tests that assert both OpenAI-compatible `data[]` and Codex-compatible `models[]` shapes. Changed files: - `headroom/providers/proxy_routes.py` - `tests/test_provider_proxy_routes.py` - `tests/test_proxy_codex_route_aliases.py` ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text ruff check headroom/providers/proxy_routes.py tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py # pass pytest tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth # pass pytest tests/test_proxy_codex_route_aliases.py # pass pytest tests/test_provider_proxy_routes.py # pass ``` ## Real Behavior Proof - Environment: isolated local Headroom proxy using the patched source. - Exact command / steps: - call `/v1/models` - run one small Codex `/v1/responses` request through the proxy - compare Headroom `/stats` - check logs for Codex model metadata decode or missing-field warnings - Observed result: - `/v1/models` succeeded - `/v1/responses` succeeded - `requests.failed` stayed flat - provider stats and proxy compression accounting increased - no Codex model metadata decode or missing-field warnings observed - Not tested: - full repository `mypy headroom` pass was not run for this submission ## Review Readiness - [x] I have performed a self-review before requesting human review - [x] This PR is ready for human review ## Checklist - [x] My code follows project's style guidelines - [x] I performed self-review my code - [ ] I commented my code, particularly in hard-to-understand areas - [ ] I made corresponding changes documentation - [x] My changes generate no new warnings - [x] I added tests prove fix is effective or feature works - [x] New and existing unit tests pass locally my changes - [ ] I updated CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes Checklist items left unchecked are intentionally not applicable or not run for this focused compatibility PR: - no new comments were needed in the implementation - no documentation or changelog update is included for this compatibility fix to an existing route - full-suite `mypy headroom` was not run in the submission pass Co-authored-by: felixboenkost-droid <258905464+felixboenkost-droid@users.noreply.github.com>
2026-06-16 21:19:22 +02:00
model_slugs = {entry["slug"] for entry in payload["models"]}
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 17:54:24 -07:00
# Spot-check: the model from issue #478's repro log must be present.
assert "gpt-5.5" in model_ids
Fix Codex ChatGPT /v1/models compatibility metadata (#1048) ## Description Fixes Codex ChatGPT/OAuth `/v1/models` metadata compatibility while keeping Headroom's existing OpenAI-compatible response shape. Headroom's ChatGPT/OAuth model-list route already returned: - `object: "list"` - `data[]` Newer Codex clients also inspect a top-level `models[]` registry metadata array. Without that shape, completions can still work, but clients may emit non-fatal model metadata decode or missing-field warnings before the follow-up `/v1/responses` call. This PR keeps `object`/`data[]` unchanged and adds a Codex-compatible `models[]` array. Upstream registry metadata is preserved where available, and only missing fields are filled with defaults. Closes: N/A ## Type of Change - [x] Bug fix (non-breaking change fixes issue) - [ ] New feature (non-breaking change adds functionality) - [ ] Breaking change (fix or feature would cause existing functionality change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add Codex registry metadata generation for the ChatGPT/OAuth `/v1/models` response. - Preserve dynamic upstream registry entries instead of reducing them to slug-only IDs. - Add fallback metadata for known Codex models if upstream registry data is unavailable. - Fill required/default Codex fields when absent, including: - `display_name` - `default_reasoning_level` - `supported_reasoning_levels` - `context_window` - tool/runtime capability flags - Keep the existing OpenAI-compatible `data[]` response shape. - Add tests that assert both OpenAI-compatible `data[]` and Codex-compatible `models[]` shapes. Changed files: - `headroom/providers/proxy_routes.py` - `tests/test_provider_proxy_routes.py` - `tests/test_proxy_codex_route_aliases.py` ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text ruff check headroom/providers/proxy_routes.py tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py # pass pytest tests/test_provider_proxy_routes.py::test_v1_models_fetches_codex_registry_under_chatgpt_auth # pass pytest tests/test_proxy_codex_route_aliases.py # pass pytest tests/test_provider_proxy_routes.py # pass ``` ## Real Behavior Proof - Environment: isolated local Headroom proxy using the patched source. - Exact command / steps: - call `/v1/models` - run one small Codex `/v1/responses` request through the proxy - compare Headroom `/stats` - check logs for Codex model metadata decode or missing-field warnings - Observed result: - `/v1/models` succeeded - `/v1/responses` succeeded - `requests.failed` stayed flat - provider stats and proxy compression accounting increased - no Codex model metadata decode or missing-field warnings observed - Not tested: - full repository `mypy headroom` pass was not run for this submission ## Review Readiness - [x] I have performed a self-review before requesting human review - [x] This PR is ready for human review ## Checklist - [x] My code follows project's style guidelines - [x] I performed self-review my code - [ ] I commented my code, particularly in hard-to-understand areas - [ ] I made corresponding changes documentation - [x] My changes generate no new warnings - [x] I added tests prove fix is effective or feature works - [x] New and existing unit tests pass locally my changes - [ ] I updated CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes Checklist items left unchecked are intentionally not applicable or not run for this focused compatibility PR: - no new comments were needed in the implementation - no documentation or changelog update is included for this compatibility fix to an existing route - full-suite `mypy headroom` was not run in the submission pass Co-authored-by: felixboenkost-droid <258905464+felixboenkost-droid@users.noreply.github.com>
2026-06-16 21:19:22 +02:00
assert "gpt-5.5" in model_slugs
gpt_55 = next(entry for entry in payload["models"] if entry["slug"] == "gpt-5.5")
assert gpt_55["display_name"] == "GPT-5.5"
assert gpt_55["supported_in_api"] is True
assert gpt_55["default_reasoning_level"] == "medium"
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 17:54:24 -07:00
for entry in payload["data"]:
assert entry["object"] == "model"
assert entry["owned_by"] == "openai"
2026-06-01 17:20:12 -06:00
def test_v1_models_get_single_dynamic_under_chatgpt_auth() -> None:
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 17:54:24 -07:00
"""The single-model variant (`/v1/models/{id}`) is also called by
Codex for some flows. It should use the Codex registry first so
dynamically exposed model slugs validate consistently."""
class FakeAsyncClient:
def __init__(self) -> None:
self.calls = 0
async def get(self, url, **kwargs): # type: ignore[no-untyped-def]
self.calls += 1
return httpx.Response(
200,
json={"models": [{"slug": "gpt-5.5"}, {"slug": "gpt-5.3-codex-spark"}]},
)
async def aclose(self) -> None:
return None
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 17:54:24 -07:00
with TestClient(_app()) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
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 17:54:24 -07:00
ok = client.get(
"/v1/models/gpt-5.3-codex-spark",
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 17:54:24 -07:00
headers={
"authorization": "Bearer eyJ-chatgpt-oauth-token",
"chatgpt-account-id": "test-account",
},
)
unknown = client.get(
"/v1/models/gpt-99-future",
headers={
"authorization": "Bearer eyJ-chatgpt-oauth-token",
"chatgpt-account-id": "test-account",
},
)
assert ok.status_code == 200
assert ok.json() == {
"id": "gpt-5.3-codex-spark",
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 17:54:24 -07:00
"object": "model",
"created": 0,
"owned_by": "openai",
}
assert unknown.status_code == 404
2026-06-01 17:20:12 -06:00
assert fake_http_client.calls == 2
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 17:54:24 -07:00
def test_v1_models_still_forwards_under_non_chatgpt_auth() -> None:
"""Non-ChatGPT auth (regular API key, Gemini, etc.) must still
forward to the upstream provider only the ChatGPT-OAuth path
short-circuits to the synthetic response."""
calls: list[tuple[str, str, str]] = []
async def fake_passthrough(self, request, base_url, sub_path="", provider_name=""): # type: ignore[no-untyped-def]
calls.append((request.url.path, base_url, provider_name))
return JSONResponse({"base_url": base_url, "provider": provider_name})
with patch.object(HeadroomProxy, "handle_passthrough", fake_passthrough):
with TestClient(_app()) as client:
response = client.get(
"/v1/models",
headers={"authorization": "Bearer sk-real-api-key"},
)
assert response.status_code == 200
# Forwarded — not synthesized — because no chatgpt-account-id header.
assert calls, "Non-ChatGPT-auth /v1/models must forward, not synthesize"
def test_v1_models_routes_claude_code_gateway_discovery_to_anthropic() -> None:
"""Claude Code gateway/OAuth model discovery can use a Bearer token that
does not look like an Anthropic API key. Route those `/v1/models` requests
to Anthropic so Claude's gateway model cache is not populated from OpenAI.
"""
calls: list[tuple[str, str, str]] = []
async def fake_passthrough(self, request, base_url, sub_path="", provider_name=""): # type: ignore[no-untyped-def]
calls.append((request.url.path, base_url, provider_name))
return JSONResponse({"base_url": base_url, "provider": provider_name})
with patch.object(HeadroomProxy, "handle_passthrough", fake_passthrough):
with TestClient(_app()) as client:
list_response = client.get(
"/v1/models",
headers={
"authorization": "Bearer claude-gateway-oauth-token",
"user-agent": "claude-code/1.5.0 (darwin; arm64)",
},
)
get_response = client.get(
"/v1/models/claude-opus-4-8",
headers={
"authorization": "Bearer claude-gateway-oauth-token",
"user-agent": "claude-code/1.5.0 (darwin; arm64)",
},
)
assert list_response.status_code == 200
assert get_response.status_code == 200
assert list_response.json() == {
"base_url": "https://api.anthropic.test",
"provider": "anthropic",
}
assert get_response.json() == {
"base_url": "https://api.anthropic.test",
"provider": "anthropic",
}
assert calls == [
("/v1/models", "https://api.anthropic.test", "anthropic"),
("/v1/models/claude-opus-4-8", "https://api.anthropic.test", "anthropic"),
]
fix(anthropic): strip styled Claude model ids (#651) ## Description Fixes #626 by normalizing Anthropic/Claude model ids that contain ANSI escape sequences or dangling style suffixes before provider lookups and upstream forwarding. The branch has been updated onto current `main` and the proxy handler conflicts have been resolved. ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Documentation - [ ] Refactor - [x] Tests only ## Changes Made - Normalize Anthropic model ids before context/pricing lookup. - Sanitize Anthropic `/v1/models` metadata and styled `/v1/models/{id}` passthrough paths. - Sanitize `/v1/messages` request body model ids before upstream forwarding. - Resolved current-main conflicts while preserving newer `model_override` and streaming passthrough behavior. ## Testing - [x] Unit tests - [x] Route/proxy tests - [x] Lint/static checks - [ ] Manual testing ### Test Output ```text UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --with pytest --with fastapi --with httpx --with uvicorn --with h2 python -m pytest tests/test_providers/test_anthropic.py tests/test_provider_proxy_routes.py::test_anthropic_model_metadata_strips_ansi_model_ids tests/test_provider_proxy_routes.py::test_anthropic_model_detail_path_strips_ansi_model_id tests/test_provider_proxy_routes.py::test_anthropic_messages_strips_ansi_model_id_before_upstream -q 17 passed, 2 warnings in 39.91s UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --with ruff ruff check headroom/providers/anthropic.py headroom/proxy/handlers/openai.py headroom/proxy/handlers/anthropic.py tests/test_providers/test_anthropic.py tests/test_provider_proxy_routes.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13.3, focused local worktree for PR #651 after merging current `upstream/main`. - Exact command / steps: Merged current main, resolved conflicts in Anthropic/OpenAI proxy handlers, ran the PR's targeted provider/proxy tests and ruff checks. - Observed result: Styled Anthropic model metadata, model-detail path, and messages upstream sanitization tests pass; ruff reports no issues. - Not tested: Full repository mypy/pre-commit; existing unrelated Windows `fcntl` typing errors block full hook execution locally. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review <!-- headroom-maintainer-template-completion:start --> ## Description This PR prepares `fix(anthropic): strip styled Claude model ids` for review by documenting the intended change, validation evidence, and remaining merge-readiness context. Linked issues: #626 ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Documentation - [ ] Refactor - [ ] Tests only ## Changes Made - Commit: fix(anthropic): normalize styled model ids - Commit: fix(proxy): strip styled Anthropic model ids - Commit: fix: format anthropic model sanitization - Commit: Merge remote-tracking branch 'upstream/main' into review/pr-651 - Touches `headroom/cache/dynamic_detector.py` - Touches `headroom/providers/anthropic.py` - Touches `headroom/proxy/handlers/anthropic.py` - Touches `headroom/proxy/handlers/openai.py` - Touches `tests/test_provider_proxy_routes.py` - Touches `tests/test_providers/test_anthropic.py` ## Testing - [x] GitHub checks reviewed - [x] Metadata/template validation - [ ] Local functional testing ### Test Output ```text gh pr view 651 --repo chopratejas/headroom --json statusCheckRollup - PR Governance / template: FAILURE - CI / changes: SUCCESS - Init E2E / docker-init-e2e: SUCCESS - Wrap E2E / docker-wrap-e2e: SUCCESS - Wrap Native E2E / wrap-native (ubuntu-latest): SUCCESS - Wrap Native E2E / wrap-native (macos-latest): SUCCESS - CI / commitlint: SUCCESS - PR Governance / label: SUCCESS - CI / lint: SUCCESS - CI / build-wheel: SUCCESS - CI / prefetch-model: SUCCESS - CI / build: SUCCESS ``` ## Real Behavior Proof - Environment: GitHub PR metadata and checks for `chopratejas/headroom` PR #651. - Exact command / steps: Reviewed PR title, commits, changed files, linked issues, labels, and check rollup; appended this maintainer template completion block without replacing the author's original description. - Observed result: PR body now contains all required governance sections, checked readiness fields, and a non-placeholder validation evidence block. - Not tested: This pass updated PR metadata only; code validation remains represented by the linked GitHub checks and any author-provided evidence above. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review <!-- headroom-maintainer-template-completion:end --> --------- Co-authored-by: Tejas Chopra <chopratejas@gmail.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-13 13:46:21 -05:00
def test_anthropic_model_metadata_strips_ansi_model_ids() -> None:
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append((method, url))
return httpx.Response(
200,
json={
"object": "list",
"data": [
{"id": "claude-opus-4-8\x1b[1m", "object": "model"},
{"id": "claude-sonnet-4-5[1m]", "object": "model"},
],
},
)
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
response = client.get("/v1/models", headers={"x-api-key": "sk-ant-test"})
assert response.status_code == 200
assert response.json()["data"] == [
{"id": "claude-opus-4-8", "object": "model"},
{"id": "claude-sonnet-4-5", "object": "model"},
]
assert fake_http_client.calls == [("GET", "https://api.anthropic.test/v1/models")]
def test_anthropic_model_detail_path_strips_ansi_model_id() -> None:
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append((method, url))
return httpx.Response(
200,
json={"id": "claude-opus-4-8\x1b[1m", "object": "model"},
)
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
response = client.get(
"/v1/models/claude-opus-4-8%1B%5B1m",
headers={"x-api-key": "sk-ant-test"},
)
assert response.status_code == 200
assert response.json()["id"] == "claude-opus-4-8"
assert fake_http_client.calls == [
("GET", "https://api.anthropic.test/v1/models/claude-opus-4-8")
]
def test_anthropic_messages_strips_ansi_model_id_before_upstream() -> None:
class FakeAsyncClient:
def __init__(self) -> None:
self.bodies: list[dict[str, Any]] = []
async def post(self, url, **kwargs): # type: ignore[no-untyped-def]
self.bodies.append(json.loads(kwargs["content"]))
return httpx.Response(
200,
json={
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-opus-4-8",
"content": [],
"stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 1},
},
)
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
response = client.post(
"/v1/messages",
headers={"x-api-key": "sk-ant-test"},
json={
"model": "claude-opus-4-8\x1b[1m",
"max_tokens": 16,
"messages": [{"role": "user", "content": "hello"}],
},
)
assert response.status_code == 200
assert fake_http_client.bodies[0]["model"] == "claude-opus-4-8"