fix(copilot): honor token precedence and derive api_mode per target model - #17622
fix(copilot): honor token precedence and derive api_mode per target model#17622patrickchin wants to merge 1 commit into
Conversation
…odel Two compounding bugs that surface as HTTP 400 model_not_supported on Copilot for most non-default-model requests, even when the user's GitHub account has access to the model. 1. _normalize_pool_priorities only special-cased anthropic, so a Copilot pool with both env:COPILOT_GITHUB_TOKEN (from `hermes model` device login) and gh_cli (from a pre-existing `gh auth login` for a different account) could let the gh CLI entry win after the persist/reload cycle. Tuple-order in api_key_env_vars only protects the first load; once the pool is persisted to ~/.hermes/auth.json, subsequent loads can keep a stale gh_cli ranked above a higher-precedence env token added later. Extends the existing anthropic precedence pattern (PR NousResearch#2647) to copilot, mirroring the precedence documented in hermes_cli/copilot_auth.py:resolve_copilot_token. 2. _copilot_runtime_api_mode short-circuited on the persisted model.api_mode without consulting the actual model being dispatched. Config written while default=gpt-5.4 persists api_mode=codex_responses, so a later /model claude-sonnet-4.6 (or gemini-3.1-pro-preview, or gpt-5-mini) was sent to /responses and rejected. Threads target_model into the resolver and always re-derives api_mode via copilot_model_api_mode(). Same fix pattern as PR NousResearch#15106 (opencode) and PR NousResearch#9033 (cron).
|
Confirming this fix is correct: applied the equivalent change locally and verified via httpx wire-level tracing that without it, is what reaches the agent constructor when the configured |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating both the persisted-pool precedence issue and the stale Copilot transport selection.
Problems
- The target-model fix is incomplete against current main.
hermes_cli/runtime_provider.py:1483is a separate explicit-runtime Copilot path that still calls_copilot_runtime_api_mode(model_cfg, api_key);_resolve_explicit_runtimedoes not receivetarget_model. An explicit credential resolution can therefore still select the persistedmodel.api_mode/model.defaultrather than the requested target.
Suggested changes
- Thread
target_modelthrough_resolve_explicit_runtimefromresolve_runtime_provider, pass it into the Copilot mode resolver, and add an explicit-credential regression test. The existing pool-path test covershermes_cli/runtime_provider.py:446, but not the path at:1483.
Automated hermes-sweeper review.
| @@ -219,7 +230,9 @@ def _resolve_runtime_from_pool_entry( | |||
| elif provider == "nous": | |||
| api_mode = "chat_completions" | |||
| elif provider == "copilot": | |||
There was a problem hiding this comment.
Please cover the sibling explicit-runtime Copilot path as well. On current main, _resolve_explicit_runtime still calls _copilot_runtime_api_mode(model_cfg, api_key) without a target model (hermes_cli/runtime_provider.py:1483), so explicit-key/base-URL resolution retains the stale configured transport after this pool-path fix.
What does this PR do?
Fixes two compounding bugs that surface as
HTTP 400 model_not_supportedon Copilot for most non-default-model requests, even when the user's GitHub account does have access to the model in question.Bug 1 — Credential pool ignores documented Copilot token precedence
hermes_cli/copilot_auth.py:resolve_copilot_tokendocuments the precedenceCOPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN > gh auth token. That precedence is honored on the firstload_pool()because_seed_from_singletonscallsresolve_copilot_token()directly and seeds a single entry.But the pool is persisted to
~/.hermes/auth.json. On subsequent loads, the previously seeded entry (e.g.gh_cli) is read back from disk before re-seeding runs. If the user later adds a higher-precedence source — for example, by runninghermes modelto log into Copilot via device-code flow, which writesCOPILOT_GITHUB_TOKENto~/.hermes/.env— the new entry is upserted at the next free priority, behind the stalegh_clientry. Subsequent requests authenticate as the wrong GitHub account, and Copilot returnsmodel_not_supportedfor any model the wrong account can't reach._normalize_pool_prioritiesis the function that re-ranks pool entries after the load/upsert cycle — but it only special-casedanthropic. PR #2647 introduced the function specifically to solve this same bug class (multi-source providers where insertion order doesn't match the user's intended precedence). Copilot acquired multiple credential sources in commit0bd3f521(April 2026) without_normalize_pool_prioritiesbeing extended to match.Bug 2 — Copilot
api_modenot recomputed per target modelCopilot serves different model families through different endpoints under
https://api.githubcopilot.com:/responses(codex_responses): GPT-5.x non-mini, GPT-5.x-codex/chat/completions: GPT-5-mini, GPT-4.1, GPT-4o, Gemini/v1/messages(anthropic_messages): Claudehermes_cli/models.copilot_model_api_mode()already encodes this mapping correctly. Buthermes_cli/runtime_provider.py:_copilot_runtime_api_modeshort-circuited on the persistedmodel.api_modewhen the configured provider was Copilot. Config written whilemodel.default = gpt-5.4recordsapi_mode: codex_responses; a later/model claude-sonnet-4.6(or any other non-codex Copilot model) was then dispatched to/responsesand rejected.PR #15106 fixed the same root cause for opencode-zen / opencode-go by threading
target_modelthroughresolve_runtime_providerand_resolve_runtime_from_pool_entry. PR #9033 fixed the equivalent in the cron path. The runtime resolver path for Copilot was missed.hermes_cli/model_switch.py:919already callscopilot_model_api_mode()at config-write time, so this fix is defense-in-depth — it covers any path that bypasses the model-switch helper (gateway dispatch, runtime overrides, fresh sessions on stale config).Related Issues
No existing issue covers this exact pair. Closely related context:
_normalize_pool_priorities(the function this PR extends to copilot) and the anthropic precedence rankingtarget_modelpattern previously applied to opencode-zen / opencode-goapi_modefix for cron0bd3f521— added Copilot env-var seeding without updating_normalize_pool_prioritiesType of Change
Changes Made
agent/credential_pool.py— refactor_normalize_pool_prioritiesto select a per-providersource_ranktable viaif/elif/elseinstead of an early-out on non-anthropic. Adds acopilotbranch mirroring the precedence inhermes_cli/copilot_auth.py:resolve_copilot_token. The existing manual/seeded split, sort, and priority rewrite logic is unchanged and now shared between both providers. Net+13lines, zero behavior change for anthropic.hermes_cli/runtime_provider.py—_copilot_runtime_api_modenow readstarget_modelfirst, falls back tomodel.default, and always invokescopilot_model_api_mode()to re-derive from the actual model being dispatched._resolve_runtime_from_pool_entryand the explicit-runtime fallback inresolve_runtime_provider) threadtarget_modelinto thecfgdict so mid-session/modelswitches see the new model.tests/agent/test_credential_pool.py—test_copilot_pool_prioritizes_env_token_over_gh_cli. Placed next to the existingtest_load_pool_seeds_copilot_via_gh_auth_token.tests/hermes_cli/test_runtime_provider_resolution.py—test_copilot_api_mode_recomputed_for_non_codex_default_model,test_copilot_api_mode_follows_target_model_on_runtime_switch. Placed next to the existingtest_opencode_go_model_derivation_beats_stale_persisted_api_modewhich uses the same precedent pattern.How to Test
Manual reproduction (requires two GitHub accounts with different Copilot entitlements):
gh auth loginas account A (limited Copilot access — e.g. Individual plan with no Claude/Gemini access).hermes modeland complete OAuth device-code login as account B (Copilot Business / Enterprise with full model access). Verify~/.hermes/.envnow containsCOPILOT_GITHUB_TOKEN=gho_….model.default: gpt-5.4so~/.hermes/config.yamlrecordsmodel.api_mode: codex_responses.hermes chat -q "hi"against any non-GPT-5 Copilot model (claude-sonnet-4.6,gemini-3.1-pro-preview,gpt-5-mini) returns400 model_not_supported. Inspect~/.hermes/auth.jsonand confirmgh_clioutranksenv:COPILOT_GITHUB_TOKEN.Automated:
→ 187 passed.
Checklist
Code
scripts/run_tests.shon the affected areas — 187 passedDocumentation & Housekeeping
hermes_cli/copilot_auth.py:resolve_copilot_tokenand the fix makes the runtime match the doc