fix(cli): resolve moa:<preset> 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:<preset> model string to that provider.

Add _normalize_moa_model() and apply it in HermesCLI.__init__ before
provider resolution: a moa:<preset> model sets requested_provider='moa'
and model=<preset>, 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
This commit is contained in:
0xDevNinja 2026-07-20 17:35:55 +05:30 committed by Teknium
parent b7a05b6b6f
commit 8d72845399
2 changed files with 79 additions and 1 deletions

31
cli.py
View file

@ -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:<preset>`` model string to ``(provider, preset)``.
Returns ``("moa", "<preset>")`` when *model* selects the MoA virtual
provider, otherwise ``(None, model)`` unchanged. This gives non-interactive
``hermes chat -Q -m moa:<preset>`` 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:<preset>``
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:<preset>`` 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:<preset>`` 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"

View file

@ -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:<preset>` 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"