mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
Introduces providers/ as the single source of truth for every inference
provider. All 29 providers declared with correct data cross-checked against
auth.py, runtime_provider.py and auxiliary_client.py.
Providers covered:
chat_completions: openrouter, nous, kimi-coding, kimi-coding-cn, qwen-oauth,
nvidia, deepseek, zai, stepfun, arcee, huggingface, xiaomi, ollama-cloud,
kilocode, alibaba, opencode-zen, opencode-go, custom, vercel (ai-gateway),
copilot, gemini, google-gemini-cli
codex_responses: xai, openai-codex
anthropic_messages: anthropic, minimax, minimax-cn
bedrock_converse: bedrock
chat_completions (ACP subprocess): copilot-acp
Key additions vs prior commit:
- Cross-checked ALL env_vars against auth.py (fixed copilot, zai, kimi-coding,
arcee, alibaba, ollama-cloud)
- Cross-checked ALL aliases against auth.py _PROVIDER_ALIASES (added 21 missing:
kimi-cn, moonshot-cn, kimi-for-coding, claude-code, github, github-model,
qwen-cli, huggingface-hub, x.ai, lmstudio/vllm/llamacpp variants, go,
opencode-go-sub, kilo-gateway)
- Fixed auth_type mismatches (bedrock: aws_sdk, copilot: copilot)
- Fixed copilot-acp api_mode to match runtime_provider.py (chat_completions)
- Added 4 missing default_aux_model values (stepfun, minimax, minimax-cn, ollama-cloud)
- fetch_models() on every profile (default hits base_url/models with Bearer auth)
- models_url field for non-standard catalog URLs (OpenRouter public endpoint)
- Transport registry _discovered guard (fixes xdist partial-registry poisoning)
- Copilot ACP client relocated agent/ -> acp_adapter/
- run_agent.py: _PROFILE_ACTIVE_PROVIDERS module-level, dead is_nvidia_nim removed
- providers/README.md contributor guide
Closes part of #14418. Remaining activation in #14515.
166 lines
6.5 KiB
Python
166 lines
6.5 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 Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Sentinel for "omit temperature entirely" (Kimi: server manages it)
|
|
OMIT_TEMPERATURE = object()
|
|
|
|
|
|
@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
|
|
|
|
# ── 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 = ()
|
|
|
|
# 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
|
|
default_aux_model: str = (
|
|
"" # cheap model for auxiliary tasks (compression, vision, etc.)
|
|
)
|
|
# empty = use main model
|
|
|
|
# ── Hooks (override in subclass for complex providers) ───
|
|
|
|
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 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 fetch_models(
|
|
self,
|
|
*,
|
|
api_key: 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. 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)
|
|
2. 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.
|
|
"""
|
|
url = (self.models_url or "").strip()
|
|
if not url:
|
|
if not self.base_url:
|
|
return None
|
|
url = self.base_url.rstrip("/") + "/models"
|
|
|
|
import json
|
|
import urllib.request
|
|
|
|
req = urllib.request.Request(url)
|
|
if api_key:
|
|
req.add_header("Authorization", f"Bearer {api_key}")
|
|
req.add_header("Accept", "application/json")
|
|
for k, v in self.default_headers.items():
|
|
req.add_header(k, v)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
items = data if isinstance(data, list) else data.get("data", [])
|
|
return [m["id"] for m in items if isinstance(m, dict) and "id" in m]
|
|
except Exception as exc:
|
|
logger.debug("fetch_models(%s): %s", self.name, exc)
|
|
return None
|