diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 4493eae5f1f8..3c737bc3528a 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -47,7 +47,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", @@ -472,6 +472,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 diff --git a/agent/models_dev.py b/agent/models_dev.py index 590f77806abf..c69a381a877b 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -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", diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 61c2bbed7865..d4bb7e7380f0 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -147,6 +147,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 @@ -244,9 +249,17 @@ class ProviderConfig: ), "zai": ProviderConfig( id="zai", - name="Z.AI / GLM", + name="Z.AI / GLM Direct API", + auth_type="api_key", + 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 API", auth_type="api_key", - inference_base_url="https://api.z.ai/api/paas/v4", + inference_base_url=ZAI_CODING_GLOBAL_BASE_URL, api_key_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), base_url_env_var="GLM_BASE_URL", ), @@ -589,17 +602,20 @@ def _resolve_api_key_provider_secret( if has_usable_secret(val): return val, env_var - # Fallback: try credential pool (e.g. zai key stored via auth.json) + # Fallback: try credential pool. Z.AI direct and Coding Plan share the same + # API key, so either provider can reuse the other's stored key while still + # resolving a separate endpoint family later. try: from agent.credential_pool import load_pool - pool = load_pool(provider_id) - if pool and pool.has_credentials(): - entry = pool.peek() - if entry: - key = getattr(entry, "access_token", "") or getattr(entry, "runtime_api_key", "") - key = str(key).strip() - if has_usable_secret(key): - return key, f"credential_pool:{provider_id}" + for pool_id in dict.fromkeys(shared_credential_provider_ids(provider_id)): + pool = load_pool(pool_id) + if pool and pool.has_credentials(): + entry = pool.peek() + if entry: + key = getattr(entry, "access_token", "") or getattr(entry, "runtime_api_key", "") + key = str(key).strip() + if has_usable_secret(key): + return key, f"credential_pool:{pool_id}" except Exception: pass @@ -610,30 +626,160 @@ 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.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"), - ("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"), + ("global", ZAI_DIRECT_GLOBAL_BASE_URL, ["glm-5.2", "glm-5"], "Global"), + ("cn", ZAI_DIRECT_CN_BASE_URL, ["glm-5.2", "glm-5"], "China"), ] +ZAI_CODING_ENDPOINTS = [ + ( + "coding-global", + ZAI_CODING_GLOBAL_BASE_URL, + ["glm-5.2", "glm-5-turbo", "glm-5.1", "glm-4.7"], + "Global (Coding Plan)", + ), + ( + "coding-cn", + ZAI_CODING_CN_BASE_URL, + ["glm-5.2", "glm-5-turbo", "glm-5.1", "glm-4.7"], + "China (Coding Plan)", + ), +] -def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> 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. +_ZAI_PROVIDER_IDS = frozenset({"zai", "zai-coding"}) +_ZAI_OFFICIAL_HOSTS = frozenset({"api.z.ai", "open.bigmodel.cn"}) +_PROVIDER_SHARED_CREDENTIAL_IDS: Dict[str, tuple[str, ...]] = { + "zai": ("zai", "zai-coding"), + "zai-coding": ("zai-coding", "zai"), +} +_PROVIDER_ENDPOINT_FAMILIES: Dict[str, list[tuple[str, str, list[str], str]]] = { + "zai": ZAI_DIRECT_ENDPOINTS, + "zai-coding": ZAI_CODING_ENDPOINTS, +} +_PROVIDER_ENDPOINT_OFFICIAL_HOSTS: Dict[str, FrozenSet[str]] = { + "zai": _ZAI_OFFICIAL_HOSTS, + "zai-coding": _ZAI_OFFICIAL_HOSTS, +} + + +def shared_credential_provider_ids(provider_id: str) -> tuple[str, ...]: + """Return provider ids that can reuse the same stored credential.""" + normalized = str(provider_id or "").strip().lower() + return _PROVIDER_SHARED_CREDENTIAL_IDS.get(normalized, (normalized,)) + + +def endpoint_family_providers() -> FrozenSet[str]: + """Provider ids whose official endpoints are split into guarded families.""" + return frozenset(_PROVIDER_ENDPOINT_FAMILIES) + + +def endpoint_family_candidates( + provider_id: str, +) -> list[tuple[str, str, list[str], str]]: + """Return endpoint candidates for a provider's guarded endpoint family.""" + return list(_PROVIDER_ENDPOINT_FAMILIES.get(str(provider_id or "").strip().lower(), [])) + + +def _normalize_base_url(url: str) -> str: + return str(url or "").strip().rstrip("/").lower() + + +def _official_base_url_key(url: str, official_hosts: FrozenSet[str]) -> Optional[str]: + """Return a canonical comparison key for known official provider URLs.""" + normalized = _normalize_base_url(url) + if not normalized: + return None + parsed = urlparse(normalized if "://" in normalized else f"https://{normalized}") + host = (parsed.hostname or "").rstrip(".").lower() + if host not in official_hosts: + return None + scheme = (parsed.scheme or "https").lower() + try: + port = parsed.port + except ValueError: + return normalized + netloc = host + if port and not ((scheme == "https" and port == 443) or (scheme == "http" and port == 80)): + netloc = f"{host}:{port}" + path = parsed.path.rstrip("/") + if parsed.params: + path = f"{path};{parsed.params}" + key = f"{scheme}://{netloc}{path}" + if parsed.query: + key = f"{key}?{parsed.query}" + if parsed.fragment: + key = f"{key}#{parsed.fragment}" + return key + + +def base_url_matches_endpoint_family( + base_url: str, + endpoints: Optional[list[tuple[str, str, list[str], str]]] = None, + official_hosts: Optional[FrozenSet[str]] = None, +) -> bool: + """Return whether a base URL is compatible with a guarded endpoint family. + + Custom proxy URLs remain allowed. Known official hosts must match one of + the selected provider family's URLs so stale env/config/cache values cannot + bleed across billing paths. """ - for ep_id, base_url, probe_models, label in ZAI_ENDPOINTS: + if not endpoints: + return True + official_hosts = official_hosts or frozenset() + normalized = _normalize_base_url(base_url) + if not normalized: + return False + allowed = { + key + for key in ( + _official_base_url_key(ep_url, official_hosts) + for _, ep_url, _, _ in endpoints + ) + if key + } + official_key = _official_base_url_key(normalized, official_hosts) + if official_key is None: + return True + return official_key in allowed + + +def provider_base_url_matches_endpoint_family(provider_id: str, base_url: str) -> bool: + """Return whether ``base_url`` is valid for the provider's endpoint family.""" + normalized = str(provider_id or "").strip().lower() + return base_url_matches_endpoint_family( + base_url, + endpoint_family_candidates(normalized), + _PROVIDER_ENDPOINT_OFFICIAL_HOSTS.get(normalized, frozenset()), + ) + + +def _zai_base_url_matches_endpoint_family( + base_url: str, + endpoints: Optional[list[tuple[str, str, list[str], str]]] = None, +) -> bool: + """Compatibility wrapper for tests and older Z.AI call-sites.""" + return base_url_matches_endpoint_family(base_url, endpoints, _ZAI_OFFICIAL_HOSTS) + + +def _detect_chat_completion_endpoint( + api_key: str, + endpoints: list[tuple[str, str, list[str], str]], + *, + timeout: float = 8.0, + log_label: str = "Provider", +) -> Optional[Dict[str, str]]: + """Probe OpenAI-compatible chat endpoints and return the first match.""" + for ep_id, base_url, probe_models, label in endpoints: for model in probe_models: try: resp = httpx.post( @@ -651,33 +797,107 @@ def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str timeout=timeout, ) if resp.status_code == 200: - logger.debug("Z.AI endpoint probe: %s (%s) model=%s OK", ep_id, base_url, model) + logger.debug( + "%s endpoint probe: %s (%s) model=%s OK", + log_label, + ep_id, + base_url, + model, + ) return { "id": ep_id, "base_url": base_url, "model": model, "label": label, } - logger.debug("Z.AI endpoint probe: %s model=%s returned %s", ep_id, model, resp.status_code) + logger.debug( + "%s endpoint probe: %s model=%s returned %s", + log_label, + ep_id, + model, + resp.status_code, + ) except Exception as exc: - logger.debug("Z.AI endpoint probe: %s model=%s failed: %s", ep_id, model, exc) + logger.debug( + "%s endpoint probe: %s model=%s failed: %s", + log_label, + ep_id, + model, + exc, + ) return None -def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> str: - """Return the correct Z.AI base URL by probing endpoints. +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. + """ + return _detect_chat_completion_endpoint( + api_key, + endpoints or ZAI_DIRECT_ENDPOINTS, + timeout=timeout, + log_label="Z.AI", + ) + + +def resolve_provider_endpoint_family_base_url( + provider_id: str, + api_key: str, + default_url: str, + env_override: str, +) -> str: + """Resolve a guarded endpoint-family base URL for a known provider.""" + normalized = str(provider_id or "").strip().lower() + if normalized in _ZAI_PROVIDER_IDS: + return _resolve_endpoint_family_base_url( + api_key, + default_url, + env_override, + provider_id=normalized, + endpoints=endpoint_family_candidates(normalized), + detect_endpoint=detect_zai_endpoint, + log_label="Z.AI", + ) + return env_override.rstrip("/") if env_override else default_url + + +def _resolve_endpoint_family_base_url( + api_key: str, + default_url: str, + env_override: str, + *, + provider_id: str, + endpoints: list[tuple[str, str, list[str], str]], + detect_endpoint: Callable[..., Optional[Dict[str, str]]], + log_label: str, +) -> str: + """Resolve a guarded endpoint-family base URL for an API-key provider. - 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. + Custom env URLs win. Official provider URLs must match the selected + endpoint family. Otherwise, probe the family candidates and cache the + detected endpoint per provider id and key hash. """ if env_override: - return env_override + if provider_base_url_matches_endpoint_family(provider_id, env_override): + return env_override + logger.warning( + "Ignoring official %s base URL %s for provider %s because it " + "does not match the selected endpoint family", + log_label, + 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 - # auxiliary-client auto-detection when the user has no Z.AI credentials + # auxiliary-client auto-detection when the user has no provider credentials # at all — the caller discards the result immediately, so the probe is # pure latency for every AIAgent construction. if not api_key: @@ -685,16 +905,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]: - logger.debug("Z.AI: using cached endpoint %s", cached["base_url"]) + if ( + key_hash == hashlib.sha256(api_key.encode()).hexdigest()[:16] + and provider_base_url_matches_endpoint_family(provider_id, cached["base_url"]) + ): + logger.debug("%s: using cached endpoint %s", log_label, cached["base_url"]) return cached["base_url"] # Probe — may take up to ~8s per endpoint. - detected = detect_zai_endpoint(api_key) + detected = detect_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] @@ -705,14 +928,47 @@ 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( + "%s %s: auto-detected endpoint %s (%s)", + log_label, + provider_id, + detected["label"], + detected["base_url"], + ) return detected["base_url"] - logger.debug("Z.AI: probe failed, falling back to default %s", default_url) + logger.debug("%s: probe failed, falling back to default %s", log_label, default_url) return default_url +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 for the selected endpoint family.""" + if endpoints is None: + return resolve_provider_endpoint_family_base_url( + provider_id, + api_key, + default_url, + env_override, + ) + return _resolve_endpoint_family_base_url( + api_key, + default_url, + env_override, + provider_id=provider_id, + endpoints=endpoints, + detect_endpoint=detect_zai_endpoint, + log_label="Z.AI", + ) + + # ============================================================================= # Error Types # ============================================================================= @@ -6285,8 +6541,13 @@ 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) + elif provider_id in endpoint_family_providers(): + base_url = resolve_provider_endpoint_family_base_url( + provider_id, + api_key, + pconfig.inference_base_url, + env_url, + ) elif env_url: base_url = env_url.rstrip("/") else: diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 127adefb39c4..576596a47fdf 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -378,7 +378,8 @@ def _build_apikey_providers_list() -> list: already present — adding plugins/model-providers// is sufficient to get into doctor. """ _static = [ - ("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True), + ("Z.AI / GLM Direct API", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True), + ("Z.AI / GLM Coding Plan API", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/coding/paas/v4/models", None, True), ("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True), ("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True), ("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True), @@ -402,7 +403,9 @@ def _build_apikey_providers_list() -> list: # don't create duplicate entries for providers already in the static list. _known_canonical: set[str] = set() _name_to_canonical = { - "Z.AI / GLM": "zai", "Kimi / Moonshot": "kimi-coding", + "Z.AI / GLM Direct API": "zai", + "Z.AI / GLM Coding Plan API": "zai-coding", + "Kimi / Moonshot": "kimi-coding", "StepFun Step Plan": "stepfun", "Kimi / Moonshot (China)": "kimi-coding-cn", "Arcee AI": "arcee", "GMI Cloud": "gmi", "DeepSeek": "deepseek", "Hugging Face": "huggingface", "NVIDIA NIM": "nvidia", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0394ef90a2e9..83c22ea189e6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3030,6 +3030,7 @@ def _active_custom_key_from_base_url() -> str: "deepseek", "xai", "zai", + "zai-coding", "kimi-coding-cn", "minimax", "minimax-cn", diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 2c4988cc76ea..6cfc9068f8af 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -91,6 +91,7 @@ # provider/ prefix when users copy the aggregator form into config.yaml. _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({ "zai", + "zai-coding", "kimi-coding", "kimi-coding-cn", "minimax", @@ -470,4 +471,3 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: # --------------------------------------------------------------------------- # Batch / convenience helpers # --------------------------------------------------------------------------- - diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index 1af46ab40aa1..ad5c8119e816 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -2284,11 +2284,99 @@ def _sort_key(m): else: print(" No change.") + +def _endpoint_options_for_provider(provider_id: str) -> list[tuple[str, str]]: + from hermes_cli.auth import endpoint_family_candidates + + return [(label, base_url) for _, base_url, _, label in endpoint_family_candidates(provider_id)] + + +def _prompt_custom_endpoint_family_base_url( + provider_id: str, + provider_label: str, + current_base: str, + fallback_base: str, +) -> str: + from hermes_cli.auth import provider_base_url_matches_endpoint_family + + try: + override = input(f"Custom {provider_label} base URL [{current_base}]: ").strip() + except (KeyboardInterrupt, EOFError): + print() + return fallback_base + if not override: + return fallback_base + if not override.startswith(("http://", "https://")): + print( + " Invalid URL — must start with http:// or https://. Ignoring custom value." + ) + return fallback_base + if not provider_base_url_matches_endpoint_family(provider_id, override): + print( + " Official provider URL does not match this endpoint family. Ignoring custom value." + ) + return fallback_base + return override.rstrip("/") + + +def _select_endpoint_family_base_url(provider_id: str, current_base: str) -> str: + from hermes_cli.main import _prompt_provider_choice + from hermes_cli.auth import ( + PROVIDER_REGISTRY, + provider_base_url_matches_endpoint_family, + ) + + options = _endpoint_options_for_provider(provider_id) + if not options: + return current_base + + provider_label = PROVIDER_REGISTRY[provider_id].name + normalized_current = str(current_base or "").strip().rstrip("/") + current_matches_family = ( + provider_base_url_matches_endpoint_family(provider_id, normalized_current) + if normalized_current + else True + ) + fallback_base = current_base if current_matches_family else options[0][1] + default_idx = 0 + if normalized_current and current_matches_family: + for idx, (_, url) in enumerate(options): + if normalized_current == url.rstrip("/"): + default_idx = idx + break + else: + default_idx = len(options) + + choices = [f"{label} ({url})" for label, url in options] + choices.append("Custom proxy URL") + selected = _prompt_provider_choice( + choices, + default=default_idx, + title=f"Select {provider_label} endpoint:", + ) + if selected is None: + return fallback_base + if selected == len(options): + return _prompt_custom_endpoint_family_base_url( + provider_id, + provider_label, + current_base, + fallback_base, + ) + return options[selected][1] + + +def _select_zai_endpoint(provider_id: str, current_base: str) -> str: + """Compatibility wrapper for Z.AI endpoint-selection tests.""" + return _select_endpoint_family_base_url(provider_id, current_base) + + def _model_flow_api_key_provider(config, provider_id, current_model=""): """Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.).""" from hermes_cli.main import _prompt_api_key from hermes_cli.auth import ( PROVIDER_REGISTRY, + endpoint_family_providers, _prompt_model_selection, _save_model_choice, deactivate_provider, @@ -2398,19 +2486,25 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): pass 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: - if not override.startswith(("http://", "https://")): - print( - " Invalid URL — must start with http:// or https://. Keeping current value." - ) - else: - save_env_value(base_url_env, override) - effective_base = override + if provider_id in endpoint_family_providers(): + chosen_base = _select_endpoint_family_base_url(provider_id, effective_base) + if chosen_base and chosen_base != effective_base and base_url_env: + save_env_value(base_url_env, chosen_base) + effective_base = chosen_base + else: + try: + override = input(f"Base URL [{effective_base}]: ").strip() + except (KeyboardInterrupt, EOFError): + print() + override = "" + if override and base_url_env: + if not override.startswith(("http://", "https://")): + print( + " Invalid URL — must start with http:// or https://. Keeping current value." + ) + else: + save_env_value(base_url_env, override) + effective_base = override # Model selection — resolution order: # 1. models.dev registry (cached, filtered for agentic/tool-capable models) @@ -2496,16 +2590,27 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): # Merge models.dev with curated list so newly added models # (not yet in models.dev) still appear in the picker. if curated: - seen = {m.lower() for m in mdev_models} - merged = list(mdev_models) - for m in curated: - if m.lower() not in seen: - merged.append(m) - seen.add(m.lower()) + if provider_id == "zai-coding": + # Coding Plan has a separate billing path and explicit + # supported model set; don't inherit direct API catalog + # breadth into this setup picker. + merged = list(curated) + else: + seen = {m.lower() for m in mdev_models} + merged = list(mdev_models) + for m in curated: + if m.lower() not in seen: + merged.append(m) + seen.add(m.lower()) model_list = merged else: model_list = mdev_models - print(f" Found {len(model_list)} model(s) from models.dev registry") + if provider_id == "zai-coding": + print( + f' Showing {len(model_list)} Coding Plan models — use "Enter custom model name" for others.' + ) + else: + print(f" Found {len(model_list)} model(s) from models.dev registry") elif curated and len(curated) >= 8: # Curated list is substantial — use it directly, skip live probe model_list = curated diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index a27292747bef..69c8b8579851 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1235,14 +1235,19 @@ def list_authenticated_providers( fetch_models_dev, get_provider_info as _mdev_pinfo, ) - from hermes_cli.auth import PROVIDER_REGISTRY + from hermes_cli.auth import ( + PROVIDER_REGISTRY, + endpoint_family_providers, + shared_credential_provider_ids, + ) from hermes_cli.models import ( OPENROUTER_MODELS, _PROVIDER_MODELS, _MODELS_DEV_PREFERRED, _merge_with_models_dev, cached_provider_model_ids, - get_curated_nous_model_ids, + get_curated_nous_model_ids, provider_label, ) results: List[dict] = [] + endpoint_provider_ids = endpoint_family_providers() seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545) seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn) # Effective base URLs of every built-in row we emit (normalized lower+rstrip). @@ -1277,6 +1282,27 @@ def _record_builtin_endpoint(slug: str) -> None: if normed: _builtin_endpoints.add(normed) + def _has_auth_store_credentials(*slugs: str) -> bool: + try: + from hermes_cli.auth import _load_auth_store + store = _load_auth_store() + providers_store = store.get("providers", {}) if store else {} + pool_store = store.get("credential_pool", {}) if store else {} + return any(slug in providers_store or slug in pool_store for slug in slugs) + except Exception: + return False + + def _has_credential_pool_credentials(*slugs: str) -> bool: + try: + from agent.credential_pool import load_pool + for slug in dict.fromkeys(slugs): + pool = load_pool(slug) + if pool.has_credentials(): + return True + except Exception as exc: + logger.debug("Credential pool check failed for %s: %s", slugs, exc) + return False + def _has_fast_aws_sdk_signal() -> bool: """Return True when explicit AWS auth config is present. @@ -1406,13 +1432,9 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: # Check if any env var is set has_creds = any(os.environ.get(ev) for ev in env_vars) if not has_creds: - try: - from hermes_cli.auth import _load_auth_store - store = _load_auth_store() - if store and store.get("credential_pool", {}).get(hermes_id): - has_creds = True - except Exception: - pass + has_creds = _has_auth_store_credentials( + *shared_credential_provider_ids(hermes_id) + ) if not has_creds: continue @@ -1430,7 +1452,10 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: slug = hermes_id pinfo = _mdev_pinfo(mdev_id) - display_name = pinfo.name if pinfo else mdev_id + if hermes_id in endpoint_provider_ids: + display_name = provider_label(hermes_id) + else: + display_name = pinfo.name if pinfo else mdev_id results.append({ "slug": slug, @@ -1459,7 +1484,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: continue # Resolve Hermes slug — e.g. "github-copilot" → "copilot" - hermes_slug = _mdev_to_hermes.get(pid, pid) + hermes_slug = pid if pid in _auth_registry else _mdev_to_hermes.get(pid, pid) if hermes_slug.lower() in seen_slugs: continue @@ -1482,26 +1507,18 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: # support OAuth (e.g. anthropic supports both API key and Claude Code # OAuth via external credential files). if not has_creds: - try: - from hermes_cli.auth import _load_auth_store - store = _load_auth_store() - providers_store = store.get("providers", {}) - if store and (pid in providers_store or hermes_slug in providers_store): - has_creds = True - except Exception as exc: - logger.debug("Auth store check failed for %s: %s", pid, exc) + has_creds = _has_auth_store_credentials( + pid, + *shared_credential_provider_ids(hermes_slug), + ) # Fallback: check the credential pool with full auto-seeding. # This catches credentials that exist in external stores (e.g. # Codex CLI ~/.codex/auth.json) which _seed_from_singletons() # imports on demand but aren't in the raw auth.json yet. if not has_creds: - try: - from agent.credential_pool import load_pool - pool = load_pool(hermes_slug) - if pool.has_credentials(): - has_creds = True - except Exception as exc: - logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc) + has_creds = _has_credential_pool_credentials( + *shared_credential_provider_ids(hermes_slug) + ) # Fallback: check external credential files directly. # The credential pool gates anthropic behind # is_provider_explicitly_configured() to prevent auxiliary tasks @@ -1624,22 +1641,13 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: _cp_has_creds = any(os.environ.get(ev) for ev in _cp_config.api_key_env_vars) # Also check auth store and credential pool if not _cp_has_creds: - try: - from hermes_cli.auth import _load_auth_store - _cp_store = _load_auth_store() - _cp_providers_store = _cp_store.get("providers", {}) - if _cp_store and _cp.slug in _cp_providers_store: - _cp_has_creds = True - except Exception: - pass + _cp_has_creds = _has_auth_store_credentials( + *shared_credential_provider_ids(_cp.slug) + ) if not _cp_has_creds: - try: - from agent.credential_pool import load_pool - _cp_pool = load_pool(_cp.slug) - if _cp_pool.has_credentials(): - _cp_has_creds = True - except Exception: - pass + _cp_has_creds = _has_credential_pool_credentials( + *shared_credential_provider_ids(_cp.slug) + ) # Special case: aws_sdk auth (bedrock) — no API key env vars, # credentials come from the boto3 credential chain (env vars, diff --git a/hermes_cli/models.py b/hermes_cli/models.py index f84ac69564e5..d378d97ca1c6 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -286,6 +286,13 @@ def _xai_curated_models() -> list[str]: "glm-4.5", "glm-4.5-flash", ], + "zai-coding": [ + "glm-5.2", + "glm-5-turbo", + "glm-5.1", + "glm-4.7", + "glm-4.5-air", + ], "xai": _xai_curated_models(), "nvidia": [ # NVIDIA flagship reasoning models @@ -1031,7 +1038,9 @@ class ProviderEntry(NamedTuple): ProviderEntry("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth + Code Assist (Code Assist OAuth flow)"), ProviderEntry("deepseek", "DeepSeek", "DeepSeek (V3, R1, coder, direct API)"), ProviderEntry("xai", "xAI", "xAI Grok (Direct API)"), - ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu direct API)"), + ProviderEntry("zai", "Z.AI / GLM Direct API", "Z.AI / GLM Direct API endpoint (/api/paas/v4)"), + ProviderEntry("zai-coding", "Z.AI / GLM Coding Plan API", + "Z.AI / GLM Coding Plan API endpoint (/api/coding/paas/v4)"), ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com & Moonshot API)"), ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Domestic direct API)"), ProviderEntry("stepfun", "StepFun Step Plan", "StepFun Step Plan (Agent / coding models via Step Plan API)"), @@ -1096,6 +1105,9 @@ class ProviderEntry(NamedTuple): # Member order is the order shown inside the group submenu. # --------------------------------------------------------------------------- PROVIDER_GROUPS: dict[str, tuple[str, str, list[str]]] = { + "zai": ("Z.AI / GLM API endpoints", + "Direct API and Coding Plan API billing paths", + ["zai", "zai-coding"]), "kimi": ("Kimi / Moonshot", "Coding Plan, Moonshot global & China endpoints", ["kimi-coding", "kimi-coding-cn"]), "minimax": ("MiniMax", "Global, OAuth Coding Plan & China endpoints", ["minimax", "minimax-oauth", "minimax-cn"]), "xai": ("xAI Grok", "Direct API or SuperGrok / Premium+ OAuth", ["xai", "xai-oauth"]), @@ -2222,6 +2234,10 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) return get_codex_model_ids(access_token=access_token) if normalized == "xai-oauth": return list(_PROVIDER_MODELS.get("xai-oauth", _PROVIDER_MODELS.get("xai", []))) + if normalized == "zai": + return _merge_with_models_dev(normalized, list(_PROVIDER_MODELS.get(normalized, []))) + if normalized == "zai-coding": + return list(_PROVIDER_MODELS.get(normalized, [])) if normalized in {"copilot", "copilot-acp"}: try: live = _fetch_github_models(_resolve_copilot_catalog_api_key()) @@ -3944,6 +3960,19 @@ def validate_requested_model( # the user may have access to models not shown in the public # listing (e.g. Z.AI Pro/Max plans can use glm-5 on coding # endpoints even though it's not in /models). Warn but allow. + if normalized in {"zai", "zai-coding"}: + try: + catalog_models = cached_provider_model_ids(normalized) + except Exception: + catalog_models = [] + catalog_lower = {m.lower(): m for m in catalog_models} + if requested_for_lookup.lower() in catalog_lower: + return { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, + } # Auto-correct if the top match is very similar (e.g. typo) auto = get_close_matches(requested_for_lookup, api_models, n=1, cutoff=0.9) diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index efc3a8576ed1..f33bb697ab50 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -107,6 +107,12 @@ class HermesOverlay: extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), base_url_env_var="GLM_BASE_URL", ), + "zai-coding": HermesOverlay( + transport="openai_chat", + extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), + base_url_override="https://api.z.ai/api/coding/paas/v4", + base_url_env_var="GLM_BASE_URL", + ), "kimi-for-coding": HermesOverlay( transport="openai_chat", base_url_env_var="KIMI_BASE_URL", @@ -377,6 +383,8 @@ class ProviderDef: "bedrock": "AWS Bedrock", "ollama-cloud": "Ollama Cloud", "xai-oauth": "xAI Grok OAuth (SuperGrok / Premium+)", + "zai": "Z.AI / GLM Direct API", + "zai-coding": "Z.AI / GLM Coding Plan API", } diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 909cbe07a080..4cf2175e264d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -102,6 +102,16 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]: return None +def _config_base_url_allowed_for_provider(provider: str, base_url: str) -> bool: + """Return whether a persisted model.base_url may override provider defaults.""" + candidate = (base_url or "").strip() + if not candidate: + return False + if provider in auth_mod.endpoint_family_providers(): + return auth_mod.provider_base_url_matches_endpoint_family(provider, candidate) + return True + + def _host_derived_api_key(base_url: str) -> str: """Look up `_API_KEY` in the env, derived from the base URL host. @@ -380,7 +390,7 @@ def _resolve_runtime_from_pool_entry( pool_url_is_default = pconfig and base_url.rstrip("/") == pconfig.inference_base_url.rstrip("/") if configured_provider == provider and pool_url_is_default: cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") - if cfg_base_url: + if cfg_base_url and _config_base_url_allowed_for_provider(provider, cfg_base_url): base_url = cfg_base_url configured_mode = _parse_api_mode(model_cfg.get("api_mode")) if provider in {"opencode-zen", "opencode-go"}: @@ -1266,10 +1276,19 @@ def _resolve_explicit_runtime( env_url = os.getenv(pconfig.base_url_env_var, "").strip().rstrip("/") base_url = explicit_base_url + if base_url and not _config_base_url_allowed_for_provider(provider, base_url): + base_url = "" if not base_url: if provider in {"kimi-coding", "kimi-coding-cn"}: creds = resolve_api_key_provider_credentials(provider) base_url = creds.get("base_url", "").rstrip("/") + elif provider in auth_mod.endpoint_family_providers(): + base_url = auth_mod.resolve_provider_endpoint_family_base_url( + provider, + explicit_api_key, + pconfig.inference_base_url, + env_url, + ) else: base_url = env_url or pconfig.inference_base_url @@ -1718,7 +1737,9 @@ def resolve_runtime_provider( cfg_provider = str(model_cfg.get("provider") or "").strip().lower() cfg_base_url = "" if cfg_provider == provider: - cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") + candidate_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") + if _config_base_url_allowed_for_provider(provider, candidate_base_url): + cfg_base_url = candidate_base_url base_url = cfg_base_url or creds.get("base_url", "").rstrip("/") api_mode = "chat_completions" if provider == "copilot": diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index b809af6ecf79..ebfba97937b0 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -94,6 +94,7 @@ def _supports_same_provider_pool_setup(provider: str) -> bool: "gemini-3-flash-preview", "gemini-3.1-flash-lite-preview", ], "zai": ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"], + "zai-coding": ["glm-5.2", "glm-5-turbo", "glm-5.1", "glm-4.7", "glm-4.5-air"], "kimi-coding": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], "kimi-coding-cn": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], "stepfun": ["step-3.5-flash", "step-3.5-flash-2603"], diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 42c8a9531568..bf57326feb26 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -133,7 +133,8 @@ def show_status(args): "DeepSeek": "DEEPSEEK_API_KEY", "xAI / Grok": "XAI_API_KEY", "NVIDIA NIM": "NVIDIA_API_KEY", - "Z.AI / GLM": "GLM_API_KEY", + "Z.AI / GLM Direct API": "GLM_API_KEY", + "Z.AI / GLM Coding Plan API": "GLM_API_KEY", "Kimi": "KIMI_API_KEY", "StepFun Step Plan": "STEPFUN_API_KEY", "MiniMax": "MINIMAX_API_KEY", @@ -357,7 +358,8 @@ def _resolve_env(env_ref) -> str: print(color("◆ API-Key Providers", Colors.CYAN, Colors.BOLD)) apikey_providers = { - "Z.AI / GLM": ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), + "Z.AI / GLM Direct API": ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), + "Z.AI / GLM Coding Plan API": ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "Kimi / Moonshot": ("KIMI_API_KEY",), "StepFun Step Plan": ("STEPFUN_API_KEY",), "MiniMax": ("MINIMAX_API_KEY",), diff --git a/plugins/model-providers/zai/__init__.py b/plugins/model-providers/zai/__init__.py index 9fcdb2bec7d2..d60a93a4f8f7 100644 --- a/plugins/model-providers/zai/__init__.py +++ b/plugins/model-providers/zai/__init__.py @@ -7,8 +7,8 @@ name="zai", aliases=("glm", "z-ai", "z.ai", "zhipu"), env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), - display_name="Z.AI (GLM)", - description="Z.AI / GLM — Zhipu AI models", + display_name="Z.AI / GLM Direct API", + description="Z.AI / GLM direct API endpoint", signup_url="https://z.ai/", fallback_models=( "glm-5.2", @@ -19,4 +19,22 @@ default_aux_model="glm-4.5-flash", ) +zai_coding = ProviderProfile( + name="zai-coding", + env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), + display_name="Z.AI / GLM Coding Plan API", + description="Z.AI / GLM Coding Plan API endpoint", + signup_url="https://z.ai/", + fallback_models=( + "glm-5.2", + "glm-5-turbo", + "glm-5.1", + "glm-4.7", + "glm-4.5-air", + ), + base_url="https://api.z.ai/api/coding/paas/v4", + default_aux_model="glm-5-turbo", +) + register_provider(zai) +register_provider(zai_coding) diff --git a/run_agent.py b/run_agent.py index 331ff2c66ab9..a6e180f7a282 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4696,7 +4696,8 @@ def _anthropic_preserve_dots(self) -> bool: MiniMax keeps dots (e.g. MiniMax-M2.7). Xiaomi MiMo keeps dots (e.g. mimo-v2.5, mimo-v2.5-pro). OpenCode Go/Zen keeps dots for non-Claude models (e.g. minimax-m2.5-free). - ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1). + ZAI/Zhipu direct and coding-plan endpoints keep dots + (e.g. glm-4.7, glm-5.1). AWS Bedrock uses dotted inference-profile IDs (e.g. ``global.anthropic.claude-opus-4-7``, ``us.anthropic.claude-sonnet-4-5-20250929-v1:0``) and rejects @@ -4707,7 +4708,7 @@ def _anthropic_preserve_dots(self) -> bool: if (getattr(self, "provider", "") or "").lower() in { "alibaba", "minimax", "minimax-cn", "opencode-go", "opencode-zen", - "zai", "bedrock", + "zai", "zai-coding", "bedrock", "xiaomi", }: return True diff --git a/tests/agent/test_minimax_provider.py b/tests/agent/test_minimax_provider.py index 2dd1510786e7..a12f9f1edaed 100644 --- a/tests/agent/test_minimax_provider.py +++ b/tests/agent/test_minimax_provider.py @@ -387,6 +387,12 @@ def test_zai_provider_preserves_dots(self): from run_agent import AIAgent assert AIAgent._anthropic_preserve_dots(agent) is True + def test_zai_coding_provider_preserves_dots(self): + from types import SimpleNamespace + agent = SimpleNamespace(provider="zai-coding", base_url="") + from run_agent import AIAgent + assert AIAgent._anthropic_preserve_dots(agent) is True + def test_bigmodel_cn_url_preserves_dots(self): from types import SimpleNamespace agent = SimpleNamespace(provider="custom", base_url="https://open.bigmodel.cn/api/paas/v4") diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index ecde355d0590..17c7056160b3 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -184,6 +184,27 @@ def test_xai_oauth_grok_build_uses_xai_models_dev_context(self): api_key="oauth-token", ) == 256000 + def test_zai_coding_uses_zai_models_dev_context(self): + """Z.AI Coding Plan should use Z.AI metadata before GLM fuzzy defaults.""" + registry = { + "zai": { + "models": { + "glm-4.5-air": { + "limit": {"context": 131072, "output": 8192}, + }, + }, + }, + } + with patch("agent.model_metadata.get_cached_context_length", return_value=None), \ + patch("agent.model_metadata._query_ollama_api_show", return_value=None), \ + patch("agent.models_dev.fetch_models_dev", return_value=registry): + assert get_model_context_length( + "glm-4.5-air", + provider="zai-coding", + base_url="https://api.z.ai/api/coding/paas/v4", + api_key="glm-key", + ) == 131072 + def test_deepseek_v4_models_1m_context(self): from agent.model_metadata import get_model_context_length from unittest.mock import patch as mock_patch diff --git a/tests/agent/test_models_dev.py b/tests/agent/test_models_dev.py index b4bbbf753dfa..5d35ddec550c 100644 --- a/tests/agent/test_models_dev.py +++ b/tests/agent/test_models_dev.py @@ -6,6 +6,7 @@ _extract_context, fetch_models_dev, get_model_capabilities, + list_agentic_models, lookup_models_dev_context, ) @@ -69,6 +70,24 @@ }, }, }, + "zai": { + "id": "zai", + "name": "Z.AI", + "models": { + "glm-4.5-air": { + "id": "glm-4.5-air", + "tool_call": True, + "limit": {"context": 131072, "output": 8192}, + }, + "glm-5v-turbo": { + "id": "glm-5v-turbo", + "attachment": True, + "tool_call": True, + "modalities": {"input": ["text", "image"]}, + "limit": {"context": 65536, "output": 8192}, + }, + }, + }, "audio-only": { "id": "audio-only", "models": { @@ -93,6 +112,10 @@ def test_openai_codex_mapped_to_openai(self): assert PROVIDER_TO_MODELS_DEV["openai"] == "openai" assert PROVIDER_TO_MODELS_DEV["openai-codex"] == "openai" + def test_zai_coding_uses_zai_catalog(self): + assert PROVIDER_TO_MODELS_DEV["zai"] == "zai" + assert PROVIDER_TO_MODELS_DEV["zai-coding"] == "zai" + class TestExtractContext: def test_valid_entry(self): @@ -150,6 +173,26 @@ def test_xai_oauth_resolves_xai_context(self, mock_fetch): mock_fetch.return_value = SAMPLE_REGISTRY assert lookup_models_dev_context("xai-oauth", "grok-build-0.1") == 256000 + @patch("agent.models_dev.fetch_models_dev") + def test_zai_coding_resolves_zai_context(self, mock_fetch): + """Z.AI Coding Plan shares the Z.AI model metadata catalog.""" + mock_fetch.return_value = SAMPLE_REGISTRY + assert lookup_models_dev_context("zai-coding", "glm-4.5-air") == 131072 + + @patch("agent.models_dev.fetch_models_dev") + def test_zai_coding_lists_zai_agentic_models(self, mock_fetch): + mock_fetch.return_value = SAMPLE_REGISTRY + models = list_agentic_models("zai-coding") + assert "glm-4.5-air" in models + assert "glm-5v-turbo" in models + + @patch("agent.models_dev.fetch_models_dev") + def test_zai_coding_uses_zai_capabilities(self, mock_fetch): + mock_fetch.return_value = SAMPLE_REGISTRY + caps = get_model_capabilities("zai-coding", "glm-5v-turbo") + assert caps is not None + assert caps.supports_vision is True + @patch("agent.models_dev.fetch_models_dev") def test_zero_context_filtered(self, mock_fetch): mock_fetch.return_value = SAMPLE_REGISTRY diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 6dacd5e353b5..8307039a3cce 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -1,5 +1,6 @@ """Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax).""" +import hashlib import os import pytest @@ -14,8 +15,15 @@ get_auth_status, AuthError, KIMI_CODE_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_ENDPOINTS, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_DIRECT_CN_BASE_URL, + ZAI_DIRECT_ENDPOINTS, + ZAI_DIRECT_GLOBAL_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL, STEPFUN_STEP_PLAN_CN_BASE_URL, + _zai_base_url_matches_endpoint_family, _resolve_kimi_base_url, ) from hermes_cli.copilot_auth import _try_gh_cli_token @@ -32,7 +40,8 @@ class TestProviderRegistry: ("copilot-acp", "GitHub Copilot ACP", "external_process"), ("copilot", "GitHub Copilot", "api_key"), ("huggingface", "Hugging Face", "api_key"), - ("zai", "Z.AI / GLM", "api_key"), + ("zai", "Z.AI / GLM Direct API", "api_key"), + ("zai-coding", "Z.AI / GLM Coding Plan API", "api_key"), ("xai", "xAI", "api_key"), ("nvidia", "NVIDIA NIM", "api_key"), ("kimi-coding", "Kimi / Moonshot", "api_key"), @@ -54,6 +63,12 @@ def test_zai_env_vars(self): 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_zai_coding_env_vars(self): + pconfig = PROVIDER_REGISTRY["zai-coding"] + 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" + assert pconfig.inference_base_url == ZAI_CODING_GLOBAL_BASE_URL + def test_xai_env_vars(self): pconfig = PROVIDER_REGISTRY["xai"] assert pconfig.api_key_env_vars == ("XAI_API_KEY",) @@ -138,7 +153,7 @@ def test_oauth_providers_unchanged(self): "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "LM_API_KEY", "LM_BASE_URL", - "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", + "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", "GLM_BASE_URL", "KIMI_API_KEY", "KIMI_BASE_URL", "STEPFUN_API_KEY", "STEPFUN_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "KILOCODE_API_KEY", "KILOCODE_BASE_URL", @@ -163,6 +178,9 @@ class TestResolveProvider: def test_explicit_zai(self): assert resolve_provider("zai") == "zai" + def test_explicit_zai_coding(self): + assert resolve_provider("zai-coding") == "zai-coding" + def test_explicit_kimi_coding(self): assert resolve_provider("kimi-coding") == "kimi-coding" @@ -545,6 +563,40 @@ def test_resolve_with_custom_base_url(self, monkeypatch): creds = resolve_api_key_provider_credentials("zai") assert creds["base_url"] == "https://custom.glm.example/v4" + def test_zai_coding_uses_coding_env_override(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-key") + monkeypatch.setenv("GLM_BASE_URL", ZAI_CODING_CN_BASE_URL) + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + + creds = resolve_api_key_provider_credentials("zai-coding") + assert creds["base_url"] == ZAI_CODING_CN_BASE_URL + + def test_zai_coding_reuses_zai_credential_pool_key(self, monkeypatch): + class _Entry: + access_token = "pooled-glm-key" + runtime_api_key = "" + + class _Pool: + def __init__(self, has_key): + self._has_key = has_key + + def has_credentials(self): + return self._has_key + + def peek(self): + return _Entry() if self._has_key else None + + def fake_load_pool(provider): + return _Pool(provider == "zai") + + monkeypatch.setattr("agent.credential_pool.load_pool", fake_load_pool) + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + + creds = resolve_api_key_provider_credentials("zai-coding") + assert creds["api_key"] == "pooled-glm-key" + assert creds["source"] == "credential_pool:zai" + assert creds["base_url"] == ZAI_CODING_GLOBAL_BASE_URL + def test_resolve_without_key_returns_empty(self): creds = resolve_api_key_provider_credentials("zai") assert creds["api_key"] == "" @@ -580,12 +632,62 @@ class TestRuntimeProviderResolution: def test_runtime_zai(self, monkeypatch): monkeypatch.setenv("GLM_API_KEY", "glm-key") + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) 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"] + assert result["base_url"] == ZAI_DIRECT_GLOBAL_BASE_URL + + def test_runtime_zai_coding(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-key") + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + from hermes_cli.runtime_provider import resolve_runtime_provider + result = resolve_runtime_provider(requested="zai-coding") + assert result["provider"] == "zai-coding" + assert result["api_mode"] == "chat_completions" + assert result["api_key"] == "glm-key" + assert result["base_url"] == ZAI_CODING_GLOBAL_BASE_URL + + def test_authenticated_picker_lists_zai_coding_plan(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-key") + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers(current_provider="zai", max_models=10) + direct = next(p for p in providers if p["slug"] == "zai") + coding = next(p for p in providers if p["slug"] == "zai-coding") + assert direct["name"] == "Z.AI / GLM Direct API" + assert coding["name"] == "Z.AI / GLM Coding Plan API" + assert coding["models"][:5] == [ + "glm-5.2", + "glm-5-turbo", + "glm-5.1", + "glm-4.7", + "glm-4.5-air", + ] + + def test_authenticated_picker_shares_zai_credential_pool_key(self, monkeypatch): + class _Pool: + def __init__(self, has_key): + self._has_key = has_key + + def has_credentials(self): + return self._has_key + + def fake_load_pool(provider): + return _Pool(provider == "zai") + + monkeypatch.setattr("agent.credential_pool.load_pool", fake_load_pool) + monkeypatch.setattr("hermes_cli.models.cached_provider_model_ids", lambda provider: []) + + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers(current_provider="zai", max_models=10) + slugs = {p["slug"] for p in providers} + assert "zai" in slugs + assert "zai-coding" in slugs def test_runtime_kimi(self, monkeypatch): monkeypatch.setenv("KIMI_API_KEY", "kimi-key") @@ -954,25 +1056,43 @@ def test_non_kimi_providers_unaffected(self, monkeypatch): class TestZaiEndpointAutoDetect: """Test that resolve_api_key_provider_credentials auto-detects Z.AI endpoints.""" - def test_probe_success_returns_detected_url(self, monkeypatch): - monkeypatch.setenv("GLM_API_KEY", "glm-coding-key") - monkeypatch.setattr( - "hermes_cli.auth.detect_zai_endpoint", - lambda *a, **kw: { - "id": "coding-global", - "base_url": "https://api.z.ai/api/coding/paas/v4", - "model": "glm-4.7", - "label": "Global (Coding Plan)", - }, - ) + def test_direct_probe_uses_only_direct_endpoints(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-key") + + def fake_detect(*_args, **kwargs): + assert kwargs["endpoints"] == ZAI_DIRECT_ENDPOINTS + return { + "id": "cn", + "base_url": ZAI_DIRECT_CN_BASE_URL, + "model": "glm-5", + "label": "China", + } + + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", fake_detect) creds = resolve_api_key_provider_credentials("zai") - assert creds["base_url"] == "https://api.z.ai/api/coding/paas/v4" + assert creds["base_url"] == ZAI_DIRECT_CN_BASE_URL + + def test_coding_probe_uses_only_coding_endpoints(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-key") + + def fake_detect(*_args, **kwargs): + assert kwargs["endpoints"] == ZAI_CODING_ENDPOINTS + return { + "id": "coding-cn", + "base_url": ZAI_CODING_CN_BASE_URL, + "model": "glm-5-turbo", + "label": "China (Coding Plan)", + } + + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", fake_detect) + creds = resolve_api_key_provider_credentials("zai-coding") + assert creds["base_url"] == ZAI_CODING_CN_BASE_URL def test_probe_failure_falls_back_to_default(self, monkeypatch): monkeypatch.setenv("GLM_API_KEY", "glm-key") monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) creds = resolve_api_key_provider_credentials("zai") - assert creds["base_url"] == "https://api.z.ai/api/paas/v4" + assert creds["base_url"] == ZAI_DIRECT_GLOBAL_BASE_URL def test_env_override_skips_probe(self, monkeypatch): """GLM_BASE_URL should always win without probing.""" @@ -990,6 +1110,98 @@ def _never_called(*a, **kw): assert creds["base_url"] == "https://custom.example/v4" assert not probe_called + def test_direct_provider_ignores_official_coding_env_override(self, monkeypatch): + """Official coding-plan URL must not hijack the direct provider.""" + monkeypatch.setenv("GLM_API_KEY", "glm-key") + monkeypatch.setenv("GLM_BASE_URL", ZAI_CODING_GLOBAL_BASE_URL) + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + + creds = resolve_api_key_provider_credentials("zai") + assert creds["base_url"] == ZAI_DIRECT_GLOBAL_BASE_URL + + def test_zai_family_guard_canonicalizes_official_hosts(self): + assert _zai_base_url_matches_endpoint_family( + "https://api.z.ai:443/api/paas/v4", + ZAI_DIRECT_ENDPOINTS, + ) + assert _zai_base_url_matches_endpoint_family( + "https://open.bigmodel.cn.:443/api/coding/paas/v4/", + ZAI_CODING_ENDPOINTS, + ) + assert not _zai_base_url_matches_endpoint_family( + "https://api.z.ai:443/api/paas/v4", + ZAI_CODING_ENDPOINTS, + ) + assert not _zai_base_url_matches_endpoint_family( + "https://open.bigmodel.cn:443/api/coding/paas/v4", + ZAI_DIRECT_ENDPOINTS, + ) + assert not _zai_base_url_matches_endpoint_family( + "https://api.z.ai./api/paas/v4", + ZAI_CODING_ENDPOINTS, + ) + assert _zai_base_url_matches_endpoint_family( + "https://proxy.example.com/api/coding/paas/v4", + ZAI_DIRECT_ENDPOINTS, + ) + + def test_direct_provider_ignores_cached_coding_endpoint(self, monkeypatch): + """Old caches may contain coding endpoints under zai; ignore them.""" + monkeypatch.setenv("GLM_API_KEY", "glm-key") + key_hash = hashlib.sha256("glm-key".encode()).hexdigest()[:16] + + monkeypatch.setattr( + "hermes_cli.auth._load_provider_state", + lambda _store, provider: { + "detected_endpoint": { + "base_url": ZAI_CODING_GLOBAL_BASE_URL, + "key_hash": key_hash, + } + }, + ) + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + + creds = resolve_api_key_provider_credentials("zai") + assert creds["base_url"] == ZAI_DIRECT_GLOBAL_BASE_URL + + def test_coding_provider_ignores_cached_direct_endpoint(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-key") + key_hash = hashlib.sha256("glm-key".encode()).hexdigest()[:16] + + monkeypatch.setattr( + "hermes_cli.auth._load_provider_state", + lambda _store, provider: { + "detected_endpoint": { + "base_url": ZAI_DIRECT_GLOBAL_BASE_URL, + "key_hash": key_hash, + } + }, + ) + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + + creds = resolve_api_key_provider_credentials("zai-coding") + assert creds["base_url"] == ZAI_CODING_GLOBAL_BASE_URL + + def test_coding_provider_ignores_direct_env_override(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-key") + monkeypatch.setenv("GLM_BASE_URL", ZAI_DIRECT_GLOBAL_BASE_URL) + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + + creds = resolve_api_key_provider_credentials("zai-coding") + assert creds["base_url"] == ZAI_CODING_GLOBAL_BASE_URL + + def test_picker_fallback_keeps_direct_and_coding_rows_without_models_dev(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-key") + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr("hermes_cli.models.cached_provider_model_ids", lambda provider: []) + + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers(current_provider="zai", max_models=10) + slugs = {p["slug"] for p in providers} + assert "zai" in slugs + assert "zai-coding" in slugs + def test_no_key_skips_probe(self, monkeypatch): """Without an API key, no probe should occur.""" monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 72d8b5e7c378..29b3ec7b703d 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -152,6 +152,10 @@ def test_commands_dict_includes_aliases(self): assert "/reload_mcp" in COMMANDS assert "/gateway" in COMMANDS + def test_provider_alias_stays_hidden(self): + assert resolve_command("provider") is None + assert "/provider" not in COMMANDS + def test_commands_by_category_covers_all_categories(self): registry_categories = {cmd.category for cmd in COMMAND_REGISTRY if not cmd.gateway_only} assert set(COMMANDS_BY_CATEGORY.keys()) == registry_categories diff --git a/tests/hermes_cli/test_doctor_dedicated_provider_skip.py b/tests/hermes_cli/test_doctor_dedicated_provider_skip.py index 8a6ba6773f18..1da5a952b770 100644 --- a/tests/hermes_cli/test_doctor_dedicated_provider_skip.py +++ b/tests/hermes_cli/test_doctor_dedicated_provider_skip.py @@ -47,4 +47,5 @@ def test_build_apikey_providers_list_includes_non_dedicated_providers(): names = {entry[0] for entry in entries} assert "DeepSeek" in names - assert "Z.AI / GLM" in names + assert "Z.AI / GLM Direct API" in names + assert "Z.AI / GLM Coding Plan API" in names diff --git a/tests/hermes_cli/test_model_normalize.py b/tests/hermes_cli/test_model_normalize.py index 77ece2101150..1ec7f0b4b422 100644 --- a/tests/hermes_cli/test_model_normalize.py +++ b/tests/hermes_cli/test_model_normalize.py @@ -167,6 +167,7 @@ def test_vendor_already_present(self): class TestIssue6211NativeProviderPrefixNormalization: @pytest.mark.parametrize("model,target_provider,expected", [ ("zai/glm-5.1", "zai", "glm-5.1"), + ("zai-coding/glm-5-turbo", "zai-coding", "glm-5-turbo"), ("google/gemini-2.5-pro", "gemini", "gemini-2.5-pro"), ("gemini/gemini-2.5-pro", "gemini", "gemini-2.5-pro"), ("moonshot/kimi-k2.5", "kimi-coding", "kimi-k2.5"), diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 75eb5b8dc708..0d7348525606 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -31,6 +31,7 @@ def config_home(tmp_path, monkeypatch): monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("GLM_BASE_URL", raising=False) monkeypatch.delenv("STEPFUN_API_KEY", raising=False) monkeypatch.delenv("STEPFUN_BASE_URL", raising=False) return home @@ -325,25 +326,25 @@ def test_invalid_base_url_rejected(self, config_home, monkeypatch, capsys): """Typing a non-URL string should not be saved as the base URL.""" from hermes_cli.auth import PROVIDER_REGISTRY - pconfig = PROVIDER_REGISTRY.get("zai") + pconfig = PROVIDER_REGISTRY.get("minimax") if not pconfig: - pytest.skip("zai not in PROVIDER_REGISTRY") + pytest.skip("minimax not in PROVIDER_REGISTRY") - monkeypatch.setenv("GLM_API_KEY", "test-key") + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") from hermes_cli.main import _model_flow_api_key_provider from hermes_cli.config import load_config, get_env_value # User types a shell command instead of a URL at the base URL prompt - with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + with patch("hermes_cli.auth._prompt_model_selection", return_value="MiniMax-M2"), \ patch("hermes_cli.auth.deactivate_provider"), \ patch("builtins.input", return_value="nano ~/.hermes/.env"): - _model_flow_api_key_provider(load_config(), "zai", "old-model") + _model_flow_api_key_provider(load_config(), "minimax", "old-model") # The garbage value should NOT have been saved - saved = get_env_value("GLM_BASE_URL") or "" + saved = get_env_value("MINIMAX_BASE_URL") or "" assert not saved or saved.startswith(("http://", "https://")), \ - f"Non-URL value was saved as GLM_BASE_URL: {saved}" + f"Non-URL value was saved as MINIMAX_BASE_URL: {saved}" captured = capsys.readouterr() assert "Invalid URL" in captured.out @@ -351,42 +352,133 @@ def test_valid_base_url_accepted(self, config_home, monkeypatch): """A proper URL should be saved normally.""" from hermes_cli.auth import PROVIDER_REGISTRY - pconfig = PROVIDER_REGISTRY.get("zai") + pconfig = PROVIDER_REGISTRY.get("minimax") if not pconfig: - pytest.skip("zai not in PROVIDER_REGISTRY") + pytest.skip("minimax not in PROVIDER_REGISTRY") - monkeypatch.setenv("GLM_API_KEY", "test-key") + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") from hermes_cli.main import _model_flow_api_key_provider from hermes_cli.config import load_config, get_env_value - with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + with patch("hermes_cli.auth._prompt_model_selection", return_value="MiniMax-M2"), \ patch("hermes_cli.auth.deactivate_provider"), \ - patch("builtins.input", return_value="https://custom.z.ai/api/paas/v4"): - _model_flow_api_key_provider(load_config(), "zai", "old-model") + patch("builtins.input", return_value="https://custom.minimax.example/v1"): + _model_flow_api_key_provider(load_config(), "minimax", "old-model") - saved = get_env_value("GLM_BASE_URL") or "" - assert saved == "https://custom.z.ai/api/paas/v4" + saved = get_env_value("MINIMAX_BASE_URL") or "" + assert saved == "https://custom.minimax.example/v1" def test_empty_base_url_keeps_default(self, config_home, monkeypatch): """Pressing Enter (empty) should not change the base URL.""" from hermes_cli.auth import PROVIDER_REGISTRY - pconfig = PROVIDER_REGISTRY.get("zai") + pconfig = PROVIDER_REGISTRY.get("minimax") if not pconfig: - pytest.skip("zai not in PROVIDER_REGISTRY") + pytest.skip("minimax not in PROVIDER_REGISTRY") - monkeypatch.setenv("GLM_API_KEY", "test-key") - monkeypatch.delenv("GLM_BASE_URL", raising=False) + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + monkeypatch.delenv("MINIMAX_BASE_URL", raising=False) from hermes_cli.main import _model_flow_api_key_provider from hermes_cli.config import load_config, get_env_value - with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + with patch("hermes_cli.auth._prompt_model_selection", return_value="MiniMax-M2"), \ patch("hermes_cli.auth.deactivate_provider"), \ patch("builtins.input", return_value=""): - _model_flow_api_key_provider(load_config(), "zai", "old-model") + _model_flow_api_key_provider(load_config(), "minimax", "old-model") - saved = get_env_value("GLM_BASE_URL") or "" + saved = get_env_value("MINIMAX_BASE_URL") or "" assert saved == "", "Empty input should not save a base URL" + +class TestZaiEndpointSelection: + """Z.AI setup should choose explicit billing-path endpoints.""" + + def test_coding_provider_cross_family_current_defaults_to_coding_endpoint(self, monkeypatch): + from hermes_cli.auth import ZAI_CODING_GLOBAL_BASE_URL, ZAI_DIRECT_GLOBAL_BASE_URL + from hermes_cli.model_setup_flows import _select_zai_endpoint + + def choose_default(_choices, default=0, title=""): + assert default == 0 + return default + + monkeypatch.setattr("hermes_cli.main._prompt_provider_choice", choose_default) + + assert ( + _select_zai_endpoint("zai-coding", ZAI_DIRECT_GLOBAL_BASE_URL) + == ZAI_CODING_GLOBAL_BASE_URL + ) + + def test_blank_custom_with_cross_family_current_uses_provider_default(self, monkeypatch): + from hermes_cli.auth import ZAI_CODING_GLOBAL_BASE_URL, ZAI_DIRECT_GLOBAL_BASE_URL + from hermes_cli.model_setup_flows import _select_zai_endpoint + + monkeypatch.setattr("hermes_cli.main._prompt_provider_choice", lambda choices, **kw: len(choices) - 1) + monkeypatch.setattr("builtins.input", lambda _prompt="": "") + + assert ( + _select_zai_endpoint("zai-coding", ZAI_DIRECT_GLOBAL_BASE_URL) + == ZAI_CODING_GLOBAL_BASE_URL + ) + + def test_direct_provider_china_endpoint_saved_to_model_config(self, config_home, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "test-key") + + from hermes_cli.auth import ZAI_DIRECT_CN_BASE_URL + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config + + with patch("hermes_cli.main._prompt_provider_choice", return_value=1), \ + patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value=""): + _model_flow_api_key_provider(load_config(), "zai", "old-model") + + model = load_config()["model"] + assert model["provider"] == "zai" + assert model["base_url"] == ZAI_DIRECT_CN_BASE_URL + assert (config_home / ".env").read_text() == f"GLM_BASE_URL={ZAI_DIRECT_CN_BASE_URL}\n" + + def test_coding_provider_china_endpoint_saved_to_model_config(self, config_home, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "test-key") + + from hermes_cli.auth import ZAI_CODING_CN_BASE_URL + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config + + model_prompt = MagicMock(return_value="glm-5-turbo") + with patch("hermes_cli.main._prompt_provider_choice", return_value=1), \ + patch("agent.models_dev.list_agentic_models", return_value=["glm-4.7", "glm-5"]), \ + patch("hermes_cli.auth._prompt_model_selection", model_prompt), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value=""): + _model_flow_api_key_provider(load_config(), "zai-coding", "old-model") + + model = load_config()["model"] + assert model["provider"] == "zai-coding" + assert model["base_url"] == ZAI_CODING_CN_BASE_URL + assert (config_home / ".env").read_text() == f"GLM_BASE_URL={ZAI_CODING_CN_BASE_URL}\n" + assert model_prompt.call_args.args[0] == [ + "glm-5.2", + "glm-5-turbo", + "glm-5.1", + "glm-4.7", + "glm-4.5-air", + ] + + def test_direct_provider_custom_proxy_endpoint_saved_to_model_config(self, config_home, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "test-key") + + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config + + with patch("hermes_cli.main._prompt_provider_choice", return_value=2), \ + patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value="https://proxy.example.com/glm/v4"): + _model_flow_api_key_provider(load_config(), "zai", "old-model") + + model = load_config()["model"] + assert model["provider"] == "zai" + assert model["base_url"] == "https://proxy.example.com/glm/v4" diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index f5d356055c33..86e3f3615fcc 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -1,5 +1,6 @@ """Tests for provider-aware `/model` validation in hermes_cli.models.""" +import os from unittest.mock import MagicMock, patch from hermes_cli.models import ( @@ -171,6 +172,8 @@ def test_known_labels_and_auto(self): assert provider_label("stepfun") == "StepFun Step Plan" assert provider_label("copilot") == "GitHub Copilot" assert provider_label("copilot-acp") == "GitHub Copilot ACP" + assert provider_label("zai") == "Z.AI / GLM Direct API" + assert provider_label("zai-coding") == "Z.AI / GLM Coding Plan API" assert provider_label("auto") == "Auto" def test_unknown_provider_preserves_original_name(self): @@ -195,6 +198,37 @@ def test_openrouter_returns_curated_list(self): def test_unknown_provider_returns_empty(self): assert provider_model_ids("some-unknown-provider") == [] + def test_zai_returns_glm_models(self): + assert "glm-5" in provider_model_ids("zai") + + def test_zai_coding_returns_coding_plan_models(self): + assert provider_model_ids("zai-coding")[:5] == [ + "glm-5.2", + "glm-5-turbo", + "glm-5.1", + "glm-4.7", + "glm-4.5-air", + ] + + def test_zai_catalogs_do_not_probe_runtime_credentials(self): + def _fail(*args, **kwargs): + raise AssertionError("picker catalog lookup must not resolve Z.AI credentials") + + def _mdev(provider): + return ["glm-4.7", "glm-5", "glm-extra"] + + with patch.dict(os.environ, {"GLM_API_KEY": "glm-key"}, clear=False), \ + patch("hermes_cli.auth.resolve_api_key_provider_credentials", side_effect=_fail), \ + patch("agent.models_dev.list_agentic_models", side_effect=_mdev): + assert provider_model_ids("zai-coding") == [ + "glm-5.2", + "glm-5-turbo", + "glm-5.1", + "glm-4.7", + "glm-4.5-air", + ] + assert provider_model_ids("zai")[:3] == ["glm-4.7", "glm-5", "glm-extra"] + def test_stepfun_prefers_live_catalog(self): with patch( "hermes_cli.auth.resolve_api_key_provider_credentials", @@ -557,6 +591,26 @@ def test_model_not_in_api_rejected_with_guidance(self): assert result["persist"] is False assert "not found" in result["message"] + def test_zai_curated_model_accepted_when_live_listing_omits_it(self): + result = _validate( + "glm-5.2", + provider="zai", + api_models=["glm-5.1", "glm-5", "glm-4.7"], + ) + assert result["accepted"] is True + assert result["persist"] is True + assert result["recognized"] is True + + def test_zai_coding_curated_model_accepted_when_live_listing_omits_it(self): + result = _validate( + "glm-5.2", + provider="zai-coding", + api_models=["glm-5-turbo", "glm-5.1", "glm-4.7"], + ) + assert result["accepted"] is True + assert result["persist"] is True + assert result["recognized"] is True + def test_warning_includes_suggestions(self): result = _validate("anthropic/claude-opus-4.5") assert result["accepted"] is True diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 3e788fe3d538..5790ecdbb126 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -3,6 +3,10 @@ from hermes_cli import runtime_provider as rp +def _clear_zai_base_url(monkeypatch): + monkeypatch.delenv("GLM_BASE_URL", raising=False) + + def test_resolve_runtime_provider_uses_credential_pool(monkeypatch): class _Entry: access_token = "pool-token" @@ -1332,6 +1336,80 @@ def test_minimax_config_base_url_ignored_for_different_provider(monkeypatch): assert resolved["base_url"] == "https://api.minimax.io/anthropic" +def test_zai_direct_ignores_persisted_coding_base_url(monkeypatch): + """Direct Z.AI must not use a stale official coding-plan config URL.""" + from hermes_cli.auth import ZAI_CODING_GLOBAL_BASE_URL, ZAI_DIRECT_GLOBAL_BASE_URL + + _clear_zai_base_url(monkeypatch) + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "zai") + monkeypatch.setattr(rp, "_get_model_config", lambda: { + "provider": "zai", + "base_url": ZAI_CODING_GLOBAL_BASE_URL, + }) + monkeypatch.setenv("GLM_API_KEY", "test-zai-key") + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + + resolved = rp.resolve_runtime_provider(requested="zai") + + assert resolved["provider"] == "zai" + assert resolved["base_url"] == ZAI_DIRECT_GLOBAL_BASE_URL + + +def test_zai_coding_ignores_persisted_direct_base_url(monkeypatch): + """Coding-plan Z.AI must not use a stale official direct API config URL.""" + from hermes_cli.auth import ZAI_CODING_GLOBAL_BASE_URL, ZAI_DIRECT_GLOBAL_BASE_URL + + _clear_zai_base_url(monkeypatch) + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "zai-coding") + monkeypatch.setattr(rp, "_get_model_config", lambda: { + "provider": "zai-coding", + "base_url": ZAI_DIRECT_GLOBAL_BASE_URL, + }) + monkeypatch.setenv("GLM_API_KEY", "test-zai-key") + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + + resolved = rp.resolve_runtime_provider(requested="zai-coding") + + assert resolved["provider"] == "zai-coding" + assert resolved["base_url"] == ZAI_CODING_GLOBAL_BASE_URL + + +def test_zai_custom_proxy_config_base_url_still_allowed(monkeypatch): + """Manual custom proxy URLs remain allowed for Z.AI config overrides.""" + _clear_zai_base_url(monkeypatch) + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "zai") + monkeypatch.setattr(rp, "_get_model_config", lambda: { + "provider": "zai", + "base_url": "https://proxy.example.com/glm/v4", + }) + monkeypatch.setenv("GLM_API_KEY", "test-zai-key") + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + + resolved = rp.resolve_runtime_provider(requested="zai") + + assert resolved["base_url"] == "https://proxy.example.com/glm/v4" + + +def test_zai_pool_ignores_persisted_coding_base_url(monkeypatch): + """Credential-pool Z.AI runtime must also reject official family mismatch.""" + from hermes_cli.auth import ZAI_CODING_GLOBAL_BASE_URL, ZAI_DIRECT_GLOBAL_BASE_URL + + _clear_zai_base_url(monkeypatch) + class _Entry: + access_token = "pool-zai-key" + source = "manual" + base_url = ZAI_DIRECT_GLOBAL_BASE_URL + + resolved = rp._resolve_runtime_from_pool_entry( + provider="zai", + entry=_Entry(), + requested_provider="zai", + model_cfg={"provider": "zai", "base_url": ZAI_CODING_GLOBAL_BASE_URL}, + ) + + assert resolved["base_url"] == ZAI_DIRECT_GLOBAL_BASE_URL + + def test_alibaba_default_coding_intl_endpoint_uses_chat_completions(monkeypatch): """Alibaba default coding-intl /v1 URL should use chat_completions mode.""" monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "alibaba") diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 3eb234030483..5ddd59928f17 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -96,6 +96,25 @@ def test_no_config_defaults(self): assert "reasoning_effort" not in tl +class TestZaiCodingProfile: + def test_coding_plan_profile_is_distinct_from_direct_api(self): + direct = get_provider_profile("zai") + coding = get_provider_profile("zai-coding") + + assert coding.name == "zai-coding" + assert coding.env_vars == direct.env_vars + assert direct.display_name == "Z.AI / GLM Direct API" + assert coding.display_name == "Z.AI / GLM Coding Plan API" + assert direct.base_url == "https://api.z.ai/api/paas/v4" + assert coding.base_url == "https://api.z.ai/api/coding/paas/v4" + assert coding.fallback_models[:5] == ( + "glm-5.2", + "glm-5-turbo", + "glm-5.1", + "glm-4.7", + "glm-4.5-air", + ) + class TestOpenRouterProfile: def test_extra_body_with_prefs(self): p = get_provider_profile("openrouter") diff --git a/tests/test_trajectory_compressor.py b/tests/test_trajectory_compressor.py index 8fcbfc38cfef..6fe921c8d173 100644 --- a/tests/test_trajectory_compressor.py +++ b/tests/test_trajectory_compressor.py @@ -30,6 +30,22 @@ def test_import_loads_env_from_hermes_home(tmp_path, monkeypatch): assert os.getenv("OPENROUTER_API_KEY") == "from-hermes-home" +def test_detect_provider_zai_direct_and_coding_urls(): + compressor = TrajectoryCompressor.__new__(TrajectoryCompressor) + + compressor.config = SimpleNamespace(base_url="https://api.z.ai/api/paas/v4") + assert compressor._detect_provider() == "zai" + + compressor.config = SimpleNamespace(base_url="https://api.z.ai/api/coding/paas/v4") + assert compressor._detect_provider() == "zai-coding" + + compressor.config = SimpleNamespace(base_url="https://open.bigmodel.cn/api/paas/v4") + assert compressor._detect_provider() == "zai" + + compressor.config = SimpleNamespace(base_url="https://open.bigmodel.cn/api/coding/paas/v4") + assert compressor._detect_provider() == "zai-coding" + + def test_generate_summary_kimi_omits_temperature(): """Kimi models should have temperature omitted — server manages it.""" config = CompressionConfig( diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b89e7f8dbbd3..ed88e020e8f4 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2741,7 +2741,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: f"Cannot resolve delegation provider '{configured_provider}': {exc}. " f"Check that the provider is configured (API key set, valid provider name), " f"or set delegation.base_url/delegation.api_key for a direct endpoint. " - f"Available providers: openrouter, nous, zai, kimi-coding, minimax." + f"Available providers: openrouter, nous, zai, zai-coding, kimi-coding, minimax." ) from exc api_key = runtime.get("api_key", "") diff --git a/trajectory_compressor.py b/trajectory_compressor.py index 9dc3826a854d..5d10aef1e769 100644 --- a/trajectory_compressor.py +++ b/trajectory_compressor.py @@ -444,7 +444,9 @@ def _detect_provider(self) -> str: and "/backend-api/codex" in url.lower() ): return "codex" - if base_url_host_matches(url, "z.ai"): + if base_url_host_matches(url, "z.ai") or base_url_host_matches(url, "bigmodel.cn"): + if "/api/coding/paas/" in url.lower().rstrip("/"): + return "zai-coding" return "zai" if ( base_url_host_matches(url, "moonshot.ai")