Files
hermes-agent/providers/base.py
brooklyn! 296ec08dcf fix(models): keep generation models out of chat
Chat catalogs and the session switch treated image and video generation
models as chat. Exclude them by the capability type and name shape the
catalog already publishes, reject selecting one as the session model, and
do not restore a primary already known to be non-chat. Desktop shows the
fallback switch in the transcript.
2026-09-25 12:07:32 -05:00

418 lines
20 KiB
Python

"""Provider profile base class.
A ProviderProfile declares everything about an inference provider in one place:
auth, endpoints, client quirks, request-time quirks. The transport reads this
instead of receiving 20+ boolean flags.
Provider profiles are DECLARATIVE — they describe the provider's behavior.
They do NOT own client construction, credential rotation, or streaming.
Those stay on AIAgent.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING:
from agent.account_usage import AccountUsageSnapshot
logger = logging.getLogger(__name__)
# Sentinel for "omit temperature entirely" (Kimi: server manages it)
OMIT_TEMPERATURE = object()
def _profile_user_agent() -> str:
"""Return a ``hermes-cli/<version>`` UA string, with a stable fallback.
Used by ``ProviderProfile.fetch_models`` so the catalog probe is not
served the default ``Python-urllib/<ver>`` UA — some providers
(OpenCode Zen, etc.) sit behind a WAF that returns 403 for that.
"""
try:
from hermes_cli.version_info import get_version_info # lazy: avoid layer cycle at import time
return f"hermes-cli/{get_version_info().base_version}"
except Exception:
return "hermes-cli"
@dataclass
class ProviderProfile:
"""Base provider profile — subclass or instantiate with overrides."""
# ── Identity ─────────────────────────────────────────────
name: str
api_mode: str = "chat_completions"
aliases: tuple = ()
# ── Human-readable metadata ───────────────────────────────
display_name: str = "" # e.g. "GMI Cloud" — shown in picker/labels
description: str = "" # e.g. "GMI Cloud (multi-model direct API)" — picker subtitle
signup_url: str = "" # e.g. "https://www.gmicloud.ai/" — shown during setup
# ── Auth & endpoints ─────────────────────────────────────
env_vars: tuple = ()
base_url: str = ""
models_url: str = "" # explicit models endpoint; falls back to {base_url}/models
auth_type: str = "api_key" # api_key|oauth_device_code|oauth_external|copilot|aws_sdk
supports_health_check: bool = True # False → doctor skips /models probe for this provider
# False → fetch_models returns None without a network call (catalog comes from an SDK/subprocess).
supports_model_listing: bool = True
# ── Provider-owned auth (optional; non-api-key plugins) ──────────
# ``auth_handler(action, args) -> bool``: ``hermes auth add|status|logout|refresh <name>`` calls it
# FIRST with the parsed CLI namespace; truthy = the plugin owned the action, falsy = built-in path.
# ``refresh_credential(entry) -> Mapping | None``: the credential pool's refresh of a pooled OAuth
# row — return the rotated fields (``access_token``, ``refresh_token``, ``expires_at_ms`` …) or raise.
# Both own their own token endpoints; Hermes passes no secrets beyond the pooled row itself.
# ``classify_api_error(error, *, status_code, error_code, message, body, model) -> Mapping | None``:
# consulted by ``agent.error_classifier.classify_api_error`` for THIS provider's failures only, after
# the generic ``transform_api_error_classification`` plugin hooks and before the built-in pipeline.
# Return ``{"reason": <FailoverReason name>, ...hint flags}`` to override, ``None`` to decline.
auth_handler: Callable[[str, Any], Any] | None = None
refresh_credential: Callable[[Any], Any] | None = None
classify_api_error: Callable[..., Any] | None = None
# ── Vision support ────────────────────────────────────────
# True when the provider's API accepts image content inside
# tool-result messages natively. Set on providers that expose
# multimodal models via tool results (Anthropic Messages API,
# OpenAI Chat Completions, Gemini, MiniMax, etc.).
# Falls back to model-catalog lookup when False and the provider
# has no registered profile.
supports_vision: bool = False
# True when the provider's API accepts list-type tool message
# content (multipart with image_url parts). Defaults to True for
# backward compatibility. Set to False for providers that accept
# multimodal user messages but reject list-type tool content
# (e.g. Xiaomi MiMo, which returns 400 "text is not set").
supports_vision_tool_messages: bool = True
# True only when this provider's Chat Completions endpoint explicitly
# documents ``prompt_cache_key`` as an accepted request body field. This
# is deliberately opt-in: many OpenAI-compatible endpoints reject unknown
# top-level fields rather than ignoring them.
supports_prompt_cache_key: bool = False
# Private replay carriers use a namespaced '<provider>.native_assistant' type
# in reasoning_details. Only this profile may receive its matching carrier;
# other providers (including an unregistered fallback) get ordinary details only.
native_reasoning_details_type: str | None = None
# ── External-process providers (auth_type="external_process") ──
# An agent CLI driven over stdio (ACP) rather than an HTTP endpoint. These
# describe how to launch it; hermes_cli/auth.py's
# resolve_external_process_provider_credentials() reads them instead of
# hardcoding one vendor's binary. Env vars are checked in order and win
# over the static defaults, so an operator can point at a custom build.
process_command: str = "" # default binary, e.g. "copilot"
process_args: tuple = () # default argv tail, e.g. ("--acp", "--stdio")
process_command_env_vars: tuple = () # env overrides for the binary, in priority order
process_args_env_var: str = "" # env override for argv (shlex-split)
# ── Model catalog ─────────────────────────────────────────
# fallback_models: curated list shown in /model picker when live fetch fails.
# Only agentic models that support tool calling should appear here.
fallback_models: tuple = ()
# model_aliases: short name -> id in fallback_models, for providers whose catalog is not
# in models.dev (external processes); `/model <alias>` resolves here before core guessing.
model_aliases: dict = field(default_factory=dict)
# hostname: base hostname for URL→provider reverse-mapping in model_metadata.py
# e.g. "api.gmi-serving.com". Derived from base_url when empty.
hostname: str = ""
# ── Client-level quirks (set once at client construction) ─
default_headers: dict[str, str] = field(default_factory=dict)
# ── Request-level quirks ─────────────────────────────────
# Temperature: None = use caller's default, OMIT_TEMPERATURE = don't send
fixed_temperature: Any = None
default_max_tokens: int | None = None
# ``response_format`` types the API rejects outright (e.g. ("json_schema",)); aux requests omit them up front.
unsupported_response_formats: tuple = ()
default_aux_model: str = (
"" # cheap model for auxiliary tasks (compression, vision, etc.)
)
# empty = use main model
# Per-model metadata in the canonical model_overrides schema. Partial entries
# patch catalog metadata; explicit user overrides still win. Exact model IDs.
model_capabilities: dict[str, dict[str, Any]] = field(default_factory=dict)
# ── Hooks (override in subclass for complex providers) ───
def fetch_account_usage(
self, *, base_url: str | None = None, api_key: str | None = None
) -> AccountUsageSnapshot | None:
"""Return an account-usage snapshot for this provider, if available.
The ``/usage`` command invokes this only when no built-in account
usage fetcher owns the provider. Implementations may make their
provider-specific request and must return an ``AccountUsageSnapshot``
or ``None``; exceptions fail open at the dispatch boundary.
"""
return None
def resolve_aux_model(self, *, vision: bool = False) -> str:
"""Return a LIVE cheap-model id for auxiliary tasks, or "".
``default_aux_model`` is a hardcoded id in source, so it rots: when the
provider retires that model every auxiliary call spends a round-trip
404ing before the retry net catches it. Providers that publish a
machine-readable recommendation should override this and query it, so
the cheap tier tracks the upstream catalog instead of a constant a human
has to remember to bump.
Contract: cheap to call (implementations must cache — this runs on
client-resolution paths), never raises, and returns "" when it has no
answer so the caller falls through to ``default_aux_model``.
"""
return ""
def get_hostname(self) -> str:
"""Return the provider's base hostname for URL-based detection.
Uses self.hostname if set explicitly, otherwise derives it from base_url.
e.g. 'https://api.gmi-serving.com/v1' → 'api.gmi-serving.com'
"""
if self.hostname:
return self.hostname
if self.base_url:
from urllib.parse import urlparse
return urlparse(self.base_url).hostname or ""
return ""
def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Provider-specific message preprocessing.
Called AFTER codex field sanitization, BEFORE developer role swap.
Default: pass-through.
"""
return messages
def build_extra_body(
self, *, session_id: str | None = None, **context: Any
) -> dict[str, Any]:
"""Provider-specific extra_body fields.
Merged into the API kwargs extra_body. Default: empty dict.
"""
return {}
def default_reasoning_config(self, model: str | None = None) -> dict | None:
"""Reasoning config the main loop sends when ``agent.reasoning_effort`` is unset.
None (default) hands the unset state to ``build_api_kwargs_extras`` as ``reasoning_config=None``,
where each profile already decides (Nous/OpenRouter fill medium; Anthropic omits). A profile
that would otherwise leave the route's own default in charge returns the config here so the
agent records it as what went on the wire (the reasoning-rejection ladder reads that).
"""
return None
def build_api_kwargs_extras(
self,
*,
reasoning_config: dict | None = None,
**context: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Provider-specific kwargs split between extra_body and top-level api_kwargs.
Returns (extra_body_additions, top_level_kwargs).
The transport merges extra_body_additions into extra_body, and
top_level_kwargs directly into api_kwargs.
This split exists because some providers put reasoning config in
extra_body (OpenRouter: extra_body.reasoning) while others put it
as top-level api_kwargs (Kimi: api_kwargs.reasoning_effort).
Default: ({}, {}).
"""
return {}, {}
def build_client_kwargs_extras(self, **context: Any) -> dict[str, Any]:
"""Provider-specific OpenAI client keyword arguments.
Values are defaults: explicit runtime/custom-provider settings win.
The returned mapping must be cheap to build and must not perform I/O.
"""
return {}
def default_vision_model(self) -> str | None:
"""Return a default vision model id for this provider, or None.
Overrideable hook for providers that discover their vision default at
runtime (e.g. from a live catalog) rather than pinning one in code.
Keeps provider-specific vision discovery inside the provider's plugin
instead of a name-check branch in shared vision resolution.
Default: None (no provider-specific vision model — the caller falls
back to the user's chat model or the aggregator chain).
"""
return None
def get_model_context_length(self, model: str) -> int | None:
"""Provider-qualified context bound; explicit user overrides take precedence."""
return None
def get_usage_cost(self, model: str, usage: Any) -> Any | None:
"""Optional CostResult from canonical usage; distinguish estimates from invoices."""
return None
def get_max_tokens(self, model: str | None) -> int | None:
"""Return the default max_tokens cap for *model*.
Overrideable hook for providers that need per-model output caps —
e.g. a relay that fronts several upstream backends, each with a
different completion-token limit. The transport calls this when
the user hasn't set an explicit max_tokens.
Default: return self.default_max_tokens (the static profile field),
ignoring the model name. Override in a subclass to vary the cap
per-model.
"""
return self.default_max_tokens
def supported_reasoning_efforts(
self, model: str | None
) -> tuple[str, ...] | None:
"""Declared reasoning-effort vocabulary for *model* on this provider.
Overrideable hook for providers whose gateway validates
``reasoning.effort`` per model instead of ignoring or clamping
unknown levels server-side (Ramp Router derives this from its live
``/v1/models`` catalog). The Responses transport consults it before
falling back to its built-in per-backend vocabularies; it is the
profile-declared analog of the OpenRouter catalog clamp on the
chat-completions path (``openrouter_model_reasoning_capabilities``).
Tri-state contract:
- ``None`` — unknown/undeclared: the transport keeps its default
vocabulary for the wire (this base implementation).
- ``()`` — the model accepts NO reasoning parameters at all; the
transport must omit reasoning fields entirely (some gateways
return HTTP 400 rather than ignoring them).
- non-empty tuple — clamp the requested effort onto these levels
(``agent.reasoning_effort.clamp_effort`` semantics: nearest
weaker supported level, never escalate).
Implementations are called on the per-request hot path and must not
block on network I/O — answer from a cache and return None while
cold.
"""
return None
def create_client(self, **client_kwargs: Any) -> Any | None:
"""Return a provider-specific client, or ``None`` for the standard one.
Most providers speak OpenAI-compatible HTTP and want the shared
``openai.OpenAI`` client the core builds — they inherit this and return
``None``. A provider whose wire protocol is not HTTP at all (the ACP
subprocess shims) or which needs a native SDK overrides this and
returns its own client object.
``client_kwargs`` is the same mapping the core would have passed to
``openai.OpenAI`` (``api_key``, ``base_url``, ``command``, ``args``,
timeouts, headers…). Unknown keys must be tolerated: the core adds to
this mapping over time, so an override should accept ``**kwargs`` and
pick what it needs rather than enumerate.
Returning ``None`` (the default) is always safe — the caller falls
through to its existing construction path.
This is the hook that lets a provider ship *outside* this tree: with it,
a profile registered from ``~/.hermes/plugins/model-providers/`` or a
pip entry point can supply its own transport without any core edit. See
``plugins/model-providers/copilot-acp/`` for the in-tree example.
"""
return None
def setup_status(self, **kwargs: Any) -> dict[str, Any] | None:
"""External-process providers: ``{available, logged_in, plan, detail, login_command}`` from the
CLI itself so setup can gate on login. ``None`` = nothing to report beyond executable presence."""
return None
def discover_models(self, **kwargs: Any) -> list[dict[str, Any]] | None:
"""External-process providers: the account's live picker as ``[{id, label, note}]`` without
any inference request; ``None`` falls back to ``fallback_models``."""
return None
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
"""Fetch the live model list from the provider's models endpoint.
Returns a list of model ID strings, or None if the fetch failed or
the provider does not support live model listing.
Resolution order for the endpoint URL:
1. base_url + "/models", but ONLY when the caller passed a base_url
that differs from this profile's default (a user-configured
model.base_url pointing at a proxy/custom endpoint). Callers
pass base_url unconditionally — falling back to the profile
default when the user configured nothing — so equality with
self.base_url means "not customised" and must not shadow
models_url.
2. self.models_url (explicit override — use when the models
endpoint differs from the inference base URL, e.g. OpenRouter
exposes a public catalog at /api/v1/models while inference is
at /api/v1)
3. self.base_url + "/models" (standard OpenAI-compat fallback)
The default implementation sends Bearer auth when api_key is given
and forwards self.default_headers. Override to customise auth, path,
response shape, or to return None for providers with no REST catalog.
Callers must always fall back to the static _PROVIDER_MODELS list
when this returns None.
"""
if not self.supports_model_listing:
return None
caller_base = (base_url or "").strip()
effective_base = caller_base or self.base_url
custom_base = bool(caller_base) and (
caller_base.rstrip("/") != (self.base_url or "").rstrip("/")
)
if custom_base:
url = caller_base.rstrip("/") + "/models"
else:
url = (self.models_url or "").strip()
if not url:
if not effective_base:
return None
url = effective_base.rstrip("/") + "/models"
import json
import urllib.request
from hermes_cli.urllib_security import open_credentialed_url
req = urllib.request.Request(url)
if api_key:
req.add_header("Authorization", f"Bearer {api_key}")
req.add_header("Accept", "application/json")
# Some providers (e.g. OpenCode Zen) sit behind a WAF that blocks
# the default ``Python-urllib/<ver>`` User-Agent. Set a generic
# hermes-cli UA so the catalog endpoint is reachable.
req.add_header("User-Agent", _profile_user_agent())
for k, v in self.default_headers.items():
req.add_header(k, v)
try:
with open_credentialed_url(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode())
items = data if isinstance(data, list) else data.get("data", [])
from hermes_cli.chat_catalog import chat_catalog_ids
return chat_catalog_ids(items)
except Exception as exc:
logger.debug("fetch_models(%s): %s", self.name, exc)
return None