mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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.
This commit is contained in:
parent
7fd0c42ced
commit
bb2acf700a
4 changed files with 146 additions and 0 deletions
|
|
@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
- The Anthropic Messages route (`POST /v1/messages`) now honors the
|
||||
`x-headroom-base-url` per-request upstream override. It previously ignored the
|
||||
header and always forwarded to `api.anthropic.com`, so clients that speak the
|
||||
Anthropic Messages wire format while authenticating against a non-Anthropic
|
||||
gateway (e.g. OpenCode Zen) were rejected upstream with `401 invalid
|
||||
x-api-key`. The route now forwards to `<x-headroom-base-url>/v1/messages`,
|
||||
consistent with the OpenAI-compatible and passthrough routes
|
||||
([#1760](https://github.com/headroomlabs-ai/headroom/issues/1760)).
|
||||
- **proxy:** the savings store now fsyncs its parent directory after the
|
||||
atomic rename, so the most recent `proxy_savings.json` write survives a
|
||||
power-loss or crash. `_save_locked` fsynced the temp file's contents but
|
||||
|
|
|
|||
|
|
@ -127,6 +127,33 @@ response = client.chat.completions.create(
|
|||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Proxy upstream override (`x-headroom-base-url`)
|
||||
|
||||
When using the proxy, send the `x-headroom-base-url` request header to route a
|
||||
single request to a different upstream instead of the configured provider URL.
|
||||
This lets a client that speaks a provider's wire format authenticate against a
|
||||
compatible gateway (for example an OpenAI-compatible endpoint, or an
|
||||
Anthropic-Messages gateway such as OpenCode Zen) without changing the proxy
|
||||
configuration.
|
||||
|
||||
The header is honored by the OpenAI-compatible routes, the Anthropic Messages
|
||||
route (`POST /v1/messages`), and the generic passthrough route. The proxy
|
||||
forwards the request to `<x-headroom-base-url>` + the original request path
|
||||
(e.g. `/v1/messages`). An empty or whitespace-only value is ignored and the
|
||||
configured upstream is used.
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8787/v1/messages \
|
||||
-H "content-type: application/json" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-H "x-headroom-base-url: https://opencode.ai/zen/go" \
|
||||
-H "x-api-key: <gateway-api-key>" \
|
||||
-d '{"model":"glm-5.2","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
|
||||
```
|
||||
|
||||
When `HEADROOM_STRIP_INTERNAL_HEADERS` is `enabled` (the default), the proxy
|
||||
reads this header for routing and then strips it before forwarding upstream.
|
||||
|
||||
## SmartCrusher Configuration
|
||||
|
||||
Fine-tune JSON compression behavior:
|
||||
|
|
|
|||
|
|
@ -491,6 +491,15 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None:
|
|||
|
||||
@app.post("/v1/messages")
|
||||
async def anthropic_messages(request: Request):
|
||||
# Honor the per-request upstream override so clients that speak the
|
||||
# Anthropic Messages wire format but authenticate against a
|
||||
# non-Anthropic gateway route correctly, consistent with the
|
||||
# OpenAI-compatible and generic passthrough routes.
|
||||
custom_base = request.headers.get("x-headroom-base-url", "").strip()
|
||||
if custom_base:
|
||||
return await proxy.handle_anthropic_messages(
|
||||
request, upstream_base_url=custom_base.rstrip("/")
|
||||
)
|
||||
return await proxy.handle_anthropic_messages(request)
|
||||
|
||||
@app.post("/anthropic/v1/messages")
|
||||
|
|
|
|||
102
tests/test_proxy/test_anthropic_upstream_header.py
Normal file
102
tests/test_proxy/test_anthropic_upstream_header.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue