diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0368fb0..dba81821d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `/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 diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 73b5f5854..9201e8f20 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -127,6 +127,33 @@ response = client.chat.completions.create( +### 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 `` + 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: " \ + -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: diff --git a/headroom/providers/proxy_routes.py b/headroom/providers/proxy_routes.py index ce6b5fe08..80abd2544 100644 --- a/headroom/providers/proxy_routes.py +++ b/headroom/providers/proxy_routes.py @@ -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") diff --git a/tests/test_proxy/test_anthropic_upstream_header.py b/tests/test_proxy/test_anthropic_upstream_header.py new file mode 100644 index 000000000..cea62070f --- /dev/null +++ b/tests/test_proxy/test_anthropic_upstream_header.py @@ -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"