diff --git a/hermes_cli/fallback_config.py b/hermes_cli/fallback_config.py index afa368391a..c66cae5428 100644 --- a/hermes_cli/fallback_config.py +++ b/hermes_cli/fallback_config.py @@ -12,6 +12,21 @@ def _normalized_base_url(value: Any) -> str: return value.strip().rstrip("/") if isinstance(value, str) else "" +def _normalize_provider_id(value: str) -> str: + """Collapse provider aliases to the canonical id (lazy to avoid import cycles). + + ``opencode-zen`` is the legacy pre-rename id of ``opencode``; ``zen`` is a + short alias. Without normalization, two fallback entries naming the same + backend under different ids are kept as separate slots, shifting the chain + and letting an alias slip past the same-backend skip (#85235). + """ + try: + from hermes_cli.providers import normalize_provider + except Exception: # pragma: no cover - import resilience + return value.lower() + return normalize_provider(value) + + def resolve_entry_api_key(entry: dict[str, Any] | None) -> str | None: """API key for one fallback entry: inline ``api_key``, else ``key_env``. @@ -94,7 +109,7 @@ def _iter_fallback_entries(raw: Any) -> list[dict[str, Any]]: def _entry_identity(entry: dict[str, Any]) -> tuple[str, str, str]: return ( - str(entry.get("provider") or "").strip().lower(), + _normalize_provider_id(str(entry.get("provider") or "").strip()), str(entry.get("model") or "").strip().lower(), _normalized_base_url(entry.get("base_url")).lower(), ) diff --git a/tests/hermes_cli/test_fallback_chain_alias_dedup.py b/tests/hermes_cli/test_fallback_chain_alias_dedup.py new file mode 100644 index 0000000000..ff72af2765 --- /dev/null +++ b/tests/hermes_cli/test_fallback_chain_alias_dedup.py @@ -0,0 +1,35 @@ +"""Test that fallback-chain identity dedup normalizes provider aliases. + +A provider can appear under two ids that are the same backend: the canonical +models.dev id (`opencode`) and a rename alias (`opencode-zen`, the legacy +pre-rename name). `_entry_identity` must collapse these to one identity so the +fallback chain does not keep two routes to the same backend (which shifts the +chain by one slot and lets an alias slip past the same-backend skip). +""" + +from hermes_cli.fallback_config import get_fallback_chain + + +def _entry(provider, model, base_url=""): + entry = {"provider": provider, "model": model} + if base_url: + entry["base_url"] = base_url + return entry + + +def test_fallback_chain_dedups_canonical_id_and_alias(): + config = { + "fallback_providers": [ + _entry("opencode", "deepseek-v4-flash-free"), + # ``opencode-zen`` is the legacy pre-rename name of ``opencode`` + # (same backend). It must not add a second slot in the chain. + _entry("opencode-zen", "deepseek-v4-flash-free"), + _entry("anthropic", "claude-sonnet-4"), + ] + } + + chain = get_fallback_chain(config) + + # The two opencode ids describe the same backend; expect a single slot. + providers = [e["provider"] for e in chain] + assert providers == ["opencode", "anthropic"], providers