diff --git a/gateway/config.py b/gateway/config.py index d3da3c30f..2b94d608f 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -412,6 +412,52 @@ class GatewayConfig: return self.unauthorized_dm_behavior +def _validate_fallback_providers() -> None: + """Validate fallback_providers from config.yaml at gateway startup. + + Checks that each entry has 'provider' and 'model' fields and logs + warnings for malformed entries. This catches broken fallback chains + before they silently degrade into no-fallback mode. + """ + try: + _home = get_hermes_home() + _config_path = _home / "config.yaml" + if not _config_path.exists(): + return + import yaml + with open(_config_path, encoding="utf-8") as _f: + _cfg = yaml.safe_load(_f) or {} + fbp = _cfg.get("fallback_providers") + if not fbp: + return + if not isinstance(fbp, list): + logger.warning( + "fallback_providers should be a YAML list, got %s. " + "Fallback chain will be disabled.", + type(fbp).__name__, + ) + return + for i, entry in enumerate(fbp): + if not isinstance(entry, dict): + logger.warning( + "fallback_providers[%d] is not a dict (got %s). Skipping entry.", + i, type(entry).__name__, + ) + continue + if not entry.get("provider"): + logger.warning( + "fallback_providers[%d] missing 'provider' field. Skipping entry.", + i, + ) + if not entry.get("model"): + logger.warning( + "fallback_providers[%d] missing 'model' field. Skipping entry.", + i, + ) + except Exception: + pass # Non-fatal; validation is advisory + + def load_gateway_config() -> GatewayConfig: """ Load gateway configuration from multiple sources. @@ -645,6 +691,19 @@ def load_gateway_config() -> GatewayConfig: platform.value, env_name, ) + # Warn about API Server enabled without a key (unauthenticated endpoint) + if Platform.API_SERVER in config.platforms: + api_cfg = config.platforms[Platform.API_SERVER] + if api_cfg.enabled and not api_cfg.extra.get("key"): + logger.warning( + "api_server is enabled but API_SERVER_KEY is not set. " + "The API endpoint will run unauthenticated. " + "Set API_SERVER_KEY in ~/.hermes/.env to secure it.", + ) + + # Validate fallback_providers structure from config.yaml + _validate_fallback_providers() + return config diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 45debf72f..6f2e72e68 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1338,6 +1338,11 @@ _KNOWN_ROOT_KEYS = { "fallback_providers", "credential_pool_strategies", "toolsets", "agent", "terminal", "display", "compression", "delegation", "auxiliary", "custom_providers", "memory", "gateway", + "session_reset", "browser", "checkpoints", "smart_model_routing", + "voice", "stt", "tts", "human_delay", "security", "privacy", + "cron", "logging", "approvals", "command_allowlist", "quick_commands", + "personalities", "skills", "honcho", "timezone", "discord", + "whatsapp", "prefill_messages_file", "file_read_max_chars", } # Valid fields inside a custom_providers list entry @@ -1498,6 +1503,72 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List[" f"Move '{key}' under the appropriate section", )) + # ── fallback_providers must be a list of dicts with provider + model ─ + fbp = config.get("fallback_providers") + if fbp is not None: + if not isinstance(fbp, list): + issues.append(ConfigIssue( + "error", + f"fallback_providers should be a YAML list, got {type(fbp).__name__}", + "Change to:\n" + " fallback_providers:\n" + " - provider: openrouter\n" + " model: google/gemini-3-flash-preview", + )) + elif fbp: + for i, entry in enumerate(fbp): + if not isinstance(entry, dict): + issues.append(ConfigIssue( + "warning", + f"fallback_providers[{i}] is not a dict (got {type(entry).__name__})", + "Each entry needs at minimum: provider, model", + )) + continue + if not entry.get("provider"): + issues.append(ConfigIssue( + "warning", + f"fallback_providers[{i}] is missing 'provider' field — this fallback will be skipped", + "Add: provider: openrouter (or another provider name)", + )) + if not entry.get("model"): + issues.append(ConfigIssue( + "warning", + f"fallback_providers[{i}] is missing 'model' field — this fallback will be skipped", + "Add: model: google/gemini-3-flash-preview (or another model slug)", + )) + + # ── session_reset validation ───────────────────────────────────────── + session_reset = config.get("session_reset", {}) + if isinstance(session_reset, dict): + idle_minutes = session_reset.get("idle_minutes") + if idle_minutes is not None: + if not isinstance(idle_minutes, (int, float)) or idle_minutes <= 0: + issues.append(ConfigIssue( + "warning", + f"session_reset.idle_minutes={idle_minutes} is invalid (must be a positive number)", + "Set to a positive integer, e.g. 1440 (24 hours). Using 0 causes immediate resets.", + )) + at_hour = session_reset.get("at_hour") + if at_hour is not None: + if not isinstance(at_hour, (int, float)) or not (0 <= at_hour <= 23): + issues.append(ConfigIssue( + "warning", + f"session_reset.at_hour={at_hour} is invalid (must be 0-23)", + "Set to an hour between 0 and 23, e.g. 4 for 4am", + )) + + # ── API Server key check ───────────────────────────────────────────── + # If api_server is enabled via env, but no key is set, warn. + # This catches the "API_SERVER_KEY not configured" error from gateway logs. + api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes") + api_server_key = os.getenv("API_SERVER_KEY", "").strip() + if api_server_enabled and not api_server_key: + issues.append(ConfigIssue( + "warning", + "API_SERVER is enabled but API_SERVER_KEY is not set — the API server will run unauthenticated", + "Set API_SERVER_KEY in ~/.hermes/.env to secure the API endpoint", + )) + return issues diff --git a/tests/hermes_cli/test_config_validation.py b/tests/hermes_cli/test_config_validation.py index b2435e585..43fd31856 100644 --- a/tests/hermes_cli/test_config_validation.py +++ b/tests/hermes_cli/test_config_validation.py @@ -249,3 +249,111 @@ class TestConfigIssueDataclass: a = ConfigIssue("error", "msg", "hint") b = ConfigIssue("error", "msg", "hint") assert a == b + + +class TestFallbackProvidersValidation: + """fallback_providers must be a list of dicts with provider + model.""" + + def test_non_list(self): + """fallback_providers as string should error.""" + issues = validate_config_structure({ + "fallback_providers": "openrouter:google/gemini-3-flash-preview", + }) + errors = [i for i in issues if i.severity == "error"] + assert any("fallback_providers" in i.message and "list" in i.message for i in errors) + + def test_dict_instead_of_list(self): + """fallback_providers as dict should error.""" + issues = validate_config_structure({ + "fallback_providers": {"provider": "openrouter", "model": "test"}, + }) + errors = [i for i in issues if i.severity == "error"] + assert any("fallback_providers" in i.message and "dict" in i.message for i in errors) + + def test_entry_missing_provider(self): + """Entry without provider should warn.""" + issues = validate_config_structure({ + "fallback_providers": [{"model": "google/gemini-3-flash-preview"}], + }) + assert any("missing 'provider'" in i.message for i in issues) + + def test_entry_missing_model(self): + """Entry without model should warn.""" + issues = validate_config_structure({ + "fallback_providers": [{"provider": "openrouter"}], + }) + assert any("missing 'model'" in i.message for i in issues) + + def test_entry_not_dict(self): + """Non-dict entries should warn.""" + issues = validate_config_structure({ + "fallback_providers": ["not-a-dict"], + }) + assert any("not a dict" in i.message for i in issues) + + def test_valid_entries(self): + """Valid fallback_providers should produce no fallback-related issues.""" + issues = validate_config_structure({ + "fallback_providers": [ + {"provider": "openrouter", "model": "google/gemini-3-flash-preview"}, + {"provider": "gemini", "model": "gemini-2.5-flash"}, + ], + }) + fb_issues = [i for i in issues if "fallback_providers" in i.message] + assert len(fb_issues) == 0 + + def test_empty_list_no_issues(self): + """Empty list is valid (fallback disabled).""" + issues = validate_config_structure({ + "fallback_providers": [], + }) + fb_issues = [i for i in issues if "fallback_providers" in i.message] + assert len(fb_issues) == 0 + + +class TestSessionResetValidation: + """session_reset.idle_minutes must be positive.""" + + def test_zero_idle_minutes(self): + """idle_minutes=0 should warn.""" + issues = validate_config_structure({ + "session_reset": {"idle_minutes": 0}, + }) + assert any("idle_minutes=0" in i.message for i in issues) + + def test_negative_idle_minutes(self): + """idle_minutes=-5 should warn.""" + issues = validate_config_structure({ + "session_reset": {"idle_minutes": -5}, + }) + assert any("idle_minutes=-5" in i.message for i in issues) + + def test_string_idle_minutes(self): + """idle_minutes as string should warn.""" + issues = validate_config_structure({ + "session_reset": {"idle_minutes": "abc"}, + }) + assert any("idle_minutes=" in i.message for i in issues) + + def test_valid_idle_minutes(self): + """Valid idle_minutes should not warn.""" + issues = validate_config_structure({ + "session_reset": {"idle_minutes": 1440}, + }) + idle_issues = [i for i in issues if "idle_minutes" in i.message] + assert len(idle_issues) == 0 + + def test_invalid_at_hour(self): + """at_hour=25 should warn.""" + issues = validate_config_structure({ + "session_reset": {"at_hour": 25}, + }) + assert any("at_hour=25" in i.message for i in issues) + + def test_valid_at_hour(self): + """Valid at_hour should not warn.""" + issues = validate_config_structure({ + "session_reset": {"at_hour": 4}, + }) + hour_issues = [i for i in issues if "at_hour" in i.message] + assert len(hour_issues) == 0