From bb3e040a463b66801323c261e9547f1e4a2ccfbd Mon Sep 17 00:00:00 2001 From: Aykut Bulgu Date: Fri, 26 Jun 2026 20:16:39 +0300 Subject: [PATCH] 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. --- headroom/providers/proxy_routes.py | 37 +++++++++++++++++++++++++++++ tests/test_provider_proxy_routes.py | 37 +++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/headroom/providers/proxy_routes.py b/headroom/providers/proxy_routes.py index 0b1e1ae97..40a4faea5 100644 --- a/headroom/providers/proxy_routes.py +++ b/headroom/providers/proxy_routes.py @@ -732,6 +732,24 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None: ) return await vertex_publisher_passthrough(request, publisher, "rawPredict") + @app.post( + "/projects/{project}/locations/{location}/publishers/anthropic/models/{model}:rawPredict" + ) + async def vertex_raw_predict_no_version( + request: Request, + project: str, + location: str, + model: str, + ): + del project + target = _vertex_target_for_location(proxy, location).rstrip("/") + "/v1" + return await proxy.handle_anthropic_messages( + request, + target, + "vertex:anthropic", + model, + ) + @app.post( "/{api_version}/projects/{project}/locations/{location}/publishers/{publisher}/models/{model}:streamRawPredict" ) @@ -754,6 +772,25 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None: ) return await vertex_publisher_passthrough(request, publisher, "streamRawPredict") + @app.post( + "/projects/{project}/locations/{location}/publishers/anthropic/models/{model}:streamRawPredict" + ) + async def vertex_stream_raw_predict_no_version( + request: Request, + project: str, + location: str, + model: str, + ): + del project + target = _vertex_target_for_location(proxy, location).rstrip("/") + "/v1" + return await proxy.handle_anthropic_messages( + request, + target, + "vertex:anthropic", + model, + True, + ) + @app.get("/v1/models") async def list_models(request: Request): chatgpt_response = await _handle_chatgpt_model_metadata( diff --git a/tests/test_provider_proxy_routes.py b/tests/test_provider_proxy_routes.py index 32312ba37..3b8a79a17 100644 --- a/tests/test_provider_proxy_routes.py +++ b/tests/test_provider_proxy_routes.py @@ -163,6 +163,24 @@ def test_provider_passthrough_routes_forward_expected_targets(monkeypatch) -> No "model": "claude-3-5-sonnet@20240620", "force_stream": False, } + 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, + } + non_anthropic_raw = client.post( + "/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:rawPredict" + ).json() + assert non_anthropic_raw.get("handler") != "handle_anthropic_messages" + non_anthropic_stream = client.post( + "/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamRawPredict" + ).json() + assert non_anthropic_stream.get("handler") != "handle_anthropic_messages" assert client.post("/v1beta/cachedContents").json()["sub_path"] == "cachedContents" assert client.get("/v1beta/cachedContents").json()["sub_path"] == "cachedContents" assert client.get("/v1beta/cachedContents/cache-1").json()["sub_path"] == "cachedContents" @@ -201,7 +219,7 @@ def test_provider_passthrough_routes_forward_expected_targets(monkeypatch) -> No assert len(calls) >= 16 assert len(gemini_calls) >= 1 assert len(gemini_count_calls) >= 1 - assert len(anthropic_calls) >= 1 + assert len(anthropic_calls) >= 2 def test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough() -> None: @@ -339,6 +357,21 @@ def test_provider_specific_routes_delegate_to_expected_proxy_handlers(monkeypatc "claude-3-5-sonnet@20240620", True, ] + assert client.post( + "/projects/p/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet@20240620:rawPredict" + ).json()["args"] == [ + "https://vertex.test/v1", + "vertex:anthropic", + "claude-3-5-sonnet@20240620", + ] + assert client.post( + "/projects/p/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet@20240620:streamRawPredict" + ).json()["args"] == [ + "https://vertex.test/v1", + "vertex:anthropic", + "claude-3-5-sonnet@20240620", + True, + ] assert client.post("/v1internal:streamGenerateContent").json()["handler"] == ( "handle_google_cloudcode_stream" ) @@ -356,7 +389,7 @@ def test_provider_specific_routes_delegate_to_expected_proxy_handlers(monkeypatc "handle_google_batch_passthrough" ) - assert len(delegated) >= 24 + assert len(delegated) >= 26 def test_openai_response_websocket_aliases_delegate_to_openai_ws_handler(monkeypatch) -> None: