From 388dd4789c4530e428b1c5b446b902c96a98ca9c Mon Sep 17 00:00:00 2001 From: teknium1 Date: Fri, 6 Mar 2026 18:55:12 -0800 Subject: [PATCH] feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn) to the inference provider system. All use standard OpenAI-compatible chat/completions endpoints with Bearer token auth. Core changes: - auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var fields. Added providers to PROVIDER_REGISTRY. Added provider aliases (glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key providers in resolve_provider(). Added resolve_api_key_provider_credentials() and get_api_key_provider_status() helpers. - runtime_provider.py: Added generic API-key provider branch in resolve_runtime_provider() — any provider with auth_type='api_key' is automatically handled. - main.py: Added providers to hermes model menu with generic _model_flow_api_key_provider() flow. Updated _has_any_provider_configured() to check all provider env vars. Updated argparse --provider choices. - setup.py: Added providers to setup wizard with API key prompts and curated model lists. - config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY, etc.) to OPTIONAL_ENV_VARS. - status.py: Added API key display and provider status section. - doctor.py: Added connectivity checks for each provider endpoint. - cli.py: Updated provider docstrings. Docs: Updated README.md, .env.example, cli-config.yaml.example, cli-commands.md, environment-variables.md, configuration.md. Tests: 50 new tests covering registry, aliases, resolution, auto-detection, credential resolution, and runtime provider dispatch. Inspired by PR #33 (numman-ali) which proposed a provider registry approach. Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related provider improvements merged earlier in this changeset. --- .env.example | 28 ++ README.md | 2 +- cli-config.yaml.example | 4 + cli.py | 4 +- hermes_cli/auth.py | 130 ++++++- hermes_cli/config.py | 80 ++++ hermes_cli/doctor.py | 43 ++- hermes_cli/main.py | 134 ++++++- hermes_cli/runtime_provider.py | 15 + hermes_cli/setup.py | 191 +++++++++- hermes_cli/status.py | 26 ++ tests/test_api_key_providers.py | 342 ++++++++++++++++++ website/docs/reference/cli-commands.md | 2 +- .../docs/reference/environment-variables.md | 10 +- website/docs/user-guide/configuration.md | 36 ++ 15 files changed, 1032 insertions(+), 15 deletions(-) create mode 100644 tests/test_api_key_providers.py diff --git a/.env.example b/.env.example index 2693931e0..e43f5a9b6 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,34 @@ OPENROUTER_API_KEY= # Examples: anthropic/claude-opus-4.6, openai/gpt-4o, google/gemini-3-flash-preview, zhipuai/glm-4-plus LLM_MODEL=anthropic/claude-opus-4.6 +# ============================================================================= +# LLM PROVIDER (z.ai / GLM) +# ============================================================================= +# z.ai provides access to ZhipuAI GLM models (GLM-4-Plus, etc.) +# Get your key at: https://z.ai or https://open.bigmodel.cn +GLM_API_KEY= +# GLM_BASE_URL=https://api.z.ai/api/paas/v4 # Override default base URL + +# ============================================================================= +# LLM PROVIDER (Kimi / Moonshot) +# ============================================================================= +# Kimi/Moonshot provides access to Moonshot AI coding models +# Get your key at: https://platform.moonshot.ai +KIMI_API_KEY= +# KIMI_BASE_URL=https://api.moonshot.ai/v1 # Override default base URL + +# ============================================================================= +# LLM PROVIDER (MiniMax) +# ============================================================================= +# MiniMax provides access to MiniMax models (global endpoint) +# Get your key at: https://www.minimax.io +MINIMAX_API_KEY= +# MINIMAX_BASE_URL=https://api.minimax.io/v1 # Override default base URL + +# MiniMax China endpoint (for users in mainland China) +MINIMAX_CN_API_KEY= +# MINIMAX_CN_BASE_URL=https://api.minimaxi.com/v1 # Override default base URL + # ============================================================================= # TOOL API KEYS # ============================================================================= diff --git a/README.md b/README.md index fa9a2bd04..b172d13f2 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), 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), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in. diff --git a/cli-config.yaml.example b/cli-config.yaml.example index c9d2c2e50..d8489d95b 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -13,6 +13,10 @@ model: # "auto" - Use Nous Portal if logged in, otherwise OpenRouter/env vars (default) # "openrouter" - Always use OpenRouter API key from OPENROUTER_API_KEY # "nous" - Always use Nous Portal (requires: hermes login) + # "zai" - Use z.ai / ZhipuAI GLM models (requires: GLM_API_KEY) + # "kimi-coding"- Use Kimi / Moonshot AI models (requires: KIMI_API_KEY) + # "minimax" - Use MiniMax global endpoint (requires: MINIMAX_API_KEY) + # "minimax-cn" - Use MiniMax China endpoint (requires: MINIMAX_CN_API_KEY) # Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var. provider: "auto" diff --git a/cli.py b/cli.py index 6433d4ce8..8898eef20 100755 --- a/cli.py +++ b/cli.py @@ -833,7 +833,7 @@ class HermesCLI: Args: model: Model to use (default: from env or claude-sonnet) toolsets: List of toolsets to enable (default: all) - provider: Inference provider ("auto", "openrouter", "nous", "openai-codex") + provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn") api_key: API key (default: from environment) base_url: API base URL (default: OpenRouter) max_turns: Maximum tool-calling iterations (default: 60) @@ -3229,7 +3229,7 @@ def main( q: Shorthand for --query toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal") model: Model to use (default: anthropic/claude-opus-4-20250514) - provider: Inference provider ("auto", "openrouter", "nous") + provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn") api_key: API key for authentication base_url: Base URL for the API max_turns: Maximum tool-calling iterations (default: 60) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 7a2fba0a9..440fc2b6f 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -72,15 +72,19 @@ CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 @dataclass class ProviderConfig: - """Describes a known OAuth provider.""" + """Describes a known inference provider.""" id: str name: str - auth_type: str # "oauth_device_code" or "api_key" + auth_type: str # "oauth_device_code", "oauth_external", or "api_key" portal_base_url: str = "" inference_base_url: str = "" client_id: str = "" scope: str = "" extra: Dict[str, Any] = field(default_factory=dict) + # For API-key providers: env vars to check (in priority order) + api_key_env_vars: tuple = () + # Optional env var for base URL override + base_url_env_var: str = "" PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { @@ -99,6 +103,38 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { auth_type="oauth_external", inference_base_url=DEFAULT_CODEX_BASE_URL, ), + "zai": ProviderConfig( + id="zai", + name="Z.AI / GLM", + auth_type="api_key", + inference_base_url="https://api.z.ai/api/paas/v4", + api_key_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), + base_url_env_var="GLM_BASE_URL", + ), + "kimi-coding": ProviderConfig( + id="kimi-coding", + name="Kimi / Moonshot", + auth_type="api_key", + inference_base_url="https://api.moonshot.ai/v1", + api_key_env_vars=("KIMI_API_KEY",), + base_url_env_var="KIMI_BASE_URL", + ), + "minimax": ProviderConfig( + id="minimax", + name="MiniMax", + auth_type="api_key", + inference_base_url="https://api.minimax.io/v1", + api_key_env_vars=("MINIMAX_API_KEY",), + base_url_env_var="MINIMAX_BASE_URL", + ), + "minimax-cn": ProviderConfig( + id="minimax-cn", + name="MiniMax (China)", + auth_type="api_key", + inference_base_url="https://api.minimaxi.com/v1", + api_key_env_vars=("MINIMAX_CN_API_KEY",), + base_url_env_var="MINIMAX_CN_BASE_URL", + ), } @@ -355,10 +391,19 @@ def resolve_provider( 1. active_provider in auth.json with valid credentials 2. Explicit CLI api_key/base_url -> "openrouter" 3. OPENAI_API_KEY or OPENROUTER_API_KEY env vars -> "openrouter" - 4. Fallback: "openrouter" + 4. Provider-specific API keys (GLM, Kimi, MiniMax) -> that provider + 5. Fallback: "openrouter" """ normalized = (requested or "auto").strip().lower() + # Normalize provider aliases + _PROVIDER_ALIASES = { + "glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai", + "kimi": "kimi-coding", "moonshot": "kimi-coding", + "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", + } + normalized = _PROVIDER_ALIASES.get(normalized, normalized) + if normalized in {"openrouter", "custom"}: return "openrouter" if normalized in PROVIDER_REGISTRY: @@ -387,6 +432,14 @@ def resolve_provider( if os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY"): return "openrouter" + # Auto-detect API-key providers by checking their env vars + for pid, pconfig in PROVIDER_REGISTRY.items(): + if pconfig.auth_type != "api_key": + continue + for env_var in pconfig.api_key_env_vars: + if os.getenv(env_var, "").strip(): + return pid + return "openrouter" @@ -1230,6 +1283,37 @@ def get_codex_auth_status() -> Dict[str, Any]: } +def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: + """Status snapshot for API-key providers (z.ai, Kimi, MiniMax).""" + pconfig = PROVIDER_REGISTRY.get(provider_id) + if not pconfig or pconfig.auth_type != "api_key": + return {"configured": False} + + api_key = "" + key_source = "" + for env_var in pconfig.api_key_env_vars: + val = os.getenv(env_var, "").strip() + if val: + api_key = val + key_source = env_var + break + + base_url = pconfig.inference_base_url + if pconfig.base_url_env_var: + env_url = os.getenv(pconfig.base_url_env_var, "").strip() + if env_url: + base_url = env_url + + return { + "configured": bool(api_key), + "provider": provider_id, + "name": pconfig.name, + "key_source": key_source, + "base_url": base_url, + "logged_in": bool(api_key), # compat with OAuth status shape + } + + def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: """Generic auth status dispatcher.""" target = provider_id or get_active_provider() @@ -1237,9 +1321,49 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: return get_nous_auth_status() if target == "openai-codex": return get_codex_auth_status() + # API-key providers + pconfig = PROVIDER_REGISTRY.get(target) + if pconfig and pconfig.auth_type == "api_key": + return get_api_key_provider_status(target) return {"logged_in": False} +def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: + """Resolve API key and base URL for an API-key provider. + + Returns dict with: provider, api_key, base_url, source. + """ + pconfig = PROVIDER_REGISTRY.get(provider_id) + if not pconfig or pconfig.auth_type != "api_key": + raise AuthError( + f"Provider '{provider_id}' is not an API-key provider.", + provider=provider_id, + code="invalid_provider", + ) + + api_key = "" + key_source = "" + for env_var in pconfig.api_key_env_vars: + val = os.getenv(env_var, "").strip() + if val: + api_key = val + key_source = env_var + break + + base_url = pconfig.inference_base_url + if pconfig.base_url_env_var: + env_url = os.getenv(pconfig.base_url_env_var, "").strip() + if env_url: + base_url = env_url.rstrip("/") + + return { + "provider": provider_id, + "api_key": api_key, + "base_url": base_url.rstrip("/"), + "source": key_source or "default", + } + + # ============================================================================= # External credential detection # ============================================================================= diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 12c8e78fb..2fa9d19cf 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -170,6 +170,86 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "GLM_API_KEY": { + "description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)", + "prompt": "Z.AI / GLM API key", + "url": "https://z.ai/", + "password": True, + "category": "provider", + "advanced": True, + }, + "ZAI_API_KEY": { + "description": "Z.AI API key (alias for GLM_API_KEY)", + "prompt": "Z.AI API key", + "url": "https://z.ai/", + "password": True, + "category": "provider", + "advanced": True, + }, + "Z_AI_API_KEY": { + "description": "Z.AI API key (alias for GLM_API_KEY)", + "prompt": "Z.AI API key", + "url": "https://z.ai/", + "password": True, + "category": "provider", + "advanced": True, + }, + "GLM_BASE_URL": { + "description": "Z.AI / GLM base URL override", + "prompt": "Z.AI / GLM base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, + "KIMI_API_KEY": { + "description": "Kimi / Moonshot API key", + "prompt": "Kimi API key", + "url": "https://platform.moonshot.cn/", + "password": True, + "category": "provider", + "advanced": True, + }, + "KIMI_BASE_URL": { + "description": "Kimi / Moonshot base URL override", + "prompt": "Kimi base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, + "MINIMAX_API_KEY": { + "description": "MiniMax API key (international)", + "prompt": "MiniMax API key", + "url": "https://www.minimax.io/", + "password": True, + "category": "provider", + "advanced": True, + }, + "MINIMAX_BASE_URL": { + "description": "MiniMax base URL override", + "prompt": "MiniMax base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, + "MINIMAX_CN_API_KEY": { + "description": "MiniMax API key (China endpoint)", + "prompt": "MiniMax (China) API key", + "url": "https://www.minimaxi.com/", + "password": True, + "category": "provider", + "advanced": True, + }, + "MINIMAX_CN_BASE_URL": { + "description": "MiniMax (China) base URL override", + "prompt": "MiniMax (China) base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, # ── Tool API keys ── "FIRECRAWL_API_KEY": { diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 36795c016..a76a6b390 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -132,7 +132,11 @@ def run_doctor(args): # Check for common issues content = env_path.read_text() - if "OPENROUTER_API_KEY" in content or "ANTHROPIC_API_KEY" in content: + if any(k in content for k in ( + "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", + "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", + "KIMI_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", + )): check_ok("API key configured") else: check_warn("No API key found in ~/.hermes/.env") @@ -468,7 +472,42 @@ def run_doctor(args): print(f"\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} ") except Exception as e: print(f"\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} ") - + + # -- API-key providers (Z.AI/GLM, Kimi, MiniMax, MiniMax-CN) -- + _apikey_providers = [ + ("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL"), + ("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL"), + ("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL"), + ("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL"), + ] + for _pname, _env_vars, _default_url, _base_env in _apikey_providers: + _key = "" + for _ev in _env_vars: + _key = os.getenv(_ev, "") + if _key: + break + if _key: + _label = _pname.ljust(20) + print(f" Checking {_pname} API...", end="", flush=True) + try: + import httpx + _base = os.getenv(_base_env, "") + _url = (_base.rstrip("/") + "/models") if _base else _default_url + _resp = httpx.get( + _url, + headers={"Authorization": f"Bearer {_key}"}, + timeout=10, + ) + if _resp.status_code == 200: + print(f"\r {color('✓', Colors.GREEN)} {_label} ") + elif _resp.status_code == 401: + print(f"\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} ") + issues.append(f"Check {_env_vars[0]} in .env") + else: + print(f"\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} ") + except Exception as _e: + print(f"\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} ") + # ========================================================================= # Check: Submodules # ========================================================================= diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f709db1d7..0332170ee 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -64,7 +64,13 @@ def _has_any_provider_configured() -> bool: # Check env vars (may be set by .env or shell). # OPENAI_BASE_URL alone counts — local models (vLLM, llama.cpp, etc.) # often don't require an API key. - provider_env_vars = ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_BASE_URL") + from hermes_cli.auth import PROVIDER_REGISTRY + + # Collect all provider env vars + provider_env_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_BASE_URL"} + for pconfig in PROVIDER_REGISTRY.values(): + if pconfig.auth_type == "api_key": + provider_env_vars.update(pconfig.api_key_env_vars) if any(os.getenv(v) for v in provider_env_vars): return True @@ -411,6 +417,10 @@ def cmd_model(args): "openrouter": "OpenRouter", "nous": "Nous Portal", "openai-codex": "OpenAI Codex", + "zai": "Z.AI / GLM", + "kimi-coding": "Kimi / Moonshot", + "minimax": "MiniMax", + "minimax-cn": "MiniMax (China)", "custom": "Custom endpoint", } active_label = provider_labels.get(active, active) @@ -425,11 +435,16 @@ def cmd_model(args): ("openrouter", "OpenRouter (100+ models, pay-per-use)"), ("nous", "Nous Portal (Nous Research subscription)"), ("openai-codex", "OpenAI Codex"), + ("zai", "Z.AI / GLM (Zhipu AI direct API)"), + ("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"), + ("minimax", "MiniMax (global direct API)"), + ("minimax-cn", "MiniMax China (domestic direct API)"), ("custom", "Custom endpoint (self-hosted / VLLM / etc.)"), ] # Reorder so the active provider is at the top - active_key = active if active in ("openrouter", "nous", "openai-codex") else "custom" + known_keys = {k for k, _ in providers} + active_key = active if active in known_keys else "custom" ordered = [] for key, label in providers: if key == active_key: @@ -454,6 +469,8 @@ def cmd_model(args): _model_flow_openai_codex(config, current_model) elif selected_provider == "custom": _model_flow_custom(config) + elif selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn"): + _model_flow_api_key_provider(config, selected_provider, current_model) def _prompt_provider_choice(choices): @@ -723,6 +740,117 @@ def _model_flow_custom(config): print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.") +# Curated model lists for direct API-key providers +_PROVIDER_MODELS = { + "zai": [ + "glm-5", + "glm-4.7", + "glm-4.5", + "glm-4.5-flash", + ], + "kimi-coding": [ + "kimi-k2.5", + "kimi-k2-thinking", + "kimi-k2-turbo-preview", + "kimi-k2-0905-preview", + ], + "minimax": [ + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", + "MiniMax-M2.1", + ], + "minimax-cn": [ + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", + "MiniMax-M2.1", + ], +} + + +def _model_flow_api_key_provider(config, provider_id, current_model=""): + """Generic flow for API-key providers (z.ai, Kimi, MiniMax).""" + from hermes_cli.auth import ( + PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice, + _update_config_for_provider, deactivate_provider, + ) + from hermes_cli.config import get_env_value, save_env_value, load_config, save_config + + pconfig = PROVIDER_REGISTRY[provider_id] + key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" + base_url_env = pconfig.base_url_env_var or "" + + # Check / prompt for API key + existing_key = "" + for ev in pconfig.api_key_env_vars: + existing_key = get_env_value(ev) or os.getenv(ev, "") + if existing_key: + break + + if not existing_key: + print(f"No {pconfig.name} API key configured.") + if key_env: + try: + new_key = input(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() + + # Optional base URL override + current_base = "" + if base_url_env: + current_base = get_env_value(base_url_env) or os.getenv(base_url_env, "") + effective_base = current_base or pconfig.inference_base_url + + try: + override = input(f"Base URL [{effective_base}]: ").strip() + except (KeyboardInterrupt, EOFError): + print() + override = "" + if override and base_url_env: + save_env_value(base_url_env, override) + effective_base = override + + # Model selection + model_list = _PROVIDER_MODELS.get(provider_id, []) + if model_list: + selected = _prompt_model_selection(model_list, current_model=current_model) + else: + try: + selected = input("Model name: ").strip() + except (KeyboardInterrupt, EOFError): + selected = None + + if selected: + # Clear custom endpoint if set (avoid confusion) + if get_env_value("OPENAI_BASE_URL"): + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + + _save_model_choice(selected) + + # Update config with provider and base URL + cfg = load_config() + model = cfg.get("model") + if isinstance(model, dict): + model["provider"] = provider_id + model["base_url"] = effective_base + save_config(cfg) + deactivate_provider() + + print(f"Default model set to: {selected} (via {pconfig.name})") + else: + print("No change.") + + def cmd_login(args): """Authenticate Hermes CLI with a provider.""" from hermes_cli.auth import login_command @@ -1141,7 +1269,7 @@ For more help on a command: ) chat_parser.add_argument( "--provider", - choices=["auto", "openrouter", "nous", "openai-codex"], + choices=["auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn"], default=None, help="Inference provider (default: auto)" ) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 1c00cca68..bf86fa88b 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -7,10 +7,12 @@ from typing import Any, Dict, Optional from hermes_cli.auth import ( AuthError, + PROVIDER_REGISTRY, format_auth_error, resolve_provider, resolve_nous_runtime_credentials, resolve_codex_runtime_credentials, + resolve_api_key_provider_credentials, ) from hermes_cli.config import load_config from hermes_constants import OPENROUTER_BASE_URL @@ -146,6 +148,19 @@ def resolve_runtime_provider( "requested_provider": requested_provider, } + # API-key providers (z.ai/GLM, Kimi, MiniMax, MiniMax-CN) + pconfig = PROVIDER_REGISTRY.get(provider) + if pconfig and pconfig.auth_type == "api_key": + creds = resolve_api_key_provider_credentials(provider) + return { + "provider": provider, + "api_mode": "chat_completions", + "base_url": creds.get("base_url", "").rstrip("/"), + "api_key": creds.get("api_key", ""), + "source": creds.get("source", "env"), + "requested_provider": requested_provider, + } + runtime = _resolve_openrouter_runtime( requested_provider=requested_provider, explicit_api_key=explicit_api_key, diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index ef3a521d7..0aaecb425 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -729,6 +729,10 @@ def run_setup_wizard(args): "Login with OpenAI Codex", "OpenRouter API key (100+ models, pay-per-use)", "Custom OpenAI-compatible endpoint (self-hosted / VLLM / etc.)", + "Z.AI / GLM (Zhipu AI models)", + "Kimi / Moonshot (Kimi coding models)", + "MiniMax (global endpoint)", + "MiniMax China (mainland China endpoint)", ] if keep_label: provider_choices.append(keep_label) @@ -864,14 +868,143 @@ def run_setup_wizard(args): config['model'] = model_name save_env_value("LLM_MODEL", model_name) print_success("Custom endpoint configured") - # else: provider_idx == 4 (Keep current) — only shown when a provider already exists + + elif provider_idx == 4: # Z.AI / GLM + selected_provider = "zai" + print() + print_header("Z.AI / GLM API Key") + pconfig = PROVIDER_REGISTRY["zai"] + print_info(f"Provider: {pconfig.name}") + print_info(f"Base URL: {pconfig.inference_base_url}") + print_info("Get your API key at: https://open.bigmodel.cn/") + print() + + existing_key = get_env_value("GLM_API_KEY") or get_env_value("ZAI_API_KEY") + if existing_key: + print_info(f"Current: {existing_key[:8]}... (configured)") + if prompt_yes_no("Update API key?", False): + api_key = prompt(" GLM API key", password=True) + if api_key: + save_env_value("GLM_API_KEY", api_key) + print_success("GLM API key updated") + else: + api_key = prompt(" GLM API key", password=True) + if api_key: + save_env_value("GLM_API_KEY", api_key) + print_success("GLM API key saved") + else: + print_warning("Skipped - agent won't work without an API key") + + # Clear custom endpoint vars if switching + if existing_custom: + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + _update_config_for_provider("zai", pconfig.inference_base_url) + + elif provider_idx == 5: # Kimi / Moonshot + selected_provider = "kimi-coding" + print() + print_header("Kimi / Moonshot API Key") + pconfig = PROVIDER_REGISTRY["kimi-coding"] + print_info(f"Provider: {pconfig.name}") + print_info(f"Base URL: {pconfig.inference_base_url}") + print_info("Get your API key at: https://platform.moonshot.cn/") + print() + + existing_key = get_env_value("KIMI_API_KEY") + if existing_key: + print_info(f"Current: {existing_key[:8]}... (configured)") + if prompt_yes_no("Update API key?", False): + api_key = prompt(" Kimi API key", password=True) + if api_key: + save_env_value("KIMI_API_KEY", api_key) + print_success("Kimi API key updated") + else: + api_key = prompt(" Kimi API key", password=True) + if api_key: + save_env_value("KIMI_API_KEY", api_key) + print_success("Kimi API key saved") + else: + print_warning("Skipped - agent won't work without an API key") + + # Clear custom endpoint vars if switching + if existing_custom: + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + _update_config_for_provider("kimi-coding", pconfig.inference_base_url) + + elif provider_idx == 6: # MiniMax + selected_provider = "minimax" + print() + print_header("MiniMax API Key") + pconfig = PROVIDER_REGISTRY["minimax"] + print_info(f"Provider: {pconfig.name}") + print_info(f"Base URL: {pconfig.inference_base_url}") + print_info("Get your API key at: https://platform.minimaxi.com/") + print() + + existing_key = get_env_value("MINIMAX_API_KEY") + if existing_key: + print_info(f"Current: {existing_key[:8]}... (configured)") + if prompt_yes_no("Update API key?", False): + api_key = prompt(" MiniMax API key", password=True) + if api_key: + save_env_value("MINIMAX_API_KEY", api_key) + print_success("MiniMax API key updated") + else: + api_key = prompt(" MiniMax API key", password=True) + if api_key: + save_env_value("MINIMAX_API_KEY", api_key) + print_success("MiniMax API key saved") + else: + print_warning("Skipped - agent won't work without an API key") + + # Clear custom endpoint vars if switching + if existing_custom: + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + _update_config_for_provider("minimax", pconfig.inference_base_url) + + elif provider_idx == 7: # MiniMax China + selected_provider = "minimax-cn" + print() + print_header("MiniMax China API Key") + pconfig = PROVIDER_REGISTRY["minimax-cn"] + print_info(f"Provider: {pconfig.name}") + print_info(f"Base URL: {pconfig.inference_base_url}") + print_info("Get your API key at: https://platform.minimaxi.com/") + print() + + existing_key = get_env_value("MINIMAX_CN_API_KEY") + if existing_key: + print_info(f"Current: {existing_key[:8]}... (configured)") + if prompt_yes_no("Update API key?", False): + api_key = prompt(" MiniMax CN API key", password=True) + if api_key: + save_env_value("MINIMAX_CN_API_KEY", api_key) + print_success("MiniMax CN API key updated") + else: + api_key = prompt(" MiniMax CN API key", password=True) + if api_key: + save_env_value("MINIMAX_CN_API_KEY", api_key) + print_success("MiniMax CN API key saved") + else: + print_warning("Skipped - agent won't work without an API key") + + # Clear custom endpoint vars if switching + if existing_custom: + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + _update_config_for_provider("minimax-cn", pconfig.inference_base_url) + + # else: provider_idx == 8 (Keep current) — only shown when a provider already exists # ========================================================================= # Step 1b: OpenRouter API Key for tools (if not already set) # ========================================================================= # Tools (vision, web, MoA) use OpenRouter independently of the main provider. # Prompt for OpenRouter key if not set and a non-OpenRouter provider was chosen. - if selected_provider in ("nous", "openai-codex", "custom") and not get_env_value("OPENROUTER_API_KEY"): + if selected_provider in ("nous", "openai-codex", "custom", "zai", "kimi-coding", "minimax", "minimax-cn") and not get_env_value("OPENROUTER_API_KEY"): print() print_header("OpenRouter API Key (for tools)") print_info("Tools like vision analysis, web search, and MoA use OpenRouter") @@ -944,6 +1077,60 @@ def run_setup_wizard(args): config['model'] = custom save_env_value("LLM_MODEL", custom) _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) + elif selected_provider == "zai": + zai_models = ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"] + model_choices = list(zai_models) + model_choices.append("Custom model") + model_choices.append(f"Keep current ({current_model})") + + keep_idx = len(model_choices) - 1 + model_idx = prompt_choice("Select default model:", model_choices, keep_idx) + + if model_idx < len(zai_models): + config['model'] = zai_models[model_idx] + save_env_value("LLM_MODEL", zai_models[model_idx]) + elif model_idx == len(zai_models): + custom = prompt("Enter model name") + if custom: + config['model'] = custom + save_env_value("LLM_MODEL", custom) + # else: keep current + elif selected_provider == "kimi-coding": + kimi_models = ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"] + model_choices = list(kimi_models) + model_choices.append("Custom model") + model_choices.append(f"Keep current ({current_model})") + + keep_idx = len(model_choices) - 1 + model_idx = prompt_choice("Select default model:", model_choices, keep_idx) + + if model_idx < len(kimi_models): + config['model'] = kimi_models[model_idx] + save_env_value("LLM_MODEL", kimi_models[model_idx]) + elif model_idx == len(kimi_models): + custom = prompt("Enter model name") + if custom: + config['model'] = custom + save_env_value("LLM_MODEL", custom) + # else: keep current + elif selected_provider in ("minimax", "minimax-cn"): + minimax_models = ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"] + model_choices = list(minimax_models) + model_choices.append("Custom model") + model_choices.append(f"Keep current ({current_model})") + + keep_idx = len(model_choices) - 1 + model_idx = prompt_choice("Select default model:", model_choices, keep_idx) + + if model_idx < len(minimax_models): + config['model'] = minimax_models[model_idx] + save_env_value("LLM_MODEL", minimax_models[model_idx]) + elif model_idx == len(minimax_models): + custom = prompt("Enter model name") + if custom: + config['model'] = custom + save_env_value("LLM_MODEL", custom) + # else: keep current else: # Static list for OpenRouter / fallback (from canonical list) from hermes_cli.models import model_ids, menu_labels diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 1604f5477..7c94b5852 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -79,6 +79,10 @@ def show_status(args): "OpenRouter": "OPENROUTER_API_KEY", "Anthropic": "ANTHROPIC_API_KEY", "OpenAI": "OPENAI_API_KEY", + "Z.AI/GLM": "GLM_API_KEY", + "Kimi": "KIMI_API_KEY", + "MiniMax": "MINIMAX_API_KEY", + "MiniMax-CN": "MINIMAX_CN_API_KEY", "Firecrawl": "FIRECRAWL_API_KEY", "Browserbase": "BROWSERBASE_API_KEY", "FAL": "FAL_KEY", @@ -137,6 +141,28 @@ def show_status(args): if codex_status.get("error") and not codex_logged_in: print(f" Error: {codex_status.get('error')}") + # ========================================================================= + # API-Key Providers + # ========================================================================= + print() + print(color("◆ API-Key Providers", Colors.CYAN, Colors.BOLD)) + + apikey_providers = { + "Z.AI / GLM": ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), + "Kimi / Moonshot": ("KIMI_API_KEY",), + "MiniMax": ("MINIMAX_API_KEY",), + "MiniMax (China)": ("MINIMAX_CN_API_KEY",), + } + for pname, env_vars in apikey_providers.items(): + key_val = "" + for ev in env_vars: + key_val = get_env_value(ev) or "" + if key_val: + break + configured = bool(key_val) + label = "configured" if configured else "not configured (run: hermes model)" + print(f" {pname:<16} {check_mark(configured)} {label}") + # ========================================================================= # Terminal Configuration # ========================================================================= diff --git a/tests/test_api_key_providers.py b/tests/test_api_key_providers.py new file mode 100644 index 000000000..a6be4d99f --- /dev/null +++ b/tests/test_api_key_providers.py @@ -0,0 +1,342 @@ +"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax).""" + +import os +import sys +import types + +import pytest + +# Ensure dotenv doesn't interfere +if "dotenv" not in sys.modules: + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *args, **kwargs: None + sys.modules["dotenv"] = fake_dotenv + +from hermes_cli.auth import ( + PROVIDER_REGISTRY, + ProviderConfig, + resolve_provider, + get_api_key_provider_status, + resolve_api_key_provider_credentials, + get_auth_status, + AuthError, +) + + +# ============================================================================= +# Provider Registry tests +# ============================================================================= + +class TestProviderRegistry: + """Test that new providers are correctly registered.""" + + @pytest.mark.parametrize("provider_id,name,auth_type", [ + ("zai", "Z.AI / GLM", "api_key"), + ("kimi-coding", "Kimi / Moonshot", "api_key"), + ("minimax", "MiniMax", "api_key"), + ("minimax-cn", "MiniMax (China)", "api_key"), + ]) + def test_provider_registered(self, provider_id, name, auth_type): + assert provider_id in PROVIDER_REGISTRY + pconfig = PROVIDER_REGISTRY[provider_id] + assert pconfig.name == name + assert pconfig.auth_type == auth_type + assert pconfig.inference_base_url # must have a default base URL + + def test_zai_env_vars(self): + pconfig = PROVIDER_REGISTRY["zai"] + assert pconfig.api_key_env_vars == ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY") + assert pconfig.base_url_env_var == "GLM_BASE_URL" + + def test_kimi_env_vars(self): + pconfig = PROVIDER_REGISTRY["kimi-coding"] + assert pconfig.api_key_env_vars == ("KIMI_API_KEY",) + assert pconfig.base_url_env_var == "KIMI_BASE_URL" + + def test_minimax_env_vars(self): + pconfig = PROVIDER_REGISTRY["minimax"] + assert pconfig.api_key_env_vars == ("MINIMAX_API_KEY",) + assert pconfig.base_url_env_var == "MINIMAX_BASE_URL" + + def test_minimax_cn_env_vars(self): + pconfig = PROVIDER_REGISTRY["minimax-cn"] + assert pconfig.api_key_env_vars == ("MINIMAX_CN_API_KEY",) + assert pconfig.base_url_env_var == "MINIMAX_CN_BASE_URL" + + def test_base_urls(self): + assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4" + assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1" + assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/v1" + assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/v1" + + def test_oauth_providers_unchanged(self): + """Ensure we didn't break the existing OAuth providers.""" + assert "nous" in PROVIDER_REGISTRY + assert PROVIDER_REGISTRY["nous"].auth_type == "oauth_device_code" + assert "openai-codex" in PROVIDER_REGISTRY + assert PROVIDER_REGISTRY["openai-codex"].auth_type == "oauth_external" + + +# ============================================================================= +# Provider Resolution tests +# ============================================================================= + +PROVIDER_ENV_VARS = ( + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", + "KIMI_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", + "OPENAI_BASE_URL", +) + + +@pytest.fixture(autouse=True) +def _clear_provider_env(monkeypatch): + for key in PROVIDER_ENV_VARS: + monkeypatch.delenv(key, raising=False) + + +class TestResolveProvider: + """Test resolve_provider() with new providers.""" + + def test_explicit_zai(self): + assert resolve_provider("zai") == "zai" + + def test_explicit_kimi_coding(self): + assert resolve_provider("kimi-coding") == "kimi-coding" + + def test_explicit_minimax(self): + assert resolve_provider("minimax") == "minimax" + + def test_explicit_minimax_cn(self): + assert resolve_provider("minimax-cn") == "minimax-cn" + + def test_alias_glm(self): + assert resolve_provider("glm") == "zai" + + def test_alias_z_ai(self): + assert resolve_provider("z-ai") == "zai" + + def test_alias_zhipu(self): + assert resolve_provider("zhipu") == "zai" + + def test_alias_kimi(self): + assert resolve_provider("kimi") == "kimi-coding" + + def test_alias_moonshot(self): + assert resolve_provider("moonshot") == "kimi-coding" + + def test_alias_minimax_underscore(self): + assert resolve_provider("minimax_cn") == "minimax-cn" + + def test_alias_case_insensitive(self): + assert resolve_provider("GLM") == "zai" + assert resolve_provider("Z-AI") == "zai" + assert resolve_provider("Kimi") == "kimi-coding" + + def test_unknown_provider_raises(self): + with pytest.raises(AuthError): + resolve_provider("nonexistent-provider-xyz") + + def test_auto_detects_glm_key(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "test-glm-key") + assert resolve_provider("auto") == "zai" + + def test_auto_detects_zai_key(self, monkeypatch): + monkeypatch.setenv("ZAI_API_KEY", "test-zai-key") + assert resolve_provider("auto") == "zai" + + def test_auto_detects_z_ai_key(self, monkeypatch): + monkeypatch.setenv("Z_AI_API_KEY", "test-z-ai-key") + assert resolve_provider("auto") == "zai" + + def test_auto_detects_kimi_key(self, monkeypatch): + monkeypatch.setenv("KIMI_API_KEY", "test-kimi-key") + assert resolve_provider("auto") == "kimi-coding" + + def test_auto_detects_minimax_key(self, monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "test-mm-key") + assert resolve_provider("auto") == "minimax" + + def test_auto_detects_minimax_cn_key(self, monkeypatch): + monkeypatch.setenv("MINIMAX_CN_API_KEY", "test-mm-cn-key") + assert resolve_provider("auto") == "minimax-cn" + + def test_openrouter_takes_priority_over_glm(self, monkeypatch): + """OpenRouter API key should win over GLM in auto-detection.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("GLM_API_KEY", "glm-key") + assert resolve_provider("auto") == "openrouter" + + +# ============================================================================= +# API Key Provider Status tests +# ============================================================================= + +class TestApiKeyProviderStatus: + + def test_unconfigured_provider(self): + status = get_api_key_provider_status("zai") + assert status["configured"] is False + assert status["logged_in"] is False + + def test_configured_provider(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "test-key-123") + status = get_api_key_provider_status("zai") + assert status["configured"] is True + assert status["logged_in"] is True + assert status["key_source"] == "GLM_API_KEY" + assert "z.ai" in status["base_url"].lower() or "api.z.ai" in status["base_url"] + + def test_fallback_env_var(self, monkeypatch): + """ZAI_API_KEY should work when GLM_API_KEY is not set.""" + monkeypatch.setenv("ZAI_API_KEY", "zai-fallback-key") + status = get_api_key_provider_status("zai") + assert status["configured"] is True + assert status["key_source"] == "ZAI_API_KEY" + + def test_custom_base_url(self, monkeypatch): + monkeypatch.setenv("KIMI_API_KEY", "kimi-key") + monkeypatch.setenv("KIMI_BASE_URL", "https://custom.kimi.example/v1") + status = get_api_key_provider_status("kimi-coding") + assert status["base_url"] == "https://custom.kimi.example/v1" + + def test_get_auth_status_dispatches_to_api_key(self, monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "mm-key") + status = get_auth_status("minimax") + assert status["configured"] is True + assert status["provider"] == "minimax" + + def test_non_api_key_provider(self): + status = get_api_key_provider_status("nous") + assert status["configured"] is False + + +# ============================================================================= +# Credential Resolution tests +# ============================================================================= + +class TestResolveApiKeyProviderCredentials: + + def test_resolve_zai_with_key(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-secret-key") + creds = resolve_api_key_provider_credentials("zai") + assert creds["provider"] == "zai" + assert creds["api_key"] == "glm-secret-key" + assert creds["base_url"] == "https://api.z.ai/api/paas/v4" + assert creds["source"] == "GLM_API_KEY" + + def test_resolve_kimi_with_key(self, monkeypatch): + monkeypatch.setenv("KIMI_API_KEY", "kimi-secret-key") + creds = resolve_api_key_provider_credentials("kimi-coding") + assert creds["provider"] == "kimi-coding" + assert creds["api_key"] == "kimi-secret-key" + assert creds["base_url"] == "https://api.moonshot.ai/v1" + + def test_resolve_minimax_with_key(self, monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "mm-secret-key") + creds = resolve_api_key_provider_credentials("minimax") + assert creds["provider"] == "minimax" + assert creds["api_key"] == "mm-secret-key" + assert creds["base_url"] == "https://api.minimax.io/v1" + + def test_resolve_minimax_cn_with_key(self, monkeypatch): + monkeypatch.setenv("MINIMAX_CN_API_KEY", "mmcn-secret-key") + creds = resolve_api_key_provider_credentials("minimax-cn") + assert creds["provider"] == "minimax-cn" + assert creds["api_key"] == "mmcn-secret-key" + assert creds["base_url"] == "https://api.minimaxi.com/v1" + + def test_resolve_with_custom_base_url(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-key") + monkeypatch.setenv("GLM_BASE_URL", "https://custom.glm.example/v4") + creds = resolve_api_key_provider_credentials("zai") + assert creds["base_url"] == "https://custom.glm.example/v4" + + def test_resolve_without_key_returns_empty(self): + creds = resolve_api_key_provider_credentials("zai") + assert creds["api_key"] == "" + assert creds["source"] == "default" + + def test_resolve_invalid_provider_raises(self): + with pytest.raises(AuthError): + resolve_api_key_provider_credentials("nous") + + def test_glm_key_priority(self, monkeypatch): + """GLM_API_KEY takes priority over ZAI_API_KEY.""" + monkeypatch.setenv("GLM_API_KEY", "primary") + monkeypatch.setenv("ZAI_API_KEY", "secondary") + creds = resolve_api_key_provider_credentials("zai") + assert creds["api_key"] == "primary" + assert creds["source"] == "GLM_API_KEY" + + def test_zai_key_fallback(self, monkeypatch): + """ZAI_API_KEY used when GLM_API_KEY not set.""" + monkeypatch.setenv("ZAI_API_KEY", "secondary") + creds = resolve_api_key_provider_credentials("zai") + assert creds["api_key"] == "secondary" + assert creds["source"] == "ZAI_API_KEY" + + +# ============================================================================= +# Runtime Provider Resolution tests +# ============================================================================= + +class TestRuntimeProviderResolution: + + def test_runtime_zai(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-key") + from hermes_cli.runtime_provider import resolve_runtime_provider + result = resolve_runtime_provider(requested="zai") + assert result["provider"] == "zai" + assert result["api_mode"] == "chat_completions" + assert result["api_key"] == "glm-key" + assert "z.ai" in result["base_url"] or "api.z.ai" in result["base_url"] + + def test_runtime_kimi(self, monkeypatch): + monkeypatch.setenv("KIMI_API_KEY", "kimi-key") + from hermes_cli.runtime_provider import resolve_runtime_provider + result = resolve_runtime_provider(requested="kimi-coding") + assert result["provider"] == "kimi-coding" + assert result["api_mode"] == "chat_completions" + assert result["api_key"] == "kimi-key" + + def test_runtime_minimax(self, monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "mm-key") + from hermes_cli.runtime_provider import resolve_runtime_provider + result = resolve_runtime_provider(requested="minimax") + assert result["provider"] == "minimax" + assert result["api_key"] == "mm-key" + + def test_runtime_auto_detects_api_key_provider(self, monkeypatch): + monkeypatch.setenv("KIMI_API_KEY", "auto-kimi-key") + from hermes_cli.runtime_provider import resolve_runtime_provider + result = resolve_runtime_provider(requested="auto") + assert result["provider"] == "kimi-coding" + assert result["api_key"] == "auto-kimi-key" + + +# ============================================================================= +# _has_any_provider_configured tests +# ============================================================================= + +class TestHasAnyProviderConfigured: + + def test_glm_key_counts(self, monkeypatch, tmp_path): + from hermes_cli import config as config_module + monkeypatch.setenv("GLM_API_KEY", "test-key") + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") + monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home) + from hermes_cli.main import _has_any_provider_configured + assert _has_any_provider_configured() is True + + def test_minimax_key_counts(self, monkeypatch, tmp_path): + from hermes_cli import config as config_module + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") + monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home) + from hermes_cli.main import _has_any_provider_configured + assert _has_any_provider_configured() is True diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 7606d9a8c..d142bb4bf 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -19,7 +19,7 @@ These are commands you run from your shell. | `hermes chat --continue` / `-c` | Resume the most recent session | | `hermes chat --resume ` / `-r ` | Resume a specific session | | `hermes chat --model ` | Use a specific model | -| `hermes chat --provider ` | Force a provider (`nous`, `openrouter`) | +| `hermes chat --provider ` | Force a provider (`nous`, `openrouter`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`) | | `hermes chat --toolsets "web,terminal"` / `-t` | Use specific toolsets | | `hermes chat --verbose` | Enable verbose/debug output | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index c35baeeaf..e82c14933 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -15,6 +15,14 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `OPENROUTER_API_KEY` | OpenRouter API key (recommended for flexibility) | | `OPENAI_API_KEY` | API key for custom OpenAI-compatible endpoints (used with `OPENAI_BASE_URL`) | | `OPENAI_BASE_URL` | Base URL for custom endpoint (VLLM, SGLang, etc.) | +| `GLM_API_KEY` | z.ai / ZhipuAI GLM API key ([z.ai](https://z.ai)) | +| `GLM_BASE_URL` | Override z.ai base URL (default: `https://api.z.ai/api/paas/v4`) | +| `KIMI_API_KEY` | Kimi / Moonshot AI API key ([moonshot.ai](https://platform.moonshot.ai)) | +| `KIMI_BASE_URL` | Override Kimi base URL (default: `https://api.moonshot.ai/v1`) | +| `MINIMAX_API_KEY` | MiniMax API key — global endpoint ([minimax.io](https://www.minimax.io)) | +| `MINIMAX_BASE_URL` | Override MiniMax base URL (default: `https://api.minimax.io/v1`) | +| `MINIMAX_CN_API_KEY` | MiniMax API key — China endpoint ([minimaxi.com](https://www.minimaxi.com)) | +| `MINIMAX_CN_BASE_URL` | Override MiniMax China base URL (default: `https://api.minimaxi.com/v1`) | | `HERMES_MODEL` | Preferred model name (checked before `LLM_MODEL`, used by gateway) | | `LLM_MODEL` | Default model name (fallback when not set in config.yaml) | | `VOICE_TOOLS_OPENAI_KEY` | OpenAI key for TTS and voice transcription (separate from custom endpoint) | @@ -24,7 +32,7 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | Variable | Description | |----------|-------------| -| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous` (default: `auto`) | +| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `zai`, `kimi-coding`, `minimax`, `minimax-cn` (default: `auto`) | | `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) | | `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index ac045c8d4..6d6897794 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -64,6 +64,10 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | **Nous Portal** | `hermes model` (OAuth, subscription-based) | | **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) | | **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` | +| **z.ai / GLM** | `GLM_API_KEY` in `~/.hermes/.env` (provider: `zai`) | +| **Kimi / Moonshot** | `KIMI_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding`) | +| **MiniMax** | `MINIMAX_API_KEY` in `~/.hermes/.env` (provider: `minimax`) | +| **MiniMax China** | `MINIMAX_CN_API_KEY` in `~/.hermes/.env` (provider: `minimax-cn`) | | **Custom Endpoint** | `OPENAI_BASE_URL` + `OPENAI_API_KEY` in `~/.hermes/.env` | :::info Codex Note @@ -74,6 +78,37 @@ The OpenAI Codex provider authenticates via device code (open a URL, enter a cod Even when using Nous Portal, Codex, or a custom endpoint, some tools (vision, web summarization, MoA) use OpenRouter independently. An `OPENROUTER_API_KEY` enables these tools. ::: +### First-Class Chinese AI Providers + +These providers have built-in support with dedicated provider IDs. Set the API key and use `--provider` to select: + +```bash +# z.ai / ZhipuAI GLM +hermes chat --provider zai --model glm-4-plus +# Requires: GLM_API_KEY in ~/.hermes/.env + +# Kimi / Moonshot AI +hermes chat --provider kimi-coding --model moonshot-v1-auto +# Requires: KIMI_API_KEY in ~/.hermes/.env + +# MiniMax (global endpoint) +hermes chat --provider minimax --model MiniMax-Text-01 +# Requires: MINIMAX_API_KEY in ~/.hermes/.env + +# MiniMax (China endpoint) +hermes chat --provider minimax-cn --model MiniMax-Text-01 +# Requires: MINIMAX_CN_API_KEY in ~/.hermes/.env +``` + +Or set the provider permanently in `config.yaml`: +```yaml +model: + provider: "zai" # or: kimi-coding, minimax, minimax-cn + default: "glm-4-plus" +``` + +Base URLs can be overridden with `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, or `MINIMAX_CN_BASE_URL` environment variables. + ## Custom & Self-Hosted LLM Providers Hermes Agent works with **any OpenAI-compatible API endpoint**. If a server implements `/v1/chat/completions`, you can point Hermes at it. This means you can use local models, GPU inference servers, multi-provider routers, or any third-party API. @@ -290,6 +325,7 @@ LLM_MODEL=meta-llama/Llama-3.1-70B-Instruct-Turbo | **Cost optimization** | ClawRouter or OpenRouter with `sort: "price"` | | **Maximum privacy** | Ollama, vLLM, or llama.cpp (fully local) | | **Enterprise / Azure** | Azure OpenAI with custom endpoint | +| **Chinese AI models** | z.ai (GLM), Kimi/Moonshot, or MiniMax (first-class providers) | :::tip You can switch between providers at any time with `hermes model` — no restart required. Your conversation history, memory, and skills carry over regardless of which provider you use.
A real terminal interfaceFull TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.