* refactor(fallback): share the pinned-owner chain rule
delegate_task's _resolve_child_fallback_chain decides which fallback chain
a child may walk: a pinned child never borrows the parent chain, an explicit
[] disables fallback, a declared list is the child's own. Cron needs the
same rule for pinned jobs (#100437), so the body moves to
hermes_cli.fallback_config.scoped_fallback_chain and the delegation helper
becomes a thin caller. Behaviour is unchanged; the delegation matrix test
still pins every cell.
* fix(cron): a pinned job never falls back to the global chain
A job with its own provider, model or base_url is an explicit operator pin
(since 0469740ab3 unpinned jobs store none of these). It still walked the
global fallback_providers chain in two places, so a pinned job could run
on a different provider and model than the one chosen:
- _resolve_job_runtime walked the chain on an AuthError or transient
network failure while resolving the pinned primary;
- _resolve_cron_agent_setup handed the global chain to every cron agent as
fallback_model, so the conversation loop's provider ladder could swap a
pinned job mid-run.
Both now read _job_fallback_chain(job, cfg), which returns no chain for a
pinned job through the same scoped_fallback_chain rule delegate_task uses
for pinned children. The pre-dispatch key check reads it too: the global
chain used to skip that check for every job, so a pinned job with a
missing key now blocks before the agent is built instead of failing in the
resolver. The transient-failure notice for a pinned job says it does not
fall back and names --unpin, instead of "No backup provider succeeded".
Unpinned jobs (including legacy *_snapshot records) and same-provider
credential-pool rotation are unchanged. The two scheduler tests that
asserted atomic provider+model fallback swaps used pinned jobs; they now
use unpinned jobs and keep the same assertions.
No per-job fallback_providers list: jobs have no generic override field
(create_job/update_job, the cronjob tool schema and the CLI enumerate each
field), so an opt-in chain would be a new surface on all of them. The
escape hatch is to leave the job unpinned and pick its model with
cron.model / cron.model_provider.
Co-authored-by: 686f6c61 <6115107+686f6c61@users.noreply.github.com>
* docs(cron): pinned jobs do not use fallback_providers
cron.md "Provider recovery" and the pre-dispatch key check, the cron rows
and section in fallback-providers.md, and the developer notes in
cron-internals.md / provider-runtime.md said every cron job inherits the
global chain. State the new rule, the compatibility note for users who
relied on a pinned job landing on the chain, and the unpinned + cron.model
alternative.
---------
Co-authored-by: 686f6c61 <6115107+686f6c61@users.noreply.github.com>
145 lines
6.2 KiB
Python
145 lines
6.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 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 (
|
|
str(entry.get("provider") or "").strip().lower(),
|
|
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
|