diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 772025845da..38e318a36fd 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2533,10 +2533,32 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List[" "Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1", )) - # ── fallback_model must be a top-level dict with provider + model ──── + # ── fallback_model: single dict OR list of dicts (chain) ───────────── fb = config.get("fallback_model") if fb is not None: - if not isinstance(fb, dict): + if isinstance(fb, list): + # Chain fallback — validate each entry + for i, entry in enumerate(fb): + if not isinstance(entry, dict): + issues.append(ConfigIssue( + "error", + f"fallback_model[{i}] should be a dict, got {type(entry).__name__}", + "Each entry needs provider + model", + )) + else: + if not entry.get("provider"): + issues.append(ConfigIssue( + "warning", + f"fallback_model[{i}] is missing 'provider' field", + "Add: provider: openrouter (or another provider)", + )) + if not entry.get("model"): + issues.append(ConfigIssue( + "warning", + f"fallback_model[{i}] is missing 'model' field", + "Add: model: ", + )) + elif not isinstance(fb, dict): issues.append(ConfigIssue( "error", f"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}", @@ -3468,7 +3490,12 @@ def save_config(config: Dict[str, Any]): if not sec or sec.get("redact_secrets") is None: parts.append(_SECURITY_COMMENT) fb = normalized.get("fallback_model", {}) - if not fb or not isinstance(fb, dict) or not (fb.get("provider") and fb.get("model")): + fb_is_valid = False + if isinstance(fb, list): + fb_is_valid = any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb) + elif isinstance(fb, dict): + fb_is_valid = bool(fb.get("provider") and fb.get("model")) + if not fb_is_valid: parts.append(_FALLBACK_COMMENT) atomic_yaml_write( diff --git a/tests/hermes_cli/test_config_validation.py b/tests/hermes_cli/test_config_validation.py index c18afc9110b..7209e638f9a 100644 --- a/tests/hermes_cli/test_config_validation.py +++ b/tests/hermes_cli/test_config_validation.py @@ -136,6 +136,40 @@ class TestFallbackModelValidation: fb_issues = [i for i in issues if "fallback" in i.message.lower()] assert len(fb_issues) == 0 + def test_valid_fallback_list(self): + """List-form fallback_model (chain) should validate when every entry has provider+model.""" + issues = validate_config_structure({ + "fallback_model": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4"}, + {"provider": "anthropic", "model": "claude-sonnet-4-6"}, + ], + }) + fb_issues = [i for i in issues if "fallback" in i.message.lower()] + assert len(fb_issues) == 0 + + def test_fallback_list_entry_missing_provider(self): + issues = validate_config_structure({ + "fallback_model": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4"}, + {"model": "claude-sonnet-4-6"}, + ], + }) + assert any("fallback_model[1]" in i.message and "provider" in i.message for i in issues) + + def test_fallback_list_entry_missing_model(self): + issues = validate_config_structure({ + "fallback_model": [ + {"provider": "openrouter"}, + ], + }) + assert any("fallback_model[0]" in i.message and "model" in i.message for i in issues) + + def test_fallback_list_entry_not_a_dict(self): + issues = validate_config_structure({ + "fallback_model": ["openrouter:anthropic/claude-sonnet-4"], + }) + assert any("fallback_model[0]" in i.message and "should be a dict" in i.message for i in issues) + class TestMissingModelSection: """Warn when custom_providers exists but model section is missing."""