159 lines
5.7 KiB
Python
159 lines
5.7 KiB
Python
|
|
"""Regression tests for gateway /model --global persistence when config.yaml
|
||
|
|
has a flat-string ``model:`` value instead of a nested dict.
|
||
|
|
|
||
|
|
Before fix: ``cfg.setdefault("model", {})`` returned the existing string and
|
||
|
|
the next assignment raised ``TypeError: 'str' object does not support item
|
||
|
|
assignment``, so every ``/model X --global`` from Telegram/Discord crashed
|
||
|
|
silently and the user-visible result was "switch failed" with no persist.
|
||
|
|
|
||
|
|
After fix: the persist block coerces a scalar ``model:`` into a nested dict
|
||
|
|
before mutation, so ``--global`` succeeds and the config is rewritten in
|
||
|
|
the proper ``model: {default: ..., provider: ...}`` form.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import yaml
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from gateway.config import Platform
|
||
|
|
from gateway.platforms.base import MessageEvent, MessageType
|
||
|
|
from gateway.run import GatewayRunner
|
||
|
|
from gateway.session import SessionSource
|
||
|
|
|
||
|
|
|
||
|
|
def _make_runner():
|
||
|
|
runner = object.__new__(GatewayRunner)
|
||
|
|
runner.adapters = {}
|
||
|
|
runner._voice_mode = {}
|
||
|
|
runner._session_model_overrides = {}
|
||
|
|
runner._running_agents = {}
|
||
|
|
return runner
|
||
|
|
|
||
|
|
|
||
|
|
def _make_event(text):
|
||
|
|
return MessageEvent(
|
||
|
|
text=text,
|
||
|
|
message_type=MessageType.TEXT,
|
||
|
|
source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _fake_switch_result():
|
||
|
|
"""Build a successful ModelSwitchResult that bypasses real provider resolution."""
|
||
|
|
from hermes_cli.model_switch import ModelSwitchResult
|
||
|
|
|
||
|
|
return ModelSwitchResult(
|
||
|
|
success=True,
|
||
|
|
new_model="gpt-5.5",
|
||
|
|
target_provider="openrouter",
|
||
|
|
provider_changed=True,
|
||
|
|
api_key="sk-test",
|
||
|
|
base_url="https://openrouter.ai/api/v1",
|
||
|
|
api_mode="chat_completions",
|
||
|
|
provider_label="OpenRouter",
|
||
|
|
is_global=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value):
|
||
|
|
"""Write a config.yaml with the given ``model:`` value and stub the heavy bits."""
|
||
|
|
import gateway.run as gateway_run
|
||
|
|
|
||
|
|
hermes_home = tmp_path / ".hermes"
|
||
|
|
hermes_home.mkdir()
|
||
|
|
cfg_path = hermes_home / "config.yaml"
|
||
|
|
cfg_path.write_text(
|
||
|
|
yaml.safe_dump({"model": model_yaml_value, "providers": {}}),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
|
||
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"hermes_cli.model_switch.switch_model",
|
||
|
|
lambda **kw: _fake_switch_result(),
|
||
|
|
)
|
||
|
|
# save_config writes to ``get_hermes_home() / config.yaml`` — point it here.
|
||
|
|
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home)
|
||
|
|
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home)
|
||
|
|
return cfg_path
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_model_global_persists_when_config_has_flat_string_model(tmp_path, monkeypatch):
|
||
|
|
"""Regression: ``model: deepseek-v4-flash`` (flat string) used to crash
|
||
|
|
the gateway ``/model X --global`` persist branch with TypeError. After
|
||
|
|
the fix, the flat string is coerced to ``{"default": ...}`` and the new
|
||
|
|
model+provider are persisted on top.
|
||
|
|
"""
|
||
|
|
cfg_path = _setup_isolated_home(tmp_path, monkeypatch, "deepseek-v4-flash")
|
||
|
|
|
||
|
|
result = await _make_runner()._handle_model_command(
|
||
|
|
_make_event("/model gpt-5.5 --global")
|
||
|
|
)
|
||
|
|
|
||
|
|
# Sanity: the handler returned a success-looking message (not a crash log).
|
||
|
|
assert result is not None
|
||
|
|
assert "gpt-5.5" in result
|
||
|
|
|
||
|
|
# The persist block must have rewritten config.yaml as a nested dict.
|
||
|
|
written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||
|
|
assert isinstance(written["model"], dict), (
|
||
|
|
"model: should be coerced to a dict, got %r" % (written["model"],)
|
||
|
|
)
|
||
|
|
assert written["model"]["default"] == "gpt-5.5"
|
||
|
|
assert written["model"]["provider"] == "openrouter"
|
||
|
|
assert written["model"]["base_url"] == "https://openrouter.ai/api/v1"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_model_global_persists_when_config_has_missing_model(tmp_path, monkeypatch):
|
||
|
|
"""Companion case: ``model:`` key absent entirely. setdefault would have
|
||
|
|
worked here, but the coercion branch also has to handle this cleanly.
|
||
|
|
"""
|
||
|
|
import gateway.run as gateway_run
|
||
|
|
|
||
|
|
hermes_home = tmp_path / ".hermes"
|
||
|
|
hermes_home.mkdir()
|
||
|
|
cfg_path = hermes_home / "config.yaml"
|
||
|
|
cfg_path.write_text(yaml.safe_dump({"providers": {}}), encoding="utf-8")
|
||
|
|
|
||
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"hermes_cli.model_switch.switch_model",
|
||
|
|
lambda **kw: _fake_switch_result(),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home)
|
||
|
|
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home)
|
||
|
|
|
||
|
|
result = await _make_runner()._handle_model_command(
|
||
|
|
_make_event("/model gpt-5.5 --global")
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result is not None
|
||
|
|
written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||
|
|
assert isinstance(written["model"], dict)
|
||
|
|
assert written["model"]["default"] == "gpt-5.5"
|
||
|
|
assert written["model"]["provider"] == "openrouter"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_model_global_persists_when_config_has_proper_dict_model(tmp_path, monkeypatch):
|
||
|
|
"""Already-correct nested dict must still work — no regression on the
|
||
|
|
common case.
|
||
|
|
"""
|
||
|
|
cfg_path = _setup_isolated_home(
|
||
|
|
tmp_path,
|
||
|
|
monkeypatch,
|
||
|
|
{"default": "old-model", "provider": "openai-codex"},
|
||
|
|
)
|
||
|
|
|
||
|
|
result = await _make_runner()._handle_model_command(
|
||
|
|
_make_event("/model gpt-5.5 --global")
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result is not None
|
||
|
|
written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||
|
|
assert written["model"]["default"] == "gpt-5.5"
|
||
|
|
assert written["model"]["provider"] == "openrouter"
|