2026-04-21 22:20:52 -05:00
|
|
|
from __future__ import annotations
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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
|
2026-04-21 22:20:52 -05:00
|
|
|
from typing import Any
|
2026-05-09 22:36:56 -07:00
|
|
|
from unittest.mock import patch
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
import httpx
|
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
from fastapi.testclient import TestClient
|
2026-04-24 15:33:30 +02:00
|
|
|
|
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
|
2026-04-21 22:20:52 -05:00
|
|
|
from headroom.proxy.server import HeadroomProxy, ProxyConfig, create_app
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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",
|
2026-04-21 22:20:52 -05:00
|
|
|
)
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
def test_provider_passthrough_routes_forward_expected_targets(monkeypatch) -> None:
|
2026-08-20 07:02:44 -07:00
|
|
|
# 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")
|
2026-04-21 22:20:52 -05:00
|
|
|
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]] = []
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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"
|
2026-04-21 22:20:52 -05:00
|
|
|
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"
|
2026-04-21 22:20:52 -05:00
|
|
|
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"] == ""
|
2026-04-21 22:20:52 -05:00
|
|
|
assert client.get("/another/path", headers={"x-goog-api-key": "test"}).json()[
|
|
|
|
|
"base_url"
|
|
|
|
|
] == ("https://api.gemini.test")
|
2026-04-24 15:33:30 +02:00
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
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")]
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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",
|
2026-04-21 22:20:52 -05:00
|
|
|
"provider_runtime": type(
|
|
|
|
|
"Runtime",
|
|
|
|
|
(),
|
|
|
|
|
{
|
|
|
|
|
"api_target": staticmethod(lambda provider: f"https://runtime.{provider}.test"),
|
|
|
|
|
"model_metadata_provider": staticmethod(lambda headers: "anthropic"),
|
|
|
|
|
},
|
|
|
|
|
)(),
|
|
|
|
|
},
|
|
|
|
|
)()
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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"
|
2026-04-21 22:20:52 -05:00
|
|
|
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"
|
2026-04-21 22:20:52 -05:00
|
|
|
)
|
2026-04-22 21:58:03 -05:00
|
|
|
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"
|
|
|
|
|
)
|
2026-04-21 22:20:52 -05:00
|
|
|
assert proxy_routes._select_passthrough_base_url(proxy, {}) == "https://legacy.anthropic.test"
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
def test_provider_specific_routes_delegate_to_expected_proxy_handlers(monkeypatch) -> None:
|
|
|
|
|
delegated: list[tuple[str, str, tuple[str, ...]]] = []
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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)})
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
monkeypatch.setattr(HeadroomProxy, name, fake)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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",
|
2026-04-21 22:20:52 -05:00
|
|
|
):
|
|
|
|
|
install(handler_name)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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"],
|
|
|
|
|
}
|
2026-04-21 22:20:52 -05:00
|
|
|
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"
|
2026-08-13 15:06:41 -05:00
|
|
|
assert client.post("/chat/completions").json()["handler"] == "handle_openai_chat"
|
2026-04-21 22:20:52 -05:00
|
|
|
assert client.post("/v1/responses").json()["handler"] == "handle_openai_responses"
|
2026-08-13 15:06:41 -05:00
|
|
|
assert client.post("/responses").json()["handler"] == "handle_openai_responses"
|
2026-04-21 22:20:52 -05:00
|
|
|
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,
|
|
|
|
|
]
|
2026-04-21 22:20:52 -05:00
|
|
|
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"
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
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
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
def test_openai_response_websocket_aliases_delegate_to_openai_ws_handler(monkeypatch) -> None:
|
|
|
|
|
seen_paths: list[str] = []
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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()
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
monkeypatch.setattr(HeadroomProxy, "handle_openai_responses_ws", fake_ws)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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}
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
assert seen_paths == [
|
|
|
|
|
"/v1/responses",
|
|
|
|
|
"/v1/codex/responses",
|
|
|
|
|
"/backend-api/responses",
|
|
|
|
|
"/backend-api/codex/responses",
|
|
|
|
|
]
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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}")
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
async def aclose(self) -> None:
|
|
|
|
|
return None
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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"})
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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]]] = []
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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})
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
async def aclose(self) -> None:
|
|
|
|
|
return None
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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"},
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-21 22:20:52 -05:00
|
|
|
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"
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-22 21:58:03 -05:00
|
|
|
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),
|
2026-04-22 21:58:03 -05:00
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-22 21:58:03 -05:00
|
|
|
class FakeAsyncClient:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.calls: list[tuple[str, str]] = []
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-22 21:58:03 -05:00
|
|
|
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
|
|
|
|
|
self.calls.append((method, url))
|
|
|
|
|
return httpx.Response(200, json={"url": url})
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-22 21:58:03 -05:00
|
|
|
async def aclose(self) -> None:
|
|
|
|
|
return None
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-22 21:58:03 -05:00
|
|
|
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
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-22 21:58:03 -05:00
|
|
|
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"),
|
|
|
|
|
]
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
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",
|
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"
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 21:58:03 -05:00
|
|
|
def test_gemini_batch_embed_contents_passthrough_uses_gemini_target(monkeypatch) -> None:
|
|
|
|
|
calls: list[tuple[str, str, str]] = []
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-22 21:58:03 -05:00
|
|
|
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})
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-22 21:58:03 -05:00
|
|
|
monkeypatch.setattr(HeadroomProxy, "handle_passthrough", fake_passthrough)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-22 21:58:03 -05:00
|
|
|
with TestClient(_app()) as client:
|
|
|
|
|
response = client.post("/v1beta/models/demo:batchEmbedContents")
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-22 21:58:03 -05:00
|
|
|
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")
|
|
|
|
|
]
|
2026-05-15 17:54:24 -07:00
|
|
|
|
|
|
|
|
|
2026-06-01 14:10:15 -06: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")
|
2026-06-01 14:10:15 -06:00
|
|
|
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,
|
2026-06-01 14:10:15 -06:00
|
|
|
"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()
|
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
|
2026-06-01 14:10:15 -06:00
|
|
|
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:
|
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
|
2026-06-01 14:10:15 -06:00
|
|
|
(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.
|
2026-05-15 17:54:24 -07:00
|
|
|
"""
|
2026-06-02 18:07:11 -07:00
|
|
|
|
2026-06-01 14:10:15 -06: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
|
|
|
|
|
|
2026-05-15 17:54:24 -07:00
|
|
|
with TestClient(_app()) as client:
|
2026-06-01 14:10:15 -06:00
|
|
|
client.app.state.proxy.http_client = FakeAsyncClient()
|
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"]}
|
2026-06-16 21:19:22 +02:00
|
|
|
model_slugs = {entry["slug"] for entry in payload["models"]}
|
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
|
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"
|
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:
|
2026-05-15 17:54:24 -07:00
|
|
|
"""The single-model variant (`/v1/models/{id}`) is also called by
|
2026-06-01 14:10:15 -06:00
|
|
|
Codex for some flows. It should use the Codex registry first so
|
|
|
|
|
dynamically exposed model slugs validate consistently."""
|
2026-06-02 18:07:11 -07:00
|
|
|
|
2026-06-01 14:10:15 -06:00
|
|
|
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
|
|
|
|
|
|
2026-05-15 17:54:24 -07:00
|
|
|
with TestClient(_app()) as client:
|
2026-06-01 14:10:15 -06:00
|
|
|
fake_http_client = FakeAsyncClient()
|
|
|
|
|
client.app.state.proxy.http_client = fake_http_client
|
2026-05-15 17:54:24 -07:00
|
|
|
ok = client.get(
|
2026-06-01 14:10:15 -06:00
|
|
|
"/v1/models/gpt-5.3-codex-spark",
|
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() == {
|
2026-06-01 14:10:15 -06:00
|
|
|
"id": "gpt-5.3-codex-spark",
|
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
|
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"
|
2026-06-06 04:28:17 +06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|