From 2fba6e31bda558459e48fe6914fa80c7d681ca8a Mon Sep 17 00:00:00 2001 From: andyst-dev <150129844+andyst-dev@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:25:51 +0200 Subject: [PATCH] fix(agent): collapse provider aliases in the fallback-chain dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_entry_identity` compared the fallback entry's provider id as a raw, lowercased string. A provider reachable under two ids — the canonical models.dev id (`opencode`) and a legacy rename alias (`opencode-zen`) — was therefore kept as two separate slots in the fallback chain. That shifts the chain by one slot and lets an alias slip past the same-backend skip, so a turn can hit the alias slot, recover from a re-seeded rate-limit pool against it, and exhaust retries without ever reaching the real paid fallback. Normalize the provider id (lazy `normalize_provider` import to avoid cycles) before computing the entry identity, so aliases collapse onto the canonical id for dedup. The returned chain still carries each entry's original provider id — only the identity used for dedup is normalized. Fixes #85235 --- hermes_cli/fallback_config.py | 17 ++++++++- .../test_fallback_chain_alias_dedup.py | 35 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_fallback_chain_alias_dedup.py 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