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()