From 1e448b55039b6968caedb8afb810ba70a3abe0c1 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Wed, 26 Aug 2026 22:43:11 +0530 Subject: [PATCH] fix(providers): route Claude requests to Copilot when the OpenAI target is a Copilot host (#3258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Through `headroom wrap vscode` / `wrap copilot --subscription`, GitHub Copilot **GPT** models work but **Claude** models fail with `Invalid bearer token` (issue #3247). The logs tell the story: ```text # GPT — works: event=outbound_request path=https://api.githubcopilot.com/chat/completions status=200 # Claude — fails: event=outbound_request path=https://api.anthropic.com/v1/messages status=401 ``` GitHub Copilot serves **both** surfaces from the same host: its OpenAI surface (`/chat/completions`, `/responses`) and its Anthropic surface for Claude models (`/v1/messages`) — `build_copilot_upstream_url` already documents and handles this. But `resolve_api_targets` resolves each provider target independently: when the Copilot flow points the **OpenAI** target at a Copilot host (so GPT works), the **Anthropic** target is left at its default `https://api.anthropic.com`. Claude-model requests are therefore forwarded to the real Anthropic API carrying the GitHub Copilot bearer, which Anthropic rejects with `Invalid bearer token`. ## Fix In `resolve_api_targets`, when the resolved OpenAI target is a Copilot upstream host **and no explicit Anthropic target was configured**, default the Anthropic target to that same Copilot host. Claude requests then reach `https://api.githubcopilot.com/v1/messages` — the surface that serves them, where the Copilot bearer is valid. An explicit `ANTHROPIC_TARGET_API_URL` always wins (only a `None` override is filled in), and non-Copilot OpenAI targets are untouched, so direct-Anthropic setups are unaffected. Reproduction: ```python resolve_api_targets(ProviderApiOverrides(openai="https://api.githubcopilot.com", anthropic=None, ...)) # BEFORE: targets.anthropic == "https://api.anthropic.com" -> Copilot bearer 401s there # AFTER: targets.anthropic == "https://api.githubcopilot.com" ``` ## 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/registry.py`: `resolve_api_targets` now fills a `None` Anthropic override with the OpenAI target when that target is a Copilot host (`is_copilot_upstream_url`). Explicit overrides and non-Copilot targets are unchanged. - `tests/test_provider_registry.py`: added three tests — Copilot OpenAI target routes Anthropic to Copilot; an explicit Anthropic override wins; a non-Copilot OpenAI target leaves the Anthropic default alone. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality ### Test Output ```text tests/test_provider_registry.py tests/test_provider_registry_extended.py tests/test_banner_upstream_targets.py -> 37 passed in 12.11s (the new Copilot test FAILS on pre-fix code — verified via git stash) uvx ruff@0.16.2 check headroom/providers/registry.py tests/test_provider_registry.py -> All checks passed! uvx mypy@1.20.2 headroom/providers/registry.py -> Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.16.2 and mypy 1.20.2 via uvx. - Exact command / steps: `resolve_api_targets` with `openai="https://api.githubcopilot.com"` (and the `api.business.githubcopilot.com` variant) and `anthropic=None` returned `anthropic="https://api.anthropic.com"` before the fix and the Copilot host after; an explicit `anthropic="https://api.anthropic.com"` is preserved; `openai="https://api.openai.com"` leaves `anthropic` at the default. - Observed result: Claude-model requests now resolve to the Copilot host that serves them; OpenAI/direct-Anthropic behavior is unchanged. - Not tested: no live macOS/VS Code Copilot round trip (environment-specific); the target-resolution seam that decides the upstream host is exercised directly. `is_copilot_upstream_url` already recognizes the github.com Copilot hosts (verified). ## Runtime Rollout Safety - Rollout-managed feature(s): none. This is upstream target resolution in the provider registry, not a rollout-channel-gated runtime feature. - Minimum rollout channel: N/A. - Stable/default behavior changed: only the broken case changes — a Copilot OpenAI target with no Anthropic override now sends Claude to Copilot instead of 401ing against api.anthropic.com. Explicit Anthropic targets and non-Copilot OpenAI targets are byte-for-byte unchanged. - Kill switch / disable path: set `ANTHROPIC_TARGET_API_URL` explicitly to opt out of the default. - Unsafe override required: no. - Qualification impact: none for non-Copilot deployments. - Rollback path: revert this PR. ## 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 - [ ] I have made corresponding changes to the documentation (N/A: internal behavior) - [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 did **not** edit `CHANGELOG.md` ## Additional Notes Fixes the routing/auth mismatch at the resolution layer so it applies uniformly across the Copilot config paths (`wrap vscode`, `wrap copilot --subscription`) that set the OpenAI target to a Copilot host. If a specific deploy sets neither target to a Copilot host (relying solely on path-based passthrough routing for OpenAI), configuring `ANTHROPIC_TARGET_API_URL` to the Copilot host remains the explicit escape hatch. --- headroom/providers/registry.py | 22 +++++++++++++-- tests/test_provider_registry.py | 49 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) 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",