mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(providers): route Claude requests to Copilot when the OpenAI target is a Copilot host (#3258)
## 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.
This commit is contained in:
parent
d12ea50122
commit
1e448b5503
2 changed files with 69 additions and 2 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue