From 9660f22e8507c8898329822831706b2adfcaed60 Mon Sep 17 00:00:00 2001 From: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:58:40 +0000 Subject: [PATCH 1/3] 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 --- litellm/llms/zai/chat/transformation.py | 27 ++++- .../llms/zai/test_zai_provider.py | 110 +++++++++++++++++- 2 files changed, 130 insertions(+), 7 deletions(-) diff --git a/litellm/llms/zai/chat/transformation.py b/litellm/llms/zai/chat/transformation.py index b53eef19d1e..a3a85934275 100644 --- a/litellm/llms/zai/chat/transformation.py +++ b/litellm/llms/zai/chat/transformation.py @@ -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 diff --git a/tests/test_litellm/llms/zai/test_zai_provider.py b/tests/test_litellm/llms/zai/test_zai_provider.py index 069ac5727f6..e513aa5a450 100644 --- a/tests/test_litellm/llms/zai/test_zai_provider.py +++ b/tests/test_litellm/llms/zai/test_zai_provider.py @@ -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 From 7fa910f0bac25c6b5862015d76408286950199f3 Mon Sep 17 00:00:00 2001 From: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:53:57 +0000 Subject: [PATCH 2/3] fix(zai): return a new optional_params dict instead of mutating the caller's The override now builds passthrough and reasoning params as read-only views and returns a fresh dict, so the caller's optional_params and its extra_body are left untouched. The regression test asserts both identity and content of the caller's dict after the call Co-authored-by: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Co-authored-by: songkuan-zheng --- litellm/llms/zai/chat/transformation.py | 27 ++++++++++++------- .../llms/zai/test_zai_provider.py | 16 ++++++++--- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/litellm/llms/zai/chat/transformation.py b/litellm/llms/zai/chat/transformation.py index a3a85934275..d8571fa7132 100644 --- a/litellm/llms/zai/chat/transformation.py +++ b/litellm/llms/zai/chat/transformation.py @@ -68,14 +68,21 @@ class ZAIChatConfig(OpenAIGPTConfig): 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, + passthrough_params: Final = MappingProxyType( + { + k: v + for k, v in non_default_params.items() + if k in supported_openai_params and k not in ZAI_REASONING_PARAMS } - return optional_params + ) + if not reasoning_params: + return {**optional_params, **passthrough_params} # mutable-ok: base class returns a dict + extra_body: Final = { # mutable-ok: the OpenAI SDK json-encodes extra_body from a plain dict + **(optional_params.get("extra_body") or MappingProxyType({})), + **reasoning_params, + } + return { # mutable-ok: base class returns a dict + **optional_params, + **passthrough_params, + "extra_body": extra_body, + } diff --git a/tests/test_litellm/llms/zai/test_zai_provider.py b/tests/test_litellm/llms/zai/test_zai_provider.py index e513aa5a450..2429e7c05c3 100644 --- a/tests/test_litellm/llms/zai/test_zai_provider.py +++ b/tests/test_litellm/llms/zai/test_zai_provider.py @@ -175,17 +175,25 @@ def test_thinking_and_reasoning_effort_move_into_extra_body(local_model_cost_map assert result["extra_body"] == {"thinking": {"type": "disabled"}, "reasoning_effort": "low"} -def test_existing_extra_body_is_kept_and_not_mutated(local_model_cost_map): +def test_caller_optional_params_and_extra_body_are_not_mutated(local_model_cost_map): from litellm.llms.zai.chat.transformation import ZAIChatConfig caller_extra_body = {"already_here": True} + caller_optional_params = {"stream": False, "extra_body": caller_extra_body} result = ZAIChatConfig()._map_openai_params( - non_default_params={"thinking": {"type": "disabled"}}, - optional_params={"extra_body": caller_extra_body}, + non_default_params={"max_tokens": 100, "thinking": {"type": "disabled"}}, + optional_params=caller_optional_params, model="glm-4.7", drop_params=False, ) - assert result["extra_body"] == {"already_here": True, "thinking": {"type": "disabled"}} + assert result == { + "stream": False, + "max_tokens": 100, + "extra_body": {"already_here": True, "thinking": {"type": "disabled"}}, + } + assert result is not caller_optional_params + assert caller_optional_params == {"stream": False, "extra_body": {"already_here": True}} + assert caller_optional_params["extra_body"] is caller_extra_body assert caller_extra_body == {"already_here": True} From ae77461294749ce00d63d3891a9b27041d801882 Mon Sep 17 00:00:00 2001 From: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:19:27 +0000 Subject: [PATCH 3/3] fix(zai): type the parameter mapping override The override now declares dict[str, object] for both parameter dicts and its return value, and narrows a caller-supplied extra_body with an isinstance check before merging it, so basedpyright no longer sees unknown types on this path Co-authored-by: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Co-authored-by: songkuan-zheng --- litellm/llms/zai/chat/transformation.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/litellm/llms/zai/chat/transformation.py b/litellm/llms/zai/chat/transformation.py index d8571fa7132..a237bfca9b8 100644 --- a/litellm/llms/zai/chat/transformation.py +++ b/litellm/llms/zai/chat/transformation.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from types import MappingProxyType from typing import Final @@ -59,11 +60,11 @@ class ZAIChatConfig(OpenAIGPTConfig): def _map_openai_params( self, - non_default_params: dict, - optional_params: dict, + non_default_params: dict[str, object], + optional_params: dict[str, object], model: str, drop_params: bool, - ) -> dict: + ) -> dict[str, object]: 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} @@ -77,8 +78,10 @@ class ZAIChatConfig(OpenAIGPTConfig): ) if not reasoning_params: return {**optional_params, **passthrough_params} # mutable-ok: base class returns a dict + existing: Final = optional_params.get("extra_body") + existing_body: Final = existing if isinstance(existing, Mapping) else MappingProxyType({}) extra_body: Final = { # mutable-ok: the OpenAI SDK json-encodes extra_body from a plain dict - **(optional_params.get("extra_body") or MappingProxyType({})), + **existing_body, **reasoning_params, } return { # mutable-ok: base class returns a dict