Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
# Model Provider Plugins
Each subdirectory is a self-contained provider profile plugin. The
directory layout mirrors `plugins/platforms/`:
```
plugins/model-providers/
├── openrouter/
│ ├── __init__.py # registers the ProviderProfile
│ └── plugin.yaml # manifest: name, kind, version, description
├── anthropic/
│ ├── __init__.py
│ └── plugin.yaml
└── ...
```
## How discovery works
`providers/__init__.py._discover_providers()` scans this directory (and
`$HERMES_HOME/plugins/model-providers/`) the first time anything calls
`get_provider_profile()` or `list_providers()`. Each `__init__.py` is
imported and expected to call `providers.register_provider(profile)`.
User plugins at `$HERMES_HOME/plugins/model-providers/<name>/` override
bundled plugins of the same name — last-writer-wins in
`register_provider()`. Drop a file there to replace a built-in.
## Adding a new provider
1. Create `plugins/model-providers/<your_provider>/__init__.py`:
```python
from providers import register_provider
from providers.base import ProviderProfile
my_provider = ProviderProfile(
name="your-provider",
aliases=("alias1", "alias2"),
display_name="Your Provider",
description="One-line description shown in the setup picker",
signup_url="https://your-provider.example.com/keys",
env_vars=("YOUR_PROVIDER_API_KEY", "YOUR_PROVIDER_BASE_URL"),
base_url="https://api.your-provider.example.com/v1",
default_aux_model="your-cheap-model",
)
register_provider(my_provider)
```
2. Create `plugins/model-providers/<your_provider>/plugin.yaml`:
```yaml
name: your-provider-profile
kind: model-provider
version: 1.0.0
description: Short sentence about the provider
author: Your Name
```
Nothing else needs to change. `auth.py`, `config.py`, `models.py`,
`doctor.py`, `model_metadata.py`, `runtime_provider.py`, and the
chat_completions transport all auto-wire from the registry.
## Non-trivial profiles
Override the `ProviderProfile` hooks in a subclass for per-provider
quirks — see `plugins/model-providers/openrouter/__init__.py` for
`build_extra_body` and `build_api_kwargs_extras` examples, and
`plugins/model-providers/gemini/__init__.py` for `thinking_config`
translation.
@@ -0,0 +1,91 @@
"""Actual Computer provider profile."""
from __future__ import annotations
import json
import logging
import os
from urllib.parse import urlparse
import urllib.request
from providers import register_provider
from providers.base import ProviderProfile, _profile_user_agent
logger = logging.getLogger(__name__)
DEFAULT_ACTUAL_BASE_URL = "https://api.actual.inc/v1"
DEFAULT_ACTUAL_LOCAL_BASE_URL = "http://127.0.0.1:8080/v1"
def _normalize_actual_base_url(base_url: str) -> str:
url = str(base_url or "").strip().rstrip("/")
if not url:
return DEFAULT_ACTUAL_BASE_URL
try:
parsed = urlparse(url)
host = (parsed.hostname or "").lower().rstrip(".")
path = parsed.path.rstrip("/")
except Exception:
return url
if host == "api.actual.inc" and path in {"", "/"}:
return url + "/v1"
if host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"} and path in {"", "/"}:
return url + "/v1"
return url
class ActualProfile(ProviderProfile):
"""Actual Computer provider.
Hosted inference defaults to api.actual.inc. Local inference is exposed by
the Actual client only when it runs in offline mode, so users opt into it by
setting ACTUAL_BASE_URL to the local API URL.
"""
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
base_url = _normalize_actual_base_url(
os.getenv("ACTUAL_BASE_URL", "").strip() or base_url or self.base_url
)
if not base_url:
return None
req = urllib.request.Request(base_url + "/models")
if api_key:
req.add_header("Authorization", f"Bearer {api_key}")
req.add_header("Accept", "application/json")
req.add_header("User-Agent", _profile_user_agent())
from hermes_cli.urllib_security import open_credentialed_url
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", [])
return [m["id"] for m in items if isinstance(m, dict) and "id" in m]
except Exception as exc:
logger.debug("fetch_models(actual): %s", exc)
return None
actual = ActualProfile(
name="actual",
aliases=("actual-computer", "actualcomputer", "aci"),
display_name="Actual Computer",
description=(
"Actual Computer - hosted inference via api.actual.inc, or local "
"offline inference via ACTUAL_BASE_URL"
),
signup_url="https://actual.inc",
env_vars=("ACTUAL_API_KEY", "ACTUAL_BASE_URL"),
base_url=DEFAULT_ACTUAL_BASE_URL,
auth_type="api_key",
api_mode="codex_responses",
)
register_provider(actual)
@@ -0,0 +1,5 @@
name: actual-provider
kind: model-provider
version: 1.0.0
description: Actual Computer inference
author: Actual Computer
@@ -0,0 +1,43 @@
"""Vercel AI Gateway provider profile.
AI Gateway routes to multiple backends. Hermes sends attribution
headers and full reasoning config passthrough.
"""
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
class VercelAIGatewayProfile(ProviderProfile):
"""Vercel AI Gateway — attribution headers + reasoning passthrough."""
def build_api_kwargs_extras(
self,
*,
reasoning_config: dict | None = None,
supports_reasoning: bool = True,
**ctx: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body: dict[str, Any] = {}
if supports_reasoning and reasoning_config is not None:
extra_body["reasoning"] = dict(reasoning_config)
elif supports_reasoning:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
return extra_body, {}
vercel = VercelAIGatewayProfile(
name="ai-gateway",
aliases=("vercel", "vercel-ai-gateway", "ai_gateway", "aigateway"),
env_vars=("AI_GATEWAY_API_KEY",),
base_url="https://ai-gateway.vercel.sh/v1",
default_headers={
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
},
default_aux_model="google/gemini-3-flash",
)
register_provider(vercel)
@@ -0,0 +1,5 @@
name: ai-gateway-provider
kind: model-provider
version: 1.0.0
description: Vercel AI Gateway
author: Nous Research
@@ -0,0 +1,44 @@
"""Alibaba Cloud Coding Plan provider profiles.
Separate from the standard `alibaba` profile because it hits a different
endpoint (coding-intl.dashscope.aliyuncs.com) with a dedicated API key tier.
Region split, mirroring the base DashScope pair (#73265):
- ``alibaba-coding-plan`` → coding-intl.dashscope.aliyuncs.com (international)
- ``alibaba-coding-plan-cn`` → coding.dashscope.aliyuncs.com (mainland China)
Profile names match the models.dev catalog keys exactly so model metadata
lines up and ``model.provider: alibaba-coding-plan-cn`` resolves at runtime.
The CN profile checks its own ``ALIBABA_CODING_PLAN_CN_API_KEY`` first (#101122,
mirroring kimi-coding-cn) and keeps the shared vars as ordered fallbacks so
existing CN users configured with the shared key keep working.
"""
from providers import register_provider
from providers.base import ProviderProfile
alibaba_coding_plan = ProviderProfile(
name="alibaba-coding-plan",
aliases=("alibaba_coding", "alibaba-coding", "dashscope-coding"),
display_name="Alibaba Cloud (Coding Plan)",
description="Alibaba Cloud Coding Plan (Dedicated coding tier)",
signup_url="https://help.aliyun.com/zh/model-studio/",
env_vars=("ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY", "ALIBABA_CODING_PLAN_BASE_URL"),
base_url="https://coding-intl.dashscope.aliyuncs.com/v1",
auth_type="api_key",
)
alibaba_coding_plan_cn = ProviderProfile(
name="alibaba-coding-plan-cn",
aliases=("alibaba-coding-cn", "dashscope-coding-cn"),
display_name="Alibaba Cloud (Coding Plan, China)",
description="Alibaba Cloud Coding Plan, mainland-China endpoint",
signup_url="https://help.aliyun.com/zh/model-studio/",
env_vars=("ALIBABA_CODING_PLAN_CN_API_KEY", "ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY", "ALIBABA_CODING_PLAN_CN_BASE_URL"),
base_url="https://coding.dashscope.aliyuncs.com/v1",
auth_type="api_key",
)
register_provider(alibaba_coding_plan)
register_provider(alibaba_coding_plan_cn)
@@ -0,0 +1,5 @@
name: alibaba-coding-plan-provider
kind: model-provider
version: 1.0.0
description: Alibaba Cloud Coding Plan
author: Nous Research
@@ -0,0 +1,64 @@
"""Alibaba Cloud DashScope provider profiles.
DashScope has region-split endpoints with the same key type:
- ``alibaba`` → dashscope-intl.aliyuncs.com (international)
- ``alibaba-cn`` → dashscope.aliyuncs.com (mainland China)
The Model Studio Token Plan (flat-token tier of the SAME vendor/service,
same OpenAI-compatible protocol, its own key + endpoints) registers here
too rather than as a new plugin directory — one module per vendor, matching
how the kimi module carries both of its endpoint variants:
- ``alibaba-token-plan`` → token-plan.ap-southeast-1.maas.aliyuncs.com
- ``alibaba-token-plan-cn`` → token-plan.cn-beijing.maas.aliyuncs.com
Profile names match the models.dev catalog keys exactly
(``alibaba`` / ``alibaba-cn``) so model metadata lines up and
``model.provider: alibaba-cn`` resolves at runtime (#73265).
"""
from providers import register_provider
from providers.base import ProviderProfile
alibaba = ProviderProfile(
name="alibaba",
aliases=("dashscope", "alibaba-cloud", "qwen-dashscope"),
env_vars=("DASHSCOPE_API_KEY",),
base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
)
alibaba_cn = ProviderProfile(
name="alibaba-cn",
aliases=("dashscope-cn", "alibaba-cloud-cn"),
display_name="Alibaba Cloud DashScope (China)",
description="Alibaba Cloud DashScope, mainland-China endpoint",
env_vars=("DASHSCOPE_API_KEY", "DASHSCOPE_CN_BASE_URL"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
register_provider(alibaba)
register_provider(alibaba_cn)
alibaba_token_plan = ProviderProfile(
name="alibaba-token-plan",
aliases=("dashscope-token-plan",),
display_name="Alibaba Cloud (Token Plan)",
description="Alibaba Cloud Model Studio Token Plan (flat-token tier)",
signup_url="https://help.aliyun.com/zh/model-studio/",
env_vars=("ALIBABA_TOKEN_PLAN_API_KEY", "ALIBABA_TOKEN_PLAN_BASE_URL"),
base_url="https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
auth_type="api_key",
)
alibaba_token_plan_cn = ProviderProfile(
name="alibaba-token-plan-cn",
aliases=("dashscope-token-plan-cn",),
display_name="Alibaba Cloud (Token Plan, China)",
description="Alibaba Cloud Model Studio Token Plan, mainland-China endpoint",
signup_url="https://help.aliyun.com/zh/model-studio/",
env_vars=("ALIBABA_TOKEN_PLAN_CN_API_KEY", "ALIBABA_TOKEN_PLAN_API_KEY", "ALIBABA_TOKEN_PLAN_CN_BASE_URL"),
base_url="https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
auth_type="api_key",
)
register_provider(alibaba_token_plan)
register_provider(alibaba_token_plan_cn)
@@ -0,0 +1,5 @@
name: alibaba-provider
kind: model-provider
version: 1.0.0
description: Alibaba DashScope (international)
author: Nous Research
@@ -0,0 +1,54 @@
"""Native Anthropic provider profile."""
import json
import logging
import urllib.request
from hermes_cli.urllib_security import open_credentialed_url
from providers import register_provider
from providers.base import ProviderProfile
logger = logging.getLogger(__name__)
class AnthropicProfile(ProviderProfile):
"""Native Anthropic — uses x-api-key header, not Bearer."""
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
"""Anthropic uses x-api-key header and anthropic-version."""
if not api_key:
return None
try:
req = urllib.request.Request("https://api.anthropic.com/v1/models")
req.add_header("x-api-key", api_key)
req.add_header("anthropic-version", "2023-06-01")
req.add_header("Accept", "application/json")
with open_credentialed_url(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode())
return [
m["id"]
for m in data.get("data", [])
if isinstance(m, dict) and "id" in m
]
except Exception as exc:
logger.debug("fetch_models(anthropic): %s", exc)
return None
anthropic = AnthropicProfile(
name="anthropic",
aliases=("claude", "claude-oauth", "claude-code"),
api_mode="anthropic_messages",
env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
base_url="https://api.anthropic.com",
auth_type="api_key",
default_aux_model="claude-haiku-4-5-20251001",
)
register_provider(anthropic)
@@ -0,0 +1,5 @@
name: anthropic-provider
kind: model-provider
version: 1.0.0
description: Anthropic (Claude)
author: Nous Research
+13
View File
@@ -0,0 +1,13 @@
"""Arcee AI provider profile."""
from providers import register_provider
from providers.base import ProviderProfile
arcee = ProviderProfile(
name="arcee",
aliases=("arcee-ai", "arceeai"),
env_vars=("ARCEEAI_API_KEY",),
base_url="https://api.arcee.ai/api/v1",
)
register_provider(arcee)
@@ -0,0 +1,5 @@
name: arcee-provider
kind: model-provider
version: 1.0.0
description: Arcee AI
author: Nous Research
@@ -0,0 +1,21 @@
"""Microsoft Foundry provider profile.
Azure Foundry exposes an OpenAI-compatible endpoint; users supply their own
base URL at setup since endpoints are per-resource.
"""
from providers import register_provider
from providers.base import ProviderProfile
azure_foundry = ProviderProfile(
name="azure-foundry",
aliases=("azure", "azure-ai-foundry", "azure-ai"),
display_name="Azure Foundry",
description="Microsoft Foundry - OpenAI-compatible endpoint (user-supplied base URL)",
signup_url="https://ai.azure.com/",
env_vars=("AZURE_FOUNDRY_API_KEY", "AZURE_FOUNDRY_BASE_URL"),
base_url="", # per-resource; user provides at setup
auth_type="api_key",
)
register_provider(azure_foundry)
@@ -0,0 +1,5 @@
name: azure-foundry-provider
kind: model-provider
version: 1.0.0
description: Microsoft Foundry
author: Nous Research
@@ -0,0 +1,30 @@
"""AWS Bedrock provider profile."""
from providers import register_provider
from providers.base import ProviderProfile
class BedrockProfile(ProviderProfile):
"""AWS Bedrock — no REST /v1/models endpoint; uses AWS SDK."""
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
"""Bedrock model listing requires AWS SDK, not a REST call."""
return None
bedrock = BedrockProfile(
name="bedrock",
aliases=("aws", "aws-bedrock", "amazon-bedrock", "amazon"),
api_mode="bedrock_converse",
env_vars=(), # AWS SDK credentials — not env vars
base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
auth_type="aws_sdk",
)
register_provider(bedrock)
@@ -0,0 +1,5 @@
name: bedrock-provider
kind: model-provider
version: 1.0.0
description: AWS Bedrock
author: Nous Research
@@ -0,0 +1,176 @@
"""CommandCode provider profile.
CommandCode provides a unified API that fronts 20+ models from DeepSeek, Qwen,
Kimi, GLM, MiniMax, StepFun, Xiaomi Mimo, Google Gemini, and OpenAI GPT — all
accessible through either OpenAI-compatible chat completions or Anthropic
Messages endpoints from a single base URL and API key.
Two provider profiles are registered:
``commandcode``
``api_mode=chat_completions`` — standard OpenAI-compatible endpoint.
Model prefix: ``deepseek/deepseek-v4-pro``, ``Qwen/Qwen3.7-Max``, etc.
``commandcode-anthropic``
``api_mode=anthropic_messages`` — Anthropic Messages API-compatible.
Model names: ``claude-sonnet-4-6``, ``claude-opus-4-7``,
``claude-haiku-4-5-20251001``.
Both use the same ``COMMANDCODE_API_KEY`` env var and
``https://api.commandcode.ai/provider/v1`` base URL. The
``commandcode-anthropic`` profile relies on ``agent/anthropic_adapter.py``
recognizing the ``api.commandcode.ai`` hostname for Bearer auth (the
CommandCode /anthropic endpoint uses ``Authorization: Bearer``, not
Anthropic's native ``x-api-key`` header).
"""
from __future__ import annotations
import json
import logging
import urllib.request
from providers import register_provider
from providers.base import ProviderProfile, _profile_user_agent
logger = logging.getLogger(__name__)
# ── Shared constants ──────────────────────────────────────────────────────────
_COMMANDCODE_BASE = "https://api.commandcode.ai/provider/v1"
_COMMANDCODE_MODELS_URL = f"{_COMMANDCODE_BASE}/models"
# Both profiles authenticate with the same key; each carries its own base-URL
# override var so each renders its own card on the desktop Keys tab (rows are
# keyed by env var, and the shared API key attributes to the first profile).
_COMMANDCODE_ENV = ("COMMANDCODE_API_KEY", "COMMANDCODE_BASE_URL")
_COMMANDCODE_ANTHROPIC_ENV = ("COMMANDCODE_API_KEY", "COMMANDCODE_ANTHROPIC_BASE_URL")
def _fetch_commandcode_models(
timeout: float = 10.0,
base_url: str | None = None,
) -> list[str] | None:
"""Fetch the live model list from the CommandCode /models endpoint.
Returns a flat list of model IDs or None on failure.
No auth required — the public models endpoint is open.
``base_url`` overrides the endpoint only when the caller passed a URL
that differs from the default ``_COMMANDCODE_BASE`` (a user-configured
``model.base_url`` / ``COMMANDCODE_BASE_URL`` pointing at a proxy or
custom deployment). The picker passes base_url unconditionally, falling
back to the profile default — equality means "not customised".
"""
caller_base = (base_url or "").strip()
if caller_base and caller_base.rstrip("/") != _COMMANDCODE_BASE.rstrip("/"):
models_url = caller_base.rstrip("/") + "/models"
else:
models_url = _COMMANDCODE_MODELS_URL
try:
req = urllib.request.Request(models_url)
req.add_header("Accept", "application/json")
req.add_header("User-Agent", _profile_user_agent())
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode())
# Response shape: {"object": "list", "data": [{"id": "..."}, ...]}
return [
m["id"]
for m in data.get("data", [])
if isinstance(m, dict) and "id" in m
]
except Exception as exc:
logger.debug("fetch_models(commandcode): %s", exc)
return None
# ── Chat Completions profile ──────────────────────────────────────────────────
class CommandCodeProfile(ProviderProfile):
"""CommandCode — OpenAI-compatible chat completions endpoint."""
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
"""Fetch from the public CommandCode /models endpoint."""
return _fetch_commandcode_models(timeout=timeout, base_url=base_url)
commandcode = CommandCodeProfile(
name="commandcode",
aliases=("commandcode-chat",),
api_mode="chat_completions",
env_vars=_COMMANDCODE_ENV,
display_name="CommandCode",
description="CommandCode — 20+ models via OpenAI-compatible API",
signup_url="https://commandcode.ai/",
base_url=_COMMANDCODE_BASE,
models_url=_COMMANDCODE_MODELS_URL,
fallback_models=(
"deepseek/deepseek-v4-pro",
"deepseek/deepseek-v4-flash",
"Qwen/Qwen3.7-Max",
"Qwen/Qwen3.6-Plus",
"moonshotai/Kimi-K2.6",
"zai-org/GLM-5.1",
"MiniMaxAI/MiniMax-M2.7",
"stepfun/Step-3.5-Flash",
"xiaomi/mimo-v2.5-pro",
"google/gemini-3.5-flash",
"gpt-5.5",
),
default_aux_model="deepseek/deepseek-v4-flash",
)
# ── Anthropic Messages profile ────────────────────────────────────────────────
class CommandCodeAnthropicProfile(ProviderProfile):
"""CommandCode — Anthropic Messages API-compatible endpoint.
Uses Bearer auth (same API key), not Anthropic's native x-api-key header.
``agent/anthropic_adapter.py`` must recognize ``api.commandcode.ai``
as a Bearer-auth domain for this to work.
"""
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
"""Fetch from the public CommandCode /models endpoint.
Filter to Anthropic-family models only (claude-*).
"""
all_models = _fetch_commandcode_models(timeout=timeout, base_url=base_url)
if all_models is None:
return None
return [m for m in all_models if m.startswith("claude-")]
commandcode_anthropic = CommandCodeAnthropicProfile(
name="commandcode-anthropic",
aliases=("commandcode-claude",),
api_mode="anthropic_messages",
env_vars=_COMMANDCODE_ANTHROPIC_ENV,
display_name="CommandCode (Anthropic)",
description="CommandCode — Claude models via Anthropic Messages API",
signup_url="https://commandcode.ai/",
base_url=_COMMANDCODE_BASE,
models_url=_COMMANDCODE_MODELS_URL,
fallback_models=(
"claude-sonnet-4-6",
"claude-opus-4-7",
"claude-haiku-4-5-20251001",
),
default_aux_model="claude-haiku-4-5-20251001",
)
# ── Registration ──────────────────────────────────────────────────────────────
register_provider(commandcode)
register_provider(commandcode_anthropic)
@@ -0,0 +1,5 @@
name: commandcode-provider
kind: model-provider
version: 1.0.0
description: CommandCode — unified multi-model API (OpenAI chat completions + Anthropic Messages)
author: Nous Research
@@ -0,0 +1,54 @@
"""GitHub Copilot ACP provider profile.
copilot-acp does not speak OpenAI-over-HTTP: it drives an external ACP
subprocess over stdio. The profile therefore supplies its own client through
:meth:`ProviderProfile.create_client` instead of letting the core build an
``openai.OpenAI``. That hook is the registration seam — this profile is its
in-tree consumer, and an out-of-tree ACP provider registered from
``~/.hermes/plugins/model-providers/`` or a pip entry point uses the exact same
three lines without touching core.
"""
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
class CopilotACPProfile(ProviderProfile):
"""GitHub Copilot ACP — external process, no REST models endpoint."""
def create_client(self, **client_kwargs: Any) -> Any:
"""Build the ACP stdio shim rather than an HTTP client."""
from agent.copilot_acp_client import CopilotACPClient
return CopilotACPClient(**client_kwargs)
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
"""Model listing is handled by the ACP subprocess."""
return None
copilot_acp = CopilotACPProfile(
name="copilot-acp",
aliases=("github-copilot-acp", "copilot-acp-agent"),
api_mode="chat_completions", # ACP subprocess uses chat_completions routing
env_vars=(), # Managed by ACP subprocess
base_url="acp://copilot", # ACP internal scheme
auth_type="external_process",
# How to launch the CLI. Previously hardcoded in
# hermes_cli/auth.py::resolve_external_process_provider_credentials; the env
# var names are unchanged, so existing setups keep working.
process_command="copilot",
process_args=("--acp", "--stdio"),
process_command_env_vars=("HERMES_COPILOT_ACP_COMMAND", "COPILOT_CLI_PATH"),
process_args_env_var="HERMES_COPILOT_ACP_ARGS",
)
register_provider(copilot_acp)
@@ -0,0 +1,5 @@
name: copilot-acp-provider
kind: model-provider
version: 1.0.0
description: GitHub Copilot via ACP subprocess
author: Nous Research
@@ -0,0 +1,80 @@
"""Copilot / GitHub Models provider profile.
Copilot uses per-model api_mode routing:
- GPT-5+ / Codex models → codex_responses
- Claude models → anthropic_messages
- Everything else → chat_completions (this profile covers that subset)
Key quirks for the chat_completions subset:
- Editor attribution headers (via copilot_default_headers())
- GitHub Models reasoning extra_body (model-catalog gated)
"""
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
class CopilotProfile(ProviderProfile):
"""GitHub Copilot / GitHub Models — editor headers + reasoning."""
def build_api_kwargs_extras(
self,
*,
model: str | None = None,
reasoning_config: dict | None = None,
supports_reasoning: bool = False,
**ctx,
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body: dict[str, Any] = {}
if supports_reasoning and model:
try:
from hermes_cli.models import github_model_reasoning_efforts
supported_efforts = github_model_reasoning_efforts(model)
if supported_efforts and reasoning_config:
effort = reasoning_config.get("effort", "medium")
# Honor the requested level when the live Copilot catalog
# lists it as supported: gpt-5.5/gpt-5.4 DO support
# ``xhigh``. Otherwise clamp to the nearest WEAKER
# supported level via the shared ladder helper — the old
# ad-hoc rules dropped everything unrecognized to
# ``medium``, which inverted the ladder: ``ultra`` (the
# strongest ask) resolved weaker than an explicit
# ``high`` (#74295).
if effort not in supported_efforts:
from hermes_cli.models import (
clamp_reasoning_effort_to_supported,
)
effort = clamp_reasoning_effort_to_supported(
effort, list(supported_efforts)
)
if effort not in supported_efforts:
# Unrecognized/bespoke level the ladder can't
# place — fall back to medium, then to the
# catalog's first entry.
effort = (
"medium"
if "medium" in supported_efforts
else supported_efforts[0]
)
if effort in supported_efforts:
extra_body["reasoning"] = {"effort": effort}
elif supported_efforts:
extra_body["reasoning"] = {"effort": "medium"}
except Exception:
pass
return extra_body, {}
copilot = CopilotProfile(
name="copilot",
aliases=("github-copilot", "github-models", "github-model", "github"),
env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"),
base_url="https://api.githubcopilot.com",
auth_type="copilot",
)
register_provider(copilot)
@@ -0,0 +1,5 @@
name: copilot-provider
kind: model-provider
version: 1.0.0
description: GitHub Copilot
author: Nous Research
+149
View File
@@ -0,0 +1,149 @@
"""Custom / Ollama (local) provider profile.
Covers any endpoint registered as provider="custom", including local
Ollama instances and OpenAI-compatible reasoning endpoints (GLM-5.2 on
Volcengine ARK, vLLM, llama.cpp). Key quirks:
- ollama_num_ctx → extra_body.options.num_ctx (local context window)
- reasoning_config disabled → top-level reasoning_effort="none"
(Ollama /v1/chat/completions ignores think=False — ollama#14820)
+ extra_body.think = False only on Ollama URLs (/api/chat and proxies)
- reasoning_config enabled + effort → top-level reasoning_effort
(the native OpenAI-compatible format GLM/ARK expect; unset omits it
so the endpoint's server default applies)
"""
from typing import Any
from urllib.parse import urlparse
from providers import register_provider
from providers.base import ProviderProfile
def _looks_like_ollama_endpoint(base_url: str | None) -> bool:
"""True when ``base_url`` is an Ollama host, not a generic OpenAI-compat relay.
``think`` is an Ollama-native extra_body field. Strict hosts (Mistral
``extra=forbid``, Groq, …) reject it with HTTP 422. Match only explicit
Ollama signatures — default port 11434, or ``ollama`` as a hostname
label — not arbitrary localhost (llama.cpp / vLLM / LM Studio).
"""
raw = (base_url or "").strip()
if not raw:
return False
parsed = urlparse(raw if "://" in raw else f"//{raw}")
# urlparse raises ValueError for non-integer / out-of-range ports
# ("http://host:99999/v1" parses fine in the OpenAI client, so the URL
# is reachable here). Treat a malformed port as "not Ollama" instead of
# killing the whole kwargs build — same try/except shape the 11434
# check in hermes_cli/models.py uses, not the same detection logic.
try:
if parsed.port == 11434:
return True
except ValueError:
return False
host = (parsed.hostname or "").lower().rstrip(".")
if not host:
return False
if host == "ollama.com" or host.endswith(".ollama.com"):
return True
return "ollama" in host.split(".")
class CustomProfile(ProviderProfile):
"""Custom/Ollama local provider — think=false and num_ctx support."""
def build_api_kwargs_extras(
self,
*,
reasoning_config: dict | None = None,
ollama_num_ctx: int | None = None,
**ctx: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
# Ollama context window
if ollama_num_ctx:
options = extra_body.get("options", {})
options["num_ctx"] = ollama_num_ctx
extra_body["options"] = options
# Reasoning / thinking control for custom OpenAI-compatible endpoints
# (GLM-5.2 on Volcengine ARK, vLLM, Ollama, llama.cpp, …).
#
# - disabled → top-level reasoning_effort="none"; extra_body.think
# = False only on Ollama URLs (Ollama's thinking-off flag)
# - enabled + effort set → TOP-LEVEL reasoning_effort string, the
# format GLM-5.2/ARK and other OpenAI-compatible reasoning APIs
# expect (GLM documents "high" and "max"; "max" is its default).
# - enabled + no effort → omit both, so the endpoint applies its own
# server-side default (do NOT force a level the user didn't pick).
#
# We deliberately do NOT emit ``think=True`` on enable: it is an
# Ollama-only flag and thinking is already server-default-on for these
# backends, so forcing it risks a 400 on GLM/vLLM endpoints that don't
# recognize it. Mirrors the DeepSeek/Zai profile precedent. The same
# constraint applies to ``think=False`` on disable — Mistral/Groq
# reject unknown fields (HTTP 422 extra_forbidden) rather than ignoring
# them, so that flag stays Ollama-URL-gated.
if reasoning_config and isinstance(reasoning_config, dict):
_effort = (reasoning_config.get("effort") or "").strip().lower()
_enabled = reasoning_config.get("enabled", True)
if _effort == "none" or _enabled is False:
# Ollama's /v1/chat/completions silently ignores
# extra_body.think (only /api/chat honours it — ollama#14820)
# but respects the top-level reasoning_effort field (#25758).
# Always emit reasoning_effort="none"; only add think=False
# when the URL is actually Ollama.
top_level["reasoning_effort"] = "none"
if _looks_like_ollama_endpoint(ctx.get("base_url")):
extra_body["think"] = False
elif _effort:
# Clamp the internal ladder onto the widest OpenAI-compatible
# wire vocabulary (shared policy in agent.reasoning_effort) —
# GLM/ARK, vLLM and SGLang all top out at "max"; forwarding
# "ultra" verbatim is a guaranteed 400 (#89503).
from agent.reasoning_effort import (
OPENAI_COMPAT_WIRE_EFFORTS,
clamp_effort,
)
top_level["reasoning_effort"] = clamp_effort(
_effort, OPENAI_COMPAT_WIRE_EFFORTS
)
return extra_body, top_level
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
"""Custom/Ollama: base_url is user-configured; fetch if set."""
if not (base_url or self.base_url):
return None
return super().fetch_models(api_key=api_key, base_url=base_url, timeout=timeout)
custom = CustomProfile(
name="custom",
aliases=(
"ollama",
"local",
"vllm",
"llamacpp",
"llama.cpp",
"llama-cpp",
),
env_vars=(), # No fixed key — custom endpoint
base_url="", # User-configured
# Without this, no max_tokens is sent and Ollama falls back to its internal
# num_predict=128, truncating responses after a few tokens (#39281). This is
# only a floor used when the user hasn't set model.max_tokens — they can
# override per-model — so we set it generously rather than lowballing it.
default_max_tokens=65536,
)
register_provider(custom)
@@ -0,0 +1,5 @@
name: custom-provider
kind: model-provider
version: 1.0.0
description: Custom / Ollama / local OpenAI-compatible endpoint
author: Nous Research
@@ -0,0 +1,81 @@
"""DeepInfra provider profile.
DeepInfra is an OpenAI-compatible inference gateway that hosts 100+ open
models (Step, GLM, Kimi, DeepSeek, MiniMax, Nemotron, Mistral, Qwen, …) as
well as image-gen / TTS / STT / embedding endpoints. The chat surface is
wired in through this profile; non-chat surfaces are wired in through
their respective plugin subsystems (``plugins/image_gen/deepinfra`` and
the TTS/STT dispatchers in ``tools/``).
"""
from providers import register_provider
from providers.base import ProviderProfile
class _DeepInfraProfile(ProviderProfile):
"""DeepInfra profile with live vision-default discovery.
Owns its own vision default so shared vision resolution in
``agent/auxiliary_client.py`` stays provider-agnostic (a
``default_vision_model()`` hook call instead of an ``if provider ==
"deepinfra"`` branch reaching into the catalog helpers).
"""
def default_vision_model(self): # type: ignore[override]
"""First vision-capable *chat* model from the live catalog, or None.
Key-gated so a box without ``DEEPINFRA_API_KEY`` never pays the
catalog round-trip. Requires the ``chat`` surface tag (not just the
``vision`` capability) so an image-gen/edit model that merely carries
a ``vision`` tag can't be picked as a chat-completions vision backend.
"""
from agent.secret_scope import get_secret
if not (get_secret("DEEPINFRA_API_KEY") or "").strip():
return None
try:
from hermes_cli.models import _fetch_deepinfra_models_by_tag
items = _fetch_deepinfra_models_by_tag("chat")
except Exception:
return None
for item in items or []:
metadata = item.get("metadata") or {}
tags = metadata.get("tags") if isinstance(metadata, dict) else None
if isinstance(tags, list) and "vision" in tags:
model_id = item.get("id")
if model_id:
return model_id
return None
deepinfra = _DeepInfraProfile(
name="deepinfra",
aliases=("deep-infra", "deepinfra-ai"),
display_name="DeepInfra",
description="DeepInfra — 100+ open models, pay-per-use",
signup_url="https://deepinfra.com/dash/api_keys",
env_vars=("DEEPINFRA_API_KEY", "DEEPINFRA_BASE_URL"),
base_url="https://api.deepinfra.com/v1/openai",
auth_type="api_key",
# The catalog spans models with different output limits. Omitting a
# provider-wide default lets DeepInfra apply its documented per-model cap;
# an explicit user ``agent.max_tokens`` still passes through normally.
default_max_tokens=None,
# Auxiliary model — cheap/fast chat model the same provider uses for
# side tasks (context compression, session search, web extract,
# vision). This is the *only* hardcoded DeepInfra model in the
# integration: aux resolution is synchronous (no time for a catalog
# round-trip on every agent turn), so we need one explicit choice.
# Every other surface (chat picker, image-gen, tts, stt, pricing)
# discovers models live from
# ``api.deepinfra.com/v1/openai/models?filter=true&sort_by=hermes``.
default_aux_model="deepseek-ai/DeepSeek-V4-Flash",
# ``fallback_models`` deliberately empty — the live catalog at
# ``hermes_cli/models.py::_fetch_deepinfra_models`` is the source of
# truth. When the live fetch fails (network/DNS), the picker shows
# no options, which is preferable to silently routing the user to a
# model that may have been retired upstream.
fallback_models=(),
)
register_provider(deepinfra)
@@ -0,0 +1,5 @@
name: deepinfra-provider
kind: model-provider
version: 1.0.0
description: DeepInfra — 100+ open models, pay-per-use
author: Georgi Atsev
@@ -0,0 +1,111 @@
"""DeepSeek provider profile.
DeepSeek's V4 family defaults to thinking-mode ON when ``extra_body.thinking``
is unset. The API then returns ``reasoning_content`` and starts enforcing
the contract that subsequent turns echo it back; combined with how Hermes
replays history this lands on the notorious HTTP 400
``reasoning_content must be passed back`` error after the first tool call
(#15700, #17212, #17825).
This profile overrides :meth:`build_api_kwargs_extras` to mirror the Kimi /
Moonshot wire shape that DeepSeek's OpenAI-compat endpoint expects:
{"reasoning_effort": "<low|medium|high|max>",
"extra_body": {"thinking": {"type": "enabled" | "disabled"}}}
Non-thinking models (``deepseek-v3-*`` variants) are left as no-ops so we
don't perturb the V3 wire format.
The legacy aliases ``deepseek-chat`` / ``deepseek-reasoner`` were retired on
2026-07-24. Use ``deepseek-v4-flash`` or ``deepseek-v4-pro``; Hermes remaps
the retired IDs in ``hermes_cli.model_normalize``.
"""
from __future__ import annotations
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
def _model_supports_thinking(model: str | None) -> bool:
"""DeepSeek thinking-capable model families.
Currently covers the V4 family (``deepseek-v4-pro``, ``deepseek-v4-flash``,
and any future ``deepseek-v4-*`` variants). Retired aliases are remapped
before requests leave Hermes, so they are not listed here.
"""
m = (model or "").strip().lower()
if not m:
return False
if m.startswith("deepseek-v") and not m.startswith("deepseek-v3"):
# deepseek-v4-*, deepseek-v5-*, etc. — every V4+ generation has
# thinking. v3 explicitly excluded.
return True
return False
class DeepSeekProfile(ProviderProfile):
"""DeepSeek — extra_body.thinking + top-level reasoning_effort."""
def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
if not _model_supports_thinking(model):
# V3 / unknown — leave wire format untouched, current behavior.
return extra_body, top_level
# Determine enabled/disabled. Default is enabled to match DeepSeek's
# API default; the API requires this to be set explicitly to avoid the
# reasoning_content echo trap on subsequent turns.
enabled = True
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False:
enabled = False
extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"}
if not enabled:
return extra_body, top_level
# Effort mapping via the shared vocabulary in agent.reasoning_effort
# (DeepSeek V4: low/medium/high/max, xhigh rounds up to max). When no
# effort is set we omit reasoning_effort so DeepSeek applies its
# server default (currently high).
if isinstance(reasoning_config, dict):
from agent.reasoning_effort import (
DEEPSEEK_V4_EFFORTS,
DEEPSEEK_V4_OVERRIDES,
clamp_effort,
)
effort = (reasoning_config.get("effort") or "").strip().lower()
if effort and effort != "none":
clamped = clamp_effort(
effort, DEEPSEEK_V4_EFFORTS, DEEPSEEK_V4_OVERRIDES
)
if clamped in DEEPSEEK_V4_EFFORTS:
top_level["reasoning_effort"] = clamped
return extra_body, top_level
deepseek = DeepSeekProfile(
name="deepseek",
aliases=("deepseek-chat",),
env_vars=("DEEPSEEK_API_KEY",),
display_name="DeepSeek",
description="DeepSeek — native DeepSeek API",
signup_url="https://platform.deepseek.com/",
fallback_models=(
"deepseek-v4-pro",
"deepseek-v4-flash",
),
base_url="https://api.deepseek.com/v1",
default_aux_model="deepseek-v4-flash",
)
register_provider(deepseek)
@@ -0,0 +1,5 @@
name: deepseek-provider
kind: model-provider
version: 1.0.0
description: DeepSeek
author: Nous Research
@@ -0,0 +1,46 @@
"""Fireworks AI provider profile.
Fireworks AI serves fast, production-grade inference for open and proprietary
models through an OpenAI-compatible chat-completions endpoint.
Address models directly by their catalog ID, e.g.
``accounts/fireworks/models/kimi-k2p6`` or ``accounts/fireworks/models/glm-5p2``.
Model IDs here track the canonical Fireworks catalog (fw-ai/fireconnect
``setup-cli``).
"""
from hermes_cli import __version__ as _HERMES_VERSION
from providers import register_provider
from providers.base import ProviderProfile
fireworks = ProviderProfile(
name="fireworks",
aliases=("fireworks-ai", "fw"),
display_name="Fireworks AI",
description="Fireworks AI — OpenAI-compatible direct model API",
signup_url="https://app.fireworks.ai/settings/users/api-keys",
env_vars=("FIREWORKS_API_KEY",),
base_url="https://api.fireworks.ai/inference/v1",
auth_type="api_key",
# Attribution headers sent on every Fireworks request. Values match the
# canonical Hermes set in agent/auxiliary_client.py. Applied through the
# generic profile.default_headers path, so they survive switch_model and
# credential rotation.
default_headers={
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
},
# Auxiliary model for cheap tasks (compaction, title generation, vision).
# A standard pay-as-you-go catalog ``/models/`` ID.
default_aux_model="accounts/fireworks/models/glm-5p2",
# Curated safety net shown in the picker when the live catalog fetch fails.
fallback_models=(
"accounts/fireworks/models/kimi-k2p6",
"accounts/fireworks/models/glm-5p2",
"accounts/fireworks/models/kimi-k2p7-code",
),
)
register_provider(fireworks)
@@ -0,0 +1,5 @@
name: fireworks-provider
kind: model-provider
version: 1.0.0
description: Fireworks AI — fast inference for open and proprietary models
author: Alex Jestin Taylor (@alex-fireworks) + Hermes Agent
@@ -0,0 +1,61 @@
"""Google Gemini provider profiles.
gemini: Google AI Studio (API key) — uses GeminiNativeClient
Reports api_mode="chat_completions" but uses a custom native client
that bypasses the standard OpenAI transport. The profile captures auth
and endpoint metadata for auth.py / runtime_provider.py migration, and
carries the thinking_config translation hook so the transport's profile
path produces the same extra_body shape the legacy flag path did.
"""
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
class GeminiProfile(ProviderProfile):
"""Gemini — translate reasoning_config to thinking_config in extra_body."""
def build_extra_body(
self, *, session_id: str | None = None, **context: Any
) -> dict[str, Any]:
"""Emit extra_body.thinking_config (native) or extra_body.extra_body.google.thinking_config
(OpenAI-compat /openai subpath), mirroring the legacy path's behavior.
"""
from agent.transports.chat_completions import (
_build_gemini_thinking_config,
_is_gemini_openai_compat_base_url,
_snake_case_gemini_thinking_config,
)
model = context.get("model") or ""
reasoning_config = context.get("reasoning_config")
base_url = context.get("base_url") or self.base_url
raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config)
if not raw_thinking_config:
return {}
body: dict[str, Any] = {}
if self.name == "gemini" and _is_gemini_openai_compat_base_url(base_url):
thinking_config = _snake_case_gemini_thinking_config(raw_thinking_config)
if thinking_config:
body["extra_body"] = {"google": {"thinking_config": thinking_config}}
else:
body["thinking_config"] = raw_thinking_config
return body
gemini = GeminiProfile(
name="gemini",
aliases=("google", "google-gemini", "google-ai-studio"),
api_mode="chat_completions",
env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
base_url="https://generativelanguage.googleapis.com/v1beta",
auth_type="api_key",
default_aux_model="gemini-3.6-flash",
)
register_provider(gemini)
@@ -0,0 +1,5 @@
name: gemini-provider
kind: model-provider
version: 1.0.0
description: Google Gemini (API key + Cloud Code OAuth)
author: Nous Research
+32
View File
@@ -0,0 +1,32 @@
"""GMI Cloud provider profile."""
from hermes_cli import __version__ as _HERMES_VERSION
from providers import register_provider
from providers.base import ProviderProfile
gmi = ProviderProfile(
name="gmi",
aliases=("gmi-cloud", "gmicloud"),
display_name="GMI Cloud",
description="GMI Cloud — multi-model direct API (slash-form model IDs)",
signup_url="https://www.gmicloud.ai/",
env_vars=("GMI_API_KEY", "GMI_BASE_URL"),
base_url="https://api.gmi-serving.com/v1",
auth_type="api_key",
# Attribution so GMI can identify traffic from Hermes Agent.
# The generic profile.default_headers fallback in run_agent.py and
# agent/auxiliary_client.py picks this up at client construction time.
default_headers={"User-Agent": f"HermesAgent/{_HERMES_VERSION}"},
default_aux_model="google/gemini-3.1-flash-lite-preview",
fallback_models=(
"zai-org/GLM-5.1-FP8",
"deepseek-ai/DeepSeek-V3.2",
"moonshotai/Kimi-K2.5",
"google/gemini-3.1-flash-lite-preview",
"anthropic/claude-sonnet-5",
"anthropic/claude-sonnet-4.6",
"openai/gpt-5.4",
),
)
register_provider(gmi)
+5
View File
@@ -0,0 +1,5 @@
name: gmi-provider
kind: model-provider
version: 1.0.0
description: GMI Cloud
author: Nous Research
@@ -0,0 +1,20 @@
"""Hugging Face provider profile."""
from providers import register_provider
from providers.base import ProviderProfile
huggingface = ProviderProfile(
name="huggingface",
aliases=("hf", "hugging-face", "huggingface-hub"),
env_vars=("HF_TOKEN",),
display_name="HuggingFace",
description="HuggingFace Inference API",
signup_url="https://huggingface.co/settings/tokens",
fallback_models=(
"Qwen/Qwen3.5-72B-Instruct",
"deepseek-ai/DeepSeek-V3.2",
),
base_url="https://router.huggingface.co/v1",
)
register_provider(huggingface)
@@ -0,0 +1,5 @@
name: huggingface-provider
kind: model-provider
version: 1.0.0
description: HuggingFace Inference Providers
author: Nous Research
@@ -0,0 +1,14 @@
"""Kilo Code provider profile."""
from providers import register_provider
from providers.base import ProviderProfile
kilocode = ProviderProfile(
name="kilocode",
aliases=("kilo-code", "kilo", "kilo-gateway"),
env_vars=("KILOCODE_API_KEY",),
base_url="https://api.kilo.ai/api/gateway",
default_aux_model="google/gemini-3.6-flash",
)
register_provider(kilocode)
@@ -0,0 +1,5 @@
name: kilocode-provider
kind: model-provider
version: 1.0.0
description: Kilo Code
author: Nous Research
@@ -0,0 +1,144 @@
"""Kimi / Moonshot provider profiles.
Kimi has dual endpoints:
- sk-kimi-* keys → api.kimi.com/coding (Anthropic Messages API)
- legacy keys → api.moonshot.ai/v1 (OpenAI chat completions)
This module covers the chat_completions path (/v1 endpoint).
"""
from typing import Any
from urllib.parse import urlparse
from hermes_cli import __version__ as _HERMES_VERSION
from providers import register_provider
from providers.base import OMIT_TEMPERATURE, ProviderProfile
def _is_confirmed_kimi_coding_url(base_url: str) -> bool:
"""Return True only for Kimi Code's canonical HTTPS API surfaces."""
try:
parsed = urlparse(base_url)
port = parsed.port
except ValueError:
return False
return (
parsed.scheme.lower() == "https"
and (parsed.hostname or "").lower() == "api.kimi.com"
and port in (None, 443)
and parsed.username is None
and parsed.password is None
and parsed.path.rstrip("/") in {"/coding", "/coding/v1"}
and not parsed.query
and not parsed.fragment
)
class KimiProfile(ProviderProfile):
"""Kimi/Moonshot — temperature omitted, thinking xor reasoning_effort."""
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
"""Use Kimi Code's OpenAI-compatible surface for model discovery."""
effective_base = (base_url or self.base_url or "").rstrip("/")
confirmed_coding_endpoint = _is_confirmed_kimi_coding_url(effective_base)
if confirmed_coding_endpoint and urlparse(effective_base).path.rstrip("/") == "/coding":
effective_base += "/v1"
models = super().fetch_models(
api_key=api_key,
base_url=effective_base or None,
timeout=timeout,
)
if models is None or confirmed_coding_endpoint:
return models
return [model for model in models if model.strip().lower() != "k3"]
def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, **context
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Kimi reasoning controls.
Moonshot's wire shape treats ``extra_body.thinking`` (a binary toggle)
and a top-level ``reasoning_effort`` as mutually exclusive — sending
both is at best redundant and risks "cannot specify both 'thinking' and
'reasoning_effort'" (HTTP 400). This mirrors the kimi-k2 handling on the
opencode-go relay: send effort when one is requested, otherwise fall
back to ``extra_body.thinking`` — never both.
"""
extra_body = {}
top_level = {}
if not reasoning_config or not isinstance(reasoning_config, dict):
# No config → thinking enabled, let the server pick the depth.
# (Previously also sent reasoning_effort="medium", which paired
# thinking + effort on every default call.)
extra_body["thinking"] = {"type": "enabled"}
return extra_body, top_level
enabled = reasoning_config.get("enabled", True)
if enabled is False:
extra_body["thinking"] = {"type": "disabled"}
return extra_body, top_level
# Enabled: prefer an explicit effort; only fall back to extra_body
# thinking when no recognized effort is requested.
# K3's vocabulary (low/high/max, default high) and its documented
# rounding (medium→high, xhigh→max) are declared in
# agent.reasoning_effort — shared with the chat-completions
# transport's Kimi path so both stay in sync.
from agent.reasoning_effort import (
KIMI_K3_EFFORTS,
KIMI_K3_OVERRIDES,
clamp_effort,
)
effort = (reasoning_config.get("effort") or "").strip().lower()
if effort and effort != "none":
k3_effort = clamp_effort(effort, KIMI_K3_EFFORTS, KIMI_K3_OVERRIDES)
else:
k3_effort = None
if k3_effort in KIMI_K3_EFFORTS:
top_level["reasoning_effort"] = k3_effort
else:
extra_body["thinking"] = {"type": "enabled"}
return extra_body, top_level
kimi = KimiProfile(
name="kimi-coding",
aliases=("kimi", "moonshot", "kimi-for-coding"),
env_vars=("KIMI_API_KEY", "KIMI_CODING_API_KEY"),
base_url="https://api.moonshot.ai/v1",
fixed_temperature=OMIT_TEMPERATURE,
default_max_tokens=32000,
default_headers={
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
},
default_aux_model="kimi-k2-turbo-preview",
)
kimi_cn = KimiProfile(
name="kimi-coding-cn",
aliases=("kimi-cn", "moonshot-cn"),
env_vars=("KIMI_CN_API_KEY",),
base_url="https://api.moonshot.cn/v1",
fixed_temperature=OMIT_TEMPERATURE,
default_max_tokens=32000,
default_headers={
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
},
default_aux_model="kimi-k2-turbo-preview",
)
register_provider(kimi)
register_provider(kimi_cn)
@@ -0,0 +1,5 @@
name: kimi-coding-provider
kind: model-provider
version: 1.0.0
description: Moonshot Kimi Coding (global + China)
author: Nous Research
+141
View File
@@ -0,0 +1,141 @@
"""Meta Model API (Muse Spark) provider plugin for Hermes Agent.
Provider profile for Meta Superintelligence Labs' Muse Spark family, served
via the OpenAI-compatible Meta Model API at ``https://api.meta.ai/v1``.
Bundled from https://github.com/albertodepaola/hermes-meta-provider. Hermes'
provider discovery (``providers/__init__.py``) imports it on first
``get_provider_profile()`` / ``list_providers()`` call, and the module-level
``register_provider()`` below wires it into the registry.
Design notes
------------
* **Zero core edits.** Everything rides on ``ProviderProfile`` hooks. No changes
to hermes' ``model_metadata.py`` / ``models.py`` / ``run_agent.py`` are needed:
- Context window (1M), reasoning and vision capabilities already resolve from
models.dev for the muse-spark family, so no static ctx table entry is required.
- The reasoning dial is emitted as a **top-level ``reasoning_effort``** kwarg
(returned in the ``top_level`` slot of ``build_api_kwargs_extras``), which the
chat-completions transport merges unconditionally. This deliberately avoids
the ``extra_body.reasoning`` path, whose emission is gated by a hardcoded
host allowlist in core (``AIAgent._supports_reasoning_extra_body``) that a
third-party plugin must not edit.
* **Meta 400 on ``reasoning_effort: "none"``.** Muse rejects ``none``; disabling
reasoning maps to ``"minimal"`` instead.
* **``default_max_tokens=16384``.** Muse spends completion budget on hidden
reasoning tokens first; small caps can finish with empty content.
"""
from __future__ import annotations
import os
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
def _resolve_effort(reasoning_config: dict | None) -> str:
"""Map Hermes' reasoning_config to a Meta-safe ``reasoning_effort`` value.
Meta's vocabulary (minimal..xhigh; rejects ``none``) is declared in
agent.reasoning_effort. Disabled/"none" maps to ``minimal`` (the closest
Meta has to off); unset/bespoke levels fall to ``medium``.
"""
rc = reasoning_config or {}
if rc.get("enabled") is False:
return "minimal"
effort = str(rc.get("effort") or "").strip().lower()
if effort in {"", "none"}:
return "minimal" if effort == "none" else "medium"
from agent.reasoning_effort import META_AI_EFFORTS, clamp_effort
clamped = clamp_effort(effort, META_AI_EFFORTS)
return clamped if clamped in META_AI_EFFORTS else "medium"
class MetaAIProfile(ProviderProfile):
"""Meta Model API — top-level reasoning_effort, self-contained."""
# Non-chat model prefixes excluded from the agent picker. The live
# /v1/models catalog includes image-generation and transcription models
# that are not suitable for agentic chat.
_NON_CHAT_PREFIXES = ("muse-image-", "muse-voice-")
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
"""Fetch and filter the live catalog, excluding non-chat models."""
live = super().fetch_models(api_key=api_key, base_url=base_url, timeout=timeout)
if live is None:
return None
return [
m for m in live
if not any(m.startswith(p) for p in self._NON_CHAT_PREFIXES)
]
def build_api_kwargs_extras(
self,
*,
reasoning_config: dict | None = None,
supports_reasoning: bool = False, # noqa: ARG002 — we self-gate below
**context: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Emit ``reasoning_effort`` as a top-level api kwarg.
We ignore the core ``supports_reasoning`` gate on purpose: that flag is
driven by a host allowlist in core we cannot (and should not) edit from
an out-of-tree plugin. Muse Spark always accepts ``reasoning_effort``,
so we resolve it from ``reasoning_config`` directly.
"""
return {}, {"reasoning_effort": _resolve_effort(reasoning_config)}
def _base_url() -> str:
"""Allow a base-URL override via ``META_BASE_URL`` without editing config."""
return os.getenv("META_BASE_URL", "").strip() or "https://api.meta.ai/v1"
meta_ai = MetaAIProfile(
name="meta-ai",
aliases=("meta", "muse", "muse-spark", "model-api", "msl"),
display_name="Meta Model API",
description="Meta Muse Spark family (Meta Superintelligence Labs)",
signup_url="https://developer.meta.com/ai/",
# MODEL_API_KEY is Meta's documented env var; the aliases are conveniences.
env_vars=("MODEL_API_KEY", "META_API_KEY", "META_MODEL_API_KEY", "META_BASE_URL"),
base_url=_base_url(),
auth_type="api_key",
# Responses API is the wire that engages Muse prompt caching: measured
# 0 cached tokens on /v1/chat/completions vs 93-99% cache hits on
# /v1/responses with prompt_cache_retention (see host_mandated_api_mode
# in hermes_cli/providers.py and the retention hint in
# agent/transports/codex.py). The MetaAIProfile chat-completions hook
# above still covers custom OpenAI-compatible endpoints configured with
# a non-api.meta.ai base URL, which fall through to chat_completions.
api_mode="codex_responses",
# Muse Spark is natively multimodal (image/video/pdf/audio in, text out).
supports_vision=True,
# ...but only on user turns: an image envelope inside a role:tool message
# 400s "messages[N].content did not match any supported type" (#101668).
supports_vision_tool_messages=False,
# Cheap contributor tier is a good default for auxiliary tasks
# (compaction, title generation, vision) when this is the main provider.
default_aux_model="muse-spark-1.2-contributor",
# Muse spends completion budget on hidden reasoning tokens first; a low cap
# can finish with empty content. 16k is a safe floor.
default_max_tokens=16384,
# Minimal fallback shown when the live /v1/models fetch fails or no
# credentials are configured yet. Keep this list small — just enough so
# the picker isn't empty when the API is unreachable.
fallback_models=(
"muse-spark-1.2",
),
)
register_provider(meta_ai)
@@ -0,0 +1,6 @@
name: meta-ai-provider
kind: model-provider
version: 1.0.0
description: Meta Model API — Muse Spark family (Meta Superintelligence Labs)
author: Beto de Paola
homepage: https://github.com/albertodepaola/hermes-meta-provider
@@ -0,0 +1,97 @@
"""MiniMax provider profiles (international + China).
The default API-key routes use anthropic_messages because their base URLs end
with /anthropic. Users can opt MiniMax-M3 into the OpenAI-compatible endpoint
with base_url=https://api.minimax.io/v1; that route needs MiniMax-specific
reasoning controls in extra_body.
"""
from typing import Any
from urllib.parse import urlparse
from providers import register_provider
from providers.base import ProviderProfile
def _is_minimax_global_openai_base_url(base_url: str | None) -> bool:
parsed = urlparse(str(base_url or "").strip())
if (parsed.hostname or "").lower() != "api.minimax.io":
return False
path = parsed.path.rstrip("/").lower()
return path == "/v1"
def _is_minimax_m3(model: str | None) -> bool:
normalized = str(model or "").strip().lower()
return normalized in {"minimax-m3", "minimax/minimax-m3"}
class MiniMaxProfile(ProviderProfile):
"""MiniMax — M3 OpenAI-compatible reasoning controls."""
def build_api_kwargs_extras(
self,
*,
reasoning_config: dict | None = None,
model: str | None = None,
base_url: str | None = None,
**context: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Emit M3 reasoning controls for api.minimax.io/v1.
MiniMax-M3's OpenAI-compatible endpoint keeps thinking inline unless
``reasoning_split`` is sent, so always request the split format on that
route. ``thinking`` controls the M3 mode; Hermes' effort levels are not
a MiniMax depth knob here, so they only select adaptive vs disabled.
"""
if not _is_minimax_global_openai_base_url(base_url) or not _is_minimax_m3(model):
return {}, {}
extra_body: dict[str, Any] = {"reasoning_split": True}
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False:
extra_body["thinking"] = {"type": "disabled"}
return extra_body, {}
if reasoning_config is not None:
extra_body["thinking"] = {"type": "adaptive"}
return extra_body, {}
minimax = MiniMaxProfile(
name="minimax",
aliases=("mini-max",),
api_mode="anthropic_messages",
env_vars=("MINIMAX_API_KEY",),
base_url="https://api.minimax.io/anthropic",
auth_type="api_key",
default_aux_model="MiniMax-M3",
)
minimax_cn = MiniMaxProfile(
name="minimax-cn",
aliases=("minimax-china", "minimax_cn"),
api_mode="anthropic_messages",
env_vars=("MINIMAX_CN_API_KEY",),
base_url="https://api.minimaxi.com/anthropic",
auth_type="api_key",
default_aux_model="MiniMax-M3",
)
minimax_oauth = MiniMaxProfile(
name="minimax-oauth",
aliases=("minimax_oauth", "minimax-oauth-io"),
api_mode="anthropic_messages",
display_name="MiniMax (OAuth)",
description="MiniMax via OAuth browser flow — no API key required",
signup_url="https://api.minimax.io/",
env_vars=(), # OAuth — tokens in auth.json, not env
base_url="https://api.minimax.io/anthropic",
auth_type="oauth_external",
default_aux_model="MiniMax-M2.7",
)
register_provider(minimax)
register_provider(minimax_cn)
register_provider(minimax_oauth)
@@ -0,0 +1,5 @@
name: minimax-provider
kind: model-provider
version: 1.0.0
description: MiniMax M-series (global + China + OAuth)
author: Nous Research
@@ -0,0 +1,104 @@
"""Nebius Token Factory provider profile."""
from __future__ import annotations
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
def _flat_model_name(model: str | None) -> str:
"""Return a lowercase model id, tolerating vendor-prefixed IDs."""
return (model or "").strip().rsplit("/", 1)[-1].lower()
def _model_supports_reasoning_effort(model: str | None) -> bool:
"""Conservative allowlist for Nebius models that expose reasoning effort."""
model_name = _flat_model_name(model)
if not model_name:
return False
return any(
marker in model_name
for marker in (
"deepseek-r1",
"deepseek-v4",
"deepseek-reasoner",
"gpt-oss",
"glm-5",
"kimi-k2",
"minimax-m2",
"qwen3",
)
)
class NebiusTokenFactoryProfile(ProviderProfile):
"""Nebius Token Factory - top-level reasoning_effort."""
def build_api_kwargs_extras(
self,
*,
reasoning_config: dict | None = None,
model: str | None = None,
supports_reasoning: bool = False,
**context: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
if not supports_reasoning and not _model_supports_reasoning_effort(model):
return {}, {}
if isinstance(reasoning_config, dict):
enabled = reasoning_config.get("enabled", True)
raw_effort = reasoning_config.get("effort", "medium")
else:
enabled = True
raw_effort = "medium"
effort = str(raw_effort or "medium").strip().lower()
if enabled is False or effort in {"none", "off", "disabled"}:
return {}, {}
# Canonical clamp (nearest weaker supported level, never escalate,
# monotonic) — the hand-rolled map this replaces inverted the ladder:
# ultra fell through to medium while xhigh mapped to high.
from agent.reasoning_effort import NEBIUS_EFFORTS, clamp_effort
effort = clamp_effort(effort, NEBIUS_EFFORTS) or "medium"
return {}, {"reasoning_effort": effort}
nebius_token_factory = NebiusTokenFactoryProfile(
name="nebius-token-factory",
aliases=(
"nebius",
"nebius-tokenfactory",
"nebius-tf",
"token-factory",
"tokenfactory",
),
display_name="Nebius Token Factory",
description="Nebius Token Factory — OpenAI-compatible inference",
signup_url="https://tokenfactory.nebius.com/",
env_vars=(
"NEBIUS_API_KEY",
"NEBIUS_TOKEN_FACTORY_API_KEY",
"NEBIUS_BASE_URL",
),
base_url="https://api.tokenfactory.nebius.com/v1",
models_url="https://api.tokenfactory.nebius.com/v1/models?verbose=true",
auth_type="api_key",
default_aux_model="nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B",
fallback_models=(
"Qwen/Qwen3.5-397B-A17B-fast",
"deepseek-ai/DeepSeek-V4-Pro",
"zai-org/GLM-5.1",
"moonshotai/Kimi-K2.5-fast",
"MiniMaxAI/MiniMax-M2.5-fast",
"deepseek-ai/DeepSeek-V3.2-fast",
"NousResearch/Hermes-4-70B",
"openai/gpt-oss-120b-fast",
"meta-llama/Llama-3.3-70B-Instruct",
),
)
register_provider(nebius_token_factory)
@@ -0,0 +1,5 @@
name: nebius-token-factory-provider
kind: model-provider
version: 1.0.0
description: Nebius Token Factory OpenAI-compatible inference
author: Nous Research
+149
View File
@@ -0,0 +1,149 @@
"""Nous Portal provider profile."""
from typing import Any
from agent.portal_tags import (
get_affinity_scope,
get_conversation_context,
nous_portal_tags,
)
from agent.transports.codex import _cache_scope_from_session_id
from providers import register_provider
from providers.base import ProviderProfile
class NousProfile(ProviderProfile):
"""Nous Portal — product tags, reasoning with Nous-specific omission."""
def resolve_aux_model(self, *, vision: bool = False) -> str:
"""Ask the Portal which cheap model it currently recommends.
``/api/nous/recommended-models`` is the authoritative, tier-aware
source (free vs paid), so the auxiliary fast tier tracks the live
catalog instead of a hardcoded id that 404s the day Nous retires it.
The underlying fetch is memory- and disk-cached with a last-known-good
fallback, so this is cheap to call and safe offline.
"""
try:
from hermes_cli.models import get_nous_recommended_aux_model
return get_nous_recommended_aux_model(vision=vision) or ""
except Exception:
return ""
def build_extra_body(
self, *, session_id: str | None = None, **context
) -> dict[str, Any]:
body: dict[str, Any] = {"tags": nous_portal_tags(session_id=session_id)}
# Top-level session_id → provider sticky routing key. Pins every
# turn of a session to the same upstream endpoint so explicit
# Anthropic cache_control breakpoints stay warm instead of
# cold-writing a fresh cache on each reroute (Anthropic/Vertex/
# Bedrock caches are instance-local). Mirrors the OpenRouter
# profile; without it the portal falls back to hashing the opening
# messages, which breaks pinning whenever those shift.
#
# Resolve it exactly like ``nous_portal_tags`` resolves the
# ``conversation=`` tag: ambient context first (the lineage ROOT id
# published by the agent loop), explicit argument as fallback.
#
# The gap this closes is the auxiliary call sites — compression,
# title generation, vision, web_extract, session_search, MoA slots.
# They funnel through ``agent.auxiliary_client`` which has no session
# handle, so they never pass ``session_id``: they carried the
# ``conversation=`` tag but NO sticky key at all, and each one routed
# independently of the conversation it belongs to. Reading the same
# ambient contextvar the tag already uses fixes that with zero
# per-call-site plumbing; a host-declared routing scope (#96811) wins
# over it when one was published for this turn.
#
# For the main loop the two agree anyway under the default
# ``compression.in_place: true`` (#38763), where compaction keeps the
# session id; the ambient root additionally keeps the key stable for
# installs that opt back into rotating compaction, and across
# delegate-subagent trees.
sticky_key = _cache_scope_from_session_id(
get_affinity_scope() or get_conversation_context() or session_id
)
if sticky_key:
body["session_id"] = sticky_key
provider_preferences = context.get("provider_preferences")
if provider_preferences:
body["provider"] = provider_preferences
return body
@staticmethod
def _cannot_disable_reasoning(model: str | None) -> bool:
"""True when a disable can't safely be sent for *model*.
Reasoning-mandatory routes answer ``reasoning: {enabled: false}``
with HTTP 400 ("Reasoning is mandatory for this model"), so the
catalog decides. Cache-only, and an unknown model (catalog cold,
unlisted, or unreachable) also answers True: a cold first turn errs
toward the old omit-everything behavior rather than risking a 400.
A route the catalog says takes no reasoning parameter at all is
treated the same way — sending it a disable is sending a parameter
the Portal has told us it doesn't accept.
"""
try:
from hermes_cli.models import (
nous_model_reasoning_capabilities,
warm_nous_reasoning_caps_async,
)
caps = nous_model_reasoning_capabilities(model)
if caps is None:
warm_nous_reasoning_caps_async()
return True
except Exception:
return True
if not caps.get("supports_reasoning"):
return True
return bool(caps.get("mandatory"))
def build_api_kwargs_extras(
self,
*,
reasoning_config: dict | None = None,
supports_reasoning: bool = False,
model: str | None = None,
**context,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Nous: passes the full reasoning_config, disable included.
The Portal honors ``reasoning: {enabled: false}`` — it is the only
wire shape that does. Sending nothing means the *upstream* default,
which for a thinking-first model like ``deepseek/deepseek-v4-pro``
(catalog: ``default_effort: high``) is thinking ON, so omitting a
disable silently ignored the user's "thinking off".
"""
extra_body = {}
if supports_reasoning:
if reasoning_config is not None:
rc = dict(reasoning_config)
if rc.get("enabled") is False and self._cannot_disable_reasoning(model):
pass # route rejects a disable — let the model think
else:
extra_body["reasoning"] = rc
else:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
return extra_body, {}
nous = NousProfile(
name="nous",
aliases=("nous-portal", "nousresearch"),
env_vars=("NOUS_API_KEY",),
display_name="Nous Research",
description="Nous Research — Hermes model family",
signup_url="https://nousresearch.com/",
fallback_models=(
"hermes-3-405b",
"hermes-3-70b",
),
base_url="https://inference-api.nousresearch.com/v1",
auth_type="oauth_device_code",
)
register_provider(nous)
+5
View File
@@ -0,0 +1,5 @@
name: nous-provider
kind: model-provider
version: 1.0.0
description: Nous Research Portal
author: Nous Research
@@ -0,0 +1,27 @@
"""NovitaAI provider profile."""
from providers import register_provider
from providers.base import ProviderProfile
novita = ProviderProfile(
name="novita",
aliases=("novita-ai", "novitaai"),
display_name="NovitaAI",
description="NovitaAI — AI-native cloud for builders and agents",
signup_url="https://novita.ai/settings/key-management",
env_vars=("NOVITA_API_KEY", "NOVITA_BASE_URL"),
base_url="https://api.novita.ai/openai/v1",
auth_type="api_key",
default_aux_model="deepseek/deepseek-v3-0324",
fallback_models=(
"moonshotai/kimi-k2.5",
"minimax/minimax-m2.7",
"zai-org/glm-5",
"deepseek/deepseek-v3-0324",
"deepseek/deepseek-r1-0528",
"qwen/qwen3-235b-a22b-fp8",
),
)
register_provider(novita)
@@ -0,0 +1,5 @@
name: novita-provider
kind: model-provider
version: 1.0.0
description: NovitaAI AI-native cloud for builders and agents
author: Nous Research
@@ -0,0 +1,59 @@
"""NVIDIA NIM provider profile."""
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
class NvidiaProviderProfile(ProviderProfile):
"""NVIDIA NIM accepts a stricter ToolMessage schema than most OpenAI-compatible APIs."""
def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
needs_sanitize = any(
isinstance(msg, dict)
and msg.get("role") == "tool"
and ("name" in msg or "tool_name" in msg)
for msg in messages
)
if not needs_sanitize:
return messages
# Copy-on-write: shallow outer-list copy, then a shallow dict copy
# only for the role:"tool" messages that actually need a field
# dropped. Avoids recursively deep-copying every message's content
# (including large tool outputs and attachments) for a turn that
# only ever needs to touch two top-level keys on a handful of
# messages. Matches the pattern already used by the shared
# sanitizer in agent/transports/chat_completions.py and by
# QwenProfile.prepare_messages().
sanitized = list(messages)
for idx, msg in enumerate(messages):
if (
isinstance(msg, dict)
and msg.get("role") == "tool"
and ("name" in msg or "tool_name" in msg)
):
msg_copy = dict(msg)
msg_copy.pop("name", None)
msg_copy.pop("tool_name", None)
sanitized[idx] = msg_copy
return sanitized
nvidia = NvidiaProviderProfile(
name="nvidia",
aliases=("nvidia-nim",),
env_vars=("NVIDIA_API_KEY",),
display_name="NVIDIA NIM",
description="NVIDIA NIM — accelerated inference",
signup_url="https://build.nvidia.com/",
fallback_models=(
"nvidia/llama-3.1-nemotron-70b-instruct",
"nvidia/llama-3.3-70b-instruct",
),
base_url="https://integrate.api.nvidia.com/v1",
default_max_tokens=16384,
)
register_provider(nvidia)
@@ -0,0 +1,5 @@
name: nvidia-provider
kind: model-provider
version: 1.0.0
description: NVIDIA NIM
author: Nous Research
@@ -0,0 +1,95 @@
"""Ollama Cloud provider profile.
Ollama Cloud's OpenAI-compatible ``/v1/chat/completions`` endpoint
supports top-level ``reasoning_effort`` with values ``none``, ``low``,
``medium``, ``high``, and ``max`` (the last being undocumented but
empirically confirmed for DeepSeek V4 — ``max`` produces ~2.5× more
thinking tokens than ``high``).
This profile maps Hermes's ``xhigh`` → ``max`` to unlock DeepSeek V4's
"Max thinking" tier through Ollama Cloud. ``low`` / ``medium`` / ``high``
pass through unchanged.
When reasoning is explicitly disabled (``enabled: false`` or
``effort: "none"``), ``reasoning_effort`` is omitted entirely so the
model runs in non-thinking mode.
"""
from __future__ import annotations
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
class OllamaCloudProfile(ProviderProfile):
"""Ollama Cloud — maps xhigh→max via top-level reasoning_effort."""
def build_api_kwargs_extras(
self,
*,
reasoning_config: dict | None = None,
supports_reasoning: bool = False,
**ctx: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Emit top-level ``reasoning_effort`` for Ollama Cloud thinking models.
Gated on ``supports_reasoning``, which the transport resolves from the
model's native ``/api/show`` ``capabilities`` (``thinking``). Models
without the thinking capability (e.g. ``gemma3``, ``qwen3-coder``) get
no ``reasoning_effort`` at all — emitting it there is a no-op the API
ignores, and gating avoids sending a meaningless field.
"""
top_level: dict[str, Any] = {}
if not supports_reasoning:
return {}, {}
if reasoning_config and isinstance(reasoning_config, dict):
enabled = reasoning_config.get("enabled", True)
if enabled is False:
# Ollama Cloud defaults to thinking ON, and ignores the
# extra_body.thinking:{type:disabled} shape (verified live).
# The ONLY way to actually suppress thinking on its
# /v1/chat/completions endpoint is top-level
# reasoning_effort:"none" — omitting the field leaves
# thinking on.
return {}, {"reasoning_effort": "none"}
effort = (reasoning_config.get("effort") or "").strip().lower()
if not effort:
# No explicit effort requested — let the model decide
# (Ollama Cloud's server default is thinking ON).
return {}, {}
if effort == "none":
return {}, {"reasoning_effort": "none"} # explicit off switch
# Accepted set {none, low, medium, high, max} is declared in
# agent.reasoning_effort ("minimal" is rejected with HTTP 400 →
# clamps to low; xhigh rounds up to max). Bespoke levels outside
# the ladder are omitted so the model applies its own default
# rather than triggering a hard 400.
from agent.reasoning_effort import (
OLLAMA_CLOUD_EFFORTS,
OLLAMA_CLOUD_OVERRIDES,
clamp_effort,
)
clamped = clamp_effort(
effort, OLLAMA_CLOUD_EFFORTS, OLLAMA_CLOUD_OVERRIDES
)
if clamped in OLLAMA_CLOUD_EFFORTS:
top_level["reasoning_effort"] = clamped
return {}, top_level
ollama_cloud = OllamaCloudProfile(
name="ollama-cloud",
aliases=("ollama_cloud",),
default_aux_model="nemotron-3-nano:30b",
env_vars=("OLLAMA_API_KEY",),
base_url="https://ollama.com/v1",
)
register_provider(ollama_cloud)
@@ -0,0 +1,5 @@
name: ollama-cloud-provider
kind: model-provider
version: 1.0.0
description: Ollama Cloud
author: Nous Research
@@ -0,0 +1,15 @@
"""OpenAI Codex (Responses API) provider profile."""
from providers import register_provider
from providers.base import ProviderProfile
openai_codex = ProviderProfile(
name="openai-codex",
aliases=("codex", "openai_codex"),
api_mode="codex_responses",
env_vars=(), # OAuth external — no API key
base_url="https://chatgpt.com/backend-api/codex",
auth_type="oauth_external",
)
register_provider(openai_codex)
@@ -0,0 +1,5 @@
name: openai-codex-provider
kind: model-provider
version: 1.0.0
description: OpenAI Codex (Responses API)
author: Nous Research
@@ -0,0 +1,67 @@
"""OpenCode Free provider profile.
OpenCode's free model tier on the Zen relay (https://opencode.ai/zen/v1).
KEYLESS: the relay serves free-tier models anonymously and rejects any
Authorization bearer it doesn't recognize with 401 — so this provider
never sends a credential at all (the runtime resolver pins the keyless
placeholder and an empty Authorization header; see
hermes_cli.models.opencode_zen_free_runtime). No OpenCode account needed.
Select via ``hermes model`` or ``/model free``.
"""
from typing import Any
from hermes_cli import __version__ as _HERMES_VERSION
from providers import register_provider
from providers.base import ProviderProfile
# Attribution headers, same values as the opencode-zen/go profiles, plus the
# empty Authorization override that keeps the SDK's "Bearer <placeholder>"
# off the wire (the free tier 401s any unrecognized bearer).
_KEYLESS_HEADERS = {
"Authorization": "",
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
}
class OpenCodeFreeProfile(ProviderProfile):
"""OpenCode Free — keyless, with Ox Alpha reasoning controls.
Ox Alpha (x-preview-f-free) is reachable through this provider as well
as opencode-zen; both share the same wire contract (reasoning_effort
accepts exactly low/high/max — anything else 400s). The translation
lives in the zen plugin; resolve it through the registered zen profile's
module so the two providers can never drift.
"""
def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
) -> tuple[dict[str, Any], dict[str, Any]]:
try:
import sys
from providers import get_provider_profile
zen_profile = get_provider_profile("opencode-zen")
zen_module = sys.modules[type(zen_profile).__module__]
return zen_module._build_ox_alpha_reasoning_extras(reasoning_config, model)
except Exception:
return {}, {}
opencode_free = OpenCodeFreeProfile(
name="opencode-free",
aliases=("free", "opencode_free"),
env_vars=(), # keyless — nothing to configure
base_url="https://opencode.ai/zen/v1",
display_name="OpenCode Free",
description="OpenCode free models — keyless, no account needed",
default_headers=dict(_KEYLESS_HEADERS),
# laguna is the fastest non-UA-gated free model; big-pickle 429s every
# client except the opencode CLI's own User-Agent (verified 2026-08-21).
default_aux_model="laguna-s-2.1-free",
)
register_provider(opencode_free)
@@ -0,0 +1,5 @@
name: opencode-free-provider
kind: model-provider
version: 1.0.0
description: OpenCode Free Models
author: bilboquet
@@ -0,0 +1,221 @@
"""OpenCode provider profiles (Zen + Go).
Both use per-model api_mode routing:
- OpenCode Zen: Claude → anthropic_messages, GPT-5/Codex/Grok → codex_responses,
Muse Spark → codex_responses, everything else → chat_completions (this profile)
- OpenCode Go: GPT / Grok / Muse Spark → codex_responses, MiniMax/Qwen → anthropic_messages,
GLM/Kimi/DeepSeek/MiMo → chat_completions (this profile)
"""
from __future__ import annotations
from typing import Any
from hermes_cli import __version__ as _HERMES_VERSION
from providers import register_provider
from providers.base import ProviderProfile
# Attribution headers sent on every OpenCode request. Same values we send
# to OpenRouter, Vercel AI Gateway, and Fireworks. Going through
# profile.default_headers means they survive model switches and credential
# rotation. Without them OpenCode only sees the OpenAI SDK's generic
# "OpenAI/Python x.y.z" User-Agent and can't tell the traffic is Hermes Agent.
_ATTRIBUTION_HEADERS = {
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
}
def _flat_model_name(model: str | None) -> str:
"""Return the bare OpenCode model ID, tolerating aggregator prefixes."""
return (model or "").strip().rsplit("/", 1)[-1].lower()
def _is_kimi_k2_model(model: str | None) -> bool:
return _flat_model_name(model).startswith("kimi-k2")
def _is_deepseek_thinking_model(model: str | None) -> bool:
m = _flat_model_name(model)
if m.startswith("deepseek-v") and not m.startswith("deepseek-v3"):
return True
return m == "deepseek-reasoner"
def _is_glm_5_2_model(model: str | None) -> bool:
"""Detect GLM-5.2 across alias spellings (glm-5.2 / glm-5-2 / glm-5p2)."""
m = _flat_model_name(model)
return any(token in m for token in ("glm-5.2", "glm-5-2", "glm-5p2"))
class OpenCodeGoProfile(ProviderProfile):
"""OpenCode Go - model-specific reasoning controls."""
# Per-model completion-token cap. The opencode-go relay's default is
# too large for mimo-v2.5-pro — it sends max_tokens=262144 but Xiaomi
# only supports 131072 completion tokens and 400s the request.
# Setting an explicit cap here prevents the relay default from being
# applied. Keys are normalized via _flat_model_name().
_MODEL_MAX_TOKENS: dict[str, int] = {
"mimo-v2.5-pro": 131072,
}
def get_max_tokens(self, model: str | None) -> int | None:
cap = self._MODEL_MAX_TOKENS.get(_flat_model_name(model))
if cap is not None:
return cap
return self.default_max_tokens
def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
if _is_glm_5_2_model(model):
# GLM-5.2 on OpenCode Go uses its native OpenAI-compatible
# reasoning_effort knob (high/max — declared in
# agent.reasoning_effort, shared with the zai profile); leave the
# server default alone when reasoning is disabled or unset.
if not isinstance(reasoning_config, dict):
return extra_body, top_level
if reasoning_config.get("enabled") is False:
return extra_body, top_level
effort = (reasoning_config.get("effort") or "").strip().lower()
if not effort or effort == "none":
return extra_body, top_level
from agent.reasoning_effort import (
GLM52_EFFORTS,
GLM52_OVERRIDES,
clamp_effort,
)
clamped = clamp_effort(effort, GLM52_EFFORTS, GLM52_OVERRIDES)
top_level["reasoning_effort"] = (
clamped if clamped in GLM52_EFFORTS else "high"
)
return extra_body, top_level
if _is_kimi_k2_model(model):
# Kimi K2 on OpenCode Go uses Moonshot's native wire shape:
# extra_body.thinking (binary toggle) + top-level reasoning_effort
# (low|medium|high). Mirrors the KimiProfile (api.moonshot.ai/v1).
if not isinstance(reasoning_config, dict):
# No config → leave server defaults alone.
return extra_body, top_level
enabled = reasoning_config.get("enabled") is not False
if not enabled:
extra_body["thinking"] = {"type": "disabled"}
return extra_body, top_level
effort = (reasoning_config.get("effort") or "").strip().lower()
if effort and effort != "none":
from agent.reasoning_effort import KIMI_K2_EFFORTS, clamp_effort
clamped = clamp_effort(effort, KIMI_K2_EFFORTS)
if clamped in KIMI_K2_EFFORTS:
top_level["reasoning_effort"] = clamped
# Avoid "cannot specify both 'thinking' and 'reasoning_effort'" HTTP 400:
# only send extra_body["thinking"] when no reasoning_effort is set.
if "reasoning_effort" not in top_level:
extra_body["thinking"] = {"type": "enabled"}
return extra_body, top_level
if not _is_deepseek_thinking_model(model):
return extra_body, top_level
enabled = True
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False:
enabled = False
if not enabled:
extra_body["thinking"] = {"type": "disabled"}
return extra_body, top_level
if isinstance(reasoning_config, dict):
effort = (reasoning_config.get("effort") or "").strip().lower()
if effort and effort != "none":
from agent.reasoning_effort import (
DEEPSEEK_V4_EFFORTS,
DEEPSEEK_V4_OVERRIDES,
clamp_effort,
)
clamped = clamp_effort(
effort, DEEPSEEK_V4_EFFORTS, DEEPSEEK_V4_OVERRIDES
)
if clamped in DEEPSEEK_V4_EFFORTS:
top_level["reasoning_effort"] = clamped
# Avoid "cannot specify both 'thinking' and 'reasoning_effort'" HTTP 400:
# only send extra_body["thinking"] when no reasoning_effort is set.
if "reasoning_effort" not in top_level:
extra_body["thinking"] = {"type": "enabled"}
return extra_body, top_level
def _build_ox_alpha_reasoning_extras(
reasoning_config: dict | None, model: str | None
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Shared Ox Alpha (x-preview-f-free) reasoning_effort translation.
Used by both the opencode-zen profile and the opencode-free keyless
profile — the model is reachable through either provider and the wire
contract is identical (low/high/max only; anything else 400s).
"""
if _flat_model_name(model) != "x-preview-f-free":
return {}, {}
if not isinstance(reasoning_config, dict):
return {}, {}
if reasoning_config.get("enabled") is False:
return {}, {}
effort = (reasoning_config.get("effort") or "").strip().lower()
if not effort or effort == "none":
return {}, {}
from agent.reasoning_effort import (
OX_ALPHA_EFFORTS,
OX_ALPHA_OVERRIDES,
clamp_effort,
)
clamped = clamp_effort(effort, OX_ALPHA_EFFORTS, OX_ALPHA_OVERRIDES)
if clamped not in OX_ALPHA_EFFORTS:
return {}, {}
return {}, {"reasoning_effort": clamped}
class OpenCodeZenProfile(ProviderProfile):
"""OpenCode Zen - model-specific reasoning controls."""
def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
) -> tuple[dict[str, Any], dict[str, Any]]:
return _build_ox_alpha_reasoning_extras(reasoning_config, model)
opencode_zen = OpenCodeZenProfile(
name="opencode-zen",
aliases=("opencode", "opencode_zen", "zen"),
env_vars=("OPENCODE_ZEN_API_KEY",),
base_url="https://opencode.ai/zen/v1",
default_headers=dict(_ATTRIBUTION_HEADERS),
default_aux_model="gemini-3-flash",
)
opencode_go = OpenCodeGoProfile(
name="opencode-go",
aliases=("opencode_go", "go", "opencode-go-sub"),
env_vars=("OPENCODE_GO_API_KEY",),
base_url="https://opencode.ai/zen/go/v1",
default_headers=dict(_ATTRIBUTION_HEADERS),
default_aux_model="glm-5",
)
register_provider(opencode_zen)
register_provider(opencode_go)
@@ -0,0 +1,5 @@
name: opencode-zen-provider
kind: model-provider
version: 1.0.0
description: OpenCode (Zen + Go)
author: Nous Research
@@ -0,0 +1,268 @@
"""OpenRouter provider profile."""
import logging
from typing import Any
from agent.portal_tags import get_affinity_scope, get_conversation_context
from agent.transports.codex import _cache_scope_from_session_id
from providers import register_provider
from providers.base import ProviderProfile
logger = logging.getLogger(__name__)
_CACHE: list[str] | None = None
# Anthropic model families that still accept an explicit "disable thinking"
# request (the manual ``thinking: {type: "disabled"}`` form OpenRouter emits
# for ``reasoning: {enabled: false}``). Everything Claude 4.6 and newer —
# including future date-stamped / named models (fable, mythos-class, …) —
# mandates reasoning and returns HTTP 400 on any disable form. We therefore
# default *unknown* Anthropic models to "cannot disable" (the modern contract)
# and keep only this explicit legacy allowlist of models that can. Mirrors the
# default-to-newest philosophy in agent/anthropic_adapter._get_anthropic_max_output.
_ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS = (
"claude-3", # 3, 3.5, 3.7
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
"claude-sonnet-4-0", "claude-sonnet-4.0",
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
"claude-opus-4-5", "claude-opus-4.5",
"claude-sonnet-4-5", "claude-sonnet-4.5",
"claude-haiku-4-5", "claude-haiku-4.5",
)
def _anthropic_reasoning_is_mandatory(model: str | None) -> bool:
"""Return True for Anthropic models that reject any disable-thinking form.
Claude 4.6+ (adaptive thinking) and newer named models have no "off"
switch — sending ``reasoning: {enabled: false}`` makes OpenRouter emit
``thinking: {type: "disabled"}``, which these models 400 on. Unknown /
new Anthropic model names default to mandatory so the next un-numbered
release doesn't reintroduce the 400.
"""
m = (model or "").lower()
if not m.startswith(("anthropic/", "claude")) and "claude" not in m:
return False
return not any(sub in m for sub in _ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS)
class OpenRouterProfile(ProviderProfile):
"""OpenRouter aggregator — provider preferences, reasoning config passthrough."""
@staticmethod
def _clamp_reasoning_to_catalog(cfg: dict[str, Any], model: str | None) -> dict[str, Any] | None:
"""Clamp ``cfg["effort"]`` to the model's catalog-advertised levels.
Returns None when the config is a disable and the catalog marks the
route reasoning-mandatory (the caller omits the field).
OpenRouter's /v1/models entries publish ``reasoning.supported_efforts``
per model (ported from PrimeIntellect-ai/prime-agent#1258). Sending an
unsupported effort (e.g. ``ultra`` to a route that stops at ``high``)
yields provider 4xx errors; clamp to the nearest LOWER supported level
instead. No-op when the catalog is unreachable, the model is unlisted,
or no supported_efforts list is published (None = all levels accepted).
"""
effort = cfg.get("effort")
disabled = cfg.get("enabled") is False or effort == "none"
if not effort and not disabled:
return cfg
try:
from hermes_cli.models import (
clamp_reasoning_effort_to_supported,
openrouter_model_reasoning_capabilities,
)
caps = openrouter_model_reasoning_capabilities(model)
if not caps or not caps.get("supports_reasoning"):
return cfg
# A reasoning-mandatory route 400s on a disable ("Reasoning is
# mandatory for this endpoint and cannot be disabled") — omit
# the field and let the model think, same as the Nous profile.
if disabled:
return None if caps.get("mandatory") else cfg
clamped = clamp_reasoning_effort_to_supported(
effort, caps.get("supported_efforts")
)
except Exception:
return cfg
if clamped and clamped != effort:
logger.debug(
"openrouter: clamped reasoning effort %r%r for %s "
"(catalog supported_efforts=%s)",
effort, clamped, model, caps.get("supported_efforts"),
)
cfg = dict(cfg)
cfg["effort"] = clamped
return cfg
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
"""Fetch from public OpenRouter catalog — no auth required.
Note: Tool-call capability filtering is applied by hermes_cli/models.py
via fetch_openrouter_models() → _openrouter_model_supports_tools(), not
here. The picker early-returns via the dedicated openrouter path before
reaching this method, so filtering here would be unreachable.
"""
global _CACHE # noqa: PLW0603
if _CACHE is not None:
return _CACHE
try:
result = super().fetch_models(api_key=None, base_url=base_url, timeout=timeout)
if result is not None:
_CACHE = result
return result
except Exception as exc:
logger.debug("fetch_models(openrouter): %s", exc)
return None
def build_extra_body(
self, *, session_id: str | None = None, **context: Any
) -> dict[str, Any]:
body: dict[str, Any] = {}
# Top-level session_id → OpenRouter's sticky routing key. Per their
# prompt-caching docs it is used directly as the routing key instead of
# hashing the opening messages, and it activates stickiness on the
# first successful request rather than only after a cache hit.
#
# Resolve it from the declared routing scope first (set only by a host
# that names its own conversation, #96811), then the ambient conversation
# contextvar, with the explicit argument as fallback. The gap this closes is the auxiliary call sites
# — compression, title generation, vision, web_extract, session_search,
# MoA slots — which funnel through ``agent.auxiliary_client``. That
# module has no session handle and passes no ``session_id``, so those
# calls sent NO sticky key at all and each routed independently of the
# conversation it belonged to (#70820).
#
# Mirrors the Nous Portal profile, which resolves the same way
# (f2f4df064d). The ambient value is the session-lineage ROOT, so it
# also stays stable for installs that opt out of the default
# ``compression.in_place: true`` and across delegate-subagent trees.
sticky_key = _cache_scope_from_session_id(
get_affinity_scope() or get_conversation_context() or session_id
)
if sticky_key:
body["session_id"] = sticky_key
prefs = context.get("provider_preferences")
if prefs:
body["provider"] = prefs
# Pareto Code router — model-gated. The plugins block is only
# meaningful for openrouter/pareto-code; sending it on any other
# model has no documented effect and would be confusing in logs.
# See: https://openrouter.ai/docs/guides/routing/routers/pareto-router
model = (context.get("model") or "")
if model == "openrouter/pareto-code":
score = context.get("openrouter_min_coding_score")
if score is not None and score != "":
try:
score_f = float(score)
except (TypeError, ValueError):
score_f = None
if score_f is not None and 0.0 <= score_f <= 1.0:
body["plugins"] = [
{"id": "pareto-router", "min_coding_score": score_f}
]
return body
def build_api_kwargs_extras(
self,
*,
reasoning_config: dict | None = None,
supports_reasoning: bool = False,
model: str | None = None,
session_id: str | None = None,
**context: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""OpenRouter passes the full reasoning_config dict as extra_body.reasoning.
For xAI Grok models routed through OpenRouter, attach the
``x-grok-conv-id`` header so that xAI's prompt cache stays pinned to
the same backend server across turns.
"""
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
extra_headers: dict[str, Any] = {}
if supports_reasoning:
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
# future named models) use *adaptive* thinking: the model decides
# how much to think, and OpenRouter ignores ``reasoning.effort`` for
# them entirely. Sending any ``reasoning`` field is therefore both
# pointless and actively harmful:
# - ``{enabled: false}`` → OpenRouter emits Anthropic's manual
# ``thinking: {type: "disabled"}``, which these models 400 on.
# - any enabled form, on a tool-continuation turn whose prior
# assistant tool_call carries no thinking block (chat_completions
# never replays signed thinking blocks), ALSO makes OpenRouter
# emit ``thinking: {type: "disabled"}`` → the same 400 on every
# turn after the first tool call.
# The only reliable behavior is to omit ``reasoning`` and let the
# model default to adaptive. See hermes-agent#42991 (disable case)
# and the tool-replay follow-up.
#
# ``reasoning.effort`` being ignored does NOT mean these models have
# no effort lever — OpenRouter honors the requested effort on the
# top-level ``verbosity`` field instead (it maps to Anthropic's
# ``output_config.effort``; ``reasoning.effort`` is accepted but
# ignored — confirmed by OpenRouter's Claude migration docs and a
# live token-spend probe in hermes-agent#43432). Route the existing
# ``reasoning_config["effort"]`` (sourced from
# ``agent.reasoning_effort``) onto ``verbosity`` so the knob the user
# already sets keeps working for these models. We still send NO
# ``reasoning`` field, preserving the #42991 400 fix.
if _anthropic_reasoning_is_mandatory(model):
cfg = reasoning_config or {}
effort = cfg.get("effort")
# Only emit when effort is actually requested and reasoning
# isn't explicitly disabled. Otherwise omit ``verbosity`` so the
# model keeps its own adaptive default (``high``).
if cfg.get("enabled", True) is not False and effort and effort != "none":
top_level["verbosity"] = effort
elif reasoning_config is not None:
clamped = self._clamp_reasoning_to_catalog(
dict(reasoning_config), model
)
if clamped is not None:
extra_body["reasoning"] = clamped
else:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
# Same resolution as build_extra_body: xAI's prompt cache is pinned per
# backend server via this header, and aux calls pass no session_id, so
# reading the ambient conversation keeps compression/vision/MoA traffic
# on the same Grok backend as the conversation it belongs to.
grok_conv_id = _cache_scope_from_session_id(
get_affinity_scope() or get_conversation_context() or session_id
)
if grok_conv_id and model and model.startswith(("x-ai/grok-", "xai/grok-")):
extra_headers["x-grok-conv-id"] = grok_conv_id
if extra_headers:
top_level["extra_headers"] = extra_headers
return extra_body, top_level
openrouter = OpenRouterProfile(
name="openrouter",
aliases=("or",),
env_vars=("OPENROUTER_API_KEY",),
display_name="OpenRouter",
description="OpenRouter — unified API for 200+ models",
signup_url="https://openrouter.ai/keys",
base_url="https://openrouter.ai/api/v1",
models_url="https://openrouter.ai/api/v1/models",
fallback_models=(
"anthropic/claude-sonnet-4.6",
"openai/gpt-5.4",
"deepseek/deepseek-chat",
"google/gemini-3.8-flash",
"qwen/qwen3-plus",
),
)
register_provider(openrouter)
@@ -0,0 +1,5 @@
name: openrouter-provider
kind: model-provider
version: 1.0.0
description: OpenRouter aggregator
author: Nous Research
@@ -0,0 +1,108 @@
"""Qwen Portal provider profile."""
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
class QwenProfile(ProviderProfile):
"""Qwen Portal — message normalization, vl_high_resolution, metadata top-level."""
@staticmethod
def _copy_part_if_request_mutable(part: dict[str, Any]) -> tuple[dict[str, Any], bool]:
image_url = part.get("image_url")
if isinstance(image_url, dict):
copied = dict(part)
copied["image_url"] = dict(image_url)
return copied, True
return part, False
def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normalize content to list-of-dicts format.
Inject cache_control on system message.
Matches the behavior of run_agent.py:_qwen_prepare_chat_messages().
"""
if not messages:
return []
prepared = list(messages)
system_idx: int | None = None
for idx, msg in enumerate(messages):
if not isinstance(msg, dict):
continue
if system_idx is None and msg.get("role") == "system":
system_idx = idx
content = msg.get("content")
if isinstance(content, str):
msg_copy = dict(msg)
msg_copy["content"] = [{"type": "text", "text": content}]
prepared[idx] = msg_copy
elif isinstance(content, list):
normalized_parts = []
changed = False
for part in content:
if isinstance(part, str):
normalized_parts.append({"type": "text", "text": part})
changed = True
elif isinstance(part, dict):
normalized_part, copied = self._copy_part_if_request_mutable(part)
normalized_parts.append(normalized_part)
changed = changed or copied
else:
changed = True
if normalized_parts and changed:
msg_copy = dict(msg)
msg_copy["content"] = normalized_parts
prepared[idx] = msg_copy
# Inject cache_control on the last part of the system message.
if system_idx is not None:
msg = prepared[system_idx]
if isinstance(msg, dict):
content = msg.get("content")
if (
isinstance(content, list)
and content
and isinstance(content[-1], dict)
):
msg_copy = dict(msg)
content_copy = list(content)
content_copy[-1] = dict(content_copy[-1])
content_copy[-1]["cache_control"] = {"type": "ephemeral"}
msg_copy["content"] = content_copy
prepared[system_idx] = msg_copy
return prepared
def build_extra_body(
self, *, session_id: str | None = None, **context
) -> dict[str, Any]:
return {"vl_high_resolution_images": True}
def build_api_kwargs_extras(
self,
*,
reasoning_config: dict | None = None,
qwen_session_metadata: dict | None = None,
**context,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Qwen metadata goes to top-level api_kwargs, not extra_body."""
top_level = {}
if qwen_session_metadata:
top_level["metadata"] = qwen_session_metadata
return {}, top_level
qwen = QwenProfile(
name="qwen-oauth",
aliases=("qwen", "qwen-portal", "qwen-cli"),
env_vars=("QWEN_API_KEY",),
base_url="https://portal.qwen.ai/v1",
auth_type="oauth_external",
default_max_tokens=65536,
)
register_provider(qwen)
@@ -0,0 +1,5 @@
name: qwen-oauth-provider
kind: model-provider
version: 1.0.0
description: Qwen Portal (OAuth)
author: Nous Research
+395
View File
@@ -0,0 +1,395 @@
"""Ramp Router (router.com) provider plugin for Hermes Agent.
Provider profile for `Ramp Router <https://docs.router.com>`_, Ramp's LLM
gateway: one OpenAI Responses-compatible endpoint at
``https://api.router.com/v1`` that routes each request across upstream
providers (OpenAI, Anthropic, xAI, Fireworks, ...) and handles fallbacks and
spend controls server-side.
Wire notes (verified live against api.router.com, Aug 2026):
* **Responses API is the native wire.** Router serves ``GET /v1/models``
and ``POST /v1/responses``; ``POST /v1/chat/completions`` is only a
minimal compatibility shim (added Aug 2026) that translates onto
Responses. Per-model reasoning-effort validation, reasoning summaries,
and prompt caching are Responses-surface features, so
``api_mode="codex_responses"`` plus the ``api.router.com`` host mandate
in ``hermes_cli/providers.py`` keep every path on the native wire —
the same shape as the ``api.openai.com`` mandate.
* **Account-scoped catalog.** Valid model IDs are whatever the key's
``GET /v1/models`` returns (BYOK accounts see extra entries), so this
profile ships **no** ``fallback_models`` — the picker relies on the live
fetch, per Router's own guidance to never hardcode model names.
* **Strict reasoning-effort validation.** Router validates
``reasoning.effort`` against each model's catalog-declared vocabulary and
returns HTTP 400 ``invalid-argument`` on a level the model does not accept
(e.g. ``max`` on grok-4.6), and 400 ``unsupported_parameter`` when a
non-reasoning model (gpt-4.1 family, gpt-4o, ...) receives any reasoning
field. The catalog publishes the vocabulary per model
(``router.capabilities.reasoning``), so ``supported_reasoning_efforts``
below feeds the codex transport's clamp from a cached copy of it.
* **Everything else passes through.** ``store: false``, ``prompt_cache_key``,
``include: ["reasoning.encrypted_content"]``, and ``reasoning.summary`` are
accepted on all models (ignored where a backend cannot honor them), tools /
``parallel_tool_calls`` / streaming SSE work across backends, and encrypted
reasoning replay round-trips on OpenAI-served models — so the generic
Responses transport path needs no Router-specific request surgery.
The capability cache mirrors the OpenRouter reasoning-caps design in
``hermes_cli/models.py``: cache-only lookups on the per-request hot path
(never HTTP), seeded for free whenever ``fetch_models()`` runs (picker,
setup, doctor), hydrated from a disk mirror across processes, and refreshed
by a background warmer when cold or stale.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any, Optional
from hermes_cli import __version__ as _HERMES_VERSION
from providers import register_provider
from providers.base import ProviderProfile, _profile_user_agent
logger = logging.getLogger(__name__)
ROUTER_DEFAULT_BASE_URL = "https://api.router.com/v1"
#: Efforts-by-model cache: ``model id -> list of accepted effort levels``.
#: ``[]`` means the catalog says the model accepts NO reasoning parameters
#: (``reasoning.supported: false``) — the transport must omit reasoning
#: entirely. A model absent from the dict is unknown (custom/BYOK route or
#: vocabulary not published) and callers fall back to their defaults.
_efforts_cache: Optional[dict[str, list[str]]] = None
_efforts_lock = threading.Lock()
_warm_started = False
_disk_checked = False
#: Disk-mirror staleness bound. Vocabularies change rarely; a stale verdict
#: beats no verdict, so a past-TTL mirror is still served while a background
#: refresh runs (same policy as the OpenRouter caps mirror).
_DISK_TTL_SECONDS = 24 * 60 * 60
def _base_url() -> str:
"""Allow a base-URL override via ``RAMP_ROUTER_BASE_URL``."""
return os.getenv("RAMP_ROUTER_BASE_URL", "").strip().rstrip("/") or ROUTER_DEFAULT_BASE_URL
def _resolve_api_key() -> str:
"""Resolve the Router key from .env / environment, preferring dotenv.
``RAMP_ROUTER_API_KEY`` is Router's documented variable;
``ROUTER_API_KEY`` is accepted as a convenience alias. Falls back to the
raw environment when the hermes_cli helper is unavailable (e.g. stripped
test environments).
"""
resolvers = []
try:
from hermes_cli.config import get_env_value_prefer_dotenv
resolvers.append(get_env_value_prefer_dotenv)
except Exception:
pass
resolvers.append(lambda var: os.environ.get(var, ""))
for resolve in resolvers:
for var in ("RAMP_ROUTER_API_KEY", "ROUTER_API_KEY"):
try:
value = str(resolve(var) or "").strip()
except Exception:
value = ""
if value:
return value
return ""
def _parse_efforts(items: Any) -> Optional[dict[str, list[str]]]:
"""Parse a Router ``/v1/models`` ``data`` array into the efforts map.
Returns None when the array has no usable entries, which callers treat
as a failed fetch rather than caching an empty verdict.
"""
if not isinstance(items, list):
return None
try:
from agent.reasoning_effort import EFFORT_LADDER
known_levels = set(EFFORT_LADDER)
except Exception:
known_levels = None
efforts_by_id: dict[str, list[str]] = {}
for item in items:
if not isinstance(item, dict):
continue
mid = str(item.get("id") or "").strip()
if not mid:
continue
router_meta = item.get("router")
reasoning = None
if isinstance(router_meta, dict):
capabilities = router_meta.get("capabilities")
if isinstance(capabilities, dict):
reasoning = capabilities.get("reasoning")
if not isinstance(reasoning, dict):
continue
if reasoning.get("supported") is False:
# Definitive negative: any reasoning field 400s on this model.
efforts_by_id[mid] = []
continue
levels = [
str(entry.get("value") or "").strip()
for entry in reasoning.get("efforts") or []
if isinstance(entry, dict) and str(entry.get("value") or "").strip()
]
if known_levels is not None:
# clamp_effort silently ignores ladder-unknown levels, and an
# all-unknown vocabulary would pass the requested effort through
# unclamped straight to a Router 400 — so a new vendor tier is
# dropped at ingest and fails loudly here instead.
unknown = [level for level in levels if level not in known_levels]
if unknown:
logger.info(
"router: model %s publishes unrecognized reasoning effort "
"level(s) %s; ignoring them (update agent/reasoning_effort "
"EFFORT_LADDER to adopt new vendor tiers)",
mid,
unknown,
)
levels = [level for level in levels if level in known_levels]
if levels:
efforts_by_id[mid] = levels
# supported=True with no (recognized) vocabulary -> leave the model
# out (unknown), so the transport keeps its default clamp behavior.
return efforts_by_id or None
def _disk_path() -> Optional[Path]:
try:
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "router_catalog.json"
except Exception:
return None
def _save_disk(efforts_by_id: dict[str, list[str]]) -> None:
path = _disk_path()
if path is None:
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(
json.dumps({"ts": time.time(), "efforts": efforts_by_id}),
encoding="utf-8",
)
tmp.replace(path)
except Exception as exc:
logger.debug("router: caps disk mirror write failed: %s", exc)
def _load_disk() -> tuple[Optional[dict[str, list[str]]], float]:
path = _disk_path()
if path is None:
return None, 0.0
try:
data = json.loads(path.read_text(encoding="utf-8"))
efforts = data.get("efforts")
if not isinstance(efforts, dict) or not efforts:
return None, 0.0
parsed = {
str(mid): [str(level) for level in levels]
for mid, levels in efforts.items()
if isinstance(levels, list)
}
try:
age = max(0.0, time.time() - float(data.get("ts") or 0))
except (TypeError, ValueError):
age = float(_DISK_TTL_SECONDS)
return (parsed or None), age
except Exception:
return None, 0.0
def _seed_efforts(items: Any) -> Optional[dict[str, list[str]]]:
"""Seed memory + disk caches from a ``/v1/models`` payload."""
global _efforts_cache
parsed = _parse_efforts(items)
if parsed is None:
return None
with _efforts_lock:
_efforts_cache = parsed
_save_disk(parsed)
return parsed
def _fetch_catalog_items(
*, api_key: str = "", base_url: str = "", timeout: float = 8.0
) -> Optional[list]:
"""Fetch the raw ``/v1/models`` ``data`` array. None on any failure."""
url = (base_url or _base_url()).rstrip("/") + "/models"
import urllib.request
from hermes_cli.urllib_security import open_credentialed_url
req = urllib.request.Request(url)
key = api_key or _resolve_api_key()
if key:
req.add_header("Authorization", f"Bearer {key}")
req.add_header("Accept", "application/json")
# Router sits behind a WAF that rejects the default Python-urllib UA.
req.add_header("User-Agent", _profile_user_agent())
try:
with open_credentialed_url(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode())
except Exception as exc:
logger.debug("router: catalog fetch failed: %s", exc)
return None
items = data if isinstance(data, list) else data.get("data", [])
return items if isinstance(items, list) else None
def _efforts_cache_only() -> Optional[dict[str, list[str]]]:
"""Memory, else the disk mirror. Never HTTP (hot-path safe)."""
global _efforts_cache, _disk_checked
with _efforts_lock:
cached = _efforts_cache
if cached is not None:
return cached
if _disk_checked:
return None
_disk_checked = True
parsed, age = _load_disk()
if parsed is None:
return None
with _efforts_lock:
if _efforts_cache is None:
_efforts_cache = parsed
cached = _efforts_cache
if age >= _DISK_TTL_SECONDS:
_warm_efforts_async()
return cached
def _warm_efforts_async() -> None:
"""Refresh the efforts cache in the background, at most once per process."""
global _warm_started
if os.environ.get("PYTEST_CURRENT_TEST"):
# Match the canonical caps warmer (hermes_cli/models.py): a mid-suite
# background fetch would make cache state timing-dependent in tests.
return
with _efforts_lock:
if _warm_started:
return
_warm_started = True
if not _resolve_api_key():
# Without a key the fetch would 401; the first authenticated
# fetch_models() (picker/setup/doctor) seeds the cache instead.
return
def _refresh() -> None:
items = _fetch_catalog_items()
if items is not None:
_seed_efforts(items)
try:
threading.Thread(
target=_refresh, name="router-caps-warm", daemon=True
).start()
except Exception as exc:
logger.debug("router: caps warmer failed to start: %s", exc)
class RouterProfile(ProviderProfile):
"""Ramp Router — Responses-only gateway with catalog-declared efforts."""
def fetch_models(
self,
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: float = 8.0,
) -> Optional[list[str]]:
"""Fetch the live, key-scoped catalog and seed the caps cache.
One request serves both consumers: the picker gets the model IDs and
the reasoning-vocabulary mirror is left warm at no extra network
cost (the same document carries both).
"""
items = _fetch_catalog_items(
api_key=api_key or "", base_url=base_url or "", timeout=timeout
)
if items is None:
return None
_seed_efforts(items)
# Deduped but not sorted: Router's listing order is deliberate
# presentation (featured/current models first), so the picker keeps it.
ids = list(
dict.fromkeys(
str(item["id"])
for item in items
if isinstance(item, dict) and item.get("id")
)
)
return ids or None
def supported_reasoning_efforts(
self, model: Optional[str]
) -> Optional[tuple[str, ...]]:
"""Catalog-declared effort vocabulary for *model* (cache-only).
Router 400s on efforts outside a model's published set and on any
reasoning field for non-reasoning models, so the codex transport
clamps (or suppresses) from this verdict. Cold cache returns None —
the transport keeps its defaults — and kicks a background warmer so
the next turn is covered.
"""
mid = str(model or "").strip()
if not mid:
return None
efforts_by_id = _efforts_cache_only()
if efforts_by_id is None:
_warm_efforts_async()
return None
levels = efforts_by_id.get(mid)
if levels is None:
return None
return tuple(levels)
router = RouterProfile(
name="router",
aliases=("ramp-router", "ramp", "router.com"),
api_mode="codex_responses",
display_name="Ramp Router",
description="Ramp Router (router.com) — routes each request to the cheapest model that clears your quality bar",
signup_url="https://app.router.com/keys",
# RAMP_ROUTER_API_KEY is Router's documented variable; ROUTER_API_KEY is
# a convenience alias. RAMP_ROUTER_BASE_URL overrides the endpoint
# (auth.py picks it up as the registry's base_url_env_var).
env_vars=("RAMP_ROUTER_API_KEY", "ROUTER_API_KEY", "RAMP_ROUTER_BASE_URL"),
base_url=_base_url(),
auth_type="api_key",
# Identify Hermes traffic to the gateway (Router attributes coding-agent
# clients by User-Agent prefix, the way it already recognizes OpenCode's
# versioned UA) — and Router's WAF rejects blank/default client UAs.
default_headers={"User-Agent": f"Hermes-Agent/{_HERMES_VERSION}"},
# Most of the catalog's frontier routes accept image input; capability is
# still model-dependent and governed by the live catalog.
supports_vision=True,
# Cheap, reasoning-capable, and vision-capable — safe for auxiliary tasks
# (compaction, titles, vision) when Router is the main provider. Also the
# model Router's own docs use as their example.
default_aux_model="gpt-5.4-mini",
# Deliberately empty: model IDs are account-scoped (BYOK accounts see
# extra entries) and Router's docs say to read the catalog at runtime
# rather than hardcode names. The picker uses fetch_models() above.
fallback_models=(),
)
register_provider(router)
@@ -0,0 +1,5 @@
name: router-provider
kind: model-provider
version: 1.0.0
description: Ramp Router (router.com) — OpenAI Responses-compatible LLM gateway
author: Neel Patel (Ramp)
@@ -0,0 +1,14 @@
"""StepFun provider profile."""
from providers import register_provider
from providers.base import ProviderProfile
stepfun = ProviderProfile(
name="stepfun",
aliases=("step", "stepfun-coding-plan"),
default_aux_model="step-3.5-flash",
env_vars=("STEPFUN_API_KEY",),
base_url="https://api.stepfun.ai/step_plan/v1",
)
register_provider(stepfun)
@@ -0,0 +1,5 @@
name: stepfun-provider
kind: model-provider
version: 1.0.0
description: StepFun Step Plan
author: Nous Research
+118
View File
@@ -0,0 +1,118 @@
"""Upstage Solar provider profile."""
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
# Model-name markers for Solar families that do NOT accept ``reasoning_effort``.
# Deny-list on purpose: newly released Solar models are assumed
# reasoning-capable by default, so only the known non-reasoning families are
# listed here. Substring match (not startswith) so dated variants like
# ``solar-mini-250127`` are covered too.
_NON_REASONING_MODEL_MARKERS = ("solar-mini", "syn-pro")
# When the user hasn't picked a reasoning effort, Hermes passes
# reasoning_config=None. Solar's own server default is "minimal" (reasoning
# off), which is the wrong default for an agentic workload. We default reasoning
# ON at this effort — matching the "medium (default)" that Hermes' /reasoning
# panel shows for an unset config, so the displayed default and the real wire
# value agree. An explicit saved setting or a `/reasoning <level>` change is
# always honored over this default; `/reasoning none` disables it.
_DEFAULT_REASONING_EFFORT = "medium"
def _model_supports_reasoning(model: str | None) -> bool:
"""Solar reasoning-capable models — True unless the model is deny-listed.
The Solar Pro family (``solar-pro``, ``solar-pro2``, ``solar-pro3`` and
dated variants like ``solar-pro3-250127``) and the Solar Open family
(``solar-open*``) accept ``reasoning_effort``; only ``solar-mini`` /
``syn-pro`` ignore the parameter, so we deny-list those and treat every
other (incl. future) Solar model as reasoning-capable.
``None``/empty model → True: the provider default (``fallback_models[0]``,
``solar-pro3``) is reasoning-capable, so an unset model gets the same
default-on behaviour.
"""
m = (model or "").strip().lower()
return not any(marker in m for marker in _NON_REASONING_MODEL_MARKERS)
class UpstageProfile(ProviderProfile):
"""Upstage Solar — top-level ``reasoning_effort`` control.
Solar Pro/Open expose reasoning through a top-level ``reasoning_effort``
field (``minimal`` | ``low`` | ``medium`` | ``high``), mirroring OpenAI's
shape. Unlike DeepSeek/Kimi it does NOT require echoing ``reasoning_content``
back on later turns, so only the request field needs wiring. We emit at most
``low`` | ``medium`` | ``high`` — the explicit values both Solar Pro 2 and
Pro 3 accept.
Default-on: Solar's own server default is ``minimal`` (off), but for an
agentic workload we default reasoning ON (``_DEFAULT_REASONING_EFFORT``)
when the user hasn't picked an effort. The user can still set any level or
turn it off with ``/reasoning none``.
"""
def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
) -> tuple[dict[str, Any], dict[str, Any]]:
top_level: dict[str, Any] = {}
# solar-mini / syn-pro (the deny-list) ignore reasoning_effort — send
# nothing. Everything else, including future Solar models, gets it.
if not _model_supports_reasoning(model):
return {}, top_level
# Unset (reasoning_config is None) → default reasoning ON for agents.
if not reasoning_config or not isinstance(reasoning_config, dict):
return {}, {"reasoning_effort": _DEFAULT_REASONING_EFFORT}
# Explicitly disabled (`/reasoning none`) → omit the field so Solar
# applies its own default (minimal = off).
if reasoning_config.get("enabled") is False:
return {}, top_level
# Map Hermes' effort vocabulary onto Solar's accepted set via the
# shared clamp (agent.reasoning_effort). minimal → omit (Solar's
# minimal means off); unknown-but-enabled bespoke levels collapse to
# high rather than silently downgrading (#62650 precedent).
effort = (reasoning_config.get("effort") or "").strip().lower()
if not effort:
top_level["reasoning_effort"] = _DEFAULT_REASONING_EFFORT
return {}, top_level
if effort == "minimal":
return {}, top_level
from agent.reasoning_effort import EFFORT_LADDER, SOLAR_EFFORTS, clamp_effort
mapped = clamp_effort(effort, SOLAR_EFFORTS)
if mapped not in SOLAR_EFFORTS:
# Bespoke level outside the ladder — Solar precedent is to run
# at full strength rather than quietly fall to the default.
mapped = "high" if effort not in EFFORT_LADDER else None
if mapped:
top_level["reasoning_effort"] = mapped
return {}, top_level
upstage = UpstageProfile(
name="upstage",
aliases=("solar",),
display_name="Upstage Solar",
description="Upstage (Solar API)",
signup_url="https://console.upstage.ai/api-keys",
env_vars=("UPSTAGE_API_KEY", "UPSTAGE_BASE_URL"),
base_url="https://api.upstage.ai/v1",
auth_type="api_key",
# default_aux_model left empty → auxiliary side tasks use the main model.
# entry [0] is the setup default — solar-pro3, the current Solar Pro flagship.
fallback_models=(
"solar-pro3",
),
)
register_provider(upstage)
@@ -0,0 +1,5 @@
name: upstage-provider
kind: model-provider
version: 1.0.0
description: Upstage (Solar API)
author: Upstage AI
@@ -0,0 +1,75 @@
"""Google Vertex AI provider profile.
vertex: Gemini models via Google Cloud's OpenAI-compatible endpoint.
Auth is OAuth2 — short-lived access tokens minted from a service-account JSON
or Application Default Credentials (ADC), NOT a static API key. Token
resolution and refresh live in ``agent/vertex_adapter.py``; runtime_provider.py
calls it to obtain a fresh ``(token, base_url)`` pair, then hands the token to
the standard OpenAI client as ``api_key``. Because the wire format is the
OpenAI-compatible chat/completions surface, no message translation is needed —
the only Gemini-specific concern is the ``thinking_config`` reasoning hook,
which is emitted here exactly as the ``gemini`` provider does for its
OpenAI-compat subpath (``extra_body.google.thinking_config``).
``auth_type="vertex"`` marks this as an OAuth-token provider (resolved
specially, like bedrock's ``aws_sdk``) so it is never treated as an
api_key provider that would mistake a credentials-file path for a key.
"""
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
class VertexProfile(ProviderProfile):
"""Vertex AI — reuse Gemini's thinking_config translation for extra_body."""
def build_extra_body(
self, *, session_id: str | None = None, **context: Any
) -> dict[str, Any]:
"""Emit ``extra_body.google.thinking_config`` for the OpenAI-compat
Vertex surface, mirroring the ``gemini`` provider's behavior.
"""
from agent.transports.chat_completions import (
_build_gemini_thinking_config,
_snake_case_gemini_thinking_config,
)
model = context.get("model") or ""
reasoning_config = context.get("reasoning_config")
raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config)
if not raw_thinking_config:
return {}
thinking_config = _snake_case_gemini_thinking_config(raw_thinking_config)
if not thinking_config:
return {}
return {"extra_body": {"google": {"thinking_config": thinking_config}}}
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
"""Vertex's OpenAI-compat endpoint has no ``/models`` listing route;
model discovery is not available. The setup wizard ships a curated list.
"""
return None
vertex = VertexProfile(
name="vertex",
aliases=("google-vertex", "vertex-ai", "gcp-vertex"),
api_mode="chat_completions",
env_vars=(), # OAuth2 via service account / ADC — not a static key env var
base_url="https://aiplatform.googleapis.com", # real base_url computed at runtime
auth_type="vertex",
default_aux_model="google/gemini-3.6-flash",
)
register_provider(vertex)
@@ -0,0 +1,5 @@
name: vertex-provider
kind: model-provider
version: 1.0.0
description: Google Vertex AI (Gemini via OpenAI-compatible endpoint, OAuth2)
author: Steve Lawton (@slawt), Hermes Agent
+17
View File
@@ -0,0 +1,17 @@
"""xAI (Grok) provider profile."""
from hermes_cli import __version__ as _HERMES_VERSION
from providers import register_provider
from providers.base import ProviderProfile
xai = ProviderProfile(
name="xai",
aliases=("grok", "x-ai", "x.ai"),
api_mode="codex_responses",
env_vars=("XAI_API_KEY",),
base_url="https://api.x.ai/v1",
auth_type="api_key",
default_headers={"User-Agent": f"Hermes-Agent/{_HERMES_VERSION}"},
)
register_provider(xai)
+5
View File
@@ -0,0 +1,5 @@
name: xai-provider
kind: model-provider
version: 1.0.0
description: xAI Grok (Responses API)
author: Nous Research
@@ -0,0 +1,16 @@
"""Xiaomi MiMo provider profile."""
from providers import register_provider
from providers.base import ProviderProfile
xiaomi = ProviderProfile(
name="xiaomi",
aliases=("mimo", "xiaomi-mimo"),
env_vars=("XIAOMI_API_KEY",),
base_url="https://api.xiaomimimo.com/v1",
supports_health_check=False, # /v1/models returns 401 even with valid key
supports_vision=True, # mimo-v2-omni is vision-capable
supports_vision_tool_messages=False, # rejects list-type tool content (400 "text is not set")
)
register_provider(xiaomi)
@@ -0,0 +1,5 @@
name: xiaomi-provider
kind: model-provider
version: 1.0.0
description: Xiaomi MiMo
author: Nous Research
+164
View File
@@ -0,0 +1,164 @@
"""ZAI / GLM provider profile.
Z.AI's GLM-4.5-and-later chat models default to thinking-mode ON when the
request omits ``thinking``. Hermes' ``reasoning_config = {"enabled": False}``
was previously a silent no-op on this route — the base profile emits nothing,
so users who turned thinking off (desktop toggle, ``/reasoning none``,
``reasoning_effort: none``/``false`` in config.yaml) kept burning thinking
tokens on every turn.
:meth:`ZaiProfile.build_api_kwargs_extras` translates the Hermes reasoning
config into the wire shape Z.AI's OpenAI-compat endpoint expects:
{"extra_body": {"thinking": {"type": "enabled" | "disabled"}}}
When no reasoning preference is set (``reasoning_config is None``) the field
is omitted so the server default applies, matching prior behavior. GLM
models before 4.5 (e.g. ``glm-4-9b``) don't accept ``thinking`` and are left
untouched.
GLM-5.2 additionally exposes a native ``reasoning_effort`` knob with exactly
two enabled levels — ``high`` and ``max`` — on the OpenAI-compatible endpoint
(per Z.AI / BigModel docs). Hermes' richer effort scale is collapsed onto
those two so the user's effort preference actually reaches the model instead
of being silently dropped.
"""
from __future__ import annotations
import re
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
_GLM_VERSION_RE = re.compile(r"^glm-(\d+)(?:\.(\d+))?")
def _model_supports_thinking(model: str | None) -> bool:
"""GLM thinking-capable model families: glm-4.5 and later (4.5, 4.6, 5…)."""
m = (model or "").strip().lower()
match = _GLM_VERSION_RE.match(m)
if not match:
return False
major = int(match.group(1))
minor = int(match.group(2) or 0)
return (major, minor) >= (4, 5)
def _is_glm_5_2(model: str | None) -> bool:
"""Detect GLM-5.2/5.3 (reasoning_effort-capable) across alias spellings.
Covers the canonical ``glm-5.2``/``glm-5.3`` plus the ``glm-5-2`` /
``glm-5p2`` variants seen on relays (Fireworks ``glm-5p2``, etc.) and any
vendor-prefixed form (``z-ai/glm-5.2``, ``zai-org-glm-5-2``). GLM-5.3
uses the same base model as 5.2 (post-training gains only) and exposes
the same ``reasoning_effort`` knob (verified live 2026-08-14: the
coding-plan endpoint accepts ``reasoning_effort: high`` for glm-5.3).
"""
m = (model or "").strip().lower()
if not m:
return False
return any(
token in m
for token in ("glm-5.2", "glm-5-2", "glm-5p2", "glm-5.3", "glm-5-3", "glm-5p3")
)
def _is_glm_5_3(model: str | None) -> bool:
"""Detect GLM-5.3 specifically — it has a wider effort vocabulary.
5.2 accepts only ``high``/``max``; 5.3 accepts a graded
``low``/``medium``/``high``/``max`` scale (verified live, issue #91789),
so effort mapping must pick the vocabulary per model.
"""
m = (model or "").strip().lower()
if not m:
return False
return any(token in m for token in ("glm-5.3", "glm-5-3", "glm-5p3"))
def _glm_5_2_reasoning_effort(
reasoning_config: dict | None, *, model: str | None = None
) -> str | None:
"""Map Hermes reasoning effort onto GLM's native vocabulary.
GLM-5.2 supports two enabled effort levels (``high``/``max``);
GLM-5.3 supports the graded ``low``/``medium``/``high``/``max`` scale.
``xhigh``/``max``/``ultra`` request the top tier; anything below the
model's floor clamps to that floor. When reasoning is explicitly
disabled, or no effort preference is supplied, the server default is
left untouched.
"""
if not isinstance(reasoning_config, dict):
return None
if reasoning_config.get("enabled") is False:
return None
effort = (reasoning_config.get("effort") or "").strip().lower()
if not effort or effort == "none":
return None
# Per-model vocabulary declared in agent.reasoning_effort; xhigh rounds
# up to max on both. 5.2 cannot think less than high; 5.3 accepts a
# graded scale down to low (issue #91789).
from agent.reasoning_effort import (
GLM52_EFFORTS,
GLM52_OVERRIDES,
GLM53_EFFORTS,
GLM53_OVERRIDES,
clamp_effort,
)
if _is_glm_5_3(model):
efforts, overrides, floor = GLM53_EFFORTS, GLM53_OVERRIDES, "low"
else:
efforts, overrides, floor = GLM52_EFFORTS, GLM52_OVERRIDES, "high"
clamped = clamp_effort(effort, efforts, overrides)
return clamped if clamped in efforts else floor
class ZaiProfile(ProviderProfile):
"""Z.AI / GLM — extra_body.thinking on/off + GLM-5.2 reasoning_effort."""
def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
if not _model_supports_thinking(model) and not _is_glm_5_2(model):
return extra_body, top_level
# Only emit when the user expressed a preference; omitting the field
# keeps the server default (enabled) exactly as before.
if isinstance(reasoning_config, dict):
enabled = reasoning_config.get("enabled") is not False
extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"}
if _is_glm_5_2(model):
effort = _glm_5_2_reasoning_effort(reasoning_config, model=model)
if effort is not None:
top_level["reasoning_effort"] = effort
return extra_body, top_level
zai = ZaiProfile(
name="zai",
aliases=("glm", "z-ai", "z.ai", "zhipu"),
env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
display_name="Z.AI (GLM)",
description="Z.AI / GLM — Zhipu AI models",
signup_url="https://z.ai/",
fallback_models=(
"glm-5.2",
"glm-5",
"glm-4-9b",
),
base_url="https://api.z.ai/api/paas/v4",
default_aux_model="glm-4.5-flash",
)
register_provider(zai)
+5
View File
@@ -0,0 +1,5 @@
name: zai-provider
kind: model-provider
version: 1.0.0
description: Z.AI / GLM
author: Nous Research