Hermes-agent
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,21 @@
|
||||
"""Alibaba Cloud Coding Plan provider profile.
|
||||
|
||||
Separate from the standard `alibaba` profile because it hits a different
|
||||
endpoint (coding-intl.dashscope.aliyuncs.com) with a dedicated API key tier.
|
||||
"""
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
register_provider(alibaba_coding_plan)
|
||||
@@ -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,13 @@
|
||||
"""Alibaba Cloud DashScope provider profile."""
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
register_provider(alibaba)
|
||||
@@ -0,0 +1,5 @@
|
||||
name: alibaba-provider
|
||||
kind: model-provider
|
||||
version: 1.0.0
|
||||
description: Alibaba DashScope (international)
|
||||
author: Nous Research
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Native Anthropic provider profile."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.request
|
||||
|
||||
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,
|
||||
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 urllib.request.urlopen(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,29 @@
|
||||
"""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,
|
||||
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,34 @@
|
||||
"""GitHub Copilot ACP provider profile.
|
||||
|
||||
copilot-acp uses an external ACP subprocess — NOT the standard
|
||||
transport. api_mode="copilot_acp" is handled separately in run_agent.py.
|
||||
The profile captures auth + endpoint metadata for registry migration.
|
||||
"""
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
|
||||
class CopilotACPProfile(ProviderProfile):
|
||||
"""GitHub Copilot ACP — external process, no REST models endpoint."""
|
||||
|
||||
def fetch_models(
|
||||
self,
|
||||
*,
|
||||
api_key: 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",
|
||||
)
|
||||
|
||||
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,58 @@
|
||||
"""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")
|
||||
# Normalize non-standard effort levels to the nearest supported
|
||||
if effort == "xhigh":
|
||||
effort = "high"
|
||||
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,73 @@
|
||||
"""Custom / Ollama (local) provider profile.
|
||||
|
||||
Covers any endpoint registered as provider="custom", including local
|
||||
Ollama instances. Key quirks:
|
||||
- ollama_num_ctx → extra_body.options.num_ctx (local context window)
|
||||
- reasoning_config disabled → extra_body.think = False
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
|
||||
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] = {}
|
||||
|
||||
# Ollama context window
|
||||
if ollama_num_ctx:
|
||||
options = extra_body.get("options", {})
|
||||
options["num_ctx"] = ollama_num_ctx
|
||||
extra_body["options"] = options
|
||||
|
||||
# Disable thinking when reasoning is turned off
|
||||
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:
|
||||
extra_body["think"] = False
|
||||
|
||||
return extra_body, {}
|
||||
|
||||
def fetch_models(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
timeout: float = 8.0,
|
||||
) -> list[str] | None:
|
||||
"""Custom/Ollama: base_url is user-configured; fetch if set."""
|
||||
if not self.base_url:
|
||||
return None
|
||||
return super().fetch_models(api_key=api_key, 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,100 @@
|
||||
"""DeepSeek provider profile.
|
||||
|
||||
DeepSeek's V4 family (and the legacy ``deepseek-reasoner``) 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 (only ``deepseek-chat`` today, which is V3) are left as
|
||||
no-ops so we don't perturb the V3 wire format.
|
||||
"""
|
||||
|
||||
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) and the legacy
|
||||
``deepseek-reasoner`` (R1). ``deepseek-chat`` is V3 with no thinking mode.
|
||||
"""
|
||||
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
|
||||
if m == "deepseek-reasoner":
|
||||
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. Pass low/medium/high through; xhigh/max → max.
|
||||
# When no effort is set we omit reasoning_effort so DeepSeek applies
|
||||
# its server default (currently high).
|
||||
if isinstance(reasoning_config, dict):
|
||||
effort = (reasoning_config.get("effort") or "").strip().lower()
|
||||
if effort in {"xhigh", "max"}:
|
||||
top_level["reasoning_effort"] = "max"
|
||||
elif effort in {"low", "medium", "high"}:
|
||||
top_level["reasoning_effort"] = effort
|
||||
|
||||
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-chat",
|
||||
"deepseek-reasoner",
|
||||
),
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
default_aux_model="deepseek-chat",
|
||||
)
|
||||
|
||||
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,72 @@
|
||||
"""Google Gemini provider profiles.
|
||||
|
||||
gemini: Google AI Studio (API key) — uses GeminiNativeClient
|
||||
google-gemini-cli: Google Cloud Code Assist (OAuth) — uses GeminiCloudCodeClient
|
||||
|
||||
Both report api_mode="chat_completions" but use custom native clients
|
||||
that bypass 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.5-flash",
|
||||
)
|
||||
|
||||
google_gemini_cli = GeminiProfile(
|
||||
name="google-gemini-cli",
|
||||
aliases=("gemini-cli", "gemini-oauth"),
|
||||
api_mode="chat_completions",
|
||||
env_vars=(), # OAuth — no API key
|
||||
base_url="cloudcode-pa://google", # Cloud Code Assist internal scheme
|
||||
auth_type="oauth_external",
|
||||
)
|
||||
|
||||
register_provider(gemini)
|
||||
register_provider(google_gemini_cli)
|
||||
@@ -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,31 @@
|
||||
"""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-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-flash-preview",
|
||||
)
|
||||
|
||||
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,80 @@
|
||||
"""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 providers import register_provider
|
||||
from providers.base import OMIT_TEMPERATURE, ProviderProfile
|
||||
|
||||
|
||||
class KimiProfile(ProviderProfile):
|
||||
"""Kimi/Moonshot — temperature omitted, thinking xor reasoning_effort."""
|
||||
|
||||
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.
|
||||
effort = (reasoning_config.get("effort") or "").strip().lower()
|
||||
if effort in {"low", "medium", "high"}:
|
||||
top_level["reasoning_effort"] = 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={"User-Agent": "hermes-agent/1.0"},
|
||||
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={"User-Agent": "hermes-agent/1.0"},
|
||||
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,45 @@
|
||||
"""MiniMax provider profiles (international + China).
|
||||
|
||||
Both use anthropic_messages api_mode — their inference_base_url
|
||||
ends with /anthropic which triggers auto-detection to anthropic_messages.
|
||||
"""
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
minimax = ProviderProfile(
|
||||
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 = ProviderProfile(
|
||||
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 = ProviderProfile(
|
||||
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,54 @@
|
||||
"""Nous Portal provider profile."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.portal_tags import nous_portal_tags
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
|
||||
class NousProfile(ProviderProfile):
|
||||
"""Nous Portal — product tags, reasoning with Nous-specific omission."""
|
||||
|
||||
def build_extra_body(
|
||||
self, *, session_id: str | None = None, **context
|
||||
) -> dict[str, Any]:
|
||||
return {"tags": nous_portal_tags()}
|
||||
|
||||
def build_api_kwargs_extras(
|
||||
self,
|
||||
*,
|
||||
reasoning_config: dict | None = None,
|
||||
supports_reasoning: bool = False,
|
||||
**context,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Nous: passes full reasoning_config, but OMITS when disabled."""
|
||||
extra_body = {}
|
||||
if supports_reasoning:
|
||||
if reasoning_config is not None:
|
||||
rc = dict(reasoning_config)
|
||||
if rc.get("enabled") is False:
|
||||
pass # Nous omits reasoning when disabled
|
||||
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.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,21 @@
|
||||
"""NVIDIA NIM provider profile."""
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
nvidia = ProviderProfile(
|
||||
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,14 @@
|
||||
"""Ollama Cloud provider profile."""
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
ollama_cloud = ProviderProfile(
|
||||
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,126 @@
|
||||
"""OpenCode provider profiles (Zen + Go).
|
||||
|
||||
Both use per-model api_mode routing:
|
||||
- OpenCode Zen: Claude → anthropic_messages, GPT-5/Codex → codex_responses,
|
||||
everything else → chat_completions (this profile)
|
||||
- OpenCode Go: MiniMax → anthropic_messages, GLM/Kimi → chat_completions
|
||||
(this 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 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"
|
||||
|
||||
|
||||
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_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 in {"xhigh", "max"}:
|
||||
top_level["reasoning_effort"] = "high"
|
||||
elif effort in {"low", "medium", "high"}:
|
||||
top_level["reasoning_effort"] = effort
|
||||
|
||||
# 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 in {"xhigh", "max"}:
|
||||
top_level["reasoning_effort"] = "max"
|
||||
elif effort in {"low", "medium", "high"}:
|
||||
top_level["reasoning_effort"] = effort
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
opencode_zen = ProviderProfile(
|
||||
name="opencode-zen",
|
||||
aliases=("opencode", "opencode_zen", "zen"),
|
||||
env_vars=("OPENCODE_ZEN_API_KEY",),
|
||||
base_url="https://opencode.ai/zen/v1",
|
||||
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_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,187 @@
|
||||
"""OpenRouter provider profile."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
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."""
|
||||
|
||||
def fetch_models(
|
||||
self,
|
||||
*,
|
||||
api_key: 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, 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] = {}
|
||||
if session_id:
|
||||
body["session_id"] = session_id
|
||||
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:
|
||||
extra_body["reasoning"] = dict(reasoning_config)
|
||||
else:
|
||||
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
|
||||
|
||||
if session_id and model and model.startswith(("x-ai/grok-", "xai/grok-")):
|
||||
extra_headers["x-grok-conv-id"] = session_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-flash-preview",
|
||||
"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,82 @@
|
||||
"""Qwen Portal provider profile."""
|
||||
|
||||
import copy
|
||||
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."""
|
||||
|
||||
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().
|
||||
"""
|
||||
prepared = copy.deepcopy(messages)
|
||||
if not prepared:
|
||||
return prepared
|
||||
|
||||
for msg in prepared:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
msg["content"] = [{"type": "text", "text": content}]
|
||||
elif isinstance(content, list):
|
||||
normalized_parts = []
|
||||
for part in content:
|
||||
if isinstance(part, str):
|
||||
normalized_parts.append({"type": "text", "text": part})
|
||||
elif isinstance(part, dict):
|
||||
normalized_parts.append(part)
|
||||
if normalized_parts:
|
||||
msg["content"] = normalized_parts
|
||||
|
||||
# Inject cache_control on the last part of the system message.
|
||||
for msg in prepared:
|
||||
if isinstance(msg, dict) and msg.get("role") == "system":
|
||||
content = msg.get("content")
|
||||
if (
|
||||
isinstance(content, list)
|
||||
and content
|
||||
and isinstance(content[-1], dict)
|
||||
):
|
||||
content[-1]["cache_control"] = {"type": "ephemeral"}
|
||||
break
|
||||
|
||||
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,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,15 @@
|
||||
"""xAI (Grok) provider profile."""
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
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,21 @@
|
||||
"""ZAI / GLM provider profile."""
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
zai = ProviderProfile(
|
||||
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",
|
||||
"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