Two diagnostics diverged after a session-only `/model` switch (#111436).
/status: `_status_model_route` only took `context_total` from a live/cached
compressor or the raw `model.context_length` pin, so between turns (no
compressor yet) it fell to the occupancy-only line ("Context: ~79,455
tokens") while /context resolved the 1M window for the same session. /status
now runs the same resolver /context uses (`_resolve_gateway_model_context`,
off the event loop — it can probe /models), fed the WINNING route's
provider/base_url/api_key so the lookup targets the endpoint that serves the
displayed model, never a losing route's endpoint. The raw config pin moves
into the resolver, which already drops it when the route no longer matches
the configured one — a session switch must not inherit the default model's
pin. A window the resolver merely invented (unknown model →
DEFAULT_FALLBACK_CONTEXT) is grounded via a catalog match: `context_source`
is "default" only when no catalog entry matches, and /status keeps the honest
occupancy-only line for that case (catalog-listed 256K models still display).
Validator: `_validate_anthropic_messages` used one soft-accept message for
both "listing unreachable" and "listing answered 200 but lacks the slug", so
a reachable endpoint was described as one that "does not implement GET
/v1/models". The two cases now get distinct wording; the reachable case
matches case-insensitively and surfaces alias candidates at similarity 0.4
(`kimi-k3` vs `k3` ≈ 0.44 sits below the default 0.5 cutoff).
Slimmer redo of #111458 by @KoNit-K, which resolved only the override route
(not persisted/DB routes) and displayed the fallback window unconditionally.
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
A user who picked `deepseek-v4.1-flash` on their own custom endpoint kept
landing on `deepseek-v4-flash-0731`. Three sites each "helped" by diffing
the pick against a catalog and moving it:
- hermes_cli/models_validate.py: the shared catalog matcher auto-corrected
any id within difflib ratio 0.9 of a listed one (`corrected_model`), and
model_switch applied it. Version bumps, dated snapshots and qualifiers
all sit inside 0.9 of a sibling, so a newer release the listing lacked
was swapped for the older one under the user's label. The matcher now
does exact membership -> suggestion text only; the id goes to the wire
verbatim and a genuine typo is refused with the listed siblings named.
Every branch that carried the correction (live listing, static catalog,
curated fallback, MiniMax, Anthropic, custom, OpenRouter preset base)
loses it in one place.
- hermes_cli/model_switch.py: a `providers.<key>` endpoint reached by its
bare key (the slug Desktop picker rows carry) validated as a built-in
and hit the hard-rejecting live-listing branch; the same endpoint as
`custom:<key>` soft-accepted. Both spellings now validate as the user's
custom endpoint.
- apps/desktop: `manualPickRemoved` (composer reseed) and
`reconcileSelectionAfterCatalogRefresh` (Refresh Models) retargeted a
sticky pick to the profile default / the row's first model whenever the
provider row did not list it. Rows are hints (discovered, curated,
capped); the gateway's switch result is the only authority on a pick.
Both helpers are removed; the pick stays put.
Tests: change-detectors pinning the swap are rewritten as invariants
(never `corrected_model`; unlisted id on a user endpoint is kept and
warned; typo is refused with a suggestion); proven red on origin/main.
Follow-up to the salvaged #96379 commits: the fallback verdict is computed once
(`accepted = api_mode in chat modes`), the warning says what actually happened
("accepted without verification" vs "was not saved") instead of promising a
save it then refused, and the contributor's ten regression tests collapse to two
parametrized invariants (chat modes persist unverified; other modes still reject;
a reachable catalog stays authoritative).
Allow custom chat-completions endpoints without a usable model catalog to persist explicitly requested model IDs with the existing verification warning.
Internal moves get no re-export aliases: models_validate reads
hermes_constants.openrouter_variant_base directly and the private
_OPENROUTER_VARIANT_SUFFIXES/_openrouter_variant_base shims are dropped.
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.