feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers

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.
This commit is contained in:
teknium1
2026-03-06 18:55:12 -08:00
parent fdebca4573
commit 388dd4789c
15 changed files with 1032 additions and 15 deletions

View File

@@ -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 # 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_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 # TOOL API KEYS
# ============================================================================= # =============================================================================

View File

@@ -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. **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.
<table> <table>
<tr><td><b>A real terminal interface</b></td><td>Full TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.</td></tr> <tr><td><b>A real terminal interface</b></td><td>Full TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.</td></tr>

View File

@@ -13,6 +13,10 @@ model:
# "auto" - Use Nous Portal if logged in, otherwise OpenRouter/env vars (default) # "auto" - Use Nous Portal if logged in, otherwise OpenRouter/env vars (default)
# "openrouter" - Always use OpenRouter API key from OPENROUTER_API_KEY # "openrouter" - Always use OpenRouter API key from OPENROUTER_API_KEY
# "nous" - Always use Nous Portal (requires: hermes login) # "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. # Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var.
provider: "auto" provider: "auto"

4
cli.py
View File

@@ -833,7 +833,7 @@ class HermesCLI:
Args: Args:
model: Model to use (default: from env or claude-sonnet) model: Model to use (default: from env or claude-sonnet)
toolsets: List of toolsets to enable (default: all) 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) api_key: API key (default: from environment)
base_url: API base URL (default: OpenRouter) base_url: API base URL (default: OpenRouter)
max_turns: Maximum tool-calling iterations (default: 60) max_turns: Maximum tool-calling iterations (default: 60)
@@ -3229,7 +3229,7 @@ def main(
q: Shorthand for --query q: Shorthand for --query
toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal") toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal")
model: Model to use (default: anthropic/claude-opus-4-20250514) 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 api_key: API key for authentication
base_url: Base URL for the API base_url: Base URL for the API
max_turns: Maximum tool-calling iterations (default: 60) max_turns: Maximum tool-calling iterations (default: 60)

View File

@@ -72,15 +72,19 @@ CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
@dataclass @dataclass
class ProviderConfig: class ProviderConfig:
"""Describes a known OAuth provider.""" """Describes a known inference provider."""
id: str id: str
name: 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 = "" portal_base_url: str = ""
inference_base_url: str = "" inference_base_url: str = ""
client_id: str = "" client_id: str = ""
scope: str = "" scope: str = ""
extra: Dict[str, Any] = field(default_factory=dict) 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] = { PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
@@ -99,6 +103,38 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
auth_type="oauth_external", auth_type="oauth_external",
inference_base_url=DEFAULT_CODEX_BASE_URL, 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 1. active_provider in auth.json with valid credentials
2. Explicit CLI api_key/base_url -> "openrouter" 2. Explicit CLI api_key/base_url -> "openrouter"
3. OPENAI_API_KEY or OPENROUTER_API_KEY env vars -> "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() 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"}: if normalized in {"openrouter", "custom"}:
return "openrouter" return "openrouter"
if normalized in PROVIDER_REGISTRY: if normalized in PROVIDER_REGISTRY:
@@ -387,6 +432,14 @@ def resolve_provider(
if os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY"): if os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY"):
return "openrouter" 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" 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]: def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
"""Generic auth status dispatcher.""" """Generic auth status dispatcher."""
target = provider_id or get_active_provider() 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() return get_nous_auth_status()
if target == "openai-codex": if target == "openai-codex":
return get_codex_auth_status() 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} 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 # External credential detection
# ============================================================================= # =============================================================================

View File

