diff --git a/headroom/proxy/handlers/anthropic.py b/headroom/proxy/handlers/anthropic.py index 538cc2b6d..5f5e4a510 100644 --- a/headroom/proxy/handlers/anthropic.py +++ b/headroom/proxy/handlers/anthropic.py @@ -27,7 +27,7 @@ import httpx from headroom.agent_savings import proxy_pipeline_kwargs from headroom.ccr.context_tracker import looks_like_claude_code_compact_summary from headroom.ccr.marker_resolution import resolve_markers_in_response -from headroom.copilot_auth import build_copilot_upstream_url +from headroom.copilot_auth import apply_copilot_api_auth, build_copilot_upstream_url from headroom.pipeline import PipelineStage, summarize_routing_markers from headroom.proxy.auth_mode import ( classify_auth_mode, @@ -3501,10 +3501,20 @@ class AnthropicHandlerMixin: # Direct Anthropic API, or a provider-compatible Anthropic # Messages endpoint such as Vertex AI publisher rawPredict. + # Both arms build through `build_copilot_upstream_url` because that + # is the only place `mark_request_routed_to_copilot` fires, and the + # outcome funnel relabels `provider` to "copilot" off that flag (see + # proxy/outcome.py). The resolved target reaches Copilot without any + # per-request `upstream_base_url` — `wrap vscode` points the + # Anthropic target at the Copilot host — so this else arm carries the + # Claude-on-Copilot traffic, and building it by f-string attributed + # every one of those turns to "anthropic" on the dashboard. + # For a non-Copilot base the builder only joins base + path, so the + # URL itself is unchanged. url = ( build_copilot_upstream_url(upstream_base_url, request.url.path) if upstream_base_url - else f"{self.ANTHROPIC_API_URL}/v1/messages" + else build_copilot_upstream_url(self.ANTHROPIC_API_URL, "/v1/messages") ) if upstream_base_url and request.url.query: url = f"{url}?{request.url.query}" @@ -3762,6 +3772,27 @@ class AnthropicHandlerMixin: headers.pop(_accept_key, None) headers["accept"] = "application/json" + # Copilot auth is applied per-URL, and until now only the + # streaming forwarder did it (``_stream_response``). This + # buffered arm sends via ``_retry_request``, which forwards + # headers untouched — so a Claude turn routed to Copilot + # arrived with whatever the client happened to send and none + # of Headroom's own credential handling: no minted or + # refreshed token (the one ``wrap vscode`` hands the proxy), + # no ``Copilot-Integration-Id`` default. A client token that + # went stale mid-session therefore 401'd here while the + # streaming path recovered. + # + # A non-Copilot URL returns the headers unchanged, so this is + # a no-op everywhere else. + # + # Mutated in place for the same reason as the accept header + # above: the closures below capture ``headers``, and the CCR + # continuation rebuilds its own header set from it. + _copilot_authed_headers = await apply_copilot_api_auth(dict(headers), url=url) + headers.clear() + headers.update(_copilot_authed_headers) + # Populated once the upstream answers 200 with parseable # JSON, so the guard below can fall back to it (#3088). _salvageable_upstream: dict[str, Any] = {} diff --git a/tests/test_proxy/test_anthropic_copilot_upstream_auth.py b/tests/test_proxy/test_anthropic_copilot_upstream_auth.py new file mode 100644 index 000000000..46ad14c0e --- /dev/null +++ b/tests/test_proxy/test_anthropic_copilot_upstream_auth.py @@ -0,0 +1,189 @@ +"""A Claude turn routed to GitHub Copilot must reach it authenticated, and be +attributed to Copilot — on the buffered (non-streaming) arm, not just streaming. + +Copilot serves Claude models from its Anthropic surface (``/v1/messages``) on +the same host as its OpenAI surface, so the resolved Anthropic target can be a +Copilot host with no per-request ``x-headroom-base-url`` in play. Two things +used to be true only on the streaming path: + +- **Auth.** ``apply_copilot_api_auth`` is keyed on the upstream URL and was + applied only by ``_stream_response``. The buffered arm sends through + ``_retry_request``, which forwards headers untouched, so the request carried + no minted token and no ``Copilot-Integration-Id``. +- **Attribution.** ``build_copilot_upstream_url`` is the only place the + routed-to-Copilot flag is set, and the buffered arm built its URL by + f-string — so ``emit_request_outcome`` never relabeled the provider and the + turn showed as "anthropic". + +Both are pinned here at the ``_retry_request`` seam: the URL that was built, the +headers as they went on the wire, and the flag as it stood at send time. +""" + +from __future__ import annotations + +import contextvars + +import pytest + +fastapi = pytest.importorskip("fastapi") +httpx = pytest.importorskip("httpx") + +from fastapi.testclient import TestClient # noqa: E402 + +from headroom import copilot_auth # noqa: E402 +from headroom.proxy.server import ProxyConfig, create_app # noqa: E402 + +MESSAGES = "/v1/messages" +COPILOT = "https://api.githubcopilot.com" +ANTHROPIC = "https://api.anthropic.com" +BODY = { + "model": "claude-sonnet-5", + "max_tokens": 16, + "stream": False, + "messages": [{"role": "user", "content": "hi"}], +} +MINTED = "tid_minted_for_test" + + +def _make_config(**overrides) -> ProxyConfig: + base = { + "optimize": False, + "cache_enabled": False, + "rate_limit_enabled": False, + "mode": "token", + } + base.update(overrides) + return ProxyConfig(**base) + + +def _stub_token_provider(monkeypatch: pytest.MonkeyPatch) -> None: + """Mint a deterministic Copilot API token instead of calling GitHub.""" + + class _Token: + token = MINTED + + class _Provider: + async def get_api_token(self, integration_id: str | None = None): + return _Token() + + monkeypatch.setattr(copilot_auth, "get_copilot_token_provider", lambda: _Provider()) + + +class _Send: + """Capture what the buffered arm was about to put on the wire.""" + + def __init__(self) -> None: + self.url: str | None = None + self.headers: dict[str, str] = {} + self.routed_to_copilot: bool | None = None + + async def __call__(self, method, url, headers, body, **kwargs): + self.url = url + self.headers = dict(headers) + # Read the flag where it matters: at send time, before the outcome + # funnel consumes it. + self.routed_to_copilot = copilot_auth.request_routed_to_copilot() + return httpx.Response( + 200, + json={ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": BODY["model"], + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 5, "output_tokens": 2}, + }, + request=httpx.Request(method, url), + ) + + +def _post(anthropic_api_url: str, monkeypatch: pytest.MonkeyPatch) -> _Send: + _stub_token_provider(monkeypatch) + send = _Send() + app = create_app(_make_config(anthropic_api_url=anthropic_api_url)) + with TestClient(app) as client: + client.app.state.proxy._retry_request = send + resp = client.post(MESSAGES, json=BODY) + assert resp.status_code == 200 + return send + + +def _headers_lower(send: _Send) -> dict[str, str]: + return {k.lower(): v for k, v in send.headers.items()} + + +def _emitted_providers(anthropic_api_url: str, monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Provider labels on the outcomes this request emitted. + + The relabel happens inside ``emit_request_outcome``, which runs in a task + created by ``asyncio.shield`` — so this also pins that the flag survives the + context copy into that task, which asserting on the flag alone would not. + """ + import headroom.telemetry.session as telemetry_session + + seen: list[str] = [] + monkeypatch.setattr( + telemetry_session, "record_outcome", lambda outcome: seen.append(outcome.provider) + ) + _post(anthropic_api_url, monkeypatch) + return seen + + +# --- Copilot target --------------------------------------------------------- + + +def test_buffered_turn_to_copilot_is_authenticated(monkeypatch: pytest.MonkeyPatch) -> None: + send = contextvars.Context().run(lambda: _post(COPILOT, monkeypatch)) + + headers = _headers_lower(send) + assert headers["authorization"] == f"Bearer {MINTED}" + # The credential and the integration id have to leave together, or GitHub + # cannot HMAC-validate the pair. + assert headers.get("copilot-integration-id") + assert headers.get("editor-version") + + +def test_buffered_turn_to_copilot_keeps_the_v1_messages_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Copilot's Anthropic surface keeps ``/v1``; stripping it 404s (#2409).""" + send = contextvars.Context().run(lambda: _post(COPILOT, monkeypatch)) + + assert send.url == f"{COPILOT}/v1/messages" + + +def test_buffered_turn_to_copilot_is_flagged_for_attribution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + send = contextvars.Context().run(lambda: _post(COPILOT, monkeypatch)) + + assert send.routed_to_copilot is True + + +def test_buffered_turn_to_copilot_is_labeled_copilot(monkeypatch: pytest.MonkeyPatch) -> None: + """End of the chain: the outcome that reaches the dashboard says "copilot".""" + providers = contextvars.Context().run(lambda: _emitted_providers(COPILOT, monkeypatch)) + + assert providers == ["copilot"] + + +# --- non-Copilot target (control) ------------------------------------------- + + +def test_anthropic_target_is_left_alone(monkeypatch: pytest.MonkeyPatch) -> None: + """Off the Copilot path both changes must be inert.""" + send = contextvars.Context().run(lambda: _post(ANTHROPIC, monkeypatch)) + + headers = _headers_lower(send) + assert send.url == f"{ANTHROPIC}/v1/messages" + assert send.routed_to_copilot is False + # No Copilot credential or handshake headers invented for a non-Copilot host. + assert headers.get("authorization") != f"Bearer {MINTED}" + assert "copilot-integration-id" not in headers + + +def test_anthropic_target_is_not_relabeled(monkeypatch: pytest.MonkeyPatch) -> None: + providers = contextvars.Context().run(lambda: _emitted_providers(ANTHROPIC, monkeypatch)) + + assert providers == ["anthropic"]