Files
hermes-agent/agent/backend_identity.py
T
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00

152 lines
6.3 KiB
Python

"""Single owner for backend identity and failure-scoped skip decisions.
Every fallback / dedup / skip / quarantine decision asks: "is this candidate the same backend
as the one that failed, along the axis that failure invalidated?" Answering inline at each
call site kept reintroducing the same bugs (same-shim aliases treated as distinct, sibling
models skipped for one model's timeout, dedup ignoring ``base_url``). "provider" conflates
three axes — credential surface (401/402), endpoint (DNS/refused), model deployment
(timeout/overload/429). Build :class:`BackendIdentity` values, ask :func:`should_skip_candidate`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Optional
logger = logging.getLogger(__name__)
class FailureScope(Enum):
"""Which identity axis a failure invalidates."""
#: Timeout, overload/429, connection blip, model-incompatible, invalid response:
#: evidence against ONE model deployment only.
MODEL = "model"
#: Auth 401 / payment 402: evidence against the shared credential.
CREDENTIAL = "credential"
#: DNS / connection-refused / unreachable host: evidence against the endpoint.
ENDPOINT = "endpoint"
def _norm(value: Optional[str]) -> str:
return (value or "").strip().lower()
@dataclass(frozen=True)
class BackendIdentity:
"""Normalized identity of one (provider, model, endpoint) deployment.
Empty fields mean "unknown" — an unknown axis can neither prove sameness nor difference
on its own; the remaining axes decide."""
provider: str = ""
model: str = ""
base_url: str = ""
@classmethod
def build(
cls, provider: Optional[str] = None, model: Optional[str] = None,
base_url: Optional[str] = None,
) -> "BackendIdentity":
return cls(
provider=_norm(provider), model=_norm(model), base_url=_norm(base_url).rstrip("/"),
)
def _both_first_class(a: BackendIdentity, b: BackendIdentity) -> bool:
"""True when both providers are distinct registered first-class providers.
Two different registry providers have distinct credential surfaces even when they share an
inference host (xai-oauth vs xai). Custom/shim aliases are NOT in the registry, so two
aliases pointing at one URL still count as the same backend."""
if not a.provider or not b.provider or a.provider == b.provider:
return False
try:
from hermes_cli.auth import PROVIDER_REGISTRY
return a.provider in PROVIDER_REGISTRY and b.provider in PROVIDER_REGISTRY
except Exception:
return False
def same_credential_surface(a: BackendIdentity, b: BackendIdentity) -> bool:
"""Do two identities share the credential a 401/402 just invalidated?
Conservative: an unprovable axis answers "different" (one wasted RTT) rather than "same"
(stranded failover). Same label = same configured credential; custom entries can each carry
their own api_key, so a shared URL alone is only a weak signal when a label is missing."""
if a.provider and b.provider:
# Different labels = different credential config (first-class registry providers explicitly so —
# #70893; custom entries can each carry their own api_key, so sameness is unprovable and we must not
# skip).
return a.provider == b.provider
return bool(a.base_url and a.base_url == b.base_url)
def same_endpoint(a: BackendIdentity, b: BackendIdentity) -> bool:
"""Do two identities sit behind the endpoint that just went unreachable?
An unknown base_url inherits the provider default, so a shared label implies the same endpoint."""
if a.base_url and b.base_url:
return a.base_url == b.base_url
return bool(a.provider and a.provider == b.provider)
def same_deployment(a: BackendIdentity, b: BackendIdentity) -> bool:
"""Are these the exact same model deployment (the thing a timeout kills)?
Provider+model must match; base_url distinguishes only when BOTH sides carry an explicit URL
(same provider+model on two explicit URLs is a pool, not a dup). Different labels with the
same URL + model are still one deployment (same-host shim aliases) — unless both labels are
first-class registry providers."""
if not (a.provider and b.provider and a.provider == b.provider):
return bool(
a.base_url
# Same-host different-label shims: same URL + same model IS the same deployment even when the
# alias labels differ (#22548) — unless both labels are first-class registry providers (#70893).
and a.base_url == b.base_url
and a.model
and a.model == b.model
and not _both_first_class(a, b)
)
if not (a.model and b.model and a.model == b.model):
return False
return not (a.base_url and b.base_url and a.base_url != b.base_url)
_SCOPE_PREDICATES = {
FailureScope.CREDENTIAL: same_credential_surface, FailureScope.ENDPOINT: same_endpoint,
FailureScope.MODEL: same_deployment,
}
def should_skip_candidate(
candidate: BackendIdentity, failed: BackendIdentity, scope: FailureScope = FailureScope.MODEL
) -> bool:
"""THE skip predicate: would trying ``candidate`` just repeat the failure?
True when it is the same backend as ``failed`` along the axis ``scope`` invalidated.
Every fallback/dedup/skip site must call this."""
return _SCOPE_PREDICATES.get(scope, same_deployment)(candidate, failed)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
_REASON_SCOPES = {
"auth error": FailureScope.CREDENTIAL,
"payment error": FailureScope.CREDENTIAL,
"rate limit": FailureScope.MODEL,
"model incompatible with route": FailureScope.MODEL,
"invalid provider response": FailureScope.MODEL,
"connection error": FailureScope.MODEL,
"timeout": FailureScope.MODEL,
}
def classify_failure_scope(reason: Optional[str]) -> FailureScope:
"""Map a human-readable failure reason to the identity axis it kills."""
return _REASON_SCOPES.get((reason or "").strip().lower(), FailureScope.MODEL)
# ---- END PLUGIN-COMPAT ----