@@ -170,6 +170,86 @@ OPTIONAL_ENV_VARS = {
"category": "provider", "category": "provider",
"advanced": True, "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 ── # ── Tool API keys ──
"FIRECRAWL_API_KEY": { "FIRECRAWL_API_KEY": {

View File

@@ -132,7 +132,11 @@ def run_doctor(args):
# Check for common issues # Check for common issues
content = env_path.read_text() 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") check_ok("API key configured")
else: else:
check_warn("No API key found in ~/.hermes/.env") 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)} ") print(f"\r {color('', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} ")
except Exception as e: except Exception as e:
print(f"\r {color('', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} ") 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 # Check: Submodules
# ========================================================================= # =========================================================================

View File

@@ -64,7 +64,13 @@ def _has_any_provider_configured() -> bool:
# Check env vars (may be set by .env or shell). # Check env vars (may be set by .env or shell).
# OPENAI_BASE_URL alone counts — local models (vLLM, llama.cpp, etc.) # OPENAI_BASE_URL alone counts — local models (vLLM, llama.cpp, etc.)
# often don't require an API key. # 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): if any(os.getenv(v) for v in provider_env_vars):
return True return True
@@ -411,6 +417,10 @@ def cmd_model(args):
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"nous": "Nous Portal", "nous": "Nous Portal",
"openai-codex": "OpenAI Codex", "openai-codex": "OpenAI Codex",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"minimax": "MiniMax",
"minimax-cn": "MiniMax (China)",
"custom": "Custom endpoint", "custom": "Custom endpoint",
} }
active_label = provider_labels.get(active, active) active_label = provider_labels.get(active, active)
@@ -425,11 +435,16 @@ def cmd_model(args):
("openrouter", "OpenRouter (100+ models, pay-per-use)"), ("openrouter", "OpenRouter (100+ models, pay-per-use)"),
("nous", "Nous Portal (Nous Research subscription)"), ("nous", "Nous Portal (Nous Research subscription)"),
("openai-codex", "OpenAI Codex"), ("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.)"), ("custom", "Custom endpoint (self-hosted / VLLM / etc.)"),
] ]
# Reorder so the active provider is at the top # 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 = [] ordered = []
for key, label in providers: for key, label in providers:
if key == active_key: if key == active_key:
@@ -454,6 +469,8 @@ def cmd_model(args):
_model_flow_openai_codex(config, current_model) _model_flow_openai_codex(config, current_model)
elif selected_provider == "custom": elif selected_provider == "custom":
_model_flow_custom(config) _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): 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.") 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): def cmd_login(args):
"""Authenticate Hermes CLI with a provider.""" """Authenticate Hermes CLI with a provider."""
from hermes_cli.auth import login_command from hermes_cli.auth import login_command
@@ -1141,7 +1269,7 @@ For more help on a command:
) )
chat_parser.add_argument( chat_parser.add_argument(
"--provider", "--provider",
choices=["auto", "openrouter", "nous", "openai-codex"], choices=["auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn"],
default=None, default=None,
help="Inference provider (default: auto)" help="Inference provider (default: auto)"
) )

View File

@@ -7,10 +7,12 @@ from typing import Any, Dict, Optional
from hermes_cli.auth import ( from hermes_cli.auth import (
AuthError, AuthError,
PROVIDER_REGISTRY,
format_auth_error, format_auth_error,
resolve_provider, resolve_provider,
resolve_nous_runtime_credentials, resolve_nous_runtime_credentials,
resolve_codex_runtime_credentials, resolve_codex_runtime_credentials,
resolve_api_key_provider_credentials,
) )
from hermes_cli.config import load_config from hermes_cli.config import load_config
from hermes_constants import OPENROUTER_BASE_URL from hermes_constants import OPENROUTER_BASE_URL
@@ -146,6 +148,19 @@ def resolve_runtime_provider(
"requested_provider": requested_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( runtime = _resolve_openrouter_runtime(
requested_provider=requested_provider, requested_provider=requested_provider,
explicit_api_key=explicit_api_key, explicit_api_key=explicit_api_key,

View File

@@ -729,6 +729,10 @@ def run_setup_wizard(args):
"Login with OpenAI Codex", "Login with OpenAI Codex",
"OpenRouter API key (100+ models, pay-per-use)", "OpenRouter API key (100+ models, pay-per-use)",
"Custom OpenAI-compatible endpoint (self-hosted / VLLM / etc.)", "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: if keep_label:
provider_choices.append(keep_label) provider_choices.append(keep_label)
@@ -864,14 +868,143 @@ def run_setup_wizard(args):
config['model'] = model_name config['model'] = model_name
save_env_value("LLM_MODEL", model_name) save_env_value("LLM_MODEL", model_name)
print_success("Custom endpoint configured") 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) # Step 1b: OpenRouter API Key for tools (if not already set)
# ========================================================================= # =========================================================================
# Tools (vision, web, MoA) use OpenRouter independently of the main provider. # 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. # 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()
print_header("OpenRouter API Key (for tools)") print_header("OpenRouter API Key (for tools)")
print_info("Tools like vision analysis, web search, and MoA use OpenRouter") print_info("Tools like vision analysis, web search, and MoA use OpenRouter")
@@ -944,6 +1077,60 @@ def run_setup_wizard(args):
config['model'] = custom config['model'] = custom
save_env_value("LLM_MODEL", custom) save_env_value("LLM_MODEL", custom)
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) _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: else:
# Static list for OpenRouter / fallback (from canonical list) # Static list for OpenRouter / fallback (from canonical list)
from hermes_cli.models import model_ids, menu_labels from hermes_cli.models import model_ids, menu_labels

View File

@@ -79,6 +79,10 @@ def show_status(args):
"OpenRouter": "OPENROUTER_API_KEY", "OpenRouter": "OPENROUTER_API_KEY",
"Anthropic": "ANTHROPIC_API_KEY", "Anthropic": "ANTHROPIC_API_KEY",
"OpenAI": "OPENAI_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", "Firecrawl": "FIRECRAWL_API_KEY",
"Browserbase": "BROWSERBASE_API_KEY", "Browserbase": "BROWSERBASE_API_KEY",
"FAL": "FAL_KEY", "FAL": "FAL_KEY",
@@ -137,6 +141,28 @@ def show_status(args):
if codex_status.get("error") and not codex_logged_in: if codex_status.get("error") and not codex_logged_in:
print(f" Error: {codex_status.get('error')}") 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 # Terminal Configuration
# ========================================================================= # =========================================================================

View File

@@ -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

View File

@@ -19,7 +19,7 @@ These are commands you run from your shell.
| `hermes chat --continue` / `-c` | Resume the most recent session | | `hermes chat --continue` / `-c` | Resume the most recent session |
| `hermes chat --resume <id>` / `-r <id>` | Resume a specific session | | `hermes chat --resume <id>` / `-r <id>` | Resume a specific session |
| `hermes chat --model <name>` | Use a specific model | | `hermes chat --model <name>` | Use a specific model |
| `hermes chat --provider <name>` | Force a provider (`nous`, `openrouter`) | | `hermes chat --provider <name>` | Force a provider (`nous`, `openrouter`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`) |
| `hermes chat --toolsets "web,terminal"` / `-t` | Use specific toolsets | | `hermes chat --toolsets "web,terminal"` / `-t` | Use specific toolsets |
| `hermes chat --verbose` | Enable verbose/debug output | | `hermes chat --verbose` | Enable verbose/debug output |

View File

@@ -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) | | `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_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.) | | `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) | | `HERMES_MODEL` | Preferred model name (checked before `LLM_MODEL`, used by gateway) |
| `LLM_MODEL` | Default model name (fallback when not set in config.yaml) | | `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) | | `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 | | 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) | | `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) |
| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | | `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) | | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) |

View File

@@ -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) | | **Nous Portal** | `hermes model` (OAuth, subscription-based) |
| **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) | | **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) |
| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` | | **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` | | **Custom Endpoint** | `OPENAI_BASE_URL` + `OPENAI_API_KEY` in `~/.hermes/.env` |
:::info Codex Note :::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. 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 ## 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. 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"` | | **Cost optimization** | ClawRouter or OpenRouter with `sort: "price"` |
| **Maximum privacy** | Ollama, vLLM, or llama.cpp (fully local) | | **Maximum privacy** | Ollama, vLLM, or llama.cpp (fully local) |
| **Enterprise / Azure** | Azure OpenAI with custom endpoint | | **Enterprise / Azure** | Azure OpenAI with custom endpoint |
| **Chinese AI models** | z.ai (GLM), Kimi/Moonshot, or MiniMax (first-class providers) |
:::tip :::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. 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.