mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(copilot): normalize subscription routing host (#1836)
## Description `headroom wrap copilot --subscription` can currently trust the token-exchange host for individual Copilot seats, which routes newer responses-API models like `gpt-5.4` to `api.individual.githubcopilot.com` and reproduces the transient `502` retry loop from issue #1694. This normalizes that public individual-seat host back to the generic Copilot API host while preserving dedicated business or explicitly pinned hosts. Closes #1694. ## 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 - Normalized exchanged Copilot subscription hosts through the existing public-host classifier instead of trusting the raw token-exchange payload. - Added a regression proving `api.individual.githubcopilot.com` downgrades to `https://api.githubcopilot.com` for subscription routing. - Added a wrap-level regression proving subscription launches export the normalized host into the proxy env. - Preserved business-host and explicit `GITHUB_COPILOT_API_URL` routing behavior. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_copilot.py -q`) - [x] Linting passes (`uv run ruff check headroom/copilot_auth.py tests/test_copilot_auth.py tests/test_cli/test_wrap_copilot.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q 57 passed, 1 warning in 0.34s uv run pytest tests/test_cli/test_wrap_copilot.py -q 31 passed, 1 warning in 0.32s uv run ruff check headroom/copilot_auth.py tests/test_copilot_auth.py tests/test_cli/test_wrap_copilot.py All checks passed! uv run ruff format --check headroom/copilot_auth.py tests/test_copilot_auth.py tests/test_cli/test_wrap_copilot.py 3 files already formatted ``` ## Real Behavior Proof - Environment: Windows, Python `uv` environment, mocked Copilot token-exchange and wrap launch surfaces. - Exact command / steps: run the focused Copilot auth and wrap regression tests after teaching subscription token-exchange routing to normalize the public individual-seat host. - Observed result: exchanged subscription tokens that advertise `https://api.individual.githubcopilot.com` now route through `https://api.githubcopilot.com`, while business-host and explicit-host pin cases stay unchanged. - Not tested: a live GitHub Copilot subscription request against the upstream service. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [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 have updated the CHANGELOG.md if applicable ## Additional Notes This is intentionally scoped to host selection for exchanged Copilot subscription tokens. It does not change token discovery, token pinning, or non-subscription OAuth routing.
This commit is contained in:
parent
4bd3ddfaa5
commit
afd9cbdfaf
3 changed files with 67 additions and 1 deletions
|
|
@ -780,7 +780,7 @@ def _api_url_from_exchange_payload(payload: dict[str, Any], *, oauth_token: str)
|
|||
api_url = _api_url_from_payload(payload)
|
||||
if api_url:
|
||||
if is_copilot_api_url(api_url):
|
||||
return api_url
|
||||
return _subscription_api_url_from_user_info_payload({"endpoints": {"api": api_url}})
|
||||
logger.warning(
|
||||
"Ignoring non-Copilot API URL from token exchange payload: %s",
|
||||
api_url,
|
||||
|
|
|
|||
|
|
@ -956,6 +956,57 @@ def test_wrap_copilot_subscription_uses_resolved_subscription_endpoint(
|
|||
assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "copilot-api"
|
||||
|
||||
|
||||
def test_wrap_copilot_subscription_normalizes_individual_public_endpoint(
|
||||
runner: CliRunner,
|
||||
wrap_modules: tuple[types.ModuleType, click.Group],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_wrap_cli, main = wrap_modules
|
||||
_clear_copilot_env(monkeypatch)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_launch_tool(**kwargs): # noqa: ANN003
|
||||
captured.update(kwargs)
|
||||
|
||||
with (
|
||||
patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
|
||||
patch("headroom.cli.wrap.has_oauth_auth", return_value=True),
|
||||
patch(
|
||||
"headroom.copilot_auth.iter_oauth_token_candidates",
|
||||
return_value=[
|
||||
types.SimpleNamespace(
|
||||
token="gho-oauth",
|
||||
source="headroom-copilot-auth:/tmp/copilot_auth.json",
|
||||
confidence="copilot-oauth",
|
||||
validate_for_subscription=True,
|
||||
)
|
||||
],
|
||||
),
|
||||
patch(
|
||||
"headroom.copilot_auth.CopilotTokenProvider._exchange_token_sync",
|
||||
staticmethod(
|
||||
lambda _headers: {
|
||||
"token": "copilot-api",
|
||||
"expires_at": 9999999999,
|
||||
"endpoints": {"api": "https://api.individual.githubcopilot.com"},
|
||||
}
|
||||
),
|
||||
),
|
||||
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["wrap", "copilot", "--subscription", "--no-rtk", "--", "--model", "gpt-5.4"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
assert captured["openai_api_url"] == DEFAULT_API_URL
|
||||
assert env["OPENAI_TARGET_API_URL"] == DEFAULT_API_URL
|
||||
assert env["GITHUB_COPILOT_API_URL"] == DEFAULT_API_URL
|
||||
|
||||
|
||||
def test_wrap_copilot_subscription_honors_api_url_override(
|
||||
runner: CliRunner,
|
||||
wrap_modules: tuple[types.ModuleType, click.Group],
|
||||
|
|
|
|||
|
|
@ -270,6 +270,21 @@ def test_api_url_from_exchange_payload_rejects_non_copilot_host(
|
|||
assert resolved == "https://api.business.githubcopilot.com"
|
||||
|
||||
|
||||
def test_api_url_from_exchange_payload_normalizes_individual_public_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
||||
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
|
||||
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
|
||||
|
||||
resolved = copilot_auth._api_url_from_exchange_payload(
|
||||
{"endpoints": {"api": "https://api.individual.githubcopilot.com"}},
|
||||
oauth_token="gho-oauth",
|
||||
)
|
||||
|
||||
assert resolved == copilot_auth.DEFAULT_API_URL
|
||||
|
||||
|
||||
def test_api_url_from_exchange_payload_rejects_non_copilot_host_without_user_info(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue