fix(proxy): route Foundry Anthropic messages (#1878)

## Description

Closes #1874

`headroom wrap claude` in Azure AI Foundry mode gives Claude Code a
local `ANTHROPIC_FOUNDRY_BASE_URL` ending in `/anthropic`. Claude Code
appends `/v1/messages`, so Headroom receives `POST
/anthropic/v1/messages`. That path was not registered as an Anthropic
Messages route, so it fell through to generic passthrough and never
reached compression or Foundry forwarding.

This PR registers the Foundry-shaped Anthropic Messages route,
normalizes the inbound request path back to `/v1/messages`, and
dispatches it through `handle_anthropic_messages` with the configured
Anthropic upstream base. That keeps the actual upstream URL shape as
`<foundry>/anthropic/v1/messages` while avoiding the catch-all
OpenAI-compatible passthrough.

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

- Added a `POST /anthropic/v1/messages` route alias for Foundry-mode
Claude Code traffic.
- Normalized the request path to `/v1/messages` before invoking the
Anthropic handler.
- Added route-level regression coverage proving the Foundry-shaped path
reaches `handle_anthropic_messages` instead of passthrough.

## Testing

- [x] Unit tests pass (`tests/test_provider_proxy_routes.py` with a
local `headroom._core` import stub)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
# Pre-fix proof with the new regression present:
tests/test_provider_proxy_routes.py F.F.................
FAILED tests/test_provider_proxy_routes.py::test_provider_passthrough_routes_forward_expected_targets
FAILED tests/test_provider_proxy_routes.py::test_provider_specific_routes_delegate_to_expected_proxy_handlers
Observed: /anthropic/v1/messages fell through to handle_passthrough with https://api.openai.test.

# After patch:
HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=/Users/vinaygupta/Desktop/git/headroom-fix-1874-foundry-anthropic-route \
  /tmp/headroom-route-test-1874/bin/python -m pytest tests/test_provider_proxy_routes.py -q
20 passed, 2 warnings in 2.19s

rtk proxy uvx ruff==0.15.17 check .
All checks passed!

rtk proxy uvx ruff==0.15.17 format --check .
1068 files already formatted

rtk proxy uvx --from mypy==1.20.2 mypy headroom/providers/proxy_routes.py --ignore-missing-imports
Success: no issues found in 1 source file

GitHub PR checks after opening readiness review:
28 passed, 0 failed
```

## Real Behavior Proof

- Environment: macOS local checkout, throwaway Python env at
`/tmp/headroom-route-test-1874`, `HEADROOM_REQUIRE_RUST_CORE=false`, and
an in-memory `headroom._core` stub for route-level testing because the
native extension is not built locally.
- Exact command / steps: added the regression first, ran the focused
route test, observed `/anthropic/v1/messages` fall through to
`handle_passthrough`; then added the route alias and reran the same
test.
- Observed result: `/anthropic/v1/messages?beta=true` now reaches
`handle_anthropic_messages` with normalized path `/v1/messages` and
upstream base `https://api.anthropic.test`.
- Not tested: live Claude Code against a real Azure AI Foundry
deployment.

## 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
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

Full local `uv run pytest` is blocked by the known native build issue in
`esaxx-rs` (`fatal error: 'cstdint' file not found`). Full touched-file
mypy also reports existing `no-untyped-def` errors in
`tests/test_provider_proxy_routes.py`; the production route file passes
mypy on its own.

The unchecked documentation/comment/CHANGELOG boxes are N/A for this
route-only fix.
This commit is contained in:
Vinay Gupta 2026-07-08 16:22:20 -05:00 committed by GitHub
parent 0ba5065d40
commit 739f654bbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 25 additions and 0 deletions

View file

@ -474,6 +474,13 @@ async def _handle_chatgpt_codex_images(
def register_provider_routes(app: FastAPI, proxy: Any) -> None:
"""Register provider-specific proxy endpoints."""
def normalize_request_path(request: Request, path: str) -> None:
request.scope["path"] = path
if "raw_path" in request.scope:
request.scope["raw_path"] = quote(path).encode("ascii")
if hasattr(request, "_url"):
delattr(request, "_url")
async def vertex_publisher_passthrough(request: Request, publisher: str, action: str):
return await proxy.handle_passthrough(
request,
@ -486,6 +493,11 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None:
async def anthropic_messages(request: Request):
return await proxy.handle_anthropic_messages(request)
@app.post("/anthropic/v1/messages")
async def foundry_anthropic_messages(request: Request):
normalize_request_path(request, "/v1/messages")
return await proxy.handle_anthropic_messages(request, _api_target(proxy, "anthropic"))
# AWS Bedrock InvokeModel passthrough. Registered ONLY when an upstream is
# configured (`--bedrock-api-url` / BEDROCK_TARGET_API_URL): without it,
# `/model/{id}/invoke` keeps falling through to the catch-all (verbatim,

View file

@ -173,6 +173,14 @@ def test_provider_passthrough_routes_forward_expected_targets(monkeypatch) -> No
"model": "claude-3-5-sonnet@20240620",
"force_stream": False,
}
assert client.post("/anthropic/v1/messages?beta=true").json() == {
"handler": "handle_anthropic_messages",
"path": "/v1/messages",
"upstream_base_url": "https://api.anthropic.test",
"provider": "anthropic",
"model": None,
"force_stream": False,
}
non_anthropic_raw = client.post(
"/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:rawPredict"
).json()
@ -336,6 +344,11 @@ def test_provider_specific_routes_delegate_to_expected_proxy_handlers(monkeypatc
with TestClient(_app()) as client:
assert client.post("/v1/messages").json()["handler"] == "handle_anthropic_messages"
assert client.post("/anthropic/v1/messages").json() == {
"handler": "handle_anthropic_messages",
"path": "/v1/messages",
"args": ["https://api.anthropic.test"],
}
assert (
client.post("/v1/messages/batches").json()["handler"] == "handle_anthropic_batch_create"
)