diff --git a/README.md b/README.md index 622910b3a..e7f3ca301 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 4f8c9a0a4..32bed28e0 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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. diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 152e536fd..7dd9fd4e3 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -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: diff --git a/gateway/run.py b/gateway/run.py index 617a38418..84795a337 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 3fab36a2c..c90dd62ec 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 404e59089..f1d4be64d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 ( diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 76dace065..836bfbfbe 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -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 # --------------------------------------------------------------------------- - diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 4b3493506..528341034 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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], *, diff --git a/hermes_cli/provider_contracts.py b/hermes_cli/provider_contracts.py new file mode 100644 index 000000000..189ac5826 --- /dev/null +++ b/hermes_cli/provider_contracts.py @@ -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) diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index e842086a4..d6312766f 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -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", diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 4c775b8a6..9a13d307f 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -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) # --------------------------------------------------------------------------- diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 45e716022..eb3e15a1f 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -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 = {} diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index e8f181fa4..271aa3d47 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -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") diff --git a/tests/hermes_cli/test_model_normalize.py b/tests/hermes_cli/test_model_normalize.py index 6de69ab30..75c8c5caa 100644 --- a/tests/hermes_cli/test_model_normalize.py +++ b/tests/hermes_cli/test_model_normalize.py @@ -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: diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 067483680..d22ab777d 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -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 diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index b493fd2b6..e5b0e9c0d 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -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.""" diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 013c6a3e3..3375cfd9d 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -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. :::
A real terminal interfaceFull TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.