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:
vladgrish 2026-07-02 06:22:42 +03:00 committed by GitHub
parent 54cfa361d3
commit cff7247efd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 118 additions and 19 deletions

View file

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

View file

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

View file

@ -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,
}