diff --git a/headroom/providers/registry.py b/headroom/providers/registry.py index 04c212a4d..a8b816f3b 100644 --- a/headroom/providers/registry.py +++ b/headroom/providers/registry.py @@ -162,9 +162,27 @@ def resolve_extra_headers( def resolve_api_targets(overrides: ProviderApiOverrides) -> ProviderApiTargets: """Resolve normalized upstream provider targets from configured overrides.""" + from headroom.copilot_auth import is_copilot_upstream_url + + openai = _normalize_api_url(overrides.openai, default=DEFAULT_OPENAI_API_URL) + + # GitHub Copilot serves BOTH its OpenAI surface (``/chat/completions``, + # ``/responses``) and its Anthropic surface (``/v1/messages``, for Claude + # models) from the same host. When the OpenAI target is a Copilot host + # (``wrap copilot --subscription`` / ``wrap vscode`` both point it there so + # GPT models work) but no Anthropic target was set, Claude-model requests + # fell back to ``DEFAULT_ANTHROPIC_API_URL`` (api.anthropic.com) and 401'd + # with the Copilot bearer — "Invalid bearer token" (#3247). Default the + # Anthropic target to the same Copilot host so those requests reach the + # surface that actually serves them. An explicit ``ANTHROPIC_TARGET_API_URL`` + # still wins (only a ``None`` override is filled in here). + anthropic_override = overrides.anthropic + if anthropic_override is None and is_copilot_upstream_url(openai): + anthropic_override = openai + return ProviderApiTargets( - anthropic=_normalize_api_url(overrides.anthropic, default=DEFAULT_ANTHROPIC_API_URL), - openai=_normalize_api_url(overrides.openai, default=DEFAULT_OPENAI_API_URL), + anthropic=_normalize_api_url(anthropic_override, default=DEFAULT_ANTHROPIC_API_URL), + openai=openai, gemini=_normalize_api_url(overrides.gemini, default=DEFAULT_GEMINI_API_URL), cloudcode=_normalize_api_url(overrides.cloudcode, default=DEFAULT_CLOUDCODE_API_URL), vertex=_normalize_api_url(overrides.vertex, default=DEFAULT_VERTEX_API_URL), diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py index 0b0ff5b04..07310cd41 100644 --- a/tests/test_provider_registry.py +++ b/tests/test_provider_registry.py @@ -56,6 +56,55 @@ def test_resolve_api_targets_normalizes_trailing_v1() -> None: assert targets.vertex == "https://vertex.example" +def test_copilot_openai_target_routes_anthropic_to_copilot() -> None: + """When the OpenAI target is a Copilot host and no Anthropic override is set, + the Anthropic target must default to the same Copilot host. + + Copilot serves Claude models via its Anthropic surface (``/v1/messages``) on + the same host. Without this, Claude requests fell back to api.anthropic.com + and 401'd with the Copilot bearer ("Invalid bearer token", #3247). + """ + targets = resolve_api_targets( + ProviderApiOverrides( + anthropic=None, + openai="https://api.githubcopilot.com", + gemini=None, + cloudcode=None, + vertex=None, + ) + ) + assert targets.openai == "https://api.githubcopilot.com" + assert targets.anthropic == "https://api.githubcopilot.com" + + +def test_explicit_anthropic_override_wins_over_copilot_default() -> None: + """An explicit Anthropic target is never overridden by the Copilot default.""" + targets = resolve_api_targets( + ProviderApiOverrides( + anthropic="https://api.anthropic.com", + openai="https://api.githubcopilot.com", + gemini=None, + cloudcode=None, + vertex=None, + ) + ) + assert targets.anthropic == "https://api.anthropic.com" + + +def test_non_copilot_openai_target_leaves_anthropic_default() -> None: + """A non-Copilot OpenAI target must not touch the Anthropic default.""" + targets = resolve_api_targets( + ProviderApiOverrides( + anthropic=None, + openai="https://api.openai.com", + gemini=None, + cloudcode=None, + vertex=None, + ) + ) + assert targets.anthropic == "https://api.anthropic.com" + + def test_proxy_config_exposes_provider_api_overrides() -> None: config = ProxyConfig( anthropic_api_url="https://anthropic.example",