fix(runtime): exchange Copilot raw OAuth token in pool resolution path - #24546
fix(runtime): exchange Copilot raw OAuth token in pool resolution path#24546lucvan wants to merge 1 commit into
Conversation
…g api_key
The credential pool resolution path in `_resolve_runtime_from_pool_entry`
returns the raw GitHub OAuth token (`gho_*`/`ghu_*`) straight from the
pool entry's `runtime_api_key`. The sibling `resolve_api_key_provider_
credentials("copilot")` path correctly calls `get_copilot_api_token()`
to exchange the raw token via `/copilot_internal/v2/token` for the
short-lived `tid=...` Bearer that Copilot's `/chat/completions`
endpoint actually expects.
When the gateway-init fallback path picks Copilot through the pool
branch, the raw token is sent unexchanged, and GitHub maps it to
whatever integrator the originating OAuth app declared as default --
typically ``copilot-language-server`` for Business seats -- which has
a restricted model allowlist that excludes Claude. Result:
HTTP 400: The requested model is not available for integrator
"copilot-language-server"
even though the headers correctly advertise
``Copilot-Integration-Id: vscode-chat``. The bug is partially masked
because the secondary `_try_activate_fallback` path constructs its
own client via `resolve_provider_client()` which DOES exchange -- so
the next-tier fallback (e.g. gpt-4.1) succeeds, hiding the first-
attempt failure.
Fix: when the pool branch resolves Copilot, run the same
`get_copilot_api_token()` exchange before assigning the returned
`api_key`. Guarded against a string already in exchanged form
(``tid=...``) so a future caller that pre-exchanges the token is not
double-exchanged.
Reproducer: configure `fallback_providers: [{provider: copilot,
model: claude-sonnet-4.6}]`, force the primary to fail at gateway
init, observe the first chat-completions call ships
`Authorization: Bearer ghu_*` (raw, 40 chars) instead of
`Bearer tid=...` (exchanged, several hundred chars).
|
Thanks for spotting this root cause 🙏 — the raw One heads-up on this diff: api_key = get_copilot_api_token(api_key)assigns the whole tuple to I opened #58830 building on your finding — it unpacks the tuple, also adopts the account-specific |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating the Copilot pool-resolution gap; current main still returns the pool key unchanged from hermes_cli/runtime_provider.py:410 through the Copilot branch at :445-447.
Problems
- The added assignment treats
get_copilot_api_token()as returning a string, but it returns(api_token, base_url)athermes_cli/copilot_auth.py:415-434. The tuple would be returned asapi_keybyhermes_cli/runtime_provider.py:524-532. - The equivalent explicit-key path remains unhandled:
hermes_cli/runtime_provider.py:1474-1503returns a Copilotexplicit_api_keywithout exchange. - This PR changes only
hermes_cli/runtime_provider.py; add regression coverage for the pool and explicit paths. Existing coverage only tests the auth resolver attests/hermes_cli/test_copilot_token_exchange.py:153-166.
Suggested changes
- Unpack token and base URL from the exchange, preserve fallback behavior, and use the account-specific exchanged endpoint where appropriate.
- Cover raw, pre-exchanged, pool, and explicit-key cases with network-free tests.
Automated hermes-sweeper review.
| if api_key and not api_key.startswith("tid="): | ||
| try: | ||
| from hermes_cli.copilot_auth import get_copilot_api_token | ||
| api_key = get_copilot_api_token(api_key) |
There was a problem hiding this comment.
get_copilot_api_token() returns (api_token, base_url), not a token string (hermes_cli/copilot_auth.py:415-434). Unpack the result here; otherwise this tuple becomes the runtime bearer value.
Problem
_resolve_runtime_from_pool_entry's Copilot branch returnsgetattr(entry, "runtime_api_key", "")directly. The sibling api-key path (resolve_api_key_provider_credentials("copilot")inhermes_cli/auth.py) correctly callsget_copilot_api_token()to exchange the rawgho_*/ghu_*GitHub OAuth token via/copilot_internal/v2/tokenfor the short-livedtid=...Bearer that Copilot's/chat/completionsendpoint actually requires.When the gateway-init fallback path picks Copilot via the pool branch, the raw token goes on the wire. GitHub then maps the raw OAuth grant to whatever integrator the originating OAuth app declared as default — typically
copilot-language-serverfor Business/Enterprise seats — which has a restricted model allowlist that excludes Claude:even though
copilot_request_headers()correctly advertisesCopilot-Integration-Id: vscode-chat(verified by adding anhttpx.Client.sendwrapper and inspecting outgoing headers). The header is ignored when the Bearer is a raw OAuth token rather than an exchanged Copilot API token.The bug is partially masked because the secondary
_try_activate_fallbackpath constructs its own client viaresolve_provider_client()which DOES exchange — so the next-tier fallback (e.g.gpt-4.1) succeeds, hiding the first-attempt failure.Fix
When the pool branch resolves Copilot, run the same
get_copilot_api_token()exchange before assigning the returnedapi_key. Guarded against a string already in exchanged form (tid=...) so a future caller that pre-exchanges the token is not double-exchanged.Strictly additive — no signature changes, only adds the missing exchange step to one specific provider branch. Other providers (
openai-codex,anthropic,nous, etc.) are unaffected.Reproduction
fallback_providers::_resolve_runtime_agent_kwargs).HTTP 400: The requested model is not available for integrator "copilot-language-server". With the fix: the request succeeds.The 40-character
ghu_*vs ~400-charactertid=...Bearer-token length difference is observable inhttpx.Client.sendif you wrap it temporarily.Test plan
_resolve_runtime_from_pool_entrytests pass._resolve_runtime_from_pool_entry({provider: "copilot", runtime_api_key: "gho_..."})returns anapi_keythat starts withtid=(mockingget_copilot_api_tokento return a sentinel exchanged token).runtime_api_keyalready starts withtid=.Note
This is a separate root cause from #17622 (which targets the same function for the
api_modecalculation but doesn't touch the returnedapi_key). The two fixes are complementary; both are needed for the gateway-init Copilot fallback to work for Claude on Business seats.🤖 Generated with Claude Code