diff --git a/cli.py b/cli.py index 0a7e67f64c..cb2267ebe7 100644 --- a/cli.py +++ b/cli.py @@ -4381,6 +4381,26 @@ def _normalize_moa_model(model: Optional[str]) -> tuple[Optional[str], Optional[ return "moa", preset return None, model +def _split_model_config_default(raw_default: Any) -> tuple[str, str]: + """Canonicalize a config ``model.default``/``model.model`` value. + + A dict-valued default (``model.default: {provider: ..., model: ...}``) + pairs the model string with the provider it must be routed through. The + dict is flattened here at the shared boundary so both halves stay + together through ``HermesCLI`` construction: the model becomes a plain + string and the provider is returned explicitly instead of being lost to + the outer merged ``model.provider`` default (often ``"auto"``, which + runtime resolution treats as authoritative and would otherwise route the + model through the wrong active provider). + + Returns ``(model, provider)``; both are ``""`` when nothing is usable. + """ + if isinstance(raw_default, dict): + provider = str(raw_default.get("provider") or "").strip() + model = raw_default.get("model") or raw_default.get("default") + return (str(model or "").strip(), provider) + return (str(raw_default or "").strip(), "") + class _VoiceInputMessage: """Sentinel wrapper for voice-transcribed messages in ``_pending_input``. @@ -4566,10 +4586,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # env vars would stomp each other. _model_config = CLI_CONFIG.get("model", {}) _raw_default = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "") - if isinstance(_raw_default, dict): - _config_model = str(_raw_default.get("model") or _raw_default.get("default") or _raw_default.get("provider") or "") - else: - _config_model = str(_raw_default or "") + # A dict-valued default (``model.default: {provider: ..., model: ...}``) + # carries its own provider; flatten it here so the nested provider is + # available when ``requested_provider`` is constructed below instead of + # being discarded and replaced by the outer merged ``model.provider`` + # (typically ``"auto"``, which is authoritative at runtime resolution). + _config_model, _nested_provider = _split_model_config_default(_raw_default) _DEFAULT_CONFIG_MODEL = "" # Track whether the user passed -m / --model so resume knows not to # clobber an explicit override with the session's stored model. @@ -4618,6 +4640,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self.requested_provider = ( _moa_provider_override or provider + or _nested_provider or CLI_CONFIG["model"].get("provider") or os.getenv("HERMES_INFERENCE_PROVIDER") or "auto" @@ -8981,10 +9004,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): ) _model_config = CLI_CONFIG.get("model", {}) _raw_default2 = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "") - if isinstance(_raw_default2, dict): - _config_model = str(_raw_default2.get("model") or _raw_default2.get("default") or _raw_default2.get("provider") or "") - else: - _config_model = str(_raw_default2 or "") + _config_model, _ = _split_model_config_default(_raw_default2) if _config_model and _config_model != getattr(self, "model", None): _config_provider = ( _model_config.get("provider", "") diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index f3fe883f74..6ec0415812 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -372,6 +372,64 @@ class TestHistoryDisplay: ) +class TestNestedDictModelDefaultPairing: + """A dict-valued ``model.default`` must keep its nested provider paired. + + ``model.default: {provider: ..., model: ...}`` canonicalizes to the string + model AND the nested provider, so ``HermesCLI`` routes the model through + that provider instead of discarding it and falling back to the outer + merged ``model.provider`` (``"auto"`` — authoritative at runtime + resolution, which would route the model through the wrong active + provider). + """ + + def test_nested_dict_default_keeps_provider_paired(self): + cli = _make_cli(config_overrides={ + "model": { + "default": {"provider": "nous", "model": "nested-default-model"}, + "provider": "auto", + }, + }) + assert cli.model == "nested-default-model" + assert cli.requested_provider == "nous" + assert cli.provider == "nous" + + def test_nested_dict_model_alias_keeps_provider_paired(self): + cli = _make_cli(config_overrides={ + "model": { + "model": {"provider": "openai", "model": "nested-alias-model"}, + "provider": "auto", + }, + }) + assert cli.model == "nested-alias-model" + assert cli.requested_provider == "openai" + assert cli.provider == "openai" + + def test_flat_string_default_still_uses_outer_provider(self): + cli = _make_cli(config_overrides={ + "model": { + "default": "flat-default-model", + "provider": "auto", + }, + }) + assert cli.model == "flat-default-model" + assert cli.requested_provider == "auto" + assert cli.provider == "auto" + + def test_nested_provider_does_not_override_explicit_provider_arg(self): + cli = _make_cli( + config_overrides={ + "model": { + "default": {"provider": "nous", "model": "nested-default-model"}, + "provider": "auto", + }, + }, + provider="anthropic", + ) + assert cli.model == "nested-default-model" + assert cli.requested_provider == "anthropic" + assert cli.provider == "anthropic" + class TestRootLevelProviderOverride: """Root-level provider/base_url in config.yaml must NOT override model.provider."""