From 4a92065b9d60fce994421440d51eab2ac50244ae Mon Sep 17 00:00:00 2001 From: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:05:57 +0000 Subject: [PATCH 1/4] fix(dashscope): map thinking and reasoning_effort to DashScope fields DashScopeChatConfig inherited the OpenAIGPTConfig supported-params list, which carries neither thinking nor reasoning_effort. Both were discarded before the request left the proxy: a 400 UnsupportedParamsError without drop_params, or a silent drop with it, so thinking stayed at the model default. Every Qwen 3.5 and later hybrid model defaults thinking on, so a caller who cannot turn it off pays for reasoning tokens on every request DashScope spells these controls enable_thinking, thinking_budget and reasoning_effort in the request body, so translate rather than forward. A thinking object maps to enable_thinking plus thinking_budget when budget_tokens is set. reasoning_effort none or disable maps to enable_thinking false, any other value enables thinking and is forwarded as is. When both are supplied the thinking object wins, because the qwen3.8 series rejects a request that carries reasoning_effort and thinking_budget together. The fields travel inside extra_body so the OpenAI SDK accepts them, and a caller-supplied extra_body is merged rather than replaced or mutated Ref: https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions Co-authored-by: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Co-authored-by: songkuan-zheng --- litellm/llms/dashscope/chat/transformation.py | 63 +++++++++ .../test_dashscope_chat_transformation.py | 126 +++++++++++++++++- 2 files changed, 188 insertions(+), 1 deletion(-) diff --git a/litellm/llms/dashscope/chat/transformation.py b/litellm/llms/dashscope/chat/transformation.py index 26e60fa959d..28ba0f76c0e 100644 --- a/litellm/llms/dashscope/chat/transformation.py +++ b/litellm/llms/dashscope/chat/transformation.py @@ -3,13 +3,44 @@ Translates from OpenAI's `/v1/chat/completions` to DashScope's `/v1/chat/complet """ from collections.abc import Coroutine +from types import MappingProxyType from typing import Any, Final, Literal, overload +from typing_extensions import ReadOnly, TypedDict + from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam from ...openai.chat.gpt_transformation import OpenAIGPTConfig +THINKING_OFF_REASONING_EFFORTS: Final = frozenset({"none", "disable"}) +DASHSCOPE_THINKING_PARAMS: Final = frozenset({"thinking", "reasoning_effort"}) + + +class DashScopeThinkingBody(TypedDict, total=False): + enable_thinking: ReadOnly[bool] + thinking_budget: ReadOnly[int] + reasoning_effort: ReadOnly[str] + + +def _dashscope_thinking_body(thinking: object, reasoning_effort: object) -> DashScopeThinkingBody: + if isinstance(thinking, dict): + enabled: Final = thinking.get("type") != "disabled" + budget: Final = thinking.get("budget_tokens") + if isinstance(budget, int) and not isinstance(budget, bool): + with_budget: Final[DashScopeThinkingBody] = {"enable_thinking": enabled, "thinking_budget": budget} + return with_budget + toggled: Final[DashScopeThinkingBody] = {"enable_thinking": enabled} + return toggled + if isinstance(reasoning_effort, str): + if reasoning_effort in THINKING_OFF_REASONING_EFFORTS: + off: Final[DashScopeThinkingBody] = {"enable_thinking": False} + return off + with_effort: Final[DashScopeThinkingBody] = {"enable_thinking": True, "reasoning_effort": reasoning_effort} + return with_effort + untouched: Final[DashScopeThinkingBody] = {} + return untouched + class DashScopeChatConfig(OpenAIGPTConfig): def remove_cache_control_flag_from_messages_and_tools( @@ -73,3 +104,35 @@ class DashScopeChatConfig(OpenAIGPTConfig): if resolved_api_base.endswith("/chat/completions"): return resolved_api_base return f"{resolved_api_base}/chat/completions" + + def get_supported_openai_params(self, model: str) -> list: + base_params: Final = super().get_supported_openai_params(model) + return [*base_params, "thinking", "reasoning_effort"] # mutable-ok: inherited list contract + + 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)) + passthrough_params: Final = MappingProxyType( + { + k: v + for k, v in non_default_params.items() + if k in supported_openai_params and k not in DASHSCOPE_THINKING_PARAMS + } + ) + native: Final = _dashscope_thinking_body( + non_default_params.get("thinking"), non_default_params.get("reasoning_effort") + ) + if not native: + return {**optional_params, **passthrough_params} # mutable-ok: dict return contract of OpenAIGPTConfig + existing: Final = optional_params.get("extra_body") + existing_body: Final = existing if isinstance(existing, dict) else MappingProxyType({}) + return { # mutable-ok: dict return contract of OpenAIGPTConfig + **optional_params, + **passthrough_params, + "extra_body": {**existing_body, **native}, # mutable-ok: the OpenAI SDK json-encodes extra_body from a dict + } diff --git a/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py b/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py index d2a90baf6b2..395bfe61cbf 100644 --- a/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py +++ b/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py @@ -7,12 +7,14 @@ DashScope is an OpenAI-compatible provider with minor customizations. -from litellm.types.llms.openai import AllMessageValues +import json + import pytest import litellm from litellm import completion from litellm.llms.dashscope.chat.transformation import DashScopeChatConfig +from litellm.types.llms.openai import AllMessageValues class TestDashScopeConfig: @@ -185,3 +187,125 @@ class TestDashScopeConfig: ) assert transformed_tools[0].get("cache_control") == {"type": "ephemeral"} + + +class TestDashScopeThinkingParams: + """thinking and reasoning_effort reach DashScope as enable_thinking, thinking_budget and reasoning_effort.""" + + @staticmethod + def _map(**non_default_params) -> dict: + return DashScopeChatConfig().map_openai_params( + non_default_params=non_default_params, + optional_params={}, + model="qwen3.8-max", + drop_params=False, + ) + + @pytest.mark.parametrize( + ("thinking", "expected"), + [ + ({"type": "enabled", "budget_tokens": 4096}, {"enable_thinking": True, "thinking_budget": 4096}), + ({"type": "enabled"}, {"enable_thinking": True}), + ({"type": "disabled"}, {"enable_thinking": False}), + ], + ) + def test_thinking_maps_to_enable_thinking_and_budget(self, thinking, expected): + assert self._map(thinking=thinking)["extra_body"] == expected + + @pytest.mark.parametrize("effort", ["low", "medium", "high", "minimal"]) + def test_reasoning_effort_enables_thinking_and_is_forwarded(self, effort): + params = self._map(reasoning_effort=effort) + + assert params["extra_body"] == {"enable_thinking": True, "reasoning_effort": effort} + + @pytest.mark.parametrize("effort", ["none", "disable"]) + def test_reasoning_effort_off_disables_thinking(self, effort): + assert self._map(reasoning_effort=effort)["extra_body"] == {"enable_thinking": False} + + def test_thinking_budget_wins_over_reasoning_effort(self): + params = self._map(thinking={"type": "enabled", "budget_tokens": 512}, reasoning_effort="high") + + assert params["extra_body"] == {"enable_thinking": True, "thinking_budget": 512} + + def test_no_thinking_params_leaves_extra_body_absent(self): + params = self._map(temperature=0.5, max_tokens=16) + + assert "extra_body" not in params + assert params["temperature"] == 0.5 + assert params["max_tokens"] == 16 + + def test_thinking_params_never_become_top_level_kwargs(self): + params = self._map(thinking={"type": "enabled"}, reasoning_effort="high") + + assert "thinking" not in params + assert "reasoning_effort" not in params + + def test_existing_extra_body_is_preserved_and_not_mutated(self): + caller_extra_body = {"enable_search": True} + params = DashScopeChatConfig().map_openai_params( + non_default_params={"thinking": {"type": "enabled"}}, + optional_params={"extra_body": caller_extra_body}, + model="qwen3.8-max", + drop_params=False, + ) + + assert params["extra_body"] == {"enable_search": True, "enable_thinking": True} + assert caller_extra_body == {"enable_search": True} + + def test_caller_optional_params_are_not_mutated(self): + caller_optional_params = {"temperature": 0.2, "extra_body": {"enable_search": True}} + params = DashScopeChatConfig().map_openai_params( + non_default_params={"thinking": {"type": "disabled"}, "max_tokens": 8}, + optional_params=caller_optional_params, + model="qwen3.8-max", + drop_params=False, + ) + + assert params == { + "temperature": 0.2, + "max_tokens": 8, + "extra_body": {"enable_search": True, "enable_thinking": False}, + } + assert params is not caller_optional_params + assert caller_optional_params == {"temperature": 0.2, "extra_body": {"enable_search": True}} + + def test_get_optional_params_accepts_thinking_without_drop_params(self): + params = litellm.get_optional_params( + model="qwen3.8-max", + custom_llm_provider="dashscope", + thinking={"type": "disabled"}, + drop_params=False, + ) + + assert params["extra_body"]["enable_thinking"] is False + assert "thinking" not in params + + @pytest.mark.respx() + def test_thinking_disabled_reaches_the_wire_as_enable_thinking(self, respx_mock): + litellm.disable_aiohttp_transport = True + api_base = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + route = respx_mock.post(f"{api_base}/chat/completions").respond( + json={ + "id": "chatcmpl-456", + "object": "chat.completion", + "created": 1677652288, + "model": "qwen3.8-max", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "4"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10}, + }, + status_code=200, + ) + + completion( + model="dashscope/qwen3.8-max", + messages=[{"role": "user", "content": "2+2?"}], + api_key="fake-dashscope-key", + api_base=api_base, + thinking={"type": "disabled"}, + drop_params=False, + ) + + sent = json.loads(route.calls.last.request.content) + assert sent["enable_thinking"] is False + assert "thinking" not in sent + assert "extra_body" not in sent From e255b59aa902124a8001cd1f7ef361df2a6d81b7 Mon Sep 17 00:00:00 2001 From: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:24:16 +0000 Subject: [PATCH 2/4] fix(dashscope): type the parameter mapping override The override declares dict[str, object] for both parameter dicts and its return value, and narrows a caller-supplied extra_body with a Mapping isinstance check before merging, 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/dashscope/chat/transformation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/litellm/llms/dashscope/chat/transformation.py b/litellm/llms/dashscope/chat/transformation.py index 28ba0f76c0e..304be9435da 100644 --- a/litellm/llms/dashscope/chat/transformation.py +++ b/litellm/llms/dashscope/chat/transformation.py @@ -2,7 +2,7 @@ Translates from OpenAI's `/v1/chat/completions` to DashScope's `/v1/chat/completions` """ -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping from types import MappingProxyType from typing import Any, Final, Literal, overload @@ -111,11 +111,11 @@ class DashScopeChatConfig(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)) passthrough_params: Final = MappingProxyType( { @@ -130,7 +130,7 @@ class DashScopeChatConfig(OpenAIGPTConfig): if not native: return {**optional_params, **passthrough_params} # mutable-ok: dict return contract of OpenAIGPTConfig existing: Final = optional_params.get("extra_body") - existing_body: Final = existing if isinstance(existing, dict) else MappingProxyType({}) + existing_body: Final = existing if isinstance(existing, Mapping) else MappingProxyType({}) return { # mutable-ok: dict return contract of OpenAIGPTConfig **optional_params, **passthrough_params, From 0f7ae365217d098186faf40f9da2844f9c9e6125 Mon Sep 17 00:00:00 2001 From: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:25:28 +0000 Subject: [PATCH 3/4] fix(dashscope): drop thinking_budget when thinking is disabled A thinking block of type disabled that still carries budget_tokens now maps to enable_thinking false alone, instead of a request that both disables thinking and sets a thinking budget Co-authored-by: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Co-authored-by: songkuan-zheng --- litellm/llms/dashscope/chat/transformation.py | 2 +- .../llms/dashscope/test_dashscope_chat_transformation.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/llms/dashscope/chat/transformation.py b/litellm/llms/dashscope/chat/transformation.py index 304be9435da..459369d4c60 100644 --- a/litellm/llms/dashscope/chat/transformation.py +++ b/litellm/llms/dashscope/chat/transformation.py @@ -27,7 +27,7 @@ def _dashscope_thinking_body(thinking: object, reasoning_effort: object) -> Dash if isinstance(thinking, dict): enabled: Final = thinking.get("type") != "disabled" budget: Final = thinking.get("budget_tokens") - if isinstance(budget, int) and not isinstance(budget, bool): + if enabled and isinstance(budget, int) and not isinstance(budget, bool): with_budget: Final[DashScopeThinkingBody] = {"enable_thinking": enabled, "thinking_budget": budget} return with_budget toggled: Final[DashScopeThinkingBody] = {"enable_thinking": enabled} diff --git a/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py b/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py index 395bfe61cbf..fbc2b0b47a7 100644 --- a/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py +++ b/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py @@ -207,6 +207,7 @@ class TestDashScopeThinkingParams: ({"type": "enabled", "budget_tokens": 4096}, {"enable_thinking": True, "thinking_budget": 4096}), ({"type": "enabled"}, {"enable_thinking": True}), ({"type": "disabled"}, {"enable_thinking": False}), + ({"type": "disabled", "budget_tokens": 4096}, {"enable_thinking": False}), ], ) def test_thinking_maps_to_enable_thinking_and_budget(self, thinking, expected): From b8f4a84a6990988618f3b30b69406b8dd739752f Mon Sep 17 00:00:00 2001 From: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:55:20 +0000 Subject: [PATCH 4/4] test(dashscope): type the thinking params test helper Co-authored-by: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Co-authored-by: songkuan-zheng --- .../llms/dashscope/test_dashscope_chat_transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py b/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py index fbc2b0b47a7..e74fd6d10c8 100644 --- a/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py +++ b/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py @@ -193,7 +193,7 @@ class TestDashScopeThinkingParams: """thinking and reasoning_effort reach DashScope as enable_thinking, thinking_budget and reasoning_effort.""" @staticmethod - def _map(**non_default_params) -> dict: + def _map(**non_default_params: object) -> dict[str, object]: return DashScopeChatConfig().map_openai_params( non_default_params=non_default_params, optional_params={},