mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Runtime resolution caught a raising api_mode_for_model hook and kept the profile's declared api_mode, but /model (model_derived_api_mode, also the resume re-derivation in the TUI, CLI and gateway), the stale codex_responses check in resolution, the agent's Responses routing check and the auxiliary client asked the same hook unguarded: a model could resolve and then fail building the switch result. agent.provider_route_hooks.model_api_mode is now the one call: a raise answers the profile's declared api_mode, logged with its traceback once per provider. A None answer keeps each caller's existing meaning.
167 lines
7.8 KiB
Python
167 lines
7.8 KiB
Python
"""Ask provider profiles about an endpoint: the named provider first, then the profile serving the host.
|
|
|
|
Endpoint-keyed ProviderProfile hooks (``client_headers``, ``request_headers``, ``max_tokens_param``,
|
|
``refresh_runtime_credentials``) belong to the provider by name AND to the backend by host: a
|
|
``custom`` route pointed at a plugin provider's host still gets that provider's quirks. The host
|
|
side is ``providers.profile_for_host`` (the profile's declared ``hostnames``), core's one
|
|
endpoint->profile lookup. Only profiles that override the hook are consulted: the base
|
|
implementations are no-ops, and hosted (``plugins.isolation: host``) proxies define only the hooks
|
|
their plugin overrides.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import logging
|
|
from typing import Any
|
|
|
|
from providers.base import ProviderProfile
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Per-request hooks documented pure in providers/base.py: one answer per argument set, so a hosted
|
|
# plugin pays one IPC round trip per distinct request shape instead of one per API call.
|
|
_PURE_HOOKS = frozenset({"request_headers", "max_tokens_param", "reasoning_payload",
|
|
"replays_function_thought_signature"})
|
|
_PURE_MEMO: dict[tuple, Any] = {}
|
|
_PURE_MEMO_MAX = 512
|
|
_MISSING = object()
|
|
# (profile name, hook) pairs whose failure a scopeless (one-shot) caller already logged: one warning per
|
|
# pair per process there; a scoped caller (an agent) warns once per pair per session.
|
|
_WARNED: set[tuple] = set()
|
|
|
|
|
|
def _overrides(profile: Any, hook: str) -> bool:
|
|
return getattr(type(profile), hook, None) is not getattr(ProviderProfile, hook)
|
|
|
|
|
|
def route_profiles(hook: str, provider: str = "", base_url: str = "") -> list[Any]:
|
|
"""Profiles overriding *hook*: the one registered for *provider*, then the one serving *base_url*'s host."""
|
|
from providers import get_provider_profile, profile_for_host
|
|
|
|
named = get_provider_profile(provider) if provider else None
|
|
ordered = [named] if named is not None and _overrides(named, hook) else []
|
|
hosted = profile_for_host(base_url) if base_url else None
|
|
if hosted is not None and hosted is not named and _overrides(hosted, hook):
|
|
ordered.append(hosted)
|
|
return ordered
|
|
|
|
|
|
def _freeze(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return tuple(sorted((str(k), _freeze(v)) for k, v in value.items()))
|
|
if isinstance(value, (list, tuple)):
|
|
return tuple(_freeze(v) for v in value)
|
|
return value
|
|
|
|
|
|
def _ask(profile: Any, hook: str, args: tuple, kwargs: dict[str, Any]) -> Any:
|
|
if hook not in _PURE_HOOKS:
|
|
return getattr(profile, hook)(*args, **kwargs)
|
|
from providers import registry_generation
|
|
|
|
key = (registry_generation(), id(profile), type(profile), hook, _freeze(args), _freeze(kwargs))
|
|
try:
|
|
hash(key)
|
|
except TypeError: # an argument with no stable value (e.g. a set): ask every time
|
|
return getattr(profile, hook)(*args, **kwargs)
|
|
# One read into a local: another thread may clear() the memo between a check and a re-read.
|
|
answer = _PURE_MEMO.get(key, _MISSING)
|
|
if answer is _MISSING:
|
|
answer = getattr(profile, hook)(*args, **kwargs)
|
|
if len(_PURE_MEMO) >= _PURE_MEMO_MAX:
|
|
_PURE_MEMO.clear()
|
|
_PURE_MEMO[key] = answer
|
|
return copy.deepcopy(answer) if isinstance(answer, (dict, list)) else answer
|
|
|
|
|
|
def _failures(scope: Any) -> set[tuple]:
|
|
if scope is None: # one-shot caller (auxiliary request): no later turn to keep stable
|
|
return set()
|
|
failed = getattr(scope, "_provider_hook_failures", None)
|
|
if failed is None:
|
|
failed = set()
|
|
scope._provider_hook_failures = failed
|
|
return failed
|
|
|
|
|
|
def _sticky_key(profile: Any, hook: str) -> tuple:
|
|
return (id(profile), type(profile), getattr(profile, "name", "?"), hook)
|
|
|
|
|
|
def hook_failed(profile: Any, hook: str, scope: Any = None) -> bool:
|
|
"""True once *profile*'s *hook* failed in *scope* (``ask_profile`` then answers the fallback)."""
|
|
return _sticky_key(profile, hook) in _failures(scope)
|
|
|
|
|
|
def ask_profile(profile: Any, hook: str, fallback: Any, *args: Any, scope: Any = None, **kwargs: Any) -> Any:
|
|
"""*profile*'s answer to the per-request *hook* (documented-pure hooks are memoized).
|
|
|
|
ONE failure policy for every per-request profile hook: a failure answers *fallback* (the generic
|
|
answer) and *scope* (the agent) keeps answering it without asking again until its session resets, so
|
|
a session's request bytes never flip between turns after a plugin bug or a lost plugin host. It is
|
|
logged once per (profile, hook) per session, so each served profile's agent reports its own
|
|
degradation. ``scope=None`` (one-shot auxiliary calls) asks again next call, warning once per process.
|
|
"""
|
|
failed = _failures(scope)
|
|
sticky = _sticky_key(profile, hook)
|
|
if sticky in failed:
|
|
return fallback
|
|
try:
|
|
return _ask(profile, hook, args, kwargs)
|
|
except Exception as exc: # plugin code: a broken hook degrades to generic behaviour, never fails the request
|
|
failed.add(sticky)
|
|
if scope is not None or sticky[2:] not in _WARNED:
|
|
_WARNED.add(sticky[2:])
|
|
logger.warning("provider %s: %s hook failed (%s: %s); using the generic answer for the rest of "
|
|
"the session", sticky[2], hook, type(exc).__name__, exc, exc_info=True)
|
|
return fallback
|
|
|
|
|
|
def ask_on_error_path(profile: Any, hook: str, fallback: Any, **kwargs: Any) -> Any:
|
|
"""*profile*'s answer to an API-error-path *hook* (credential refresh, stale-credential test, refresh
|
|
notice), asked every time: a lost plugin host may be back for the next error. A raise answers
|
|
*fallback* ("not refreshed"), logged with its traceback once per (provider, hook) per process, so the
|
|
original API error still reaches the fallback walk instead of escaping its handler."""
|
|
try:
|
|
return getattr(profile, hook)(**kwargs)
|
|
except Exception: # health: allow BLE001 -- plugin hook boundary; logged with traceback once below
|
|
key = (getattr(profile, "name", "?"), hook)
|
|
if key not in _ERROR_PATH_WARNED:
|
|
_ERROR_PATH_WARNED.add(key)
|
|
logger.warning("provider %s: %s hook failed; handling the API error without it", key[0], hook,
|
|
exc_info=True)
|
|
return fallback
|
|
|
|
|
|
_ERROR_PATH_WARNED: set[tuple] = set()
|
|
|
|
|
|
def model_api_mode(profile: Any, model: str, *, api_key: str | None = None) -> str | None:
|
|
"""*profile*'s ``api_mode_for_model`` answer for *model* (None = no per-model wire). The ONE guard for
|
|
resolution, ``/model``, resume and the agent: a raising hook (a live catalog fetch, a lost plugin host)
|
|
answers the profile's declared ``api_mode``, logged with its traceback once per provider per process,
|
|
so a model pick never fails where runtime resolution succeeded."""
|
|
try:
|
|
return profile.api_mode_for_model(model, api_key=api_key)
|
|
except Exception: # health: allow BLE001 -- plugin hook boundary; logged with traceback once below
|
|
name = getattr(profile, "name", "?")
|
|
if name not in _API_MODE_WARNED:
|
|
_API_MODE_WARNED.add(name)
|
|
logger.warning("provider %s: api_mode_for_model failed; using its declared api_mode", name,
|
|
exc_info=True)
|
|
return profile.api_mode or "chat_completions"
|
|
|
|
|
|
_API_MODE_WARNED: set[str] = set()
|
|
|
|
|
|
def route_answer(hook: str, *, provider: str = "", scope: Any = None, **kwargs: Any) -> Any:
|
|
"""First truthy answer of *hook* across ``route_profiles``; None when no profile claims the endpoint.
|
|
Failures follow ``ask_profile``'s policy (*scope* = the agent, when there is one)."""
|
|
for profile in route_profiles(hook, provider, str(kwargs.get("base_url") or "")):
|
|
answer = ask_profile(profile, hook, None, scope=scope, **kwargs)
|
|
if answer:
|
|
return answer
|
|
return None
|