headroom/tests/test_proxy/test_anthropic_upstream_header.py
Adryan Eka Vandra bb2acf700a
fix(proxy): honor x-headroom-base-url on /v1/messages route (#1763)
## Description

The Anthropic Messages route (`POST /v1/messages`) ignored the
`x-headroom-base-url` per-request upstream override and unconditionally
forwarded to `api.anthropic.com`. `handle_anthropic_messages` already
accepts `upstream_base_url` (it builds the upstream URL via
`build_copilot_upstream_url`), but the route never passed it. Clients
that speak the Anthropic Messages wire format while authenticating
against a non-Anthropic gateway (e.g. OpenCode Zen's "Go" tier) were
forwarded to the real Anthropic API, which rejected the gateway key with
`401 invalid x-api-key`.

The route now reads and trims `x-headroom-base-url` and passes it
through as `upstream_base_url`, mirroring the OpenAI-compatible routes
and the generic passthrough route (`proxy_routes.py:996`).

Closes #1760

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

- `headroom/providers/proxy_routes.py`: the `/v1/messages` route reads
`x-headroom-base-url`; when present it strips whitespace and a trailing
slash and passes the value as `upstream_base_url` to
`handle_anthropic_messages`. Absent or whitespace-only headers keep the
previous default (`api.anthropic.com`).
- `tests/test_proxy/test_anthropic_upstream_header.py`: new test module
pinning the route contract (header present, absent, empty,
whitespace-only, trimming + trailing-slash stripping).
- `docs/content/docs/configuration.mdx`: new "Proxy upstream override
(`x-headroom-base-url`)" subsection under Per-Request Overrides
documenting the header across the OpenAI, Anthropic Messages, and
passthrough routes.
- `CHANGELOG.md`: `Unreleased > Fixed` entry for the `/v1/messages`
override.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ python -m pytest tests/test_proxy/ -k "anthropic or passthrough or bedrock"
collected 140 items / 91 deselected / 49 selected
tests/test_proxy/test_anthropic_upstream_header.py ....                  [ 65%]
...
49 passed, 91 deselected, 1 warning in 79.68s

$ ruff check headroom/providers/proxy_routes.py tests/test_proxy/test_anthropic_upstream_header.py
All checks passed!

$ mypy headroom/providers/proxy_routes.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

Ran the actual `headroom proxy` against a local mock upstream (a tiny
HTTP server on `127.0.0.1:9911` that logs the path it receives) to
reproduce the issue's before/after.

- Environment: local, macOS, Python 3.12; ran `headroom proxy --port
8799` against a local mock upstream (a tiny HTTP server on
`127.0.0.1:9911` that logs the path it receives).
- Exact command / steps: started the proxy and the mock upstream, then
sent one `POST /v1/messages` **with** the override header and one
**without** it (negative control), using these two `curl` commands.

  ```bash
# WITH the override header — expect routing to the mock at
127.0.0.1:9911
  curl http://127.0.0.1:8799/v1/messages \
-H "content-type: application/json" -H "anthropic-version: 2023-06-01" \
    -H "x-headroom-base-url: http://127.0.0.1:9911" \
    -H "x-api-key: zen-test-key" \
-d
'{"model":"glm-5.2","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'

# WITHOUT the override header — expect routing to the real
api.anthropic.com
  curl http://127.0.0.1:8799/v1/messages \
-H "content-type: application/json" -H "anthropic-version: 2023-06-01" \
    -H "x-api-key: sk-ant-fake" \
-d
'{"model":"claude-3-5-sonnet-20241022","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
  ```

- Observed result: with the header, the mock upstream logged `HIT
path=/v1/messages x-api-key=zen-test-key` and the proxy returned `HTTP
200`, confirming the request was routed to
`<x-headroom-base-url>/v1/messages` carrying the gateway key. Without
the header, the request went to the real `api.anthropic.com` (returned
`HTTP 401` with a genuine `request_id` and
`{"type":"authentication_error","message":"invalid x-api-key"}`) and the
mock received no additional hit — matching the pre-fix behavior in the
issue. Also verified by TDD: the two override unit cases failed before
the route change (`assert None == 'https://opencode.ai/zen/go'`) and
passed after it; all 4 new cases and 49 related proxy tests are green.
- Not tested: a request against the real OpenCode Zen gateway (no
credentials); the gateway path is verified with a local mock upstream
instead.

## 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
- [x] 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Manual testing against the real OpenCode Zen gateway is N/A (no
credentials); a local mock upstream is used instead to prove the routing
(see Real Behavior Proof).
- Scope is limited to `/v1/messages`. The related
`/v1/messages/count_tokens` route uses a fixed passthrough target and is
out of scope for this issue.
2026-07-09 12:43:40 -05:00

102 lines
3.3 KiB
Python

"""Tests that the ``/v1/messages`` route honors ``x-headroom-base-url``.
The Anthropic Messages route must forward the per-request upstream
override header to ``handle_anthropic_messages`` so clients that speak
the Anthropic wire format but authenticate against a non-Anthropic
gateway (e.g. OpenCode Zen's "Go" tier) route correctly — consistent
with the OpenAI-compatible routes and the generic passthrough route,
which already honor it (see ``providers/proxy_routes.py``).
Contract pinned here:
- header present → its value is passed as ``upstream_base_url``
- header absent → no override (``upstream_base_url`` unset/None)
- header empty/whitespace → no override (must not blank the upstream)
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
fastapi = pytest.importorskip("fastapi")
from fastapi.responses import JSONResponse # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from headroom.proxy.server import ProxyConfig, create_app # noqa: E402
MESSAGES = "/v1/messages"
BODY = {"model": "glm-5.2", "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}]}
def _make_config(**overrides) -> ProxyConfig:
base = {
"optimize": False,
"cache_enabled": False,
"rate_limit_enabled": False,
"mode": "token",
}
base.update(overrides)
return ProxyConfig(**base)
def _install_handler_spy(proxy) -> AsyncMock:
"""Replace handle_anthropic_messages with a spy returning a 200."""
spy = AsyncMock(return_value=JSONResponse({"ok": True}))
proxy.handle_anthropic_messages = spy
return spy
def _base_url_kwarg(spy: AsyncMock):
call = spy.call_args
return call.kwargs.get("upstream_base_url")
def test_header_passed_as_upstream_base_url():
app = create_app(_make_config())
with TestClient(app) as client:
spy = _install_handler_spy(client.app.state.proxy)
resp = client.post(
MESSAGES,
json=BODY,
headers={"x-headroom-base-url": "https://opencode.ai/zen/go"},
)
assert resp.status_code == 200
assert _base_url_kwarg(spy) == "https://opencode.ai/zen/go"
def test_missing_header_leaves_upstream_unset():
app = create_app(_make_config())
with TestClient(app) as client:
spy = _install_handler_spy(client.app.state.proxy)
resp = client.post(MESSAGES, json=BODY)
assert resp.status_code == 200
assert _base_url_kwarg(spy) is None
def test_empty_or_whitespace_header_leaves_upstream_unset():
for value in ("", " "):
app = create_app(_make_config())
with TestClient(app) as client:
spy = _install_handler_spy(client.app.state.proxy)
resp = client.post(MESSAGES, json=BODY, headers={"x-headroom-base-url": value})
assert resp.status_code == 200
assert _base_url_kwarg(spy) is None
def test_header_value_is_trimmed_and_trailing_slash_stripped():
app = create_app(_make_config())
with TestClient(app) as client:
spy = _install_handler_spy(client.app.state.proxy)
resp = client.post(
MESSAGES,
json=BODY,
headers={"x-headroom-base-url": " https://opencode.ai/zen/go/ "},
)
assert resp.status_code == 200
assert _base_url_kwarg(spy) == "https://opencode.ai/zen/go"