Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ def _extract_url_query_params(url: str):
"z-ai": "zai",
"z.ai": "zai",
"zhipu": "zai",
"zai-coding-plan": "zai-coding",
"z-ai-coding": "zai-coding",
"glm-coding": "zai-coding",
"glm-coding-plan": "zai-coding",
"zhipu-coding": "zai-coding",
"zhipu-coding-plan": "zai-coding",
"kimi": "kimi-coding",
"moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn",
Expand Down Expand Up @@ -261,6 +267,7 @@ def _get_aux_model_for_provider(provider_id: str) -> str:
_API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"gemini": "gemini-3-flash-preview",
"zai": "glm-4.5-flash",
"zai-coding": "glm-5-turbo",
"kimi-coding": "kimi-k2-turbo-preview",
"stepfun": "step-3.5-flash",
"kimi-coding-cn": "kimi-k2-turbo-preview",
Expand All @@ -287,6 +294,7 @@ def _get_aux_model_for_provider(provider_id: str) -> str:
_PROVIDER_VISION_MODELS: Dict[str, str] = {
"xiaomi": "mimo-v2.5",
"zai": "glm-5v-turbo",
"zai-coding": "glm-5v-turbo",
}

# Providers whose endpoint does not accept image input, even though the
Expand Down Expand Up @@ -3110,7 +3118,7 @@ def resolve_provider_client(
Args:
provider: Provider identifier. One of:
"openrouter", "nous", "openai-codex" (or "codex"),
"zai", "kimi-coding", "minimax", "minimax-cn",
"zai", "zai-coding", "kimi-coding", "minimax", "minimax-cn",
"custom" (OPENAI_BASE_URL + OPENAI_API_KEY),
"auto" (full auto-detection chain).
model: Model slug override. If None, uses the provider's default
Expand Down Expand Up @@ -4669,7 +4677,7 @@ def _build_call_kwargs(
# error code 1210 ("API 调用参数有误") on multimodal requests — skip it.
_model_lower = (model or "").lower()
_skip_max_tokens = (
provider == "zai"
provider in {"zai", "zai-coding"}
and ("4v" in _model_lower or "5v" in _model_lower or "-v" in _model_lower)
)
if _skip_max_tokens:
Expand Down
20 changes: 19 additions & 1 deletion agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1924,7 +1924,25 @@ def _env_payload(
if provider == "kimi-coding":
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
elif provider == "zai":
base_url = _resolve_zai_base_url(token, pconfig.inference_base_url, env_url)
from hermes_cli.auth import ZAI_DIRECT_ENDPOINTS

base_url = _resolve_zai_base_url(
token,
pconfig.inference_base_url,
env_url,
provider_id="zai",
endpoints=ZAI_DIRECT_ENDPOINTS,
)
elif provider == "zai-coding":
from hermes_cli.auth import ZAI_CODING_ENDPOINTS

base_url = _resolve_zai_base_url(
token,
pconfig.inference_base_url,
env_url,
provider_id="zai-coding",
endpoints=ZAI_CODING_ENDPOINTS,
)
changed |= _upsert_entry(
entries,
provider,
Expand Down
8 changes: 6 additions & 2 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def _resolve_requests_verify() -> bool | str:
# are preserved so the full model name reaches cache lookups and server queries.
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
"gemini", "ollama-cloud", "zai", "zai-coding", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
"opencode-zen", "opencode-go", "kilocode", "alibaba", "novita",
"qwen-oauth",
"xiaomi",
Expand All @@ -56,7 +56,8 @@ def _resolve_requests_verify() -> bool | str:
"custom", "local",
# Common aliases
"google", "google-gemini", "google-ai-studio",
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
"glm", "z-ai", "z.ai", "zhipu", "zai-coding-plan", "glm-coding",
"glm-coding-plan", "zhipu-coding", "zhipu-coding-plan", "github", "github-copilot",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
"ollama",
"stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen",
Expand Down Expand Up @@ -401,6 +402,9 @@ def _infer_provider_from_url(base_url: str) -> Optional[str]:
return None
parsed = urlparse(normalized if "://" in normalized else f"https://{normalized}")
host = parsed.netloc.lower() or parsed.path.lower()
path = parsed.path.lower()
if ("api.z.ai" in host or "open.bigmodel.cn" in host) and "/coding/" in path:
return "zai-coding"
for url_part, provider in _URL_TO_PROVIDER.items():
if url_part in host:
return provider
Expand Down
1 change: 1 addition & 0 deletions agent/models_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ class ProviderInfo:
"openai": "openai",
"openai-codex": "openai",
"zai": "zai",
"zai-coding": "zai",
"kimi": "kimi-for-coding",
"kimi-coding": "kimi-for-coding",
"moonshot": "kimi-for-coding",
Expand Down
139 changes: 116 additions & 23 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@
# any remote service.
LMSTUDIO_NOAUTH_PLACEHOLDER = "dummy-lm-api-key"

ZAI_DIRECT_GLOBAL_BASE_URL = "https://api.z.ai/api/paas/v4"
ZAI_DIRECT_CN_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"
ZAI_CODING_GLOBAL_BASE_URL = "https://api.z.ai/api/coding/paas/v4"
ZAI_CODING_CN_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"


# =============================================================================
# Provider Registry
Expand Down Expand Up @@ -258,10 +263,17 @@ class ProviderConfig:
id="zai",
name="Z.AI / GLM",
auth_type="api_key",
inference_base_url="https://api.z.ai/api/paas/v4",
inference_base_url=ZAI_DIRECT_GLOBAL_BASE_URL,
api_key_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
base_url_env_var="GLM_BASE_URL",
),
"zai-coding": ProviderConfig(
id="zai-coding",
name="Z.AI / GLM Coding Plan",
auth_type="api_key",
inference_base_url=ZAI_CODING_GLOBAL_BASE_URL,
api_key_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
),
"kimi-coding": ProviderConfig(
id="kimi-coding",
name="Kimi / Moonshot",
Expand Down Expand Up @@ -622,30 +634,76 @@ def _resolve_api_key_provider_secret(
# Z.AI Endpoint Detection
# =============================================================================

# Z.AI has separate billing for general vs coding plans, and global vs China
# endpoints. A key that works on one may return "Insufficient balance" on
# another. We probe at setup time and store the working endpoint.
# Z.AI has separate billing for direct API vs coding-plan paths, and global vs
# China endpoints. Keep the two billing paths separate: the direct provider may
# only probe direct endpoints, and the coding-plan provider may only probe
# coding endpoints.
# Each entry lists candidate models to try in order — newer coding plan accounts
# may only have access to recent models (glm-5.1, glm-5v-turbo) while older
# ones still use glm-4.7.

ZAI_ENDPOINTS = [
ZAI_DIRECT_ENDPOINTS = [
# (id, base_url, probe_models, label)
("global", "https://api.z.ai/api/paas/v4", ["glm-5"], "Global"),
("cn", "https://open.bigmodel.cn/api/paas/v4", ["glm-5"], "China"),
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
("global", ZAI_DIRECT_GLOBAL_BASE_URL, ["glm-5"], "Global"),
("cn", ZAI_DIRECT_CN_BASE_URL, ["glm-5"], "China"),
]

ZAI_CODING_ENDPOINTS = [
(
"coding-global",
ZAI_CODING_GLOBAL_BASE_URL,
["glm-5-turbo", "glm-5.1", "glm-4.7"],
"Global (Coding Plan)",
),
(
"coding-cn",
ZAI_CODING_CN_BASE_URL,
["glm-5-turbo", "glm-5.1", "glm-4.7"],
"China (Coding Plan)",
),
]

def _normalize_zai_base_url(url: str) -> str:
return str(url or "").strip().rstrip("/").lower()


def _zai_base_url_matches_endpoint_family(
base_url: str,
endpoints: Optional[list[tuple[str, str, list[str], str]]] = None,
) -> bool:
"""Return whether a Z.AI base URL is compatible with the endpoint family.

Custom proxy URLs remain allowed. Official Z.AI hosts must match one of the
direct or coding-plan URLs for the selected provider to avoid billing-path
bleed from old caches or legacy GLM_BASE_URL values.
"""
if not endpoints:
return True
normalized = _normalize_zai_base_url(base_url)
if not normalized:
return False
allowed = {_normalize_zai_base_url(ep_url) for _, ep_url, _, _ in endpoints}
if normalized in allowed:
return True
parsed = urlparse(normalized if "://" in normalized else f"https://{normalized}")
host = (parsed.netloc or parsed.path).split("/")[0].lower()
if host in {"api.z.ai", "open.bigmodel.cn"}:
return False
return True


def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str, str]]:
def detect_zai_endpoint(
api_key: str,
timeout: float = 8.0,
endpoints: Optional[list[tuple[str, str, list[str], str]]] = None,
) -> Optional[Dict[str, str]]:
"""Probe z.ai endpoints to find one that accepts this API key.

Returns {"id": ..., "base_url": ..., "model": ..., "label": ...} for the
first working endpoint, or None if all fail. For endpoints with multiple
candidate models, tries each in order and returns the first that succeeds.
"""
for ep_id, base_url, probe_models, label in ZAI_ENDPOINTS:
for ep_id, base_url, probe_models, label in (endpoints or ZAI_DIRECT_ENDPOINTS):
for model in probe_models:
try:
resp = httpx.post(
Expand Down Expand Up @@ -676,16 +734,31 @@ def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str
return None


def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> str:
def _resolve_zai_base_url(
api_key: str,
default_url: str,
env_override: str,
*,
provider_id: str = "zai",
endpoints: Optional[list[tuple[str, str, list[str], str]]] = None,
) -> str:
"""Return the correct Z.AI base URL by probing endpoints.

If the user has explicitly set GLM_BASE_URL, that always wins.
Otherwise, probe the candidate endpoints to find one that accepts the
key. The detected endpoint is cached in provider state (auth.json) keyed
on a hash of the API key so subsequent starts skip the probe.
If the user has explicitly set a custom GLM_BASE_URL, that wins. Official
Z.AI URLs must match the selected provider's direct-vs-coding endpoint
family. Otherwise, probe the candidate endpoints to find one that accepts
the key. The detected endpoint is cached in provider state (auth.json)
keyed on a hash of the API key so subsequent starts skip the probe.
"""
if env_override:
return env_override
if _zai_base_url_matches_endpoint_family(env_override, endpoints):
return env_override
logger.warning(
"Ignoring official Z.AI base URL %s for provider %s because it "
"does not match the selected endpoint family",
env_override,
provider_id,
)

# No API key set → don't probe (would fire N×M HTTPS requests with an
# empty Bearer token, all returning 401). This path is hit during
Expand All @@ -697,16 +770,19 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) ->

# Check provider-state cache for a previously-detected endpoint.
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "zai") or {}
state = _load_provider_state(auth_store, provider_id) or {}
cached = state.get("detected_endpoint")
if isinstance(cached, dict) and cached.get("base_url"):
key_hash = cached.get("key_hash", "")
if key_hash == hashlib.sha256(api_key.encode()).hexdigest()[:16]:
if (
key_hash == hashlib.sha256(api_key.encode()).hexdigest()[:16]
and _zai_base_url_matches_endpoint_family(cached["base_url"], endpoints)
):
logger.debug("Z.AI: using cached endpoint %s", cached["base_url"])
return cached["base_url"]

# Probe — may take up to ~8s per endpoint.
detected = detect_zai_endpoint(api_key)
detected = detect_zai_endpoint(api_key, endpoints=endpoints)
if detected and detected.get("base_url"):
# Persist the detection result keyed on the API key hash.
key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16]
Expand All @@ -717,8 +793,8 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) ->
"label": detected.get("label", ""),
"key_hash": key_hash,
}
_save_provider_state(auth_store, "zai", state)
logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"])
_save_provider_state(auth_store, provider_id, state)
logger.info("Z.AI %s: auto-detected endpoint %s (%s)", provider_id, detected["label"], detected["base_url"])
return detected["base_url"]

