From afd9cbdfafba0d31bd376a4a43dbcd41b30ec909 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Mon, 6 Jul 2026 09:23:48 -0400 Subject: [PATCH] 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. --- headroom/copilot_auth.py | 2 +- tests/test_cli/test_wrap_copilot.py | 51 +++++++++++++++++++++++++++++ tests/test_copilot_auth.py | 15 +++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/headroom/copilot_auth.py b/headroom/copilot_auth.py index 6484ed533..67654f5cd 100644 --- a/headroom/copilot_auth.py +++ b/headroom/copilot_auth.py @@ -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, diff --git a/tests/test_cli/test_wrap_copilot.py b/tests/test_cli/test_wrap_copilot.py index 0323012e7..a99794d13 100644 --- a/tests/test_cli/test_wrap_copilot.py +++ b/tests/test_cli/test_wrap_copilot.py @@ -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], diff --git a/tests/test_copilot_auth.py b/tests/test_copilot_auth.py index 389c53776..4b22c9997 100644 --- a/tests/test_copilot_auth.py +++ b/tests/test_copilot_auth.py @@ -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: