Files
hermes-agent/hermes_cli/fallback_config.py
andyst-dev 4578259ddc fix(fallback): log when provider-alias collapse degrades to lowercase (reviewer)
The lazy import in _normalize_provider_id is wrapped in a broad except that
silently returns value.lower(), disabling alias collapse with no signal. A
future import cycle/refactor could regress the same-backend dedup invisibly.
Now logs at WARNING (with exc_info) when the fallback path is taken, so the
degradation is visible.
2026-10-06 15:00:21 -05:00

168 lines
7.2 KiB
Python

"""Helpers for reading the effective fallback provider chain from config."""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
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
# Silent degradation: alias collapse is disabled and we fall back to a
# bare lowercase. Log so a future import cycle/refactor can't regress
# the dedup invisibly.
logger.warning(
"fallback provider alias collapse degraded to lowercase: "
"could not import hermes_cli.providers.normalize_provider",
exc_info=True,
)
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``.
Mirrors the custom-provider convention (``api_key_env`` accepted as alias); None when neither
yields a value so ``resolve_runtime_provider`` falls through to standard credential resolution.
``key_env`` goes through ``agent.secret_scope.get_secret``, not raw ``os.getenv``: in a
multiplexed gateway a bare env read ignores the active profile's scope and can return another
profile's credential.
"""
if not isinstance(entry, dict):
return None
if inline := str(entry.get("api_key") or "").strip():
return inline
if key_env := str(entry.get("key_env") or entry.get("api_key_env") or "").strip():
from agent.secret_scope import get_secret
return (get_secret(key_env) or "").strip() or None
return None
def effective_runtime_provider(
entry: dict[str, Any] | None, runtime: dict[str, Any] | None
) -> str:
"""Provider identity to persist/display for a resolved fallback entry.
``resolve_runtime_provider`` returns the bare billing class ``"custom"``
for every named ``providers:`` / ``custom_providers:`` entry; the entry's
configured id only survives in ``requested_provider``. Fallback resolvers
that persist ``runtime["provider"]`` as the agent identity therefore label
sessions/billing rows ``custom`` instead of the configured provider name —
while the manual ``/model`` switch path correctly persists the named id
(#98739). Same class as the delegation fix in ``tools/delegate_tool.py``.
Returns the entry's requested identity when the resolved provider is the
bare ``custom`` class; a genuinely ad-hoc endpoint (requested provider IS
``custom``) keeps the bare class unchanged.
"""
runtime = runtime or {}
resolved = str(runtime.get("provider") or "").strip()
if resolved.lower() != "custom":
return resolved
requested = str(
runtime.get("requested_provider")
or (entry or {}).get("provider")
or ""
).strip()
if requested and requested.lower() != "custom":
return requested
return resolved
def pre_agent_fallback_notice(
primary_provider: Any, primary_model: Any, fallback_provider: Any, fallback_model: Any
) -> str:
"""User-visible one-shot line for a provider switch made during credential resolution, before
any AIAgent exists (#74349). Shared by the messaging gateway, the TUI/Desktop gateway and cron
so the three pre-agent fallback paths cannot drift in wording."""
primary_desc = "/".join(str(p).strip() for p in (primary_provider, primary_model) if p) or "primary"
fallback_desc = "/".join(str(p).strip() for p in (fallback_provider, fallback_model) if p) or "fallback"
return f"⚠️ Provider fallback: {primary_desc} unavailable; using {fallback_desc} for this response."
def _iter_fallback_entries(raw: Any) -> list[dict[str, Any]]:
candidates = [raw] if isinstance(raw, dict) else raw if isinstance(raw, list) else []
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 = {**entry, "provider": provider, "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 (
_normalize_provider_id(str(entry.get("provider") or "").strip()),
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 not in seen:
seen.add(identity)
chain.append(entry)
return chain
def scoped_fallback_chain(
inherited: list[dict[str, Any]] | None, declared: Any, *, pinned: bool, owner: str,
) -> list[dict[str, Any]] | None:
"""Fallback chain for a route owner that can pin its own primary (delegated child, cron job).
A pinned owner (explicit provider, endpoint or model) never borrows the *inherited* chain: the
operator chose that route, and a chain entry is a different provider and usually a different
model. Predictability beats liveness for an explicit pin. An unpinned owner inherits the chain
when *declared* is absent/None. An explicit ``[]`` disables fallback either way; any other
*declared* value is the owner's own chain, normalized by :func:`get_fallback_chain` (malformed
entries are dropped; nothing usable left falls back to the pinned/inherited default).
"""
default = None if pinned else (inherited or None)
if declared is None:
return default
if declared == []:
return None
normalized = get_fallback_chain({"fallback_providers": declared})
if not normalized:
logger.warning("%s fallback_providers has no usable routes; using the %s default",
owner, "pinned" if pinned else "inherited")
return normalized or default