From 808e762bbfabd6afd5df3d5aac9d67d09b045726 Mon Sep 17 00:00:00 2001 From: nibzard Date: Wed, 13 May 2026 10:49:44 +0200 Subject: [PATCH] fix(zai): comprehensive Z.AI/GLM provider support Fixes multiple issues with Z.AI (api.z.ai) GLM models that caused timeouts, empty responses, and missing reasoning support. Root causes identified: - Z.AI has a server-side 30s idle timeout. When tool_call arguments are generated in a single batch (no tool_stream), the connection goes silent and gets killed, causing ReadTimeout errors. - Z.AI uses thinking: {type: enabled/disabled} (not OpenRouter's reasoning key) for extended thinking. Without this, models put all content into reasoning_content with empty content field. - Coding Plan endpoints (api.z.ai/api/coding/paas/v4) require X-Title header for proper routing. Changes: - plugins/model-providers/zai/__init__.py: Replace bare ProviderProfile with ZaiProfile subclass that injects thinking parameter. Register 4 provider variants: zai (Global), zai-cn (China), zai-coding-global, zai-coding-cn with correct base URLs, env vars, and X-Title headers. - agent/transports/chat_completions.py: In BOTH legacy and profile paths, detect Z.AI endpoints and inject tool_stream=true when tools are present. Legacy path also injects thinking parameter for provider:custom users who point base_url at z.ai. - run_agent.py: Add _is_zai detection in _build_api_kwargs, pass is_zai flag to legacy transport path. Add z-ai/ to _supports_reasoning_extra_body reasoning_model_prefixes. Add new provider names to _anthropic_preserve_dots allowlist. - tests: 15 new tests covering all Z.AI-specific transport behavior in both legacy and profile paths. Closes: #12758, #14619, #16592, #11494, #18863 Supersedes: #13911 (provider split approach) Relates-to: #16479 --- agent/transports/chat_completions.py | 30 +++ plugins/model-providers/zai/__init__.py | 120 +++++++++++- run_agent.py | 8 +- .../agent/transports/test_chat_completions.py | 175 ++++++++++++++++++ 4 files changed, 324 insertions(+), 9 deletions(-) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 7edb69e42c741..d360fd0407781 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -319,6 +319,30 @@ def build_kwargs( provider_name = str(params.get("provider_name") or "").strip().lower() base_url = params.get("base_url") + # Z.AI / BigModel (GLM): stream tool-call args incrementally to avoid + # long silent gaps that trigger the server-side 30s idle timeout. + # Also inject thinking parameter for extended reasoning support. + _is_zai = ( + provider_name == "zai" + or provider_name == "zai-cn" + or provider_name == "zai-coding-global" + or provider_name == "zai-coding-cn" + or ("z.ai" in str(base_url or "").lower()) + or ("bigmodel.cn" in str(base_url or "").lower()) + ) + + if _is_zai and tools: + extra_body.setdefault("tool_stream", True) + + if _is_zai: + _zai_thinking_enabled = True + if reasoning_config and isinstance(reasoning_config, dict): + if reasoning_config.get("enabled") is False: + _zai_thinking_enabled = False + extra_body["thinking"] = { + "type": "enabled" if _zai_thinking_enabled else "disabled", + } + provider_prefs = params.get("provider_preferences") if provider_prefs and is_openrouter: extra_body["provider"] = provider_prefs @@ -483,6 +507,12 @@ def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params): if profile_body: extra_body.update(profile_body) + # Z.AI / GLM: tool_stream must be set when tools are present to + # avoid 30s idle timeouts on the server side. + _zai_profile_names = ("zai", "zai-cn", "zai-coding-global", "zai-coding-cn") + if profile.name in _zai_profile_names and tools: + extra_body["tool_stream"] = True + # Profile's reasoning/thinking extra_body entries if extra_body_from_profile: extra_body.update(extra_body_from_profile) diff --git a/plugins/model-providers/zai/__init__.py b/plugins/model-providers/zai/__init__.py index 70aa8704d140f..b8c80358963f6 100644 --- a/plugins/model-providers/zai/__init__.py +++ b/plugins/model-providers/zai/__init__.py @@ -1,21 +1,125 @@ -"""ZAI / GLM provider profile.""" +"""ZAI / GLM provider profiles. + +Z.AI (GLM) — api.z.ai (Global) and open.bigmodel.cn (China) +Both support Coding Plan endpoints at /api/coding/paas/v4. +""" + +from typing import Any from providers import register_provider from providers.base import ProviderProfile -zai = ProviderProfile( + +class ZaiProfile(ProviderProfile): + """Z.AI / GLM — thinking parameter, tool_stream, Coding Plan headers.""" + + def build_extra_body( + self, *, session_id: str | None = None, **context: Any + ) -> dict[str, Any]: + """Inject thinking parameter for Z.AI/GLM models.""" + reasoning_config = context.get("reasoning_config") + body: dict[str, Any] = {} + + if reasoning_config and isinstance(reasoning_config, dict): + if reasoning_config.get("enabled") is False: + body["thinking"] = {"type": "disabled"} + else: + body["thinking"] = {"type": "enabled"} + else: + # Default: thinking enabled (GLM-5+ models support it) + body["thinking"] = {"type": "enabled"} + + return body + + def build_api_kwargs_extras( + self, + *, + reasoning_config: dict | None = None, + **context: Any, + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Z.AI returns no provider-specific top-level kwargs beyond defaults.""" + return {}, {} + + +# ── Global ────────────────────────────────────────────────────────────── +zai = ZaiProfile( 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", + aliases=("z-ai", "z.ai"), + env_vars=("ZAI_API_KEY", "Z_AI_API_KEY"), + display_name="Z.AI", + description="Z.AI (GLM) — api.z.ai", signup_url="https://z.ai/", + base_url="https://api.z.ai/api/paas/v4", + hostname="api.z.ai", + default_headers={ + "X-Title": "Hermes-Agent", + }, + default_aux_model="glm-4.5-flash", fallback_models=( "glm-5", - "glm-4-9b", + "glm-5-turbo", + "glm-4.7", ), - base_url="https://api.z.ai/api/paas/v4", +) + +# ── China ─────────────────────────────────────────────────────────────── +zai_cn = ZaiProfile( + name="zai-cn", + aliases=("glm", "zhipu", "bigmodel"), + env_vars=("GLM_API_KEY",), + display_name="Zhipu AI", + description="Zhipu AI (GLM) — open.bigmodel.cn", + signup_url="https://open.bigmodel.cn/", + base_url="https://open.bigmodel.cn/api/paas/v4", + hostname="open.bigmodel.cn", default_aux_model="glm-4.5-flash", + fallback_models=( + "glm-5", + "glm-5-turbo", + "glm-4.7", + ), +) + +# ── Global Coding Plan ───────────────────────────────────────────────── +zai_coding_global = ZaiProfile( + name="zai-coding-global", + aliases=("glm-coding-global", "z-ai-coding"), + env_vars=("ZAI_CODING_API_KEY",), + display_name="Z.AI Coding Plan", + description="Z.AI Coding Plan — api.z.ai/api/coding", + signup_url="https://z.ai/pricing", + base_url="https://api.z.ai/api/coding/paas/v4", + hostname="api.z.ai", + default_headers={ + "X-Title": "Hermes-Agent", + }, + default_aux_model="glm-4.7", + fallback_models=( + "glm-5", + "glm-5-turbo", + "glm-4.7", + ), +) + +# ── China Coding Plan ────────────────────────────────────────────────── +zai_coding_cn = ZaiProfile( + name="zai-coding-cn", + aliases=("glm-coding-cn",), + env_vars=("GLM_CODING_API_KEY",), + display_name="Zhipu AI Coding Plan", + description="Zhipu AI Coding Plan — open.bigmodel.cn/api/coding", + signup_url="https://open.bigmodel.cn/", + base_url="https://open.bigmodel.cn/api/coding/paas/v4", + hostname="open.bigmodel.cn", + default_aux_model="glm-4.7", + fallback_models=( + "glm-5", + "glm-5-turbo", + "glm-4.7", + ), ) register_provider(zai) +register_provider(zai_cn) +register_provider(zai_coding_global) +register_provider(zai_coding_cn) diff --git a/run_agent.py b/run_agent.py index f0597c90880d4..430910a86d8db 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9359,7 +9359,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-cn", "zai-coding-global", "zai-coding-cn", "bedrock", "xiaomi", }: return True @@ -9530,6 +9530,10 @@ def _build_api_kwargs(self, api_messages: list) -> dict: ) _is_tokenhub = base_url_host_matches(self._base_url_lower, "tokenhub.tencentmaas.com") _is_lmstudio = (self.provider or "").strip().lower() == "lmstudio" + _is_zai = ( + base_url_host_matches(self._base_url_lower, "z.ai") + or base_url_host_matches(self._base_url_lower, "open.bigmodel.cn") + ) # Temperature: _fixed_temperature_for_model may return OMIT_TEMPERATURE # sentinel (temperature omitted entirely), a numeric override, or None. @@ -9641,6 +9645,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: is_kimi=_is_kimi, is_tokenhub=_is_tokenhub, is_lmstudio=_is_lmstudio, + is_zai=_is_zai, is_custom_provider=self.provider == "custom", ollama_num_ctx=self._ollama_num_ctx, provider_preferences=_prefs or None, @@ -9693,6 +9698,7 @@ def _supports_reasoning_extra_body(self) -> bool: "anthropic/", "openai/", "x-ai/", + "z-ai/", "google/gemini-2", "qwen/qwen3", "tencent/hy3-preview", diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 7ed0d4da634d7..517af48811f13 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -769,3 +769,178 @@ def test_with_cache(self, transport): r = SimpleNamespace(usage=SimpleNamespace(prompt_tokens_details=details)) result = transport.extract_cache_stats(r) assert result == {"cached_tokens": 500, "creation_tokens": 100} + + +class TestZaiTransport: + """Tests for Z.AI / GLM provider-specific transport behavior.""" + + def _msgs(self): + return [{"role": "user", "content": "Hello"}] + + def _tools(self): + return [{"type": "function", "function": {"name": "read_file", "parameters": {}}}] + + def test_zai_legacy_tool_stream(self, transport): + """Legacy path: provider_name=zai + tools → tool_stream in extra_body.""" + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + tools=self._tools(), + provider_name="zai", + ) + assert kw["extra_body"]["tool_stream"] is True + + def test_zai_legacy_thinking_default(self, transport): + """Legacy path: provider_name=zai → thinking enabled by default.""" + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + provider_name="zai", + ) + assert kw["extra_body"]["thinking"] == {"type": "enabled"} + + def test_zai_legacy_thinking_disabled(self, transport): + """Legacy path: reasoning_config.enabled=False → thinking disabled.""" + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + provider_name="zai", + reasoning_config={"enabled": False}, + ) + assert kw["extra_body"]["thinking"] == {"type": "disabled"} + + def test_zai_legacy_no_tool_stream_without_tools(self, transport): + """Legacy path: no tools → tool_stream not set.""" + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + provider_name="zai", + ) + assert "tool_stream" not in kw["extra_body"] + + def test_zai_legacy_detection_by_base_url(self, transport): + """Legacy path: detect z.ai by base_url containing z.ai.""" + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + tools=self._tools(), + provider_name="custom", + base_url="https://api.z.ai/api/coding/paas/v4", + ) + assert kw["extra_body"]["tool_stream"] is True + assert kw["extra_body"]["thinking"] == {"type": "enabled"} + + def test_zai_legacy_detection_by_bigmodel_url(self, transport): + """Legacy path: detect z.ai by base_url containing bigmodel.cn.""" + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + tools=self._tools(), + provider_name="custom", + base_url="https://open.bigmodel.cn/api/paas/v4", + ) + assert kw["extra_body"]["tool_stream"] is True + + def test_zai_legacy_detection_zai_cn_provider(self, transport): + """Legacy path: provider_name=zai-cn → tool_stream + thinking.""" + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + tools=self._tools(), + provider_name="zai-cn", + ) + assert kw["extra_body"]["tool_stream"] is True + assert kw["extra_body"]["thinking"] == {"type": "enabled"} + + def test_zai_legacy_detection_zai_coding_global(self, transport): + """Legacy path: provider_name=zai-coding-global → tool_stream + thinking.""" + kw = transport.build_kwargs( + model="glm-5-turbo", + messages=self._msgs(), + tools=self._tools(), + provider_name="zai-coding-global", + ) + assert kw["extra_body"]["tool_stream"] is True + + def test_non_zai_no_tool_stream(self, transport): + """Non-Z.AI provider: tool_stream must NOT be injected.""" + kw = transport.build_kwargs( + model="gpt-4o", + messages=self._msgs(), + tools=self._tools(), + provider_name="openrouter", + base_url="https://openrouter.ai/api/v1", + ) + assert "tool_stream" not in (kw.get("extra_body") or {}) + + def test_zai_profile_path_tool_stream(self, transport): + """Profile path: zai profile + tools → tool_stream in extra_body.""" + from providers import get_provider_profile + profile = get_provider_profile("zai") + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + tools=self._tools(), + provider_profile=profile, + ) + assert kw["extra_body"]["tool_stream"] is True + + def test_zai_profile_path_thinking(self, transport): + """Profile path: zai profile → thinking enabled via profile hook.""" + from providers import get_provider_profile + profile = get_provider_profile("zai") + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + provider_profile=profile, + ) + assert kw["extra_body"]["thinking"] == {"type": "enabled"} + + def test_zai_profile_path_thinking_disabled(self, transport): + """Profile path: reasoning_config.enabled=False → thinking disabled.""" + from providers import get_provider_profile + profile = get_provider_profile("zai") + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + provider_profile=profile, + reasoning_config={"enabled": False}, + ) + assert kw["extra_body"]["thinking"] == {"type": "disabled"} + + def test_zai_profile_path_no_tool_stream_without_tools(self, transport): + """Profile path: zai profile without tools → no tool_stream.""" + from providers import get_provider_profile + profile = get_provider_profile("zai") + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + provider_profile=profile, + ) + assert "tool_stream" not in kw["extra_body"] + + def test_zai_coding_global_profile(self, transport): + """Profile path: zai-coding-global profile works with tool_stream.""" + from providers import get_provider_profile + profile = get_provider_profile("zai-coding-global") + kw = transport.build_kwargs( + model="glm-5-turbo", + messages=self._msgs(), + tools=self._tools(), + provider_profile=profile, + ) + assert kw["extra_body"]["tool_stream"] is True + assert kw["extra_body"]["thinking"] == {"type": "enabled"} + + def test_zai_cn_profile(self, transport): + """Profile path: zai-cn profile works with tool_stream.""" + from providers import get_provider_profile + profile = get_provider_profile("zai-cn") + kw = transport.build_kwargs( + model="glm-5", + messages=self._msgs(), + tools=self._tools(), + provider_profile=profile, + ) + assert kw["extra_body"]["tool_stream"] is True + assert kw["extra_body"]["thinking"] == {"type": "enabled"}