From ed54469d0612bd88e25eed01974b9ff8e8948c1b Mon Sep 17 00:00:00 2001 From: dodo-reach <254021826+dodo-reach@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:05:53 +0200 Subject: [PATCH] fix(gateway): show MoA presets in model picker --- gateway/slash_commands.py | 1 + hermes_cli/model_switch.py | 36 +++++++++++++++++++ .../test_model_command_async_offload.py | 26 ++++++++++++++ .../hermes_cli/test_list_picker_providers.py | 34 ++++++++++++++++++ 4 files changed, 97 insertions(+) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index b2b8089b51b..0bcf5457455 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -1206,6 +1206,7 @@ class GatewaySlashCommandsMixin: user_providers=user_provs, custom_providers=custom_provs, max_models=50, + include_moa=True, ) except Exception: providers = [] diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 92427967464..048cb9c1d73 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -2277,6 +2277,39 @@ def list_authenticated_providers( return results +def _prepend_moa_picker_provider(providers: List[dict], current_provider: str = "") -> List[dict]: + """Add the virtual MoA provider row used by interactive model pickers. + + ``list_authenticated_providers()`` only returns real/auth-backed providers. + The CLI model inventory adds MoA separately so named presets appear next to + normal providers; gateway pickers call ``list_picker_providers()`` directly, + so they need the same virtual row here. + """ + try: + from hermes_cli.config import load_config + from hermes_cli.moa_config import normalize_moa_config + + cfg = normalize_moa_config(load_config().get("moa") or {}) + models = list(cfg.get("presets", {}).keys()) + if not models: + return providers + moa_row = { + "slug": "moa", + "name": "Mixture of Agents", + "is_current": (current_provider or "").lower() == "moa", + "is_user_defined": False, + "models": models, + "total_models": len(models), + "source": "virtual", + "authenticated": True, + "auth_type": "virtual", + "warning": "Aggregator acts as the selected model; references provide analysis before each call.", + } + return [moa_row] + [p for p in providers if str(p.get("slug", "")).lower() != "moa"] + except Exception: + return providers + + def list_picker_providers( current_provider: str = "", current_base_url: str = "", @@ -2284,6 +2317,7 @@ def list_picker_providers( custom_providers: list | None = None, max_models: int | None = None, current_model: str = "", + include_moa: bool = False, ) -> List[dict]: """Interactive-picker variant of :func:`list_authenticated_providers`. @@ -2314,6 +2348,8 @@ def list_picker_providers( max_models=max_models, current_model=current_model, ) + if include_moa: + providers = _prepend_moa_picker_provider(providers, current_provider=current_provider) filtered: List[dict] = [] for p in providers: diff --git a/tests/gateway/test_model_command_async_offload.py b/tests/gateway/test_model_command_async_offload.py index 006bfd94a37..12cdf75146e 100644 --- a/tests/gateway/test_model_command_async_offload.py +++ b/tests/gateway/test_model_command_async_offload.py @@ -166,3 +166,29 @@ async def test_picker_path_offloads_list_picker_providers(_isolated_config, monk "list_picker_providers must be dispatched via asyncio.to_thread " "(it was called inline on the event loop instead)" ) + + +@pytest.mark.asyncio +async def test_picker_path_requests_moa_presets(_isolated_config, monkeypatch): + """Gateway /model pickers must opt into the virtual MoA preset provider.""" + captured = {} + + def _fake_list_picker_providers(**kwargs): + captured.update(kwargs) + return [{"slug": "moa", "name": "Mixture of Agents", "is_current": False, + "models": ["battle", "smart"], "total_models": 2}] + + monkeypatch.setattr( + "hermes_cli.model_switch.list_picker_providers", + _fake_list_picker_providers, + ) + + runner = _make_runner() + runner.adapters = {Platform.TELEGRAM: _FakePickerAdapter()} + monkeypatch.setattr(runner, "_thread_metadata_for_source", lambda *a, **k: None, raising=False) + monkeypatch.setattr(runner, "_reply_anchor_for_event", lambda *a, **k: None, raising=False) + + result = await runner._handle_model_command(_make_event()) + + assert result is None + assert captured["include_moa"] is True diff --git a/tests/hermes_cli/test_list_picker_providers.py b/tests/hermes_cli/test_list_picker_providers.py index 1d3e75e036e..480d42aa160 100644 --- a/tests/hermes_cli/test_list_picker_providers.py +++ b/tests/hermes_cli/test_list_picker_providers.py @@ -109,6 +109,40 @@ def test_non_openrouter_rows_passed_through_unchanged(monkeypatch): assert result[1]["models"] == ["gemini-3-flash-preview"] +def test_include_moa_adds_virtual_provider_with_named_presets(monkeypatch): + """Gateway pickers opt into a virtual MoA provider so presets are tappable.""" + base = [_make_provider("minimax", models=["MiniMax-M3"])] + moa_config = { + "moa": { + "default_preset": "battle", + "presets": { + "battle": {"enabled": True}, + "smart": {"enabled": True}, + }, + } + } + + monkeypatch.setattr(model_switch, "list_authenticated_providers", + lambda **kw: list(base)) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: moa_config) + monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models", + lambda *a, **kw: pytest.fail("should not be called")) + + result = model_switch.list_picker_providers( + current_provider="moa", + max_models=50, + include_moa=True, + ) + + assert [p["slug"] for p in result] == ["moa", "minimax"] + moa = result[0] + assert moa["name"] == "Mixture of Agents" + assert moa["is_current"] is True + assert moa["source"] == "virtual" + assert moa["models"] == ["battle", "smart"] + assert moa["total_models"] == 2 + + def test_empty_models_row_dropped(monkeypatch): """Built-in provider with an empty ``models`` list is dropped.""" base = [