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.
This commit is contained in:
Aykut Bulgu 2026-06-26 20:16:39 +03:00 committed by GitHub
parent c30ec4cda8
commit bb3e040a46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 72 additions and 2 deletions

View file

@ -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(

View file

@ -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: