Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
name: zai-provider
|
||||
kind: model-provider
|
||||
version: 1.0.0
|
||||
description: Z.AI / GLM
|
||||
author: Nous Research
|
||||
Reference in New Issue
Block a user