From 8d72845399a84f4b1660142c3d5047d49d3baec6 Mon Sep 17 00:00:00 2001 From: 0xDevNinja Date: Mon, 20 Jul 2026 17:35:55 +0530 Subject: [PATCH] fix(cli): resolve moa: model in non-interactive mode hermes chat -Q -m moa:strategy failed with 'model moa:strategy is not supported' (HTTP 401/400): the raw model string was passed straight to the real provider. The MoA virtual provider only got wired up through the interactive /moa command and the model picker, never through the -Q one-shot startup path. resolve_runtime_provider already handles requested_provider == 'moa', and agent_init builds the MoAClient off provider == 'moa' (surface-agnostic). The only gap was mapping the moa: model string to that provider. Add _normalize_moa_model() and apply it in HermesCLI.__init__ before provider resolution: a moa: model sets requested_provider='moa' and model=, so the existing MoA path runs in non-interactive mode too. The moa: prefix wins over an explicit --provider (previously --provider deepseek -m moa:strategy silently dropped MoA). Fixes #56828 --- cli.py | 31 +++++++++++++++++++++- tests/cli/test_moa_command.py | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 1502521a213..7d86deb1d7f 100644 --- a/cli.py +++ b/cli.py @@ -3854,6 +3854,28 @@ def save_config_value(key_path: str, value: any) -> bool: # HermesCLI Class # ============================================================================ + +def _normalize_moa_model(model: Optional[str]) -> tuple[Optional[str], Optional[str]]: + """Map a ``moa:`` model string to ``(provider, preset)``. + + Returns ``("moa", "")`` when *model* selects the MoA virtual + provider, otherwise ``(None, model)`` unchanged. This gives non-interactive + ``hermes chat -Q -m moa:`` the same routing the interactive + ``/moa`` command and the model picker already use: ``resolve_runtime_provider`` + handles ``requested_provider == "moa"`` and ``agent_init`` builds the + MoAClient off ``provider == "moa"``. Without this the raw ``moa:`` + string is sent to the real provider and rejected with a 401/400 "model not + supported" (#56828). + """ + if isinstance(model, str): + stripped = model.strip() + if stripped.lower().startswith("moa:"): + preset = stripped.split(":", 1)[1].strip() + if preset: + return "moa", preset + return None, model + + class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): """ Interactive CLI for the Hermes Agent. @@ -3984,6 +4006,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "") _DEFAULT_CONFIG_MODEL = "" self.model = model or _config_model or _DEFAULT_CONFIG_MODEL + # A ``moa:`` model string selects the MoA virtual provider in + # one shot (parity with interactive ``/moa`` and the model picker). Do + # this before provider resolution so ``-Q -m moa:`` routes + # through MoA instead of hitting the real provider with an unknown + # model (#56828). A ``moa:`` prefix wins over an explicit ``--provider``. + _moa_provider_override, self.model = _normalize_moa_model(self.model) # Read max_tokens from config (env var override: HERMES_MAX_TOKENS) _env_mt = os.environ.get("HERMES_MAX_TOKENS") if _env_mt: @@ -4019,7 +4047,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Provider selection is resolved lazily at use-time via _ensure_runtime_credentials(). self.requested_provider = ( - provider + _moa_provider_override + or provider or CLI_CONFIG["model"].get("provider") or os.getenv("HERMES_INFERENCE_PROVIDER") or "auto" diff --git a/tests/cli/test_moa_command.py b/tests/cli/test_moa_command.py index c526a0f37af..7a60dd226bc 100644 --- a/tests/cli/test_moa_command.py +++ b/tests/cli/test_moa_command.py @@ -80,3 +80,52 @@ def test_decode_legacy_encoded_moa_turn_still_works(): prompt, cfg = decode_moa_turn(encoded) assert prompt == "hello" assert cfg["reference_models"] == [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}] + + +class TestNormalizeMoaModel: + """#56828: `-Q -m moa:` must route through the MoA virtual provider. + + ``_normalize_moa_model`` maps the model string to (provider, preset); the + __init__ wiring then forces ``requested_provider="moa"`` so the existing + resolve_runtime_provider / agent_init MoA path runs in non-interactive mode. + """ + + def test_moa_prefix_maps_to_provider_and_preset(self): + from cli import _normalize_moa_model + assert _normalize_moa_model("moa:strategy") == ("moa", "strategy") + + def test_moa_prefix_is_case_insensitive_and_trims(self): + from cli import _normalize_moa_model + assert _normalize_moa_model(" MOA:code-review ") == ("moa", "code-review") + + def test_bare_moa_without_preset_is_not_treated_as_virtual(self): + from cli import _normalize_moa_model + # No preset after the colon → leave untouched (no provider override). + assert _normalize_moa_model("moa:") == (None, "moa:") + + def test_non_moa_model_unchanged(self): + from cli import _normalize_moa_model + assert _normalize_moa_model("anthropic/claude-opus-4.8") == (None, "anthropic/claude-opus-4.8") + + def test_none_model_unchanged(self): + from cli import _normalize_moa_model + assert _normalize_moa_model(None) == (None, None) + + def test_colon_model_that_is_not_moa_unchanged(self): + from cli import _normalize_moa_model + # A provider:model form for a real provider must not be hijacked. + assert _normalize_moa_model("openrouter:deepseek/deepseek-v4") == ( + None, + "openrouter:deepseek/deepseek-v4", + ) + + def test_override_wins_over_explicit_provider(self): + # __init__ resolves requested_provider as + # ``_moa_provider_override or provider or ...``, so a moa: prefix must + # take precedence over an explicit --provider (the #56828 deepseek case + # where MoA was silently ignored). + from cli import _normalize_moa_model + override, model = _normalize_moa_model("moa:strategy") + requested_provider = override or "deepseek" or "auto" + assert requested_provider == "moa" + assert model == "strategy"