From 739f654bbd71b3e31ade40ae9eadf812b362beec Mon Sep 17 00:00:00 2001 From: Vinay Gupta <58447456+aivinay@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:22:20 -0500 Subject: [PATCH] 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 `/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. --- headroom/providers/proxy_routes.py | 12 ++++++++++++ tests/test_provider_proxy_routes.py | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/headroom/providers/proxy_routes.py b/headroom/providers/proxy_routes.py index d215edf8c..ce6b5fe08 100644 --- a/headroom/providers/proxy_routes.py +++ b/headroom/providers/proxy_routes.py @@ -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, diff --git a/tests/test_provider_proxy_routes.py b/tests/test_provider_proxy_routes.py index c80c03701..be588a3c1 100644 --- a/tests/test_provider_proxy_routes.py +++ b/tests/test_provider_proxy_routes.py @@ -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" )