From 7245bc77eb3b92b311c622806821375709bbe4d2 Mon Sep 17 00:00:00 2001 From: QuenVix Date: Sat, 23 May 2026 09:31:27 +0300 Subject: [PATCH] fix(fallback): merge fallback_providers with legacy fallback_model configurations --- cli.py | 11 ++-- gateway/run.py | 19 +++---- hermes_cli/fallback_cmd.py | 19 +++---- hermes_cli/fallback_config.py | 72 +++++++++++++++++++++++++++ hermes_cli/oneshot.py | 13 ++--- tests/cli/test_cli_init.py | 14 ++++++ tests/gateway/test_auth_fallback.py | 43 ++++++++++++++++ tests/hermes_cli/test_fallback_cmd.py | 25 ++++++++++ 8 files changed, 178 insertions(+), 38 deletions(-) create mode 100644 hermes_cli/fallback_config.py diff --git a/cli.py b/cli.py index 4cdc6cc139e..67939ab1d1a 100644 --- a/cli.py +++ b/cli.py @@ -51,6 +51,8 @@ os.environ["HERMES_QUIET"] = "1" # Our own modules import yaml +from hermes_cli.fallback_config import get_fallback_chain + # prompt_toolkit for fixed input area TUI from prompt_toolkit.history import FileHistory from prompt_toolkit.styles import Style as PTStyle @@ -3049,12 +3051,9 @@ class HermesCLI: pass # Fallback provider chain — tried in order when primary fails after retries. - # Supports new list format (fallback_providers) and legacy single-dict (fallback_model). - fb = CLI_CONFIG.get("fallback_providers") or CLI_CONFIG.get("fallback_model") or [] - # Normalize legacy single-dict to a one-element list - if isinstance(fb, dict): - fb = [fb] if fb.get("provider") and fb.get("model") else [] - self._fallback_model = fb + # Merge new ``fallback_providers`` entries with any legacy + # ``fallback_model`` entries so old configs still participate. + self._fallback_model = get_fallback_chain(CLI_CONFIG) # Signature of the currently-initialised agent's runtime. Used to # rebuild the agent when provider / model / base_url changes across diff --git a/gateway/run.py b/gateway/run.py index c658cf8f430..9ca87452f97 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -54,6 +54,7 @@ from agent.account_usage import fetch_account_usage, render_account_usage_lines from agent.async_utils import safe_schedule_threadsafe from agent.i18n import t from hermes_cli.config import cfg_get +from hermes_cli.fallback_config import get_fallback_chain # --- Agent cache tuning --------------------------------------------------- # Bounds the per-session AIAgent cache to prevent unbounded growth in @@ -1008,14 +1009,10 @@ def _try_resolve_fallback_provider() -> dict | None: return None with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - fb = cfg.get("fallback_providers") or cfg.get("fallback_model") - if not fb: + fb_list = get_fallback_chain(cfg) + if not fb_list: return None - # Normalize to list - fb_list = fb if isinstance(fb, list) else [fb] for entry in fb_list: - if not isinstance(entry, dict): - continue try: explicit_api_key = entry.get("api_key") if not explicit_api_key: @@ -2888,12 +2885,12 @@ class GatewayRunner: return {} @staticmethod - def _load_fallback_model() -> list | dict | None: + def _load_fallback_model() -> list | None: """Load fallback provider chain from config.yaml. - Returns a list of provider dicts (``fallback_providers``), a single - dict (legacy ``fallback_model``), or None if not configured. - AIAgent.__init__ normalizes both formats into a chain. + Returns the merged effective chain from ``fallback_providers`` plus any + legacy ``fallback_model`` entries. ``fallback_providers`` stays first + when both keys are present. """ try: import yaml as _y @@ -2901,7 +2898,7 @@ class GatewayRunner: if cfg_path.exists(): with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or None + fb = get_fallback_chain(cfg) if fb: return fb except Exception: diff --git a/hermes_cli/fallback_cmd.py b/hermes_cli/fallback_cmd.py index 9f2e6b97d46..09142ea99ea 100644 --- a/hermes_cli/fallback_cmd.py +++ b/hermes_cli/fallback_cmd.py @@ -21,6 +21,8 @@ from __future__ import annotations import copy from typing import Any, Dict, List, Optional +from hermes_cli.fallback_config import get_fallback_chain + # --------------------------------------------------------------------------- # Helpers @@ -30,20 +32,11 @@ def _read_chain(config: Dict[str, Any]) -> List[Dict[str, Any]]: """Return the normalized fallback chain as a list of dicts. Accepts both the new list format (``fallback_providers``) and the legacy - single-dict format (``fallback_model``). The returned list is always a - fresh copy — callers can mutate without touching the config dict. + ``fallback_model`` format. When both are present, the effective chain is + merged with ``fallback_providers`` entries kept first. The returned list is + always a fresh copy — callers can mutate without touching the config dict. """ - chain = config.get("fallback_providers") or [] - if isinstance(chain, list): - result = [dict(e) for e in chain if isinstance(e, dict) and e.get("provider") and e.get("model")] - if result: - return result - legacy = config.get("fallback_model") - if isinstance(legacy, dict) and legacy.get("provider") and legacy.get("model"): - return [dict(legacy)] - if isinstance(legacy, list): - return [dict(e) for e in legacy if isinstance(e, dict) and e.get("provider") and e.get("model")] - return [] + return get_fallback_chain(config) def _write_chain(config: Dict[str, Any], chain: List[Dict[str, Any]]) -> None: diff --git a/hermes_cli/fallback_config.py b/hermes_cli/fallback_config.py new file mode 100644 index 00000000000..d7cfc952d2d --- /dev/null +++ b/hermes_cli/fallback_config.py @@ -0,0 +1,72 @@ +"""Helpers for reading the effective fallback provider chain from config.""" + +from __future__ import annotations + +from typing import Any + + +def _normalized_base_url(value: Any) -> str: + if not isinstance(value, str): + return "" + return value.strip().rstrip("/") + + +def _iter_fallback_entries(raw: Any) -> list[dict[str, Any]]: + if isinstance(raw, dict): + candidates = [raw] + elif isinstance(raw, list): + candidates = raw + else: + return [] + + entries: list[dict[str, Any]] = [] + for entry in candidates: + if not isinstance(entry, dict): + continue + provider = str(entry.get("provider") or "").strip() + model = str(entry.get("model") or "").strip() + if not provider or not model: + continue + + normalized = dict(entry) + normalized["provider"] = provider + normalized["model"] = model + + base_url = _normalized_base_url(entry.get("base_url")) + if base_url: + normalized["base_url"] = base_url + + entries.append(normalized) + return entries + + +def _entry_identity(entry: dict[str, Any]) -> tuple[str, str, str]: + return ( + str(entry.get("provider") or "").strip().lower(), + str(entry.get("model") or "").strip().lower(), + _normalized_base_url(entry.get("base_url")).lower(), + ) + + +def get_fallback_chain(config: dict[str, Any] | None) -> list[dict[str, Any]]: + """Return the effective fallback chain merged across old and new config keys. + + ``fallback_providers`` remains the primary source of truth and keeps its + order. Legacy ``fallback_model`` entries are appended afterwards unless + they target the same provider/model/base_url route as an earlier entry. + The returned list always contains fresh dict copies. + """ + + config = config or {} + chain: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + + for key in ("fallback_providers", "fallback_model"): + for entry in _iter_fallback_entries(config.get(key)): + identity = _entry_identity(entry) + if identity in seen: + continue + seen.add(identity) + chain.append(entry) + + return chain diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index ebc684f2857..c6f9cd3c389 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -28,6 +28,8 @@ import sys from contextlib import redirect_stderr, redirect_stdout from typing import Optional +from hermes_cli.fallback_config import get_fallback_chain + def _normalize_toolsets(toolsets: object = None) -> list[str] | None: if not toolsets: @@ -301,14 +303,9 @@ def _run_agent( toolsets_list = sorted(_get_platform_tools(cfg, "cli")) session_db = _create_session_db_for_oneshot() - # Read fallback chain from profile config — supports both the new list - # format (fallback_providers) and the legacy single-dict (fallback_model). - # Mirrors the same normalization in cli.py so oneshot workers (e.g. kanban - # workers spawned via `hermes -p chat -q ...`) honour the - # profile's fallback chain just like interactive sessions do. - _fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or [] - if isinstance(_fb, dict): - _fb = [_fb] if _fb.get("provider") and _fb.get("model") else [] + # Read the effective fallback chain from profile config so oneshot workers + # honour the same merge semantics as interactive CLI and gateway sessions. + _fb = get_fallback_chain(cfg) agent = AIAgent( api_key=runtime.get("api_key"), diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index b05df5220c5..b87325ac4c2 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -102,6 +102,20 @@ class TestVerboseAndToolProgress: assert cli.tool_progress_mode in {"off", "new", "all", "verbose"} +class TestFallbackChainInit: + def test_merges_new_and_legacy_fallback_config(self): + cli = _make_cli(config_overrides={ + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + ], + "fallback_model": {"provider": "nous", "model": "Hermes-4"}, + }) + assert cli._fallback_model == [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4"}, + ] + + class TestBusyInputMode: def test_default_busy_input_mode_is_interrupt(self): cli = _make_cli() diff --git a/tests/gateway/test_auth_fallback.py b/tests/gateway/test_auth_fallback.py index 3edb8b1ee9a..f49b3d6973f 100644 --- a/tests/gateway/test_auth_fallback.py +++ b/tests/gateway/test_auth_fallback.py @@ -71,3 +71,46 @@ class TestResolveRuntimeAgentKwargsAuthFallback: from gateway.run import _resolve_runtime_agent_kwargs with pytest.raises(RuntimeError): _resolve_runtime_agent_kwargs() + + def test_legacy_fallback_is_appended_after_fallback_providers(self, tmp_path, monkeypatch): + """When both keys exist, the legacy entry still participates in resolution.""" + config_path = tmp_path / "config.yaml" + config_path.write_text( + "fallback_providers:\n" + " - provider: openrouter\n" + " model: anthropic/claude-sonnet-4.6\n" + "fallback_model:\n" + " provider: nous\n" + " model: Hermes-4\n" + ) + + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + + calls = [] + + def _mock_resolve(**kwargs): + requested = kwargs.get("requested") + calls.append(requested) + if requested == "openrouter": + raise RuntimeError("openrouter unavailable") + return { + "api_key": "nous-key", + "base_url": "https://portal.nousresearch.com/v1", + "provider": "nous", + "api_mode": "chat_completions", + "command": None, + "args": None, + "credential_pool": None, + } + + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=_mock_resolve, + ): + from gateway.run import _try_resolve_fallback_provider + + result = _try_resolve_fallback_provider() + + assert calls == ["openrouter", "nous"] + assert result["provider"] == "nous" + assert result["model"] == "Hermes-4" diff --git a/tests/hermes_cli/test_fallback_cmd.py b/tests/hermes_cli/test_fallback_cmd.py index a88c84b3aa8..2eed7d62f97 100644 --- a/tests/hermes_cli/test_fallback_cmd.py +++ b/tests/hermes_cli/test_fallback_cmd.py @@ -55,6 +55,31 @@ class TestReadChain: {"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"}, ] + def test_merges_new_and_legacy_formats(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = { + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + ], + "fallback_model": {"provider": "nous", "model": "Hermes-4"}, + } + assert _read_chain(cfg) == [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4"}, + ] + + def test_legacy_duplicate_is_deduplicated_after_merge(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = { + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + ], + "fallback_model": {"provider": "OpenRouter", "model": "anthropic/claude-sonnet-4.6"}, + } + assert _read_chain(cfg) == [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + ] + def test_migrates_legacy_single_dict(self): from hermes_cli.fallback_cmd import _read_chain cfg = {"fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}}