From d666e68a621d4a2353f1abca992dc6797f4e0f39 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/4] 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 +++++- tests/unit/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/unit/llms/zai/test_zai_provider.py b/tests/unit/llms/zai/test_zai_provider.py index 32849d5eef1..c5993aeca93 100644 --- a/tests/unit/llms/zai/test_zai_provider.py +++ b/tests/unit/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 @@ -61,9 +62,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", @@ -87,9 +86,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", @@ -99,3 +96,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 bb8fb214de69a71e773ff5ec53390a51c33cf44e 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/4] 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 +++++++++++++++--------- tests/unit/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/unit/llms/zai/test_zai_provider.py b/tests/unit/llms/zai/test_zai_provider.py index c5993aeca93..23dcfae879b 100644 --- a/tests/unit/llms/zai/test_zai_provider.py +++ b/tests/unit/llms/zai/test_zai_provider.py @@ -147,17 +147,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 0b937512ed95a7100eb32a8f4af41fc99a5faf49 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/4] 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 From e1088967e2183b0951d7db9d7764bb39a8c1d2ab Mon Sep 17 00:00:00 2001 From: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:04:33 +0000 Subject: [PATCH 4/4] fix(zai): keep the thinking override inside the type-discipline budget Main consumed the LIT001 budget headroom, so the override signature now takes Mapping for its parameters and marks the inherited dict/list return contracts as mutable-ok. Co-authored-by: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Co-authored-by: songkuan-zheng --- litellm/llms/zai/chat/transformation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/llms/zai/chat/transformation.py b/litellm/llms/zai/chat/transformation.py index a237bfca9b8..efe7bcd5d3c 100644 --- a/litellm/llms/zai/chat/transformation.py +++ b/litellm/llms/zai/chat/transformation.py @@ -60,11 +60,11 @@ class ZAIChatConfig(OpenAIGPTConfig): def _map_openai_params( self, - non_default_params: dict[str, object], - optional_params: dict[str, object], + non_default_params: Mapping[str, object], + optional_params: Mapping[str, object], model: str, drop_params: bool, - ) -> dict[str, object]: + ) -> dict[str, object]: # mutable-ok: dict return contract of OpenAIGPTConfig 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}