From faaf2ae0930e0b7130263cfce162e1b9d59b9467 Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Sat, 15 Aug 2026 20:12:36 +0800 Subject: [PATCH] fix(gateway): read routed profile model config --- gateway/run.py | 16 ++-- gateway/slash_commands.py | 2 +- .../test_model_command_profile_config.py | 81 +++++++++++++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 tests/gateway/test_model_command_profile_config.py diff --git a/gateway/run.py b/gateway/run.py index f85febca42..5ac8c5d7f5 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3333,19 +3333,21 @@ def _gateway_config_home() -> Path: return _hermes_home -def _load_gateway_config() -> dict: - """Load and parse ~/.hermes/config.yaml, returning {} on any error. +def _load_gateway_config(config_path: "Path | None" = None) -> dict: + """Load and parse a gateway config.yaml, returning {} on any error. - Uses the module-level ``_hermes_home`` (so tests that monkeypatch it - still see their fixture) and shares the mtime-keyed raw-yaml cache - from ``hermes_cli.config.read_raw_config`` when the paths match. + Defaults to the active gateway home (so tests that monkeypatch + ``_hermes_home`` still see their fixture). Callers handling multiplexed + profile routes may pass that profile's explicit config path. The canonical + path shares the mtime-keyed raw-yaml cache from + ``hermes_cli.config.read_raw_config``. Managed scope is overlaid on the result (via the shared helper) so the gateway honors administrator-pinned values — neither read_raw_config nor a direct yaml.safe_load carries the managed merge on its own. Fail-open. """ - config_home = _gateway_config_home() - config_path = config_home / 'config.yaml' + if config_path is None: + config_path = _gateway_config_home() / 'config.yaml' raw: dict = {} used_canonical = False try: diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 49ba11c572..4aca0b876c 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -1772,7 +1772,7 @@ class GatewaySlashCommandsMixin: excluded_provs = [] config_path = (_command_profile_home or _hermes_home) / "config.yaml" try: - cfg = _load_gateway_config() + cfg = _load_gateway_config(config_path=config_path) if cfg: model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): diff --git a/tests/gateway/test_model_command_profile_config.py b/tests/gateway/test_model_command_profile_config.py new file mode 100644 index 0000000000..011f6db0fa --- /dev/null +++ b/tests/gateway/test_model_command_profile_config.py @@ -0,0 +1,81 @@ +"""Regression coverage for profile-scoped gateway ``/model`` reads.""" + +from types import SimpleNamespace + +import pytest + +from gateway.config import Platform +from gateway.platforms.base import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionSource + + +class _CapturingPickerAdapter: + def __init__(self): + self.kwargs = None + + async def send_model_picker(self, **kwargs): + self.kwargs = kwargs + return SimpleNamespace(success=True) + + +def _make_event(): + return MessageEvent( + text="/model", + message_type=MessageType.TEXT, + source=SessionSource( + platform=Platform.TELEGRAM, + chat_id="secondary-chat", + chat_type="group", + ), + ) + + +@pytest.mark.asyncio +async def test_model_picker_reads_routed_profile_config(tmp_path, monkeypatch): + import gateway.run as gateway_run + + default_home = tmp_path / "default" + secondary_home = tmp_path / "profiles" / "secondary" + default_home.mkdir() + secondary_home.mkdir(parents=True) + (default_home / "config.yaml").write_text( + "model:\n default: default-model\n provider: default-provider\n", + encoding="utf-8", + ) + (secondary_home / "config.yaml").write_text( + "model:\n default: secondary-model\n provider: secondary-provider\n", + encoding="utf-8", + ) + monkeypatch.setattr(gateway_run, "_hermes_home", default_home) + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr( + "hermes_cli.model_switch.list_picker_providers", + lambda **_kwargs: [ + { + "slug": "secondary-provider", + "name": "Secondary Provider", + "is_current": True, + "models": ["secondary-model"], + "total_models": 1, + } + ], + ) + + runner = object.__new__(GatewayRunner) + adapter = _CapturingPickerAdapter() + runner.adapters = {Platform.TELEGRAM: adapter} + runner.config = SimpleNamespace(multiplex_profiles=True) + runner._voice_mode = {} + runner._session_model_overrides = {} + runner._running_agents = {} + runner._resolve_profile_home_for_source = lambda _source: secondary_home + runner._thread_metadata_for_source = lambda *_args, **_kwargs: None + runner._reply_anchor_for_event = lambda *_args, **_kwargs: None + + result = await runner._handle_model_command(_make_event()) + + assert result is None + assert adapter.kwargs is not None + assert adapter.kwargs["current_model"] == "secondary-model" + assert adapter.kwargs["current_provider"] == "secondary-provider"