Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@

- **New RFC: Stable Assistant Turn Anchors for Live-to-Final rendering.** Defines a frontend presentation/reconciliation model for anchoring one assistant turn across live streaming, settlement, replay/reload/recovery, Compact Worklog, Transparent Stream, terminal states, artifacts, and side effects. (#3926)

## [v0.51.354] — 2026-06-10 — Release LR (preserve explicit @provider:model picks across cold catalogs)

### Fixed

- **An explicit `@provider:model` pick no longer snaps back to the default model when the provider's group is briefly missing from the cached catalog.** Providers that discover their models live (ollama-cloud, deepseek, xai) can momentarily lack their group in the cached catalog snapshot used on hot `GET /api/session` and chat-switch paths; a selection like `@ollama-cloud:minimax-m3` was being silently reverted to the global default on the 2nd-and-later turn. The resolver now preserves the explicit selection when the provider is known or configured (decided from the static registry + config, not the cold catalog), while a genuinely-unknown provider still falls back to the default instead of routing to an unrecognized one. The cached-catalog performance path is unchanged. (#3950)

## [v0.51.353] — 2026-06-10 — Release LQ (cross-client live-turn recovery)

### Fixed
Expand Down
53 changes: 53 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,59 @@ def _named_custom_provider_slug_for_base_url(
return ""


def _provider_is_known_or_configured(
provider_id: object,
config_obj: dict | None = None,
) -> bool:
"""True when ``provider_id`` is a provider Hermes recognizes (static registry)
or the user has configured (named custom provider), decided from the STATIC
registry + config state only — never from a live/cold catalog snapshot.

This distinguishes a provider Hermes knows how to route (e.g. ``ollama-cloud``,
whose model group simply isn't folded into the current cached catalog yet, or a
named ``custom_providers`` entry) from a *genuinely unknown* one
(``@removed:...`` that is in no registry and configured nowhere). The former's
explicitly-qualified selection is preserved across a cold catalog; the latter
falls back to the default so chat/start doesn't route to an unrecognized
provider.

DELIBERATE SCOPE (see the @provider:model guard in
``_resolve_compatible_session_model_state``): registry membership counts as
"known" even when the user has no key configured for that built-in. We do NOT
require authenticated-credential evidence here, on purpose. The only fully
reliable "is this provider authenticated" signal is the live auth store /
catalog rebuild — exactly the cost the caller's ``prefer_cached_catalog`` hot
path avoids — and a cheap env/config-only credential check would mis-classify
providers authenticated via OAuth/auth-store (``ollama-cloud`` among them),
re-introducing the original silent-revert bug for them. A known-but-unconfigured
pick is therefore kept and surfaces a clear run-time auth error rather than a
silent swap to the default.

Deliberately does NOT consult ``get_available_models()`` / the catalog groups,
which are exactly what is cold here — re-deriving them live would defeat the
``prefer_cached_catalog`` hot-path win this guards.
"""
raw = str(provider_id or "").strip().lower()
if not raw:
return False
# Configured custom provider: a named slug in custom_providers, or any
# ``custom`` / ``custom:<slug>`` form when custom_providers are defined.
if _named_custom_provider_slug_for_provider(raw, config_obj):
return True
if raw == "custom" or raw.startswith("custom:"):
return bool(_custom_provider_entries(config_obj))
# Known first-party / built-in provider id (alias-resolved). Static registry
# knowledge that is always available, so a live-discovery provider whose
# catalog group is momentarily absent still counts as known.
canonical = _resolve_provider_alias(raw)
return (
raw in _PROVIDER_DISPLAY
or canonical in _PROVIDER_DISPLAY
or raw in _PROVIDER_MODELS
or canonical in _PROVIDER_MODELS
)


# Well-known models per provider (used to populate dropdown for direct API providers)
_PROVIDER_MODELS = {
"anthropic": [
Expand Down
60 changes: 60 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,7 @@ def _clear_live_models_cache() -> None:
_resolve_cli_toolsets,
_INDEX_HTML_PATH,
get_available_models,
_provider_is_known_or_configured,
IMAGE_EXTS,
MD_EXTS,
MIME_MAP,
Expand Down Expand Up @@ -2632,6 +2633,16 @@ def _resolve_compatible_session_model_state(
if not provider_raw or not bare_model:
return model, requested_provider, False

# A fresh, explicit user pick is by definition not a stale artifact, so
# honor the @provider:model exactly as chosen — never reroute it via the
# active-provider family repair or the cold-catalog fallback below (a bare
# id like "gpt-oss-120b" under an OpenAI-active agent would otherwise get
# pulled to OpenAI by the family-match branch). If the named provider is
# unreachable the user sees a clear run-time error rather than a silent
# model swap. Must sit above the family-match repair (#3737 principle).
if explicit_model_pick:
return model, provider_raw, False

raw_provider_ids, normalized_provider_ids = _catalog_provider_id_sets(catalog)
hint_matches_active = (
provider_raw == raw_active_provider
Expand Down Expand Up @@ -2663,6 +2674,55 @@ def _resolve_compatible_session_model_state(
else None
)
return bare_model, provider_context, True
# On NON-explicit resolves (2nd+ turn, chat switch — explicit picks already
# returned above), preserve the selection only when all three hold:
#
# * provider_normalized == "" — a non-first-party provider hint
# (ollama-cloud / deepseek / xai / a named custom proxy). First-party
# families fall through to the stale-cross-provider repair below.
#
# * the BARE model is not a first-party family id (does not start with
# gpt/claude/gemini), i.e. not a misrouted first-party model that a
# vanished provider used to host (e.g. "@copilot:claude-opus-4.6").
#
# * the provider is KNOWN or CONFIGURED. This is the load-bearing
# distinction: catalog-absence has two causes —
# (a) a cold live-discovery provider (ollama-cloud is configured; its
# group just isn't in this cached snapshot yet) → preserve, and
# (b) a genuinely removed/unknown provider ("@removed:mistral-large"
# configured nowhere) → fall through to the default so chat/start
# doesn't route to an unreachable provider.
# _provider_is_known_or_configured() decides this from the static
# provider registry + config state, NOT from the cold catalog snapshot
# (re-deriving that live would defeat the prefer_cached_catalog win).
#
# DELIBERATE: the registry test treats a KNOWN built-in (deepseek, minimax,
# ollama-cloud, …) as preservable even when the user has no key configured
# for it. We accept this on purpose. The only fully-reliable "is this
# provider authenticated" signal is the live auth store / catalog rebuild —
# exactly the cost this hot path avoids — and a cheap config/env-only check
# would mis-classify providers configured via OAuth/auth-store (ollama-cloud
# among them), re-introducing the original silent-revert bug for them. So a
# known-but-unconfigured pick is kept; the user gets a clear run-time auth
# error instead of a silent swap to the default. Pinned by
# test_at_provider_known_unconfigured_builtin_is_intentionally_preserved.
#
# KNOWN LIMITATION: the first-party-family test is a bare-name prefix match
# (the same approximation _model_matches_active_provider_family uses). A
# genuine third-party model whose name merely *starts* with gpt/claude/
# gemini (e.g. "@ollama:gpt4all-mini") is therefore still mis-classified as
# first-party and reverted on non-explicit paths. A name-based check cannot
# disambiguate that; the behavior is pinned by
# test_at_provider_first_party_named_third_party_model_known_limitation.
_bare_is_first_party_family = any(
bare_model.lower().startswith(_p) for _p in ("gpt", "claude", "gemini")
)
if (
not provider_normalized
and not _bare_is_first_party_family
and _provider_is_known_or_configured(provider_raw)
):
return model, provider_raw, False
Comment on lines +2720 to +2725

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 openrouter still reverts on cold catalog

_normalize_provider_id("openrouter") returns "openrouter" (non-empty), so not provider_normalized is False and the new cold-catalog preserve guard never fires for @openrouter:... picks. A user with a cold catalog and an @openrouter:some-private-model selection will still silently revert to the default on 2nd+ turns, the same class of bug being fixed here for ollama-cloud/deepseek/xai. If openrouter's live-model-discovery behaviour is similar enough that cold-catalog transients occur in practice, it should be added to the preserve path (or _normalize_provider_id updated to return "" for it alongside the other live-discovery providers). Is openrouter intentionally left out of the cold-catalog preserve guard, or is this an oversight that should be addressed in a follow-up?

if default_model:
provider_context = (
raw_active_provider
Expand Down
Loading
Loading