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.
168 lines
7.2 KiB
Python
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
|