mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(providers): add Volcengine and BytePlus support
Based on PR #8952 by @Maaannnn. Adds Volcengine and BytePlus as first-class providers, each with standard and Coding Plan model catalogs. The model prefix (volcengine/ vs volcengine-coding-plan/) determines the runtime base URL automatically. - New hermes_cli/provider_contracts.py centralises all constants - ProviderConfig entries in auth.py with api_key auth - Model catalogs, aliases, and provider ordering in models.py/providers.py - Auxiliary client entries and context window resolution - gateway /provider command detects known Volcengine/BytePlus endpoints - Comprehensive tests and docs update
This commit is contained in:
parent
5e8262da26
commit
ccde71a6ab
17 changed files with 599 additions and 14 deletions
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
|
||||
|
||||
Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
|
||||
Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [Volcengine](https://www.volcengine.com/product/ark), [BytePlus](https://www.byteplus.com/en/product/modelark), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
|
||||
|
||||
<table>
|
||||
<tr><td><b>A real terminal interface</b></td><td>Full TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.</td></tr>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ from openai import OpenAI
|
|||
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_cli.provider_contracts import PROVIDER_AUX_MODELS
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
|
||||
|
||||
|
|
@ -74,6 +75,10 @@ _PROVIDER_ALIASES = {
|
|||
"minimax_cn": "minimax-cn",
|
||||
"claude": "anthropic",
|
||||
"claude-code": "anthropic",
|
||||
"volcengine-coding-plan": "volcengine",
|
||||
"volcengine_coding_plan": "volcengine",
|
||||
"byteplus-coding-plan": "byteplus",
|
||||
"byteplus_coding_plan": "byteplus",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -144,6 +149,8 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
|
|||
"opencode-go": "glm-5",
|
||||
"kilocode": "google/gemini-3-flash-preview",
|
||||
"ollama-cloud": "nemotron-3-nano:30b",
|
||||
"volcengine": PROVIDER_AUX_MODELS["volcengine"],
|
||||
"byteplus": PROVIDER_AUX_MODELS["byteplus"],
|
||||
}
|
||||
|
||||
# Vision-specific model overrides for direct providers.
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ from urllib.parse import urlparse
|
|||
import requests
|
||||
import yaml
|
||||
|
||||
from hermes_cli.provider_contracts import model_context_window
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -30,6 +30,10 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
|||
"qwen-oauth",
|
||||
"xiaomi",
|
||||
"arcee",
|
||||
"volcengine",
|
||||
"volcengine-coding-plan",
|
||||
"byteplus",
|
||||
"byteplus-coding-plan",
|
||||
"custom", "local",
|
||||
# Common aliases
|
||||
"google", "google-gemini", "google-ai-studio",
|
||||
|
|
@ -257,6 +261,8 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
|||
"api.xiaomimimo.com": "xiaomi",
|
||||
"xiaomimimo.com": "xiaomi",
|
||||
"ollama.com": "ollama-cloud",
|
||||
"ark.cn-beijing.volces.com": "volcengine",
|
||||
"ark.ap-southeast.bytepluses.com": "byteplus",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1119,12 +1125,20 @@ def get_model_context_length(
|
|||
ctx = _resolve_nous_context_length(model)
|
||||
if ctx:
|
||||
return ctx
|
||||
if effective_provider in {"volcengine", "byteplus"}:
|
||||
ctx = model_context_window(model)
|
||||
if ctx:
|
||||
return ctx
|
||||
if effective_provider:
|
||||
from agent.models_dev import lookup_models_dev_context
|
||||
ctx = lookup_models_dev_context(effective_provider, model)
|
||||
if ctx:
|
||||
return ctx
|
||||
|
||||
ctx = model_context_window(model)
|
||||
if ctx:
|
||||
return ctx
|
||||
|
||||
# 6. OpenRouter live API metadata (provider-unaware fallback)
|
||||
metadata = fetch_model_metadata()
|
||||
if model in metadata:
|
||||
|
|
|
|||
|
|
@ -5690,6 +5690,7 @@ class GatewayRunner:
|
|||
from hermes_cli.models import (
|
||||
list_available_providers,
|
||||
normalize_provider,
|
||||
provider_for_base_url,
|
||||
_PROVIDER_LABELS,
|
||||
)
|
||||
|
||||
|
|
@ -5718,7 +5719,10 @@ class GatewayRunner:
|
|||
# Detect custom endpoint from config base_url
|
||||
if current_provider == "openrouter":
|
||||
_cfg_base = model_cfg.get("base_url", "") if isinstance(model_cfg, dict) else ""
|
||||
if _cfg_base and "openrouter.ai" not in _cfg_base:
|
||||
inferred_provider = provider_for_base_url(_cfg_base)
|
||||
if inferred_provider:
|
||||
current_provider = inferred_provider
|
||||
elif _cfg_base and "openrouter.ai" not in _cfg_base:
|
||||
current_provider = "custom"
|
||||
|
||||
current_label = _PROVIDER_LABELS.get(current_provider, current_provider)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ import httpx
|
|||
import yaml
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config
|
||||
from hermes_cli.provider_contracts import (
|
||||
VOLCENGINE_PROVIDER,
|
||||
BYTEPLUS_PROVIDER,
|
||||
VOLCENGINE_STANDARD_BASE_URL,
|
||||
BYTEPLUS_STANDARD_BASE_URL,
|
||||
base_url_for_provider_model,
|
||||
)
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -307,6 +314,20 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
|||
api_key_env_vars=("XIAOMI_API_KEY",),
|
||||
base_url_env_var="XIAOMI_BASE_URL",
|
||||
),
|
||||
"volcengine": ProviderConfig(
|
||||
id=VOLCENGINE_PROVIDER,
|
||||
name="Volcengine",
|
||||
auth_type="api_key",
|
||||
inference_base_url=VOLCENGINE_STANDARD_BASE_URL,
|
||||
api_key_env_vars=("VOLCENGINE_API_KEY",),
|
||||
),
|
||||
"byteplus": ProviderConfig(
|
||||
id=BYTEPLUS_PROVIDER,
|
||||
name="BytePlus",
|
||||
auth_type="api_key",
|
||||
inference_base_url=BYTEPLUS_STANDARD_BASE_URL,
|
||||
api_key_env_vars=("BYTEPLUS_API_KEY",),
|
||||
),
|
||||
"ollama-cloud": ProviderConfig(
|
||||
id="ollama-cloud",
|
||||
name="Ollama Cloud",
|
||||
|
|
@ -1015,6 +1036,10 @@ def resolve_provider(
|
|||
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
|
||||
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
|
||||
"aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock",
|
||||
"volcengine-coding-plan": "volcengine",
|
||||
"volcengine_coding_plan": "volcengine",
|
||||
"byteplus-coding-plan": "byteplus",
|
||||
"byteplus_coding_plan": "byteplus",
|
||||
"go": "opencode-go", "opencode-go-sub": "opencode-go",
|
||||
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
|
||||
# Local server aliases — route through the generic custom provider
|
||||
|
|
@ -1157,6 +1182,21 @@ def _qwen_cli_auth_path() -> Path:
|
|||
return Path.home() / ".qwen" / "oauth_creds.json"
|
||||
|
||||
|
||||
def _current_model_for_provider(provider_id: str) -> str:
|
||||
"""Return the currently configured model when it belongs to the provider."""
|
||||
try:
|
||||
config = read_raw_config()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
model_cfg = config.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
configured_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if configured_provider == provider_id:
|
||||
return str(model_cfg.get("default") or model_cfg.get("model") or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _read_qwen_cli_tokens() -> Dict[str, Any]:
|
||||
auth_path = _qwen_cli_auth_path()
|
||||
if not auth_path.exists():
|
||||
|
|
@ -2555,7 +2595,11 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]:
|
|||
if pconfig.base_url_env_var:
|
||||
env_url = os.getenv(pconfig.base_url_env_var, "").strip()
|
||||
|
||||
if provider_id in ("kimi-coding", "kimi-coding-cn"):
|
||||
active_model = _current_model_for_provider(provider_id)
|
||||
|
||||
if provider_id in {VOLCENGINE_PROVIDER, BYTEPLUS_PROVIDER}:
|
||||
base_url = base_url_for_provider_model(provider_id, active_model) or pconfig.inference_base_url
|
||||
elif provider_id in ("kimi-coding", "kimi-coding-cn"):
|
||||
base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url)
|
||||
elif env_url:
|
||||
base_url = env_url
|
||||
|
|
@ -2650,7 +2694,11 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
|
|||
if pconfig.base_url_env_var:
|
||||
env_url = os.getenv(pconfig.base_url_env_var, "").strip()
|
||||
|
||||
if provider_id in ("kimi-coding", "kimi-coding-cn"):
|
||||
active_model = _current_model_for_provider(provider_id)
|
||||
|
||||
if provider_id in {VOLCENGINE_PROVIDER, BYTEPLUS_PROVIDER}:
|
||||
base_url = base_url_for_provider_model(provider_id, active_model) or pconfig.inference_base_url
|
||||
elif provider_id in ("kimi-coding", "kimi-coding-cn"):
|
||||
base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url)
|
||||
elif provider_id == "zai":
|
||||
base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url)
|
||||
|
|
|
|||
|
|
@ -1570,6 +1570,8 @@ def select_provider_and_model(args=None):
|
|||
_model_flow_stepfun(config, current_model)
|
||||
elif selected_provider == "bedrock":
|
||||
_model_flow_bedrock(config, current_model)
|
||||
elif selected_provider in ("volcengine", "byteplus"):
|
||||
_model_flow_contract_provider(config, selected_provider, current_model)
|
||||
elif selected_provider in (
|
||||
"gemini",
|
||||
"deepseek",
|
||||
|
|
@ -1954,7 +1956,7 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
|
|||
print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else ""))
|
||||
|
||||
|
||||
def _prompt_provider_choice(choices, *, default=0):
|
||||
def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
|
||||
"""Show provider selection menu with curses arrow-key navigation.
|
||||
|
||||
Falls back to a numbered list when curses is unavailable (e.g. piped
|
||||
|
|
@ -1963,8 +1965,7 @@ def _prompt_provider_choice(choices, *, default=0):
|
|||
"""
|
||||
try:
|
||||
from hermes_cli.setup import _curses_prompt_choice
|
||||
|
||||
idx = _curses_prompt_choice("Select provider:", choices, default)
|
||||
idx = _curses_prompt_choice(title, choices, default)
|
||||
if idx >= 0:
|
||||
print()
|
||||
return idx
|
||||
|
|
@ -1972,7 +1973,7 @@ def _prompt_provider_choice(choices, *, default=0):
|
|||
pass
|
||||
|
||||
# Fallback: numbered list
|
||||
print("Select provider:")
|
||||
print(title)
|
||||
for i, c in enumerate(choices, 1):
|
||||
marker = "→" if i - 1 == default else " "
|
||||
print(f" {marker} {i}. {c}")
|
||||
|
|
@ -2944,6 +2945,10 @@ def _model_flow_named_custom(config, provider_info):
|
|||
|
||||
# Curated model lists for direct API-key providers — single source in models.py
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
from hermes_cli.provider_contracts import (
|
||||
base_url_for_provider_model,
|
||||
provider_models,
|
||||
)
|
||||
|
||||
|
||||
def _current_reasoning_effort(config) -> str:
|
||||
|
|
@ -4033,6 +4038,70 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
|||
print("No change.")
|
||||
|
||||
|
||||
def _model_flow_contract_provider(config, provider_id, current_model=""):
|
||||
"""Provider flow for Volcengine / BytePlus contract-backed catalogs."""
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
_prompt_model_selection,
|
||||
_save_model_choice,
|
||||
deactivate_provider,
|
||||
)
|
||||
from hermes_cli.config import get_env_value, load_config, save_config, save_env_value
|
||||
|
||||
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||
key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
|
||||
existing_key = ""
|
||||
for env_var in pconfig.api_key_env_vars:
|
||||
existing_key = get_env_value(env_var) or os.getenv(env_var, "")
|
||||
if existing_key:
|
||||
break
|
||||
|
||||
if not existing_key:
|
||||
print(f"No {pconfig.name} API key configured.")
|
||||
if key_env:
|
||||
try:
|
||||
import getpass
|
||||
|
||||
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not new_key:
|
||||
print("Cancelled.")
|
||||
return
|
||||
save_env_value(key_env, new_key)
|
||||
print("API key saved.")
|
||||
print()
|
||||
else:
|
||||
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
||||
print()
|
||||
|
||||
model_list = provider_models(provider_id)
|
||||
if not model_list:
|
||||
print(f"No curated model catalog found for {pconfig.name}.")
|
||||
return
|
||||
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
if not selected:
|
||||
print("No change.")
|
||||
return
|
||||
|
||||
_save_model_choice(selected)
|
||||
|
||||
cfg = load_config()
|
||||
model = cfg.get("model")
|
||||
if not isinstance(model, dict):
|
||||
model = {"default": model} if model else {}
|
||||
cfg["model"] = model
|
||||
model["provider"] = provider_id
|
||||
model["base_url"] = base_url_for_provider_model(provider_id, selected)
|
||||
model.pop("api_mode", None)
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
print(f"Default model set to: {selected} (via {pconfig.name})")
|
||||
|
||||
|
||||
def _run_anthropic_oauth_flow(save_env_value):
|
||||
"""Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
|
||||
from agent.anthropic_adapter import (
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({
|
|||
"xiaomi",
|
||||
"arcee",
|
||||
"ollama-cloud",
|
||||
"volcengine",
|
||||
"byteplus",
|
||||
"custom",
|
||||
})
|
||||
|
||||
|
|
@ -423,4 +425,3 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
|
|||
# ---------------------------------------------------------------------------
|
||||
# Batch / convenience helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ from hermes_cli import __version__ as _HERMES_VERSION
|
|||
# Check (error 1010) don't reject the default ``Python-urllib/*`` signature.
|
||||
_HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}"
|
||||
|
||||
from hermes_cli.provider_contracts import (
|
||||
BYTEPLUS_PROVIDER,
|
||||
VOLCENGINE_PROVIDER,
|
||||
provider_models,
|
||||
)
|
||||
|
||||
COPILOT_BASE_URL = "https://api.githubcopilot.com"
|
||||
COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
|
||||
COPILOT_EDITOR_VERSION = "vscode/1.104.1"
|
||||
|
|
@ -356,6 +362,8 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
|||
"us.meta.llama4-maverick-17b-instruct-v1:0",
|
||||
"us.meta.llama4-scout-17b-instruct-v1:0",
|
||||
],
|
||||
VOLCENGINE_PROVIDER: provider_models(VOLCENGINE_PROVIDER),
|
||||
BYTEPLUS_PROVIDER: provider_models(BYTEPLUS_PROVIDER),
|
||||
}
|
||||
|
||||
# Vercel AI Gateway: derive the bare-model-id catalog from the curated
|
||||
|
|
@ -690,6 +698,8 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
|||
ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (200+ models, $5 free credit, no markup)"),
|
||||
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"),
|
||||
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
|
||||
ProviderEntry(VOLCENGINE_PROVIDER, "Volcengine", "Volcengine (standard + Coding Plan catalogs)"),
|
||||
ProviderEntry(BYTEPLUS_PROVIDER, "BytePlus", "BytePlus (standard + Coding Plan catalogs)"),
|
||||
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"),
|
||||
ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models — build.nvidia.com or local NIM)"),
|
||||
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (reuses local Qwen CLI login)"),
|
||||
|
|
@ -719,7 +729,6 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
|||
_PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
|
||||
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
|
||||
|
||||
|
||||
_PROVIDER_ALIASES = {
|
||||
"glm": "zai",
|
||||
"z-ai": "zai",
|
||||
|
|
@ -782,6 +791,10 @@ _PROVIDER_ALIASES = {
|
|||
"nemotron": "nvidia",
|
||||
"ollama": "custom", # bare "ollama" = local; use "ollama-cloud" for cloud
|
||||
"ollama_cloud": "ollama-cloud",
|
||||
"volcengine-coding-plan": VOLCENGINE_PROVIDER,
|
||||
"volcengine_coding_plan": VOLCENGINE_PROVIDER,
|
||||
"byteplus-coding-plan": BYTEPLUS_PROVIDER,
|
||||
"byteplus_coding_plan": BYTEPLUS_PROVIDER,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1242,7 +1255,6 @@ def list_available_providers() -> list[dict[str, str]]:
|
|||
"""
|
||||
# Derive display order from canonical list + custom
|
||||
provider_order = [p.slug for p in CANONICAL_PROVIDERS] + ["custom"]
|
||||
|
||||
# Build reverse alias map
|
||||
aliases_for: dict[str, list[str]] = {}
|
||||
for alias, canonical in _PROVIDER_ALIASES.items():
|
||||
|
|
@ -1258,7 +1270,7 @@ def list_available_providers() -> list[dict[str, str]]:
|
|||
from hermes_cli.auth import get_auth_status, has_usable_secret
|
||||
if pid == "custom":
|
||||
custom_base_url = _get_custom_base_url() or ""
|
||||
has_creds = bool(custom_base_url.strip())
|
||||
has_creds = bool(custom_base_url.strip()) and provider_for_base_url(custom_base_url) is None
|
||||
elif pid == "openrouter":
|
||||
has_creds = has_usable_secret(os.getenv("OPENROUTER_API_KEY", ""))
|
||||
else:
|
||||
|
|
@ -1324,6 +1336,44 @@ def _get_custom_base_url() -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def provider_for_base_url(base_url: str) -> Optional[str]:
|
||||
"""Return a known built-in provider for a configured base URL, if any."""
|
||||
normalized = str(base_url or "").strip().rstrip("/")
|
||||
if not normalized or "openrouter.ai" in normalized.lower():
|
||||
return None
|
||||
|
||||
url_lower = normalized.lower()
|
||||
host_to_provider = {
|
||||
"ark.cn-beijing.volces.com": VOLCENGINE_PROVIDER,
|
||||
"ark.ap-southeast.bytepluses.com": BYTEPLUS_PROVIDER,
|
||||
"api.z.ai": "zai",
|
||||
"api.moonshot.ai": "kimi-coding",
|
||||
"api.kimi.com": "kimi-coding",
|
||||
"api.minimax.io": "minimax",
|
||||
"api.minimaxi.com": "minimax-cn",
|
||||
"dashscope.aliyuncs.com": "alibaba",
|
||||
"dashscope-intl.aliyuncs.com": "alibaba",
|
||||
"portal.qwen.ai": "qwen-oauth",
|
||||
"router.huggingface.co": "huggingface",
|
||||
"generativelanguage.googleapis.com": "gemini",
|
||||
"api.deepseek.com": "deepseek",
|
||||
"api.githubcopilot.com": "copilot",
|
||||
"models.github.ai": "copilot",
|
||||
"opencode.ai": "opencode-go",
|
||||
"api.x.ai": "xai",
|
||||
"api.xiaomimimo.com": "xiaomi",
|
||||
"xiaomimimo.com": "xiaomi",
|
||||
"api.anthropic.com": "anthropic",
|
||||
"inference-api.nousresearch.com": "nous",
|
||||
}
|
||||
for host, provider_id in host_to_provider.items():
|
||||
if host in url_lower:
|
||||
canonical = normalize_provider(provider_id)
|
||||
if canonical in _PROVIDER_LABELS and canonical != "custom":
|
||||
return canonical
|
||||
return None
|
||||
|
||||
|
||||
def curated_models_for_provider(
|
||||
provider: Optional[str],
|
||||
*,
|
||||
|
|
|
|||
139
hermes_cli/provider_contracts.py
Normal file
139
hermes_cli/provider_contracts.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""Source-of-truth contracts for built-in providers without models.dev catalogs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
VOLCENGINE_PROVIDER = "volcengine"
|
||||
BYTEPLUS_PROVIDER = "byteplus"
|
||||
|
||||
VOLCENGINE_STANDARD_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
VOLCENGINE_CODING_PLAN_BASE_URL = "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
BYTEPLUS_STANDARD_BASE_URL = "https://ark.ap-southeast.bytepluses.com/api/v3"
|
||||
BYTEPLUS_CODING_PLAN_BASE_URL = "https://ark.ap-southeast.bytepluses.com/api/coding/v3"
|
||||
|
||||
VOLCENGINE_STANDARD_MODELS: Tuple[str, ...] = (
|
||||
"doubao-seed-2-0-pro-260215",
|
||||
"doubao-seed-2-0-lite-260215",
|
||||
"doubao-seed-2-0-mini-260215",
|
||||
"doubao-seed-2-0-code-preview-260215",
|
||||
"kimi-k2-5-260127",
|
||||
"glm-4-7-251222",
|
||||
"deepseek-v3-2-251201",
|
||||
)
|
||||
|
||||
VOLCENGINE_CODING_PLAN_MODELS: Tuple[str, ...] = (
|
||||
"doubao-seed-2.0-code",
|
||||
"doubao-seed-2.0-pro",
|
||||
"doubao-seed-2.0-lite",
|
||||
"doubao-seed-code",
|
||||
"minimax-m2.5",
|
||||
"glm-4.7",
|
||||
"deepseek-v3.2",
|
||||
"kimi-k2.5",
|
||||
)
|
||||
|
||||
BYTEPLUS_STANDARD_MODELS: Tuple[str, ...] = (
|
||||
"seed-2-0-pro-260328",
|
||||
"seed-2-0-lite-260228",
|
||||
"seed-2-0-mini-260215",
|
||||
"kimi-k2-5-260127",
|
||||
"glm-4-7-251222",
|
||||
)
|
||||
|
||||
BYTEPLUS_CODING_PLAN_MODELS: Tuple[str, ...] = (
|
||||
"dola-seed-2.0-pro",
|
||||
"dola-seed-2.0-lite",
|
||||
"bytedance-seed-code",
|
||||
"glm-4.7",
|
||||
"kimi-k2.5",
|
||||
"gpt-oss-120b",
|
||||
)
|
||||
|
||||
VOLCENGINE_STANDARD_MODEL_REFS: Tuple[str, ...] = tuple(
|
||||
f"{VOLCENGINE_PROVIDER}/{model_id}" for model_id in VOLCENGINE_STANDARD_MODELS
|
||||
)
|
||||
VOLCENGINE_CODING_PLAN_MODEL_REFS: Tuple[str, ...] = tuple(
|
||||
f"{VOLCENGINE_PROVIDER}-coding-plan/{model_id}" for model_id in VOLCENGINE_CODING_PLAN_MODELS
|
||||
)
|
||||
BYTEPLUS_STANDARD_MODEL_REFS: Tuple[str, ...] = tuple(
|
||||
f"{BYTEPLUS_PROVIDER}/{model_id}" for model_id in BYTEPLUS_STANDARD_MODELS
|
||||
)
|
||||
BYTEPLUS_CODING_PLAN_MODEL_REFS: Tuple[str, ...] = tuple(
|
||||
f"{BYTEPLUS_PROVIDER}-coding-plan/{model_id}" for model_id in BYTEPLUS_CODING_PLAN_MODELS
|
||||
)
|
||||
|
||||
PROVIDER_MODEL_CATALOGS: Dict[str, Tuple[str, ...]] = {
|
||||
VOLCENGINE_PROVIDER: VOLCENGINE_STANDARD_MODEL_REFS + VOLCENGINE_CODING_PLAN_MODEL_REFS,
|
||||
BYTEPLUS_PROVIDER: BYTEPLUS_STANDARD_MODEL_REFS + BYTEPLUS_CODING_PLAN_MODEL_REFS,
|
||||
}
|
||||
|
||||
PROVIDER_AUX_MODELS: Dict[str, str] = {
|
||||
VOLCENGINE_PROVIDER: "volcengine/doubao-seed-2-0-lite-260215",
|
||||
BYTEPLUS_PROVIDER: "byteplus/seed-2-0-lite-260228",
|
||||
}
|
||||
|
||||
MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
|
||||
"doubao-seed-2-0-pro-260215": 256000,
|
||||
"doubao-seed-2-0-lite-260215": 256000,
|
||||
"doubao-seed-2-0-mini-260215": 256000,
|
||||
"doubao-seed-2-0-code-preview-260215": 256000,
|
||||
"kimi-k2-5-260127": 256000,
|
||||
"glm-4-7-251222": 200000,
|
||||
"deepseek-v3-2-251201": 128000,
|
||||
"doubao-seed-2.0-code": 256000,
|
||||
"doubao-seed-2.0-pro": 256000,
|
||||
"doubao-seed-2.0-lite": 256000,
|
||||
"doubao-seed-code": 256000,
|
||||
"minimax-m2.5": 200000,
|
||||
"glm-4.7": 200000,
|
||||
"deepseek-v3.2": 128000,
|
||||
"kimi-k2.5": 256000,
|
||||
"seed-2-0-pro-260328": 256000,
|
||||
"seed-2-0-lite-260228": 256000,
|
||||
"seed-2-0-mini-260215": 256000,
|
||||
}
|
||||
|
||||
|
||||
def provider_models(provider_id: str) -> List[str]:
|
||||
"""Return the full user-facing model catalog for a provider."""
|
||||
return list(PROVIDER_MODEL_CATALOGS.get(provider_id, ()))
|
||||
|
||||
|
||||
def _bare_model_name(model_name: str) -> str:
|
||||
value = (model_name or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
if "/" in value:
|
||||
return value.split("/", 1)[1].strip()
|
||||
return value
|
||||
|
||||
|
||||
def is_coding_plan_model(provider_id: str, model_name: str) -> bool:
|
||||
"""Return True when a model belongs to the coding-plan catalog."""
|
||||
raw = (model_name or "").strip()
|
||||
bare = _bare_model_name(raw)
|
||||
if provider_id == VOLCENGINE_PROVIDER:
|
||||
return raw in VOLCENGINE_CODING_PLAN_MODEL_REFS or bare in VOLCENGINE_CODING_PLAN_MODELS
|
||||
if provider_id == BYTEPLUS_PROVIDER:
|
||||
return raw in BYTEPLUS_CODING_PLAN_MODEL_REFS or bare in BYTEPLUS_CODING_PLAN_MODELS
|
||||
return False
|
||||
|
||||
|
||||
def base_url_for_provider_model(provider_id: str, model_name: str) -> str:
|
||||
"""Resolve the source-of-truth base URL for a provider+model pair."""
|
||||
if provider_id == VOLCENGINE_PROVIDER:
|
||||
if is_coding_plan_model(provider_id, model_name):
|
||||
return VOLCENGINE_CODING_PLAN_BASE_URL
|
||||
return VOLCENGINE_STANDARD_BASE_URL
|
||||
if provider_id == BYTEPLUS_PROVIDER:
|
||||
if is_coding_plan_model(provider_id, model_name):
|
||||
return BYTEPLUS_CODING_PLAN_BASE_URL
|
||||
return BYTEPLUS_STANDARD_BASE_URL
|
||||
return ""
|
||||
|
||||
|
||||
def model_context_window(model_name: str) -> int | None:
|
||||
"""Return a known context window for a model, if specified by the contract."""
|
||||
bare = _bare_model_name(model_name)
|
||||
return MODEL_CONTEXT_WINDOWS.get(bare)
|
||||
|
|
@ -23,6 +23,12 @@ import logging
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_cli.provider_contracts import (
|
||||
BYTEPLUS_PROVIDER,
|
||||
BYTEPLUS_STANDARD_BASE_URL,
|
||||
VOLCENGINE_PROVIDER,
|
||||
VOLCENGINE_STANDARD_BASE_URL,
|
||||
)
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -163,6 +169,16 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
|||
transport="openai_chat",
|
||||
base_url_env_var="OLLAMA_BASE_URL",
|
||||
),
|
||||
VOLCENGINE_PROVIDER: HermesOverlay(
|
||||
transport="openai_chat",
|
||||
extra_env_vars=("VOLCENGINE_API_KEY",),
|
||||
base_url_override=VOLCENGINE_STANDARD_BASE_URL,
|
||||
),
|
||||
BYTEPLUS_PROVIDER: HermesOverlay(
|
||||
transport="openai_chat",
|
||||
extra_env_vars=("BYTEPLUS_API_KEY",),
|
||||
base_url_override=BYTEPLUS_STANDARD_BASE_URL,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -273,6 +289,10 @@ ALIASES: Dict[str, str] = {
|
|||
# xiaomi
|
||||
"mimo": "xiaomi",
|
||||
"xiaomi-mimo": "xiaomi",
|
||||
"volcengine-coding-plan": VOLCENGINE_PROVIDER,
|
||||
"volcengine_coding_plan": VOLCENGINE_PROVIDER,
|
||||
"byteplus-coding-plan": BYTEPLUS_PROVIDER,
|
||||
"byteplus_coding_plan": BYTEPLUS_PROVIDER,
|
||||
|
||||
# bedrock
|
||||
"aws": "bedrock",
|
||||
|
|
@ -306,6 +326,8 @@ _LABEL_OVERRIDES: Dict[str, str] = {
|
|||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"stepfun": "StepFun Step Plan",
|
||||
"xiaomi": "Xiaomi MiMo",
|
||||
VOLCENGINE_PROVIDER: "Volcengine",
|
||||
BYTEPLUS_PROVIDER: "BytePlus",
|
||||
"local": "Local endpoint",
|
||||
"bedrock": "AWS Bedrock",
|
||||
"ollama-cloud": "Ollama Cloud",
|
||||
|
|
|
|||
|
|
@ -782,6 +782,44 @@ def test_resolve_api_key_provider_skips_unconfigured_anthropic(monkeypatch):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModelDefaultElimination:
|
||||
"""_resolve_api_key_provider must skip providers without known aux models."""
|
||||
|
||||
def test_unknown_provider_skipped(self, monkeypatch):
|
||||
"""Providers not in _API_KEY_PROVIDER_AUX_MODELS are skipped, not sent model='default'."""
|
||||
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
|
||||
|
||||
# Verify our known providers have entries
|
||||
assert "gemini" in _API_KEY_PROVIDER_AUX_MODELS
|
||||
assert "kimi-coding" in _API_KEY_PROVIDER_AUX_MODELS
|
||||
|
||||
# A random provider_id not in the dict should return None
|
||||
assert _API_KEY_PROVIDER_AUX_MODELS.get("totally-unknown-provider") is None
|
||||
|
||||
def test_known_provider_gets_real_model(self):
|
||||
"""Known providers get a real model name, not 'default'."""
|
||||
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
|
||||
|
||||
for provider_id, model in _API_KEY_PROVIDER_AUX_MODELS.items():
|
||||
assert model != "default", f"{provider_id} should not map to 'default'"
|
||||
assert isinstance(model, str) and model.strip(), \
|
||||
f"{provider_id} should have a non-empty model string"
|
||||
|
||||
def test_contract_providers_have_aux_models(self):
|
||||
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
|
||||
|
||||
assert _API_KEY_PROVIDER_AUX_MODELS["volcengine"] == "volcengine/doubao-seed-2-0-lite-260215"
|
||||
assert _API_KEY_PROVIDER_AUX_MODELS["byteplus"] == "byteplus/seed-2-0-lite-260228"
|
||||
|
||||
|
||||
class TestContractProviderAliases:
|
||||
def test_coding_plan_aliases_normalize_to_canonical_provider(self):
|
||||
from agent.auxiliary_client import _normalize_aux_provider
|
||||
|
||||
assert _normalize_aux_provider("volcengine-coding-plan") == "volcengine"
|
||||
assert _normalize_aux_provider("byteplus-coding-plan") == "byteplus"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _try_payment_fallback reason parameter (#7512 bug 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -222,6 +222,22 @@ class TestGetModelContextLength:
|
|||
mock_fetch.return_value = {}
|
||||
assert get_model_context_length("unknown/never-heard-of-this") == CONTEXT_PROBE_TIERS[0]
|
||||
|
||||
@patch("agent.model_metadata.fetch_model_metadata")
|
||||
def test_volcengine_contract_model_uses_contract_context_length(self, mock_fetch):
|
||||
mock_fetch.return_value = {}
|
||||
assert get_model_context_length(
|
||||
"volcengine/doubao-seed-2-0-pro-260215",
|
||||
provider="volcengine",
|
||||
) == 256000
|
||||
|
||||
@patch("agent.model_metadata.fetch_model_metadata")
|
||||
def test_byteplus_contract_model_infers_provider_from_url(self, mock_fetch):
|
||||
mock_fetch.return_value = {}
|
||||
assert get_model_context_length(
|
||||
"byteplus-coding-plan/kimi-k2.5",
|
||||
base_url="https://ark.ap-southeast.bytepluses.com/api/coding/v3",
|
||||
) == 256000
|
||||
|
||||
@patch("agent.model_metadata.fetch_model_metadata")
|
||||
def test_partial_match_in_defaults(self, mock_fetch):
|
||||
mock_fetch.return_value = {}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ class TestProviderRegistry:
|
|||
("minimax-cn", "MiniMax (China)", "api_key"),
|
||||
("ai-gateway", "Vercel AI Gateway", "api_key"),
|
||||
("kilocode", "Kilo Code", "api_key"),
|
||||
("volcengine", "Volcengine", "api_key"),
|
||||
("byteplus", "BytePlus", "api_key"),
|
||||
])
|
||||
def test_provider_registered(self, provider_id, name, auth_type):
|
||||
assert provider_id in PROVIDER_REGISTRY
|
||||
|
|
@ -111,6 +113,16 @@ class TestProviderRegistry:
|
|||
assert pconfig.api_key_env_vars == ("HF_TOKEN",)
|
||||
assert pconfig.base_url_env_var == "HF_BASE_URL"
|
||||
|
||||
def test_volcengine_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["volcengine"]
|
||||
assert pconfig.api_key_env_vars == ("VOLCENGINE_API_KEY",)
|
||||
assert pconfig.base_url_env_var == ""
|
||||
|
||||
def test_byteplus_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["byteplus"]
|
||||
assert pconfig.api_key_env_vars == ("BYTEPLUS_API_KEY",)
|
||||
assert pconfig.base_url_env_var == ""
|
||||
|
||||
def test_base_urls(self):
|
||||
assert PROVIDER_REGISTRY["copilot"].inference_base_url == "https://api.githubcopilot.com"
|
||||
assert PROVIDER_REGISTRY["copilot-acp"].inference_base_url == "acp://copilot"
|
||||
|
|
@ -122,6 +134,8 @@ class TestProviderRegistry:
|
|||
assert PROVIDER_REGISTRY["ai-gateway"].inference_base_url == "https://ai-gateway.vercel.sh/v1"
|
||||
assert PROVIDER_REGISTRY["kilocode"].inference_base_url == "https://api.kilo.ai/api/gateway"
|
||||
assert PROVIDER_REGISTRY["huggingface"].inference_base_url == "https://router.huggingface.co/v1"
|
||||
assert PROVIDER_REGISTRY["volcengine"].inference_base_url == "https://ark.cn-beijing.volces.com/api/v3"
|
||||
assert PROVIDER_REGISTRY["byteplus"].inference_base_url == "https://ark.ap-southeast.bytepluses.com/api/v3"
|
||||
|
||||
def test_oauth_providers_unchanged(self):
|
||||
"""Ensure we didn't break the existing OAuth providers."""
|
||||
|
|
@ -147,6 +161,7 @@ PROVIDER_ENV_VARS = (
|
|||
"NOUS_API_KEY", "GITHUB_TOKEN", "GH_TOKEN",
|
||||
"OPENAI_BASE_URL", "HERMES_COPILOT_ACP_COMMAND", "COPILOT_CLI_PATH",
|
||||
"HERMES_COPILOT_ACP_ARGS", "COPILOT_ACP_BASE_URL",
|
||||
"VOLCENGINE_API_KEY", "BYTEPLUS_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -232,6 +247,14 @@ class TestResolveProvider:
|
|||
assert resolve_provider("github-copilot-acp") == "copilot-acp"
|
||||
assert resolve_provider("copilot-acp-agent") == "copilot-acp"
|
||||
|
||||
def test_alias_volcengine_coding_plan(self):
|
||||
assert resolve_provider("volcengine-coding-plan") == "volcengine"
|
||||
assert resolve_provider("volcengine_coding_plan") == "volcengine"
|
||||
|
||||
def test_alias_byteplus_coding_plan(self):
|
||||
assert resolve_provider("byteplus-coding-plan") == "byteplus"
|
||||
assert resolve_provider("byteplus_coding_plan") == "byteplus"
|
||||
|
||||
def test_explicit_huggingface(self):
|
||||
assert resolve_provider("huggingface") == "huggingface"
|
||||
|
||||
|
|
@ -339,6 +362,23 @@ class TestApiKeyProviderStatus:
|
|||
assert status["configured"] is True
|
||||
assert status["base_url"] == STEPFUN_STEP_PLAN_CN_BASE_URL
|
||||
|
||||
def test_volcengine_status_uses_coding_plan_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("VOLCENGINE_API_KEY", "volc-test-key")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.read_raw_config",
|
||||
lambda: {
|
||||
"model": {
|
||||
"provider": "volcengine",
|
||||
"default": "volcengine-coding-plan/doubao-seed-2.0-code",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
status = get_api_key_provider_status("volcengine")
|
||||
|
||||
assert status["configured"] is True
|
||||
assert status["base_url"] == "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
|
||||
def test_copilot_status_uses_gh_cli_token(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_gh_cli_token")
|
||||
status = get_api_key_provider_status("copilot")
|
||||
|
|
@ -394,6 +434,25 @@ class TestResolveApiKeyProviderCredentials:
|
|||
assert creds["base_url"] == "https://api.z.ai/api/paas/v4"
|
||||
assert creds["source"] == "GLM_API_KEY"
|
||||
|
||||
def test_resolve_byteplus_with_coding_plan_model_uses_coding_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("BYTEPLUS_API_KEY", "byteplus-secret-key")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.read_raw_config",
|
||||
lambda: {
|
||||
"model": {
|
||||
"provider": "byteplus",
|
||||
"default": "byteplus-coding-plan/dola-seed-2.0-pro",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
creds = resolve_api_key_provider_credentials("byteplus")
|
||||
|
||||
assert creds["provider"] == "byteplus"
|
||||
assert creds["api_key"] == "byteplus-secret-key"
|
||||
assert creds["base_url"] == "https://ark.ap-southeast.bytepluses.com/api/coding/v3"
|
||||
assert creds["source"] == "BYTEPLUS_API_KEY"
|
||||
|
||||
def test_resolve_copilot_with_github_token(self, monkeypatch):
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "gh-env-secret")
|
||||
creds = resolve_api_key_provider_credentials("copilot")
|
||||
|
|
|
|||
|
|
@ -179,6 +179,19 @@ class TestIssue6211NativeProviderPrefixNormalization:
|
|||
assert normalize_model_for_provider(model, target_provider) == expected
|
||||
|
||||
|
||||
class TestContractProviderPrefixNormalization:
|
||||
@pytest.mark.parametrize("model,target_provider,expected", [
|
||||
("volcengine/doubao-seed-2-0-pro-260215", "volcengine", "doubao-seed-2-0-pro-260215"),
|
||||
("volcengine-coding-plan/doubao-seed-2.0-code", "volcengine", "doubao-seed-2.0-code"),
|
||||
("byteplus/seed-2-0-pro-260328", "byteplus", "seed-2-0-pro-260328"),
|
||||
("byteplus-coding-plan/dola-seed-2.0-pro", "byteplus", "dola-seed-2.0-pro"),
|
||||
])
|
||||
def test_contract_provider_prefixes_strip_to_native_model(
|
||||
self, model, target_provider, expected
|
||||
):
|
||||
assert normalize_model_for_provider(model, target_provider) == expected
|
||||
|
||||
|
||||
# ── detect_vendor ──────────────────────────────────────────────────────
|
||||
|
||||
class TestDetectVendor:
|
||||
|
|
|
|||
|
|
@ -102,6 +102,31 @@ class TestProviderPersistsAfterModelSave:
|
|||
)
|
||||
assert model.get("default") == "kimi-k2.5"
|
||||
|
||||
def test_volcengine_contract_provider_persists_coding_plan_model(self, config_home, monkeypatch):
|
||||
"""Volcengine should persist a prefixed coding-plan model and matching base URL."""
|
||||
monkeypatch.setenv("VOLCENGINE_API_KEY", "volc-test-key")
|
||||
|
||||
from hermes_cli.main import _model_flow_contract_provider
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
return_value="volcengine-coding-plan/doubao-seed-2.0-code",
|
||||
), patch(
|
||||
"hermes_cli.auth.deactivate_provider",
|
||||
):
|
||||
_model_flow_contract_provider(load_config(), "volcengine", "old-model")
|
||||
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict), f"model should be dict, got {type(model)}"
|
||||
assert model.get("provider") == "volcengine"
|
||||
assert model.get("default") == "volcengine-coding-plan/doubao-seed-2.0-code"
|
||||
assert model.get("base_url") == "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
assert "api_mode" not in model
|
||||
|
||||
def test_copilot_provider_saved_when_selected(self, config_home):
|
||||
"""_model_flow_copilot should persist provider/base_url/model together."""
|
||||
from hermes_cli.main import _model_flow_copilot
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from hermes_cli.models import (
|
|||
OPENROUTER_MODELS, fetch_openrouter_models, model_ids, detect_provider_for_model,
|
||||
is_nous_free_tier, partition_nous_models_by_tier,
|
||||
check_nous_free_tier, _FREE_TIER_CACHE_TTL,
|
||||
list_available_providers, provider_for_base_url,
|
||||
)
|
||||
import hermes_cli.models as _models_mod
|
||||
|
||||
|
|
@ -291,6 +292,41 @@ class TestDetectProviderForModel:
|
|||
assert result is not None
|
||||
assert result[0] not in ("nous",) # nous has claude models but shouldn't be suggested
|
||||
|
||||
def test_volcengine_coding_plan_model_detected(self):
|
||||
result = detect_provider_for_model(
|
||||
"volcengine-coding-plan/doubao-seed-2.0-code",
|
||||
"openrouter",
|
||||
)
|
||||
assert result == ("volcengine", "volcengine-coding-plan/doubao-seed-2.0-code")
|
||||
|
||||
def test_byteplus_standard_model_detected(self):
|
||||
result = detect_provider_for_model(
|
||||
"byteplus/seed-2-0-pro-260328",
|
||||
"openrouter",
|
||||
)
|
||||
assert result == ("byteplus", "byteplus/seed-2-0-pro-260328")
|
||||
|
||||
|
||||
class TestConfiguredBaseUrlProviderDetection:
|
||||
def test_provider_for_base_url_detects_volcengine(self):
|
||||
assert provider_for_base_url("https://ark.cn-beijing.volces.com/api/v3") == "volcengine"
|
||||
|
||||
def test_provider_for_base_url_detects_byteplus_coding(self):
|
||||
assert provider_for_base_url("https://ark.ap-southeast.bytepluses.com/api/coding/v3") == "byteplus"
|
||||
|
||||
def test_known_builtin_endpoint_is_not_listed_as_custom(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.models._get_custom_base_url", lambda: "https://ark.cn-beijing.volces.com/api/v3")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.get_auth_status",
|
||||
lambda pid: {"configured": pid == "volcengine", "logged_in": pid == "volcengine"},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.auth.has_usable_secret", lambda value: False)
|
||||
|
||||
providers = {p["id"]: p for p in list_available_providers()}
|
||||
|
||||
assert providers["volcengine"]["authenticated"] is True
|
||||
assert providers["custom"]["authenticated"] is False
|
||||
|
||||
|
||||
class TestIsNousFreeTier:
|
||||
"""Tests for is_nous_free_tier — account tier detection."""
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
|
|||
| **Alibaba Cloud** | `DASHSCOPE_API_KEY` in `~/.hermes/.env` (provider: `alibaba`, aliases: `dashscope`, `qwen`) |
|
||||
| **Kilo Code** | `KILOCODE_API_KEY` in `~/.hermes/.env` (provider: `kilocode`) |
|
||||
| **Xiaomi MiMo** | `XIAOMI_API_KEY` in `~/.hermes/.env` (provider: `xiaomi`, aliases: `mimo`, `xiaomi-mimo`) |
|
||||
| **Volcengine** | `hermes model` or `VOLCENGINE_API_KEY` in `~/.hermes/.env` (provider: `volcengine`) |
|
||||
| **BytePlus** | `hermes model` or `BYTEPLUS_API_KEY` in `~/.hermes/.env` (provider: `byteplus`) |
|
||||
| **OpenCode Zen** | `OPENCODE_ZEN_API_KEY` in `~/.hermes/.env` (provider: `opencode-zen`) |
|
||||
| **OpenCode Go** | `OPENCODE_GO_API_KEY` in `~/.hermes/.env` (provider: `opencode-go`) |
|
||||
| **DeepSeek** | `DEEPSEEK_API_KEY` in `~/.hermes/.env` (provider: `deepseek`) |
|
||||
|
|
@ -274,17 +276,59 @@ hermes chat --provider xiaomi --model mimo-v2-pro
|
|||
# Arcee AI (Trinity models)
|
||||
hermes chat --provider arcee --model trinity-large-thinking
|
||||
# Requires: ARCEEAI_API_KEY in ~/.hermes/.env
|
||||
|
||||
# Volcengine
|
||||
hermes chat --provider volcengine --model volcengine/doubao-seed-2-0-pro-260215
|
||||
# Requires: VOLCENGINE_API_KEY in ~/.hermes/.env
|
||||
|
||||
# Volcengine Coding Plan catalog (same provider, same API key)
|
||||
hermes chat --provider volcengine --model volcengine-coding-plan/doubao-seed-2.0-code
|
||||
|
||||
# BytePlus
|
||||
hermes chat --provider byteplus --model byteplus/seed-2-0-pro-260328
|
||||
# Requires: BYTEPLUS_API_KEY in ~/.hermes/.env
|
||||
|
||||
# BytePlus Coding Plan catalog (same provider, same API key)
|
||||
hermes chat --provider byteplus --model byteplus-coding-plan/dola-seed-2.0-pro
|
||||
```
|
||||
|
||||
Or set the provider permanently in `config.yaml`:
|
||||
```yaml
|
||||
model:
|
||||
provider: "zai" # or: kimi-coding, kimi-coding-cn, minimax, minimax-cn, alibaba, xiaomi, arcee
|
||||
provider: "zai" # or: kimi-coding, kimi-coding-cn, minimax, minimax-cn, alibaba, xiaomi, arcee, volcengine, byteplus
|
||||
default: "glm-5"
|
||||
```
|
||||
|
||||
Base URLs can be overridden with `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, `MINIMAX_CN_BASE_URL`, `DASHSCOPE_BASE_URL`, or `XIAOMI_BASE_URL` environment variables.
|
||||
|
||||
### Volcengine and BytePlus Contract Catalogs
|
||||
|
||||
Hermes exposes **two** built-in providers for these integrations:
|
||||
|
||||
- `volcengine`
|
||||
- `byteplus`
|
||||
|
||||
Each provider includes both its standard catalog and its Coding Plan catalog. The selected model ID determines the runtime base URL automatically:
|
||||
|
||||
- `volcengine/...` -> `https://ark.cn-beijing.volces.com/api/v3`
|
||||
- `volcengine-coding-plan/...` -> `https://ark.cn-beijing.volces.com/api/coding/v3`
|
||||
- `byteplus/...` -> `https://ark.ap-southeast.bytepluses.com/api/v3`
|
||||
- `byteplus-coding-plan/...` -> `https://ark.ap-southeast.bytepluses.com/api/coding/v3`
|
||||
|
||||
In `hermes model`, the setup flow is:
|
||||
|
||||
1. Enter API key
|
||||
2. Select a model
|
||||
|
||||
If you pick a `volcengine-coding-plan/...` or `byteplus-coding-plan/...` model, Hermes automatically uses the corresponding coding-plan base URL.
|
||||
|
||||
The API key is shared per provider:
|
||||
|
||||
- `VOLCENGINE_API_KEY` works for both `volcengine/...` and `volcengine-coding-plan/...`
|
||||
- `BYTEPLUS_API_KEY` works for both `byteplus/...` and `byteplus-coding-plan/...`
|
||||
|
||||
Use `hermes model` to pick from the built-in curated catalogs. Hermes saves the canonical prefixed model ID in `config.yaml`, so standard and Coding Plan variants remain unambiguous.
|
||||
|
||||
:::note Z.AI Endpoint Auto-Detection
|
||||
When using the Z.AI / GLM provider, Hermes automatically probes multiple endpoints (global, China, coding variants) to find one that accepts your API key. You don't need to set `GLM_BASE_URL` manually — the working endpoint is detected and cached automatically.
|
||||
:::
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue