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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6661,6 +6661,19 @@ def _anthropic_preserve_dots(self) -> bool:
or "bedrock-runtime." in base
)

def _is_zai_direct(self) -> bool:
"""Return True when using z.ai/Zhipu directly (not via OpenRouter).

Detects the zai provider or known z.ai/bigmodel endpoint URLs.
Used to inject the z.ai-native ``thinking`` parameter for preserved
thinking and to re-inject ``reasoning_content`` on assistant messages
for multi-turn reasoning continuity.
"""
if (getattr(self, "provider", "") or "").lower() == "zai":
return True
base = (getattr(self, "base_url", "") or "").lower()
return "bigmodel.cn" in base or "api.z.ai" in base

def _is_qwen_portal(self) -> bool:
"""Return True when the base URL targets Qwen Portal."""
return base_url_host_matches(self._base_url_lower, "portal.qwen.ai")
Expand Down Expand Up @@ -6890,6 +6903,98 @@ def _build_api_kwargs(self, api_messages: list) -> dict:
anthropic_max_output=_ant_max,
)

# Provider preferences (only, ignore, order, sort) are OpenRouter-
# specific. Only send to OpenRouter-compatible endpoints.
# TODO: Nous Portal will add transparent proxy support — re-enable
# for _is_nous when their backend is updated.
if provider_preferences and _is_openrouter:
extra_body["provider"] = provider_preferences
_is_nous = "nousresearch" in self._base_url_lower

if self._supports_reasoning_extra_body():
if _is_github_models:
github_reasoning = self._github_models_reasoning_extra_body()
if github_reasoning is not None:
extra_body["reasoning"] = github_reasoning
else:
if self.reasoning_config is not None:
rc = dict(self.reasoning_config)
# Nous Portal requires reasoning enabled — don't send
# enabled=false to it (would cause 400).
if _is_nous and rc.get("enabled") is False:
pass # omit reasoning entirely for Nous when disabled
else:
extra_body["reasoning"] = rc
else:
extra_body["reasoning"] = {
"enabled": True,
"effort": "medium"
}

# Nous Portal product attribution
if _is_nous:
extra_body["tags"] = ["product=hermes-agent"]

# Ollama num_ctx: override the 2048 default so the model actually
# uses the context window it was trained for. Passed via the OpenAI
# SDK's extra_body → options.num_ctx, which Ollama's OpenAI-compat
# endpoint forwards to the runner as --ctx-size.
if self._ollama_num_ctx:
options = extra_body.get("options", {})
options["num_ctx"] = self._ollama_num_ctx
extra_body["options"] = options

# Ollama / custom provider: pass think=false when reasoning is disabled.
# Ollama does not recognise the OpenRouter-style `reasoning` extra_body
# field, so we use its native `think` parameter instead.
# This prevents thinking-capable models (Qwen3, etc.) from generating
# <think> blocks and producing empty-response errors when the user has
# set reasoning_effort: none.
if self.provider == "custom" and self.reasoning_config and isinstance(self.reasoning_config, dict):
_effort = (self.reasoning_config.get("effort") or "").strip().lower()
_enabled = self.reasoning_config.get("enabled", True)
if _effort == "none" or _enabled is False:
extra_body["think"] = False

if self._is_qwen_portal():
extra_body["vl_high_resolution_images"] = True

# z.ai/Zhipu GLM-5/4.7 preserved thinking mode.
# z.ai uses a top-level ``thinking`` parameter (not OpenRouter's
# ``reasoning`` in extra_body). When ``type`` is ``enabled`` the
# model always produces ``reasoning_content`` in its response.
# ``compact_history: false`` ensures reasoning survives across
# multi-turn agent loops.
if self._is_zai_direct():
_model_lower = (self.model or "").lower()
# GLM-5.x, GLM-5-turbo, GLM-4.7 support compulsory thinking.
# Older models (4.6, 4.5) auto-determine whether to think.
if any(p in _model_lower for p in ("glm-5", "glm-4.7")):
if self.reasoning_config and isinstance(self.reasoning_config, dict):
if self.reasoning_config.get("enabled") is False:
extra_body["thinking"] = {"type": "disabled"}
else:
extra_body["thinking"] = {
"type": "enabled",
"compact_history": False,
}
else:
# Default: enable preserved thinking for reasoning-capable GLM.
extra_body["thinking"] = {
"type": "enabled",
"compact_history": False,
}

if extra_body:
api_kwargs["extra_body"] = extra_body

# Priority Processing / generic request overrides (e.g. service_tier).
# Applied last so overrides win over any defaults set above.
if self.request_overrides:
api_kwargs.update(self.request_overrides)

return api_kwargs

def _supports_reasoning_extra_body(self) -> bool:
"""Return True when reasoning extra_body is safe to send for this route/model.

Expand Down Expand Up @@ -8312,6 +8417,12 @@ def _handle_max_iterations(self, messages: list, api_call_count: int) -> str:
api_msg = msg.copy()
for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
api_msg.pop(internal_field, None)
# z.ai/GLM: re-inject reasoning as reasoning_content for
# preserved thinking. The ``thinking`` parameter with
# compact_history=false requires the previous turn's
# reasoning_content to be present on assistant messages.
if self._is_zai_direct() and msg.get("role") == "assistant" and msg.get("reasoning"):
api_msg["reasoning_content"] = msg["reasoning"]
if _needs_sanitize:
self._sanitize_tool_calls_for_strict_api(api_msg)
api_messages.append(api_msg)
Expand Down
260 changes: 260 additions & 0 deletions tests/run_agent/test_zai_thinking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
"""
Tests for z.ai/GLM preserved thinking support in AIAgent.

Covers:
- _is_zai_direct() detection (provider + URL)
- _build_api_kwargs() injecting ``thinking`` parameter for GLM models
- reasoning_config gating (enabled/disabled)
- Multi-turn reasoning_content re-injection on message sanitization
"""

import pytest
from unittest.mock import MagicMock, patch

from run_agent import AIAgent


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_tool_defs(*names: str) -> list:
"""Build minimal tool definition list accepted by AIAgent.__init__."""
return [
{
"type": "function",
"function": {
"name": n,
"description": f"{n} tool",
"parameters": {"type": "object", "properties": {}},
},
}
for n in names
]


def _make_zai_agent(model="glm-5.1", base_url="https://api.z.ai/api/paas/v4",
provider="zai", reasoning_config=None):
"""Create a minimal AIAgent wired to z.ai."""
with (
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("run_agent.OpenAI"),
):
a = AIAgent(
api_key="test-glm-key",
model=model,
base_url=base_url,
provider=provider,
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
a.client = MagicMock()
a.reasoning_config = reasoning_config
a.api_mode = "chat_completions"
return a


# ===========================================================================
# _is_zai_direct()
# ===========================================================================

class TestIsZaiDirect:

def test_detected_by_provider(self):
a = _make_zai_agent(provider="zai")
assert a._is_zai_direct() is True

def test_detected_by_bigmodel_cn_url(self):
a = _make_zai_agent(base_url="https://open.bigmodel.cn/api/paas/v4")
assert a._is_zai_direct() is True

def test_detected_by_api_z_ai_url(self):
a = _make_zai_agent(base_url="https://api.z.ai/api/paas/v4")
assert a._is_zai_direct() is True

def test_not_detected_for_openrouter(self):
a = _make_zai_agent(
base_url="https://openrouter.ai/api/v1",
provider="openrouter",
)
assert a._is_zai_direct() is False

def test_not_detected_for_openai(self):
a = _make_zai_agent(
base_url="https://api.openai.com/v1",
provider="openai",
)
assert a._is_zai_direct() is False

def test_not_detected_for_empty_provider(self):
a = _make_zai_agent(provider="")
a.base_url = "https://api.unknown.com/v1"
a._base_url_lower = a.base_url.lower()
assert a._is_zai_direct() is False


# ===========================================================================
# _build_api_kwargs() — thinking parameter injection
# ===========================================================================

class TestZaiThinkingParam:

def test_glm51_gets_thinking_enabled(self):
"""GLM-5.1 should get thinking=enabled by default."""
a = _make_zai_agent(model="glm-5.1")
kwargs = a._build_api_kwargs([{"role": "user", "content": "hi"}])
assert kwargs["extra_body"]["thinking"] == {
"type": "enabled",
"compact_history": False,
}

def test_glm5_gets_thinking_enabled(self):
a = _make_zai_agent(model="glm-5")
kwargs = a._build_api_kwargs([{"role": "user", "content": "hi"}])
assert kwargs["extra_body"]["thinking"]["type"] == "enabled"
assert kwargs["extra_body"]["thinking"]["compact_history"] is False

def test_glm5_turbo_gets_thinking_enabled(self):
a = _make_zai_agent(model="glm-5-turbo")
kwargs = a._build_api_kwargs([{"role": "user", "content": "hi"}])
assert kwargs["extra_body"]["thinking"]["type"] == "enabled"

def test_glm47_gets_thinking_enabled(self):
a = _make_zai_agent(model="glm-4.7")
kwargs = a._build_api_kwargs([{"role": "user", "content": "hi"}])
assert kwargs["extra_body"]["thinking"]["type"] == "enabled"

def test_glm46_no_thinking_param(self):
"""GLM-4.6 auto-determines thinking, no parameter injected."""
a = _make_zai_agent(model="glm-4.6")
kwargs = a._build_api_kwargs([{"role": "user", "content": "hi"}])
assert "thinking" not in kwargs.get("extra_body", {})

def test_glm45_no_thinking_param(self):
a = _make_zai_agent(model="glm-4.5")
kwargs = a._build_api_kwargs([{"role": "user", "content": "hi"}])
assert "thinking" not in kwargs.get("extra_body", {})

def test_reasoning_config_disabled(self):
"""reasoning_config enabled=False should disable thinking."""
a = _make_zai_agent(model="glm-5.1", reasoning_config={"enabled": False})
kwargs = a._build_api_kwargs([{"role": "user", "content": "hi"}])
assert kwargs["extra_body"]["thinking"] == {"type": "disabled"}

def test_reasoning_config_enabled_with_effort(self):
"""reasoning_config with effort should still enable thinking."""
a = _make_zai_agent(
model="glm-5.1",
reasoning_config={"enabled": True, "effort": "high"},
)
kwargs = a._build_api_kwargs([{"role": "user", "content": "hi"}])
assert kwargs["extra_body"]["thinking"]["type"] == "enabled"
# z.ai doesn't use "effort" — it's always compulsory when enabled.
# The effort key is not forwarded for z.ai.
assert "effort" not in kwargs["extra_body"]["thinking"]

def test_no_thinking_for_non_zai_provider(self):
"""OpenAI provider should never get z.ai thinking param."""
a = _make_zai_agent(
model="gpt-4o",
base_url="https://api.openai.com/v1",
provider="openai",
)
kwargs = a._build_api_kwargs([{"role": "user", "content": "hi"}])
assert "thinking" not in kwargs.get("extra_body", {})

def test_bigmodel_cn_url_gets_thinking(self):
"""China endpoint URL should trigger thinking too."""
a = _make_zai_agent(
model="glm-5.1",
base_url="https://open.bigmodel.cn/api/paas/v4",
provider="",
)
kwargs = a._build_api_kwargs([{"role": "user", "content": "hi"}])
assert kwargs["extra_body"]["thinking"]["type"] == "enabled"


# ===========================================================================
# Multi-turn reasoning_content re-injection
# ===========================================================================

class TestZaiMultiTurnReasoning:

def test_reasoning_content_injected_on_assistant_msg(self):
"""Assistant messages with reasoning should get reasoning_content
when z.ai direct is active."""
a = _make_zai_agent(model="glm-5.1")
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there", "reasoning": "I should greet the user."},
{"role": "user", "content": "What is 2+2?"},
]
# Re-use the sanitization loop from the agent loop.
# We simulate what happens in run_conversation().
api_messages = []
for msg in messages:
api_msg = msg.copy()
for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
api_msg.pop(internal_field, None)
if a._is_zai_direct() and msg.get("role") == "assistant" and msg.get("reasoning"):
api_msg["reasoning_content"] = msg["reasoning"]
api_messages.append(api_msg)

# System message: no reasoning
assert "reasoning" not in api_messages[0]
assert "reasoning_content" not in api_messages[0]

# User message: no reasoning
assert "reasoning" not in api_messages[1]
assert "reasoning_content" not in api_messages[1]

# Assistant message: reasoning stripped, reasoning_content injected
assert "reasoning" not in api_messages[2]
assert api_messages[2]["reasoning_content"] == "I should greet the user."

# Next user message: untouched
assert api_messages[3]["content"] == "What is 2+2?"

def test_reasoning_content_not_injected_for_non_zai(self):
"""Non-z.ai providers should NOT get reasoning_content re-injected."""
a = _make_zai_agent(
model="gpt-4o",
base_url="https://api.openai.com/v1",
provider="openai",
)
messages = [
{"role": "assistant", "content": "Hi", "reasoning": "thinking..."},
]
api_messages = []
for msg in messages:
api_msg = msg.copy()
for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
api_msg.pop(internal_field, None)
if a._is_zai_direct() and msg.get("role") == "assistant" and msg.get("reasoning"):
api_msg["reasoning_content"] = msg["reasoning"]
api_messages.append(api_msg)

# reasoning stripped, reasoning_content NOT injected
assert "reasoning" not in api_messages[0]
assert "reasoning_content" not in api_messages[0]

def test_assistant_without_reasoning_untouched(self):
"""Assistant messages without reasoning should not get empty
reasoning_content."""
a = _make_zai_agent(model="glm-5.1")
messages = [
{"role": "assistant", "content": "Hi"},
]
api_messages = []
for msg in messages:
api_msg = msg.copy()
for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
api_msg.pop(internal_field, None)
if a._is_zai_direct() and msg.get("role") == "assistant" and msg.get("reasoning"):
api_msg["reasoning_content"] = msg["reasoning"]
api_messages.append(api_msg)

assert "reasoning_content" not in api_messages[0]