diff --git a/cli.py b/cli.py index 6b7b9e7a4e..fcd5aad20d 100644 --- a/cli.py +++ b/cli.py @@ -7705,10 +7705,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): sid = getattr(self, "session_id", None) if not db or not sid: return + provider = result.target_provider + # Bare "custom" is the resolved billing class, not a routable + # identity — persisting it verbatim makes a later resume hard-fail + # when the config default has moved off the custom endpoint + # (resolve_runtime_provider only trusts config base_url for bare + # custom while the config provider is still custom-ish). Heal to + # the durable custom: menu key, else drop the provider — + # same recovery the TUI gateway applies on its read path. + if str(provider or "").strip().lower() == "custom": + try: + from hermes_cli.runtime_provider import canonical_custom_identity + provider = canonical_custom_identity( + base_url=result.base_url or None, + model=result.new_model or None, + ) or None + except Exception: + provider = None route = { k: v for k, v in { - "provider": result.target_provider, + "provider": provider, "base_url": result.base_url, "api_mode": result.api_mode, }.items() @@ -7762,6 +7779,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): stored_provider = _stored_runtime.get("provider") or None stored_base_url = _stored_runtime.get("base_url") or None stored_api_mode = _stored_runtime.get("api_mode") or None + # Heal bare "custom" persisted by older builds / gateway turns: it's + # the resolved billing class, not a routable identity. Recover the + # durable custom: menu key from the endpoint, else drop the + # provider so resume keeps the ambient default (matches the TUI + # gateway's _stored_session_runtime_overrides recovery). + if str(stored_provider or "").strip().lower() == "custom": + try: + from hermes_cli.runtime_provider import canonical_custom_identity + stored_provider = canonical_custom_identity( + base_url=stored_base_url or None, + model=stored_model or None, + ) or None + except Exception: + stored_provider = None model_changed = stored_model != self.model provider_changed = bool(stored_provider) and stored_provider != self.provider if not model_changed and not provider_changed: @@ -7790,11 +7821,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): resolved = resolve_runtime_provider(requested=stored_provider) if resolved.get("api_key"): self.api_key = resolved["api_key"] + self._credential_pool = resolved.get("credential_pool") if not stored_base_url and resolved.get("base_url"): self.base_url = resolved["base_url"] if not stored_api_mode and resolved.get("api_mode"): self.api_mode = resolved["api_mode"] - self._credential_pool = resolved.get("credential_pool") except Exception: logger.debug( "Credential re-resolution for resumed session provider " @@ -9698,10 +9729,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): else: _cprint(" (session only — add --global to persist)") - # Persist session-scoped switches so --resume / session.resume - # restore them; --global switches live in config.yaml instead. - if not persist_global: - HermesCLI._persist_model_switch_to_session(self, result) + # Persist the switch to this session's row so --resume / + # session.resume restore it. --global also updates config.yaml + # (future sessions), but the row still records what THIS session + # actually runs — otherwise a later resume would restore the stale + # creation-time model over the user's new global choice. + HermesCLI._persist_model_switch_to_session(self, result) def _handle_model_picker_selection(self, persist_global: bool = False) -> None: state = self._model_picker_state @@ -10054,10 +10087,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): else: _cprint(" (session only — add --global to persist)") - # Persist session-scoped switches so --resume / session.resume - # restore them; --global lives in config.yaml, --once is ephemeral - # (restored after one turn). - if not persist_global and not one_turn: + # Persist the switch to this session's row so --resume / + # session.resume restore it (--global also updates config.yaml but + # the row still records what THIS session runs; --once is ephemeral + # and restored after one turn, so it must not touch the row). + if not one_turn: HermesCLI._persist_model_switch_to_session(self, result) def _handle_codex_runtime(self, cmd_original: str) -> None: diff --git a/tests/cli/test_resume_model_restore.py b/tests/cli/test_resume_model_restore.py index dae3ea6afe..c77034a81a 100644 --- a/tests/cli/test_resume_model_restore.py +++ b/tests/cli/test_resume_model_restore.py @@ -164,6 +164,53 @@ def test_persist_model_switch_swallows_db_errors(): stub._persist_model_switch_to_session(_Result()) # must not raise +def test_persist_model_switch_heals_bare_custom(monkeypatch): + """Bare 'custom' is not routable — heal to custom: or drop (C1).""" + written = {} + + class _DB: + def update_session_model(self, sid, model): + written["model"] = model + + def patch_session_model_config(self, sid, patch): + written["patch"] = patch + + class _BareResult: + new_model = "qwen3.6-plus" + target_provider = "custom" + base_url = "https://my-endpoint/v1" + api_mode = "" + + import hermes_cli.runtime_provider as rp + monkeypatch.setattr(rp, "canonical_custom_identity", + lambda base_url=None, model=None: "custom:myendpoint") + stub = _make_stub(_session_db=_DB(), session_id="s1") + stub._persist_model_switch_to_session(_BareResult()) + assert written["patch"]["provider"] == "custom:myendpoint" + + # Healing fails -> provider dropped entirely, not persisted bare. + monkeypatch.setattr(rp, "canonical_custom_identity", + lambda base_url=None, model=None: None) + written.clear() + stub._persist_model_switch_to_session(_BareResult()) + assert "provider" not in written["patch"] + assert "provider" not in written["patch"]["gateway_runtime"] + + +def test_restore_session_model_heals_bare_custom_stored_rows(monkeypatch): + """Rows persisted by older builds may carry bare 'custom' — heal or drop.""" + import hermes_cli.runtime_provider as rp + monkeypatch.setattr(rp, "canonical_custom_identity", + lambda base_url=None, model=None: None) + stub = _make_stub() + stub._restore_session_model(_row(model_config={ + "gateway_runtime": {"provider": "custom"}, + })) + # Provider dropped -> model restored but provider stays ambient. + assert stub.model == "glm-4.7" + assert stub.provider == "openrouter" + + # ── round trip: persist → get_session shape → restore ───────────────