fix(tui-gateway): heal or fall back when a resumed session's provider is stale

A session row persists the provider identity a chat actually used. When that
provider is later renamed or removed (e.g. a custom_providers:/providers:
entry deleted, or a provider renamed oldone->newone), Desktop/TUI resume
restores the stale name into agent init and dies with:

  agent init failed: Unknown provider '<name>'

while the CLI resumes the same session fine with the configured default.

- runtime_provider: add is_routable_provider() (full resolution chain:
  built-in -> providers: -> custom_providers: -> models.dev)
- _stored_session_runtime_overrides: heal a non-routable provider via
  canonical_custom_identity (base_url -> model -> configured provider),
  drop to the configured default when unrecoverable, and clear the stale
  base_url after healing so a dead endpoint cannot override the registry URL
- _start_agent_build: gate deferred-resume overrides on provider routability;
  when the stored provider is gone, prefer the model the user picked for THIS
  session, else the configured default
- tests: is_routable_provider cases, heal/fallback round-trips, gate checks

Refs #75128
This commit is contained in:
lorzl
2026-08-27 13:45:15 +08:00
committed by Teknium
parent 31e41eed34
commit 99a6852019
5 changed files with 302 additions and 27 deletions
+39
View File
@@ -1104,6 +1104,45 @@ def canonical_custom_identity(
return None
def is_routable_provider(provider: Optional[str]) -> bool:
"""Whether a provider name currently resolves to a routable route.
Empty/None is vacuously routable: agent build falls back to the
configured default instead of failing. A name that resolves through
the full chain (built-in -> user ``providers:`` -> ``custom_providers:``
-> models.dev) is routable; anything else would fail agent init with
"Unknown provider '<name>'".
Session resume uses this to detect a stale/renamed/removed provider
persisted in an older session snapshot, so recovery can fall back to
the configured default or the model the user picked instead of letting
the agent build die.
"""
name = str(provider or "").strip()
if not name or name.lower() == "auto":
return True
if name.lower() == "custom":
# The bare string is the resolved billing class shared by every
# named custom entry — not a routable identity. restore paths must
# heal it (canonical_custom_identity) or fall back, never hand it
# straight to agent init.
return False
try:
from hermes_cli.providers import resolve_provider_full
config = load_config()
return (
resolve_provider_full(
name,
config.get("providers"),
get_compatible_custom_providers(config),
)
is not None
)
except Exception:
return False
def _normalize_base_url_for_match(value) -> str:
return str(value or "").strip().rstrip("/").lower()
@@ -98,3 +98,51 @@ def test_legacy_unkeyed_entry_keeps_its_name_identity(monkeypatch):
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
assert rp.canonical_custom_identity(config_provider="Legacy Endpoint") == "custom:legacy-endpoint"
class TestIsRoutableProvider:
"""``is_routable_provider`` gates session-resume fallback: a persisted
provider name that no longer resolves (renamed/removed) must be detected
so recovery falls back instead of failing agent init with
"Unknown provider '<name>'".
"""
def test_empty_auto_and_builtin_are_routable(self, keyed_provider_config):
assert rp.is_routable_provider(None) is True
assert rp.is_routable_provider("") is True
assert rp.is_routable_provider("auto") is True
assert rp.is_routable_provider("openrouter") is True
def test_bare_custom_is_not_routable(self, keyed_provider_config):
# The resolved billing class, not a routable identity — restore
# paths must heal it (canonical_custom_identity) or fall back.
assert rp.is_routable_provider("custom") is False
def test_registered_names_are_routable(self, keyed_provider_config):
assert rp.is_routable_provider(PROVIDER_KEY) is True
assert rp.is_routable_provider(CANONICAL) is True
def test_stale_name_is_not_routable(self, keyed_provider_config):
# Same endpoint family, but the OLD slug no longer matches any
# configured entry — the regression this gate exists for.
assert rp.is_routable_provider("stale-endpoint") is False
assert rp.is_routable_provider("custom:stale-endpoint") is False
def test_legacy_unkeyed_name_is_routable(self, monkeypatch):
config = {
"custom_providers": [
{
"name": "Legacy Endpoint",
"base_url": "https://legacy.invalid/v1",
"api_key": "sk-legacy",
"models": ["legacy-model"],
}
]
}
monkeypatch.setattr(rp, "load_config", lambda *a, **k: config)
monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: config)
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
assert rp.is_routable_provider("legacy-endpoint") is True
assert rp.is_routable_provider("custom:legacy-endpoint") is True
assert rp.is_routable_provider("Legacy Endpoint") is True
+18 -2
View File
@@ -3887,7 +3887,7 @@ def test_session_cwd_set_profile_session_updates_profile_db(monkeypatch, tmp_pat
assert "launch_update" not in captured
def test_stored_session_runtime_overrides_skips_bare_billing_provider():
def test_stored_session_runtime_overrides_skips_bare_billing_provider(monkeypatch):
"""A bare billing bucket ("custom"/"auto") must not be restored as the provider
identity on resume. A custom endpoint that never used `/model` persists only
`billing_provider="custom"`; restoring that broke `session.resume` with "No LLM provider
@@ -3909,7 +3909,23 @@ def test_stored_session_runtime_overrides_skips_bare_billing_provider():
assert ov["provider_override"] == "anthropic"
assert ov["model_override"]["provider"] == "anthropic"
# An explicit routable provider in model_config wins over the bare billing bucket.
# An explicit ROUTABLE provider in model_config wins over the bare billing
# bucket. It must actually resolve in the registry — a stale/renamed
# provider is dropped (see TestStaleProviderNameFallsBack).
cfg = {
"custom_providers": [
{
"name": "myendpoint",
"base_url": "https://myendpoint.invalid/v1",
"api_key": "sk-test",
"model": "m",
}
]
}
import hermes_cli.runtime_provider as rp
monkeypatch.setattr(rp, "load_config", lambda: cfg)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: cfg)
ov = server._stored_session_runtime_overrides(
{"model": "m", "billing_provider": "custom", "model_config": {"provider": "custom:myendpoint"}}
)
@@ -343,3 +343,129 @@ class TestModelNameRecoversEntryIdentity:
)
class TestStaleProviderNameFallsBack:
"""A session row stored under a provider that was renamed or removed must
not sink agent init with "Unknown provider '<name>'": heal to the entry
that still serves the stored model/base_url, else drop the provider so
resume falls back to the configured default (or the user's pick)."""
def test_stale_bare_name_heals_via_model(self, monkeypatch):
"""Registry serves mimo-v2.5-pro; the row still names the OLD slug —
the exact shape of the renamed-provider report (oldone -> newone)."""
monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG)
monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"])
from tui_gateway.server import _stored_session_runtime_overrides
row = {
"model": "mimo-v2.5-pro",
"model_config": json.dumps(
{"model": "mimo-v2.5-pro", "provider": "stale-provider"}
),
"billing_provider": "custom",
}
overrides = _stored_session_runtime_overrides(row)
assert overrides["provider_override"] == "custom:mimo-v2.5-pro"
assert overrides["model_override"]["provider"] == "custom:mimo-v2.5-pro"
def test_stale_prefixed_name_heals_and_drops_stale_base_url(self, monkeypatch):
"""Healing must also drop the snapshot's base_url so the registry URL
(the renamed provider's current endpoint) is not overridden."""
monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG)
monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"])
from tui_gateway.server import _stored_session_runtime_overrides
row = {
"model": "mimo-v2.5-pro",
"model_config": json.dumps(
{
"model": "mimo-v2.5-pro",
"provider": "custom:stale-provider",
"base_url": "https://old.invalid/v1",
"api_mode": "chat_completions",
}
),
"billing_provider": "custom:stale-provider",
}
overrides = _stored_session_runtime_overrides(row)
assert overrides["provider_override"] == "custom:mimo-v2.5-pro"
assert overrides["model_override"]["base_url"] is None
def test_unrecoverable_provider_drops_to_default(self, monkeypatch):
"""No entry serves the stored model AND no configured default names a
real entry → the provider is dropped; resume falls back to the
configured default instead of failing the build."""
config = {"custom_providers": NAMED_CONFIG["custom_providers"]}
monkeypatch.setattr(rp, "load_config", lambda: config)
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
from tui_gateway.server import _stored_session_runtime_overrides
row = {
"model": "no-such-model",
"model_config": json.dumps(
{"model": "no-such-model", "provider": "dead-provider"}
),
"billing_provider": "custom",
}
overrides = _stored_session_runtime_overrides(row)
assert "provider_override" not in overrides
assert overrides["model_override"]["provider"] is None
def test_valid_provider_is_untouched(self, monkeypatch):
"""A live provider must round-trip unchanged — no healing, no drops."""
monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG)
monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"])
from tui_gateway.server import _stored_session_runtime_overrides
row = {
"model": "mimo-v2.5-pro",
"model_config": json.dumps(
{
"model": "mimo-v2.5-pro",
"provider": "custom:mimo-v2.5-pro",
"base_url": MIMO_URL,
"api_mode": "chat_completions",
}
),
"billing_provider": "custom:mimo-v2.5-pro",
}
overrides = _stored_session_runtime_overrides(row)
assert overrides["provider_override"] == "custom:mimo-v2.5-pro"
assert overrides["model_override"]["base_url"] == MIMO_URL
class TestOverridesHaveRoutableProvider:
def test_gate_detects_stale_provider(self, monkeypatch):
monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG)
monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"])
from tui_gateway.server import _overrides_have_routable_provider
assert (
_overrides_have_routable_provider(
{"provider_override": "custom:mimo-v2.5-pro"}
)
is True
)
assert (
_overrides_have_routable_provider(
{"provider_override": "custom:stale-provider"}
)
is False
)
assert (
_overrides_have_routable_provider(
{"model_override": {"provider": None}}
)
is False
)
assert _overrides_have_routable_provider({}) is False
+71 -25
View File
@@ -3225,7 +3225,11 @@ def _start_agent_build(sid: str, session: dict) -> None:
kw["session_id"] = resume_sid
kw["platform_override"] = _session_source(current)
resume_overrides = current.get("resume_runtime_overrides")
if isinstance(resume_overrides, dict) and resume_overrides:
if (
isinstance(resume_overrides, dict)
and resume_overrides
and _overrides_have_routable_provider(resume_overrides)
):
# Cold deferred resume: restore the full persisted runtime
# identity (model/provider/base_url/api_mode/reasoning/tier)
# exactly as the eager resume path's _stored_session_runtime_
@@ -3233,9 +3237,11 @@ def _start_agent_build(sid: str, session: dict) -> None:
# provider and fail with "No LLM provider configured".
kw.update(resume_overrides)
else:
# Model/effort/fast the desktop picked for a brand-new chat
# ride in as per-session overrides so the first build uses
# them directly (no global config, no build-then-switch).
# No stored runtime, or the stored provider no longer
# resolves (renamed/removed since the row was written) —
# never let that sink agent init with "Unknown provider".
# Fall back to the model/effort/fast the desktop picked
# for THIS session, else the configured default.
if override := current.get("model_override"):
kw["model_override"] = override
if (reasoning := current.get("create_reasoning_override")) is not None:
@@ -5202,6 +5208,30 @@ def _resolve_startup_runtime() -> tuple[str, str | None]:
from hermes_state import _BARE_BILLING_PROVIDERS
def _overrides_have_routable_provider(overrides: dict) -> bool:
"""Whether persisted runtime overrides still name a routable provider.
A session row written under a provider that has since been renamed or
removed would otherwise fail agent init with "Unknown provider".
Empty provider counts as NOT routable here, so the caller falls back
to the model the user picked for this session / the configured
default instead of restoring a provider-less snapshot override.
"""
provider = str(overrides.get("provider_override") or "").strip()
if not provider:
provider = str(
(overrides.get("model_override") or {}).get("provider") or ""
).strip()
if not provider:
return False
try:
from hermes_cli.runtime_provider import is_routable_provider
return is_routable_provider(provider)
except Exception:
return False
def _stored_session_runtime_overrides(row: dict | None) -> dict:
"""Return runtime fields persisted with a stored session.
@@ -5245,30 +5275,46 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:
reasoning_config = model_config.get("reasoning_config")
service_tier = str(model_config.get("service_tier") or "").strip()
# Heal a bare ``"custom"`` provider stored by an older build (or any leak
# site that bypassed _runtime_model_config's normalization). Bare custom is
# the resolved billing class, not a routable identity — restoring it as the
# session's provider override routes the resume to the OpenRouter default
# URL with no api_key, surfacing as "No LLM provider configured". Recover
# the durable ``custom:<name>`` menu key from the stored base_url, then
# from the entry that serves the stored model, falling back to the
# configured provider when the row has neither (the recurring Desktop/TUI
# regression vector). If none names a real entry,
# drop the bare provider entirely so resume falls back to the configured
# default rather than the broken OpenRouter route.
if provider.strip().lower() == "custom":
healed = None
# Heal a stale/expired provider name persisted by an older build — not
# just the bare ``"custom"`` billing class. A renamed or removed custom
# provider (e.g. ``oldone`` -> ``newone``) stored in the session row
# would otherwise fail agent init with "Unknown provider '<name>'".
# Recover the durable ``custom:<name>`` menu key from the stored
# base_url, then from the entry that serves the stored model, falling
# back to the configured provider when the row has neither. When
# nothing names a real entry, drop the provider entirely so resume
# falls back to the configured default rather than the broken route.
if provider:
routable = False
try:
from hermes_cli.runtime_provider import canonical_custom_identity
from hermes_cli.runtime_provider import is_routable_provider
healed = canonical_custom_identity(
base_url=base_url or None, model=model or None
)
routable = is_routable_provider(provider)
except Exception:
logger.debug(
"custom provider identity recovery failed", exc_info=True
)
provider = healed or ("" if not base_url else provider)
routable = False
if not routable:
healed = None
try:
from hermes_cli.runtime_provider import canonical_custom_identity
healed = canonical_custom_identity(
base_url=base_url or None, model=model or None
)
except Exception:
logger.debug(
"custom provider identity recovery failed", exc_info=True
)
if healed:
logger.info(
"healed stale session provider %r to %r", provider, healed
)
provider = healed
# The healed identity owns a registered endpoint; drop the
# snapshot's base_url so it can't override the registry URL
# (e.g. a stale direct endpoint behind a renamed proxy).
base_url = ""
else:
provider = ""
if model:
# Use the same dict-shaped override that live /model switches use so a