fix(zai): pass thinking and reasoning_effort via extra_body

ZAIChatConfig inherits OpenAIGPTConfig._map_openai_params, which copies every
supported param to the top level of optional_params. The OpenAI SDK has no
`thinking` kwarg, so a request to a GLM reasoning model with
`thinking={"type": "disabled"}` failed inside the SDK with "unexpected keyword
argument 'thinking'" and surfaced as a 500. `reasoning_effort` was not in the
supported list at all, so it was rejected as unsupported or silently dropped

Both are Z.AI request body fields, so the zai config now lists them as supported
on reasoning models and moves them into extra_body, which the SDK flattens into
the JSON body. The merged extra_body is built as a new dict so a caller-owned
extra_body is never mutated

Co-authored-by: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com>
Co-authored-by: songkuan-zheng <songkuan-zheng@users.noreply.github.com>
This commit is contained in:
songkuan-zheng 2026-09-11 10:58:40 +00:00
parent 9a715df212
commit 9660f22e85
2 changed files with 130 additions and 7 deletions

View file

@ -1,3 +1,4 @@
from types import MappingProxyType
from typing import Final
from litellm.secret_managers.main import get_secret_str
@ -6,6 +7,7 @@ from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
ZAI_API_BASE: Final = "https://api.z.ai/api/paas/v4"
ZAI_REASONING_PARAMS: Final = frozenset(("thinking", "reasoning_effort"))
class ZAIChatConfig(OpenAIGPTConfig):
@ -49,8 +51,31 @@ class ZAIChatConfig(OpenAIGPTConfig):
try:
if litellm.supports_reasoning(model=model, custom_llm_provider=self.custom_llm_provider):
base_params.append("thinking")
return [*base_params, *sorted(ZAI_REASONING_PARAMS)] # mutable-ok: base class returns a list
except Exception:
pass
return base_params
def _map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
supported_openai_params: Final = frozenset(self.get_supported_openai_params(model))
reasoning_params: Final = MappingProxyType(
{k: v for k, v in non_default_params.items() if k in ZAI_REASONING_PARAMS and k in supported_openai_params}
)
optional_params.update(
(k, v)
for k, v in non_default_params.items()
if k in supported_openai_params and k not in ZAI_REASONING_PARAMS
)
if reasoning_params:
optional_params["extra_body"] = { # mutable-ok: the OpenAI SDK json-encodes extra_body from a plain dict
**(optional_params.get("extra_body") or MappingProxyType({})),
**reasoning_params,
}
return optional_params

View file

@ -2,6 +2,7 @@
Tests for Z.AI (Zhipu AI) provider - GLM models
"""
import json
import math
import pytest
@ -89,9 +90,7 @@ async def test_zai_completion_call(respx_mock, zai_response, monkeypatch):
monkeypatch.setenv("ZAI_API_KEY", "test-api-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
respx_mock.post("https://api.z.ai/api/paas/v4/chat/completions").respond(
json=zai_response
)
respx_mock.post("https://api.z.ai/api/paas/v4/chat/completions").respond(json=zai_response)
response = await litellm.acompletion(
model="zai/glm-4.6",
@ -115,9 +114,7 @@ def test_zai_sync_completion(respx_mock, zai_response, monkeypatch):
monkeypatch.setenv("ZAI_API_KEY", "test-api-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
respx_mock.post("https://api.z.ai/api/paas/v4/chat/completions").respond(
json=zai_response
)
respx_mock.post("https://api.z.ai/api/paas/v4/chat/completions").respond(json=zai_response)
response = completion(
model="zai/glm-4.6",
@ -127,3 +124,104 @@ def test_zai_sync_completion(respx_mock, zai_response, monkeypatch):
assert response.choices[0].message.content == "Hello! How can I help you today?"
assert response.usage.total_tokens == 25
@pytest.fixture
def zai_thinking_response():
return {
"id": "chatcmpl-zai-thinking",
"object": "chat.completion",
"created": 1700000000,
"model": "glm-4.7",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},
}
def _captured_body(respx_mock) -> dict:
assert len(respx_mock.calls) == 1
return json.loads(respx_mock.calls[0].request.content.decode("utf-8"))
def test_reasoning_params_supported_on_reasoning_models(local_model_cost_map):
from litellm.llms.zai.chat.transformation import ZAIChatConfig
params = ZAIChatConfig().get_supported_openai_params(model="glm-4.7")
assert "thinking" in params
assert "reasoning_effort" in params
def test_reasoning_params_not_supported_without_reasoning_flag(monkeypatch):
from litellm.llms.zai.chat.transformation import ZAIChatConfig
monkeypatch.setattr(litellm, "supports_reasoning", lambda **kwargs: False)
params = ZAIChatConfig().get_supported_openai_params(model="glm-4-32b-0414-128k")
assert "thinking" not in params
assert "reasoning_effort" not in params
def test_thinking_and_reasoning_effort_move_into_extra_body(local_model_cost_map):
from litellm.llms.zai.chat.transformation import ZAIChatConfig
result = ZAIChatConfig()._map_openai_params(
non_default_params={"max_tokens": 100, "thinking": {"type": "disabled"}, "reasoning_effort": "low"},
optional_params={},
model="glm-4.7",
drop_params=False,
)
assert result["max_tokens"] == 100
assert "thinking" not in result
assert "reasoning_effort" not in result
assert result["extra_body"] == {"thinking": {"type": "disabled"}, "reasoning_effort": "low"}
def test_existing_extra_body_is_kept_and_not_mutated(local_model_cost_map):
from litellm.llms.zai.chat.transformation import ZAIChatConfig
caller_extra_body = {"already_here": True}
result = ZAIChatConfig()._map_openai_params(
non_default_params={"thinking": {"type": "disabled"}},
optional_params={"extra_body": caller_extra_body},
model="glm-4.7",
drop_params=False,
)
assert result["extra_body"] == {"already_here": True, "thinking": {"type": "disabled"}}
assert caller_extra_body == {"already_here": True}
def test_reasoning_params_dropped_for_non_reasoning_model(monkeypatch):
from litellm.utils import get_optional_params
monkeypatch.setattr(litellm, "supports_reasoning", lambda **kwargs: False)
result = get_optional_params(
model="glm-4-32b-0414-128k",
custom_llm_provider="zai",
thinking={"type": "disabled"},
reasoning_effort="low",
drop_params=True,
)
assert "thinking" not in result
assert "reasoning_effort" not in result
assert "thinking" not in result.get("extra_body", {})
assert "reasoning_effort" not in result.get("extra_body", {})
@pytest.mark.asyncio
async def test_thinking_and_reasoning_effort_reach_http_body(
respx_mock, zai_thinking_response, monkeypatch, local_model_cost_map
):
monkeypatch.setenv("ZAI_API_KEY", "test-api-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
respx_mock.post("https://api.z.ai/api/paas/v4/chat/completions").respond(json=zai_thinking_response)
await litellm.acompletion(
model="zai/glm-4.7",
messages=[{"role": "user", "content": "hi"}],
thinking={"type": "disabled"},
reasoning_effort="low",
)
body = _captured_body(respx_mock)
assert body["thinking"] == {"type": "disabled"}
assert body["reasoning_effort"] == "low"
assert "extra_body" not in body