fix(copilot): resolve reasoning effort from the live catalog for all families - #87164
fix(copilot): resolve reasoning effort from the live catalog for all families#87164allenliang2022 wants to merge 3 commits into
Conversation
…families
`github_model_reasoning_efforts()` is the single chokepoint that decides
both whether a reasoning control is offered and which effort is actually
sent. Callers reach it in two ways:
- with an explicit `catalog` (the setup flow, which already has one), or
- with neither `catalog` nor `api_key` — every other caller, including
`_supports_reasoning_extra_body()` and
`_github_models_reasoning_extra_body()`.
On the second path the live `/models` catalog was never fetched, because
the catalog branch only runs when an `api_key` was passed in. So those
callers always fell through to the static table, which only knew GPT-5
and the o-series. Every other Copilot family resolved to `[]`, which
means the reasoning gate reports "unsupported" and a configured
`reasoning_effort` is silently dropped — even though the catalog
advertises `capabilities.supports.reasoning_effort` for them.
Two changes:
1. Resolve a Copilot token automatically when the caller supplied
neither a catalog nor a key, then fetch the catalog once and thread
it through both the id-normalisation and the capability lookup (it
was previously fetched separately by each). An explicit `catalog`
still wins and skips auth entirely, so callers that already hold one
are unaffected and no test needs a network stub it did not need
before. Token resolution is best-effort and never raises: a missing
or unusable credential degrades to the static fallback rather than
breaking model selection.
2. Widen that static fallback so an offline/unauthenticated process
degrades to something usable instead of "no reasoning at all". It now
covers Claude 4.6+, Gemini 3+/2.5+, Grok 4.5+ and MAI-Code with a
conservative low/medium/high, gated by version so families without an
effort dial (Claude pre-4.6 incl. haiku-4.5, Gemini 1.5/2.0, grok-4)
still return `[]`.
The catalog stays authoritative throughout: it can advertise levels the
fallback does not list (`xhigh`, `max`) and can deny a family the
fallback would have allowed. The fallback only decides what happens when
there is no catalog answer to defer to.
Note on scope: NousResearch#51953 fixes the same silent-drop symptom for Claude by
rewiring the two `AIAgent` gates to a new catalog-backed helper. This
instead fixes the shared entry point, so callers that are not those two
gates are covered too, and pairs it with a fallback that is no longer
GPT-only. If NousResearch#51953 lands first these are complementary rather than
conflicting — its helper would simply be calling an entry point that
already resolves correctly.
…owing it
The previous commit added a `_resolve_copilot_catalog_api_key()` that
duplicated a function of the same name already defined earlier in this
module. Python keeps the last definition, so the new one silently
replaced the original for every caller in the file — including the
`/model` picker's catalog fetch, which is not related to reasoning at
all.
That matters because the two are not equivalent. The original resolves
credentials the Copilot auth helpers alone do not: after trying
`resolve_api_key_provider_credentials()`, it falls back to
`read_credential_pool("copilot")` and exchanges each candidate, which is
the only path that works for users whose token lives in
`credential_pool.copilot[]` (added by `hermes auth add copilot` or seeded
from `.env`). Shadowing it would have made the picker fall back to a
stale hardcoded model list for exactly those users.
Delete the duplicate and call the existing helper. It is strictly more
capable, so the reasoning lookup keeps working and the picker is no
longer affected. Verified against a live account: the catalog-backed
ladders are unchanged (opus-5 low..max, gemini-3.6-flash minimal..high,
grok-4.6 low..xhigh, haiku-4.5 empty).
Add a regression test that asserts there is exactly one definition of the
helper and that the surviving one is the credential-pool-aware version.
Mutation-checked: re-introducing the duplicate turns it RED, removing it
again turns it GREEN.
|
Follow-up: I found and fixed a defect in my own first commit before review. While auditing every call site of the function I touched, I noticed that That is not a cosmetic duplication. The two are not equivalent: the original
I also added a regression test asserting there is exactly one definition of |
fix(copilot): resolve reasoning effort from the live catalog for all families Making the live catalog authoritative and adding offline fallbacks for Claude/Gemini/Grok/MAI-Code is a solid improvement. Observations:
|
…d Claude
Three fixes from review feedback on the previous two commits.
1. Token resolution was not cached, so it ran on every lookup.
`fetch_github_model_catalog` has a 5-minute TTL, but
`_resolve_copilot_catalog_api_key()` has none, and it is the expensive
half: it can shell out to `gh auth token` and exchange credential-pool
candidates. Since `github_model_reasoning_efforts()` runs on hot paths
(the composer's reasoning chip, the agent's per-turn send gates), the
catalog was served from cache while the credential was re-resolved from
scratch every single call.
Measured on this machine before the fix: 20 lookups took 6.94s — 20 token
resolutions, ~347ms per lookup, with the token resolution alone at ~1.1s
cold. After: 50 lookups in 14.3ms (~0.29ms each), one token resolution.
Added `_cached_copilot_reasoning_token()` with a 5-minute TTL matching the
catalog cache, plus a 60-second negative TTL so a missing credential is
retried soon but does not re-run the subprocess on every call. The
underlying helper is untouched, so the `/model` picker keeps its existing
behaviour.
2. The offline Claude gate returned True for ids it could not version.
An id containing "claude" that matched no version pattern fell through to
a bare `return True`, so `claude-sonnet` and `claude-opus` were treated as
effort-capable. Offline, that is the expensive direction to be wrong in:
the control is offered and the provider rejects it.
The gate now fails closed — an id must positively prove a version at or
above the adaptive-thinking generation. The date-stamp guard is also
applied to the bare-major branch, so `claude-opus-4-20250514` reads as
major 4 rather than parsing the stamp.
Note on `claude-5-haiku`, which the review flagged as suspicious: it
resolves to True, and that is intentional. Haiku 4.5 lacking a ladder is a
property of the 4.5 generation, not of the name "haiku"; a 5-series haiku
would follow its version like every other model. The gate is versioned,
not keyed on size qualifiers. The live catalog remains authoritative
either way.
3. The guard test read source text, which this repo bans outright.
`test_reuses_the_existing_catalog_token_helper` used `inspect.getsource`
and counted a substring. AGENTS.md ("Never read source code in tests")
calls this a hard antipattern, and the objection is correct on the merits:
it would fail a pure rename and would not catch an import-time shadow.
Replaced with two behavioural tests:
- a pool-only credential (stage 1 empty, token only in
`credential_pool.copilot[]`) must still reach the catalog fetch and win
the catalog ladder — this is the capability a shadowing regression
actually destroys
- the token must be resolved once across five lookups
Also added a parametrised boundary table for the offline Claude gate (21
cases: explicit minors, bare majors, the 3.x family, date stamps, and
unversioned ids) as the review requested, and an autouse fixture that
resets the module-level token cache around each test so a real resolution
cannot leak in and satisfy the cache assertion for free.
Mutation-checked all three: removing the cache, restoring the permissive
`return True` tail, and bypassing the pool-aware resolution each turn the
corresponding test RED, and all pass again after restore.
516 tests pass across the copilot/model suites and provider profiles.
|
Thanks — points 1, 4 and 5 were all real and are fixed in 1. Per-call token resolution — confirmed, and worse than describedYou were right that this needed checking, and the measurement is worse than "an HTTP round trip". So the catalog was being served from cache while the credential was re-resolved from scratch on every single call: Added Guarded by 5. Unversioned Claude returning True — confirmedReproduced: The gate now fails closed: an id must positively prove a version at or above the adaptive-thinking generation. I also applied the date-stamp guard to the bare-major branch, so 2.
|
What does this PR do?
hermes_cli.models.github_model_reasoning_efforts()is the single chokepoint that decides both whether a Copilot model offers a reasoning control and which effort is actually sent. Callers reach it two ways:catalog— the setup flow, which already holds onecatalognorapi_key— every other caller, includingAIAgent._supports_reasoning_extra_body()andAIAgent._github_models_reasoning_extra_body()On that second path the live
/modelscatalog was never fetched, because the catalog branch only ran when anapi_keyhad been passed in. Those callers therefore always fell through to the static table in_github_reasoning_efforts_for_model_id(), which only knew GPT-5 and the o-series:So every non-OpenAI Copilot family resolved to
[]— the gate reports "unsupported" and a configuredreasoning_effortis silently dropped, even though the catalog advertisescapabilities.supports.reasoning_effortfor them.Changes
1. Fetch the catalog on the no-argument path.
When the caller supplied neither a catalog nor a key, resolve a Copilot token (via the same
copilot_authhelpers the rest of the stack uses), fetch the catalog once, and thread it through both the id normalisation and the capability lookup — previously each fetched separately.An explicit
catalogstill wins and skips auth entirely, so callers that already hold one are unaffected and no existing test needs a network stub it did not need before. Token resolution is best-effort and never raises: a missing or unusable credential degrades to the static fallback rather than breaking model selection.2. Widen the static fallback so an offline process degrades to something usable.
Rather than "no reasoning at all", the fallback now covers Claude 4.6+, Gemini 3+/2.5+, Grok 4.5+ and MAI-Code with a conservative
low/medium/high, version-gated so families without an effort dial still return[]— Claude pre-4.6 (including haiku-4.5), Gemini 1.5/2.0, and grok-4 / grok-4-fast.The catalog stays authoritative throughout: it can advertise levels the fallback does not list (
xhigh,max) and can deny a family the fallback would have allowed. The fallback only decides what happens when there is no catalog answer to defer to.Relationship to #51953
#51953 fixes the same silent-drop symptom for Claude by rewiring the two
AIAgentgates to a new catalog-backed helper. This PR instead fixes the shared entry point, so callers that are not those two gates are covered too, and pairs it with a fallback that is no longer GPT-only.These are complementary rather than conflicting. If #51953 lands first, its helper would simply be calling an entry point that already resolves correctly.
Tests
tests/hermes_cli/test_model_validation.py::TestGithubReasoningEfforts:[]Both directions are asserted, so widening the gate too far fails as loudly as not widening it at all.
Verification on this branch, rebuilt on current
main:I also confirmed the tests fail first: reverting the auto-resolve makes the catalog test fail with
['low','medium','high'] != ['low','medium','high','xhigh','max']— i.e. it falls back to the static ladder instead of the catalog's.Manual verification
Confirmed against a running instance, not tests alone: the reasoning gates now resolve non-empty ladders for the Claude / Gemini / Grok / MAI-Code models the catalog advertises, and a family the catalog does not advertise still resolves empty.
Not verified
I did not capture an outbound request body to confirm the emitted
reasoning_effortfield end-to-end; the verification above is at the resolver and gate level. The consuming code paths are unchanged by this PR — only the value they receive is.Checklist
@teknium1