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:
28
.env.example
28
.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
|
# 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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
4
cli.py
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
342
tests/test_api_key_providers.py
Normal file
342
tests/test_api_key_providers.py
Normal 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
|
||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user