Merge branch 'main' into feat/metaprogramming-guardrails

This commit is contained in:
JD Davis 2026-08-26 13:30:09 -05:00 committed by GitHub
commit 645efae6de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 222 additions and 2 deletions

View file

@ -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] = {}

View file

@ -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"]