mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
fix: Vertex AI support for Claude Code with ANTHROPIC_VERTEX_BASE_URL (#1393)
## Description Fixes two bugs that prevent headroom from working with Claude Code in Vertex AI mode (`CLAUDE_CODE_USE_VERTEX=1` + `ANTHROPIC_VERTEX_BASE_URL`). Closes #1392 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Add `vertex_raw_predict_no_version` route for `/projects/{project}/locations/{location}/publishers/{publisher}/models/{model}:rawPredict` — Claude Code omits the `/v1` API version prefix when using `ANTHROPIC_VERTEX_BASE_URL`, causing all requests to fall through to the catch-all handler which forwards to OpenAI (404). The new handler prepends `/v1` to `request.scope["path"]` before calling `handle_anthropic_messages`. - Add `vertex_stream_raw_predict_no_version` route for `:streamRawPredict` — same fix for streaming. - In `_start_proxy` (`headroom/cli/wrap.py`): auto-set `HEADROOM_HTTP2=false` in the proxy subprocess env when `CLAUDE_CODE_USE_VERTEX` or `ANTHROPIC_VERTEX_PROJECT_ID` is detected. Vertex AI RST_STREAMs HTTP/2 connections (`StreamReset error_code:2`); HTTP/1.1 works correctly. ## Testing - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text # Direct curl to patched proxy — versionless paths now routed correctly $ curl -s -w "\nHTTP:%{http_code}" -X POST \ "http://127.0.0.1:8787/projects/$GCP_PROJECT/locations/$CLOUD_ML_REGION/publishers/anthropic/models/claude-haiku-4-5@20251001:streamRawPredict" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -H "anthropic-version: 2023-06-01" \ -d '{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}],"max_tokens":5,"stream":true}' event: message_start ... event: message_stop HTTP:200 $ curl -s -w "\nHTTP:%{http_code}" -X POST \ "http://127.0.0.1:8787/projects/$GCP_PROJECT/locations/$CLOUD_ML_REGION/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict" \ ... HTTP:200 # Before fix: both returned HTTP:404 (falling through to catch-all → OpenAI) # Before HTTP/2 fix: streamRawPredict returned StreamReset error_code:2 ``` ## Real Behavior Proof - Environment: macOS Apple Silicon, Python 3.14.3, headroom-ai 0.27.0 (patched locally), Claude Code 2.1.176, `CLAUDE_CODE_USE_VERTEX=1`, `CLOUD_ML_REGION=<region>`, `ANTHROPIC_VERTEX_PROJECT_ID=<project-id>` - Exact command / steps: `headroom wrap claude -- --model haiku -p "test"` and `headroom wrap claude -- --model sonnet -p "test"` - Observed result: Before fix — all models fail with "There's an issue with the selected model" (404 from catch-all routing to OpenAI). After fix — Claude Code connects and responds successfully via proxy (HTTP 200 from Vertex confirmed via curl). - Not tested: automated unit/integration tests (require live GCP credentials), non-Vertex backends (code paths untouched) ## 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 - [ ] 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 versionless route fix is the critical one — without it, 100% of Claude Code Vertex requests fail. The HTTP/2 fix is defense-in-depth; users can also set `HEADROOM_HTTP2=false` manually. Both fixes are non-breaking: existing `/v1/projects/...` routes are untouched, and the HTTP/2 change only applies when a Vertex env var is present. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
This commit is contained in:
parent
54cfa361d3
commit
cff7247efd
3 changed files with 118 additions and 19 deletions
|
|
@ -447,6 +447,10 @@ def _start_proxy(
|
|||
# Ensure proxy subprocess uses UTF-8 (Windows defaults to cp1252)
|
||||
proxy_env = os.environ.copy()
|
||||
proxy_env["PYTHONIOENCODING"] = "utf-8"
|
||||
# Vertex AI RST_STREAMs HTTP/2 connections (error_code:2). Force HTTP/1.1
|
||||
# when wrapping a Vertex-mode client so upstream requests succeed.
|
||||
if os.environ.get("CLAUDE_CODE_USE_VERTEX") or os.environ.get("ANTHROPIC_VERTEX_PROJECT_ID"):
|
||||
proxy_env.setdefault("HEADROOM_HTTP2", "false")
|
||||
# Tell the proxy which agent is being wrapped (for traffic learning output)
|
||||
if agent_type != "unknown":
|
||||
proxy_env["HEADROOM_AGENT_TYPE"] = agent_type
|
||||
|
|
|
|||
|
|
@ -733,22 +733,25 @@ 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"
|
||||
"/projects/{project}/locations/{location}/publishers/{publisher}/models/{model}:rawPredict"
|
||||
)
|
||||
async def vertex_raw_predict_no_version(
|
||||
request: Request,
|
||||
project: str,
|
||||
location: str,
|
||||
publisher: 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,
|
||||
)
|
||||
if publisher == "anthropic":
|
||||
del project
|
||||
target = _vertex_target_for_location(proxy, location).rstrip("/") + "/v1"
|
||||
return await proxy.handle_anthropic_messages(
|
||||
request,
|
||||
target,
|
||||
"vertex:anthropic",
|
||||
model,
|
||||
)
|
||||
return await vertex_publisher_passthrough(request, publisher, "rawPredict")
|
||||
|
||||
@app.post(
|
||||
"/{api_version}/projects/{project}/locations/{location}/publishers/{publisher}/models/{model}:streamRawPredict"
|
||||
|
|
@ -773,23 +776,26 @@ 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"
|
||||
"/projects/{project}/locations/{location}/publishers/{publisher}/models/{model}:streamRawPredict"
|
||||
)
|
||||
async def vertex_stream_raw_predict_no_version(
|
||||
request: Request,
|
||||
project: str,
|
||||
location: str,
|
||||
publisher: 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,
|
||||
)
|
||||
if publisher == "anthropic":
|
||||
del project
|
||||
target = _vertex_target_for_location(proxy, location).rstrip("/") + "/v1"
|
||||
return await proxy.handle_anthropic_messages(
|
||||
request,
|
||||
target,
|
||||
"vertex:anthropic",
|
||||
model,
|
||||
True,
|
||||
)
|
||||
return await vertex_publisher_passthrough(request, publisher, "streamRawPredict")
|
||||
|
||||
@app.get("/v1/models")
|
||||
async def list_models(request: Request):
|
||||
|
|
|
|||
|
|
@ -138,3 +138,92 @@ def test_vertex_rawpredict_anthropic_runs_compression_handler(monkeypatch) -> No
|
|||
assert captured["provider"] == "vertex:anthropic"
|
||||
assert captured["base_url"] == "https://europe-west1-aiplatform.googleapis.com"
|
||||
assert captured["model"] == "claude-sonnet-4-6"
|
||||
|
||||
|
||||
def test_vertex_rawpredict_versionless_anthropic_rewrites_to_v1(monkeypatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake(
|
||||
self,
|
||||
request,
|
||||
base_url,
|
||||
provider,
|
||||
model,
|
||||
force_stream=False,
|
||||
): # type: ignore[no-untyped-def]
|
||||
captured.update(
|
||||
path=request.url.path,
|
||||
raw_path=request.scope.get("raw_path"),
|
||||
base_url=str(base_url),
|
||||
provider=str(provider),
|
||||
model=str(model),
|
||||
force_stream=force_stream,
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
monkeypatch.setattr(HeadroomProxy, "handle_anthropic_messages", fake)
|
||||
|
||||
with TestClient(_default_vertex_app()) as client:
|
||||
resp = client.post(
|
||||
"/projects/p/locations/europe-west1/publishers/anthropic/models/"
|
||||
"claude-sonnet-4-6:rawPredict",
|
||||
json={"anthropic_version": "vertex-2023-10-16", "messages": []},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert captured == {
|
||||
"path": (
|
||||
"/projects/p/locations/europe-west1/publishers/anthropic/models/"
|
||||
"claude-sonnet-4-6:rawPredict"
|
||||
),
|
||||
"raw_path": (
|
||||
b"/projects/p/locations/europe-west1/publishers/anthropic/models/"
|
||||
b"claude-sonnet-4-6:rawPredict"
|
||||
),
|
||||
"base_url": "https://europe-west1-aiplatform.googleapis.com/v1",
|
||||
"provider": "vertex:anthropic",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"force_stream": False,
|
||||
}
|
||||
|
||||
|
||||
def test_vertex_stream_rawpredict_versionless_anthropic_forces_stream(monkeypatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake(
|
||||
self,
|
||||
request,
|
||||
base_url,
|
||||
provider,
|
||||
model,
|
||||
force_stream=False,
|
||||
): # type: ignore[no-untyped-def]
|
||||
captured.update(
|
||||
path=request.url.path,
|
||||
base_url=str(base_url),
|
||||
provider=str(provider),
|
||||
model=str(model),
|
||||
force_stream=force_stream,
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
monkeypatch.setattr(HeadroomProxy, "handle_anthropic_messages", fake)
|
||||
|
||||
with TestClient(_default_vertex_app()) as client:
|
||||
resp = client.post(
|
||||
"/projects/p/locations/europe-west1/publishers/anthropic/models/"
|
||||
"claude-sonnet-4-6:streamRawPredict",
|
||||
json={"anthropic_version": "vertex-2023-10-16", "messages": []},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert captured == {
|
||||
"path": (
|
||||
"/projects/p/locations/europe-west1/publishers/anthropic/models/"
|
||||
"claude-sonnet-4-6:streamRawPredict"
|
||||
),
|
||||
"base_url": "https://europe-west1-aiplatform.googleapis.com/v1",
|
||||
"provider": "vertex:anthropic",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"force_stream": True,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue