fix(agent): collapse provider aliases in the fallback-chain dedup

`_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
This commit is contained in:
andyst-dev
2026-08-13 14:25:51 +02:00
committed by brooklyn!
parent 7974173708
commit 2fba6e31bd
2 changed files with 51 additions and 1 deletions

View File

@@ -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(),
)

View File

@@ -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