mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04: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.
This commit is contained in:
parent
c30ec4cda8
commit
bb3e040a46
2 changed files with 72 additions and 2 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue