resolve_persist_behavior() hard-coded the provider-scoped path as exploratory (return False before the config default was read), so model.persist_switch_by_default: true was silently ignored whenever --provider was passed - which is the exact shape the Desktop picker sends. For twin provider pairs (two custom_providers entries sharing one base_url with different keys) that is not exploration of a different backend: it is tenant or account selection on the same backend. The session-scoped pick is dropped, and the next chat re-derives the provider from the shared base_url, which deterministically returns one fixed twin - the request is then served with the other tenant's key, silently: wrong credentials, quotas and billing, no error. Read the flag before the explicit_provider bail-out so an opt-in to persistence covers provider picks too. --session and --once stay the explicit opt-outs and --global still persists; without the flag provider picks remain exploratory, so the existing behavior is unchanged for everyone who never set the key. Fixes #123150
98 lines
4.7 KiB
Python
98 lines
4.7 KiB
Python
"""Tests for session-scoped-by-default model switching.
|
|
|
|
Covers:
|
|
- ``resolve_persist_behavior`` applies the config-gated default and the
|
|
``--session`` / ``--global`` overrides.
|
|
- The default (no flags) is session-only, which is the user-facing fix: a
|
|
plain ``/model <name>`` affects only the current session unless the user
|
|
passes ``--global`` or sets ``model.persist_switch_by_default: true``.
|
|
"""
|
|
|
|
from unittest.mock import patch
|
|
|
|
from hermes_cli.model_switch import resolve_persist_behavior
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_persist_behavior
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolvePersistBehavior:
|
|
def test_session_flag_always_session_only(self):
|
|
# --session opts out even if the config default is True.
|
|
with _config({"model": {"persist_switch_by_default": True}}):
|
|
assert resolve_persist_behavior(False, True) is False
|
|
|
|
|
|
def test_no_provider_uses_config_default(self):
|
|
# No --provider → respects config default (True).
|
|
with _config({"model": {"persist_switch_by_default": True}}):
|
|
assert resolve_persist_behavior(False, False, explicit_provider="") is True
|
|
|
|
def test_provider_pick_honors_persist_switch_by_default(self):
|
|
# #123150: with twin providers sharing one base_url, an explicit provider pick
|
|
# selects a tenant/account on the same backend. Scoping it to the session by
|
|
# default silently serves the next chat with the other twin's key, so the
|
|
# user's persist opt-in must cover provider picks too.
|
|
cfg = {"model": {"default": "agnes-3.0-flash", "provider": "custom:family-snowflake",
|
|
"persist_switch_by_default": True}}
|
|
with _config(cfg):
|
|
assert resolve_persist_behavior(False, False, explicit_provider="custom:family-xizhao") is True
|
|
# --session / --once remain the explicit opt-outs.
|
|
assert resolve_persist_behavior(False, True, explicit_provider="custom:family-xizhao") is False
|
|
assert resolve_persist_behavior(False, False, is_once=True,
|
|
explicit_provider="custom:family-xizhao") is False
|
|
# --global still persists.
|
|
assert resolve_persist_behavior(True, False, explicit_provider="custom:family-xizhao") is True
|
|
|
|
def test_provider_pick_stays_exploratory_without_the_flag(self):
|
|
# The exploratory default is unchanged unless the user opts in.
|
|
cfg = {"model": {"default": "agnes-3.0-flash", "provider": "custom:family-snowflake"}}
|
|
with _config(cfg):
|
|
assert resolve_persist_behavior(False, False, explicit_provider="custom:family-xizhao") is False
|
|
assert resolve_persist_behavior(False, False, explicit_provider="") is False
|
|
|
|
def test_first_pick_persists_then_session_only(self):
|
|
# #90235 / #86414: the ONE policy every surface (CLI, gateway, Desktop
|
|
# picker) defers to. With no default ever configured, the first pick
|
|
# persists (even with --provider, which is how the Desktop picker
|
|
# always sends it) so resolve_provider never falls through to a stray
|
|
# env key on restart. Once a default exists, a plain pick is
|
|
# session-only unless --global / persist_switch_by_default.
|
|
with _config({"model": {}}):
|
|
assert resolve_persist_behavior(False, False, explicit_provider="anthropic") is True
|
|
with _config({"model": ""}):
|
|
assert resolve_persist_behavior(False, False) is True
|
|
with _config({"model": {"default": "gpt-5.6", "provider": "openai-codex"}}):
|
|
assert resolve_persist_behavior(False, False, explicit_provider="openai-api") is False
|
|
assert resolve_persist_behavior(False, False) is False
|
|
assert resolve_persist_behavior(True, False, explicit_provider="openai-api") is True
|
|
with _config({"model": "gpt-5.6"}):
|
|
assert resolve_persist_behavior(False, False) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _config:
|
|
"""Context manager that patches ``load_config`` to return a fixed dict."""
|
|
|
|
def __init__(self, cfg: dict):
|
|
self.cfg = cfg
|
|
|
|
def __enter__(self):
|
|
self._patch = patch(
|
|
"hermes_cli.config.load_config",
|
|
return_value=self.cfg,
|
|
)
|
|
# resolve_persist_behavior imports load_config lazily inside the
|
|
# function, so patching the source module is sufficient.
|
|
self._patch.start()
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
self._patch.stop()
|