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:
@@ -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(),
|
||||
)
|
||||
|
||||
35
tests/hermes_cli/test_fallback_chain_alias_dedup.py
Normal file
35
tests/hermes_cli/test_fallback_chain_alias_dedup.py
Normal 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
|
||||
Reference in New Issue
Block a user