logger.debug("Z.AI: probe failed, falling back to default %s", default_url)
Expand Down Expand Up @@ -1478,6 +1554,9 @@ def resolve_provider(
# Normalize provider aliases
_PROVIDER_ALIASES = {
"glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai",
"zai-coding-plan": "zai-coding", "z-ai-coding": "zai-coding",
"glm-coding": "zai-coding", "glm-coding-plan": "zai-coding",
"zhipu-coding": "zai-coding", "zhipu-coding-plan": "zai-coding",
"google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini",
"x-ai": "xai", "x.ai": "xai", "grok": "xai",
"xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth",
Expand Down Expand Up @@ -6086,7 +6165,21 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
if provider_id in {"kimi-coding", "kimi-coding-cn"}:
base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url)
elif provider_id == "zai":
base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url)
base_url = _resolve_zai_base_url(
api_key,
pconfig.inference_base_url,
env_url,
provider_id="zai",
endpoints=ZAI_DIRECT_ENDPOINTS,
)
elif provider_id == "zai-coding":
base_url = _resolve_zai_base_url(
api_key,
pconfig.inference_base_url,
env_url,
provider_id="zai-coding",
endpoints=ZAI_CODING_ENDPOINTS,
)
elif env_url:
base_url = env_url.rstrip("/")
else:
Expand Down
4 changes: 2 additions & 2 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2157,8 +2157,8 @@ def _ensure_hermes_home_managed(home: Path):
"advanced": True,
},
"GLM_BASE_URL": {
"description": "Z.AI / GLM base URL override",
"prompt": "Z.AI / GLM base URL (leave empty for default)",
"description": "Z.AI / GLM direct API base URL override",
"prompt": "Z.AI / GLM direct API base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
Expand Down
Loading
Loading