Merge pull request #38100 from FelipeRodriguesGare/bugfix/tencent-thinking-extra-body

fix(tencent): route thinking through extra_body in chat completions
This commit is contained in:
Mateo Wang 2026-08-27 18:23:11 -07:00 committed by GitHub
commit 10cd9259a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 290 additions and 18 deletions

View file

@ -3,14 +3,36 @@ Translates from OpenAI's `/v1/chat/completions` to Tencent TokenHub's
OpenAI-compatible endpoint.
"""
from typing import Final
from collections.abc import Mapping
from typing import Final, TypedDict
from typing_extensions import ReadOnly
import litellm
from litellm.secret_managers.main import get_secret_str
from litellm.utils import supports_reasoning
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
class ThinkingPayload(TypedDict, total=False):
"""Tencent TokenHub `thinking` object.
`type` ("enabled"/"disabled"/"adaptive") is required by TokenHub when the
object is passed; `budget_tokens` is auto-filled server-side when omitted.
Ref: https://www.tencentcloud.com/document/product/1300/82345
"""
type: ReadOnly[str]
budget_tokens: ReadOnly[int]
class ThinkingExtraBody(TypedDict, total=False):
"""`extra_body` payload carrying TokenHub's `thinking` object."""
thinking: ReadOnly[Mapping[str, object]]
class TencentChatConfig(OpenAIGPTConfig):
def get_supported_openai_params(self, model: str) -> list:
params: Final = super().get_supported_openai_params(model)
@ -25,18 +47,71 @@ class TencentChatConfig(OpenAIGPTConfig):
model: str,
drop_params: bool,
) -> dict:
optional_params = super().map_openai_params(non_default_params, optional_params, model, drop_params)
mapped_params: Final = super().map_openai_params(non_default_params, optional_params, model, drop_params)
thinking_value: Final = optional_params.pop("thinking", None)
reasoning_effort: Final = optional_params.pop("reasoning_effort", None)
thinking_value: Final = mapped_params.pop("thinking", None)
reasoning_effort: Final = mapped_params.pop("reasoning_effort", None)
if thinking_value is not None:
if isinstance(thinking_value, dict):
optional_params["thinking"] = thinking_value
elif reasoning_effort is not None and reasoning_effort != "none":
optional_params["thinking"] = {"type": "enabled"}
thinking: Final = self._resolve_thinking_payload(
model=model,
thinking_value=thinking_value, # pyright: ignore[reportUnknownArgumentType] # value popped from the untyped provider params dict
reasoning_effort=reasoning_effort, # pyright: ignore[reportUnknownArgumentType] # value popped from the untyped provider params dict
)
if thinking is not None:
# TokenHub expects `thinking` in the request JSON body, but the
# OpenAI SDK's chat.completions.create() rejects unknown top-level
# kwargs, so it travels via `extra_body`, which the SDK merges into
# the payload. A plain assignment is merge-safe: get_optional_params
# spreads this dict into its own extra_body assembly downstream.
extra_body: Final[ThinkingExtraBody] = {"thinking": thinking}
mapped_params["extra_body"] = extra_body
return mapped_params
return optional_params
@classmethod
def _resolve_thinking_payload(
cls,
model: str,
thinking_value: object,
reasoning_effort: object,
) -> Mapping[str, object] | None:
if isinstance(thinking_value, dict):
return cls._coerce_thinking_type_for_model(model=model, thinking=thinking_value) # pyright: ignore[reportUnknownArgumentType] # isinstance narrows to dict[Unknown, Unknown] out of the untyped provider params dict
if isinstance(reasoning_effort, str):
# TokenHub recommends explicitly disabling thinking rather than
# relying on per-model defaults (deepseek-v4-* default to enabled).
payload: Final[ThinkingPayload] = {"type": "disabled" if reasoning_effort == "none" else "enabled"}
return cls._coerce_thinking_type_for_model(model=model, thinking=payload)
return None
@staticmethod
def _coerce_thinking_type_for_model(model: str, thinking: Mapping[str, object]) -> Mapping[str, object]:
"""Coerce `thinking.type` to a value the model accepts.
MiniMax models on TokenHub only accept "adaptive"/"disabled" and reject
"enabled" with a 400; "adaptive" (the model decides when to think) is
the closest semantic, so "enabled" is coerced for them. The capability
is read from the model map's `supports_adaptive_thinking` flag, so
aliases and newly onboarded adaptive-only models need no code change.
Ref: https://www.tencentcloud.com/document/product/1300/82345
"""
if thinking.get("type") != "enabled" or not TencentChatConfig._is_adaptive_thinking_model(model):
return thinking
budget: Final[object] = thinking.get("budget_tokens")
if isinstance(budget, int):
coerced_with_budget: Final[ThinkingPayload] = {"type": "adaptive", "budget_tokens": budget}
return coerced_with_budget
coerced: Final[ThinkingPayload] = {"type": "adaptive"}
return coerced
@staticmethod
def _is_adaptive_thinking_model(model: str) -> bool:
"""Read `supports_adaptive_thinking` from the model map under tencent."""
try:
model_info: Final[Mapping[str, object]] = litellm.get_model_info(model=model, custom_llm_provider="tencent")
except Exception: # noqa: BLE001 # get_model_info raises a bare Exception for unmapped models
return False
return model_info.get("supports_adaptive_thinking") is True
def _get_openai_compatible_provider_info(
self, api_base: str | None, api_key: str | None

View file

@ -51102,6 +51102,26 @@
"supports_reasoning": true,
"supports_vision": false
},
"tencent/minimax-m3": {
"cache_creation_input_token_cost": 0.0,
"cache_read_input_token_cost": 6e-08,
"input_cost_per_token": 3e-07,
"input_cost_per_token_cache_hit": 6e-08,
"litellm_provider": "tencent",
"max_input_tokens": 1000000,
"mode": "chat",
"output_cost_per_token": 1.2e-06,
"source": "https://www.tencentcloud.com/products/tokenhub",
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_adaptive_thinking": true,
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_vision": false
},
"cognition/swe-1.6": {
"input_cost_per_token": 5e-07,
"output_cost_per_token": 2.5e-06,

View file

@ -51102,6 +51102,26 @@
"supports_reasoning": true,
"supports_vision": false
},
"tencent/minimax-m3": {
"cache_creation_input_token_cost": 0.0,
"cache_read_input_token_cost": 6e-08,
"input_cost_per_token": 3e-07,
"input_cost_per_token_cache_hit": 6e-08,
"litellm_provider": "tencent",
"max_input_tokens": 1000000,
"mode": "chat",
"output_cost_per_token": 1.2e-06,
"source": "https://www.tencentcloud.com/products/tokenhub",
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_adaptive_thinking": true,
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_vision": false
},
"cognition/swe-1.6": {
"input_cost_per_token": 5e-07,
"output_cost_per_token": 2.5e-06,

View file

@ -45,7 +45,8 @@ def test_map_openai_params_passes_thinking_dict_through():
drop_params=False,
)
assert result["thinking"] == {"type": "enabled", "budget_tokens": 1024}
assert "thinking" not in result
assert result["extra_body"]["thinking"] == {"type": "enabled", "budget_tokens": 1024}
def test_map_openai_params_converts_reasoning_effort_to_thinking():
@ -61,10 +62,11 @@ def test_map_openai_params_converts_reasoning_effort_to_thinking():
drop_params=False,
)
assert result["thinking"] == {"type": "enabled"}
assert "thinking" not in result
assert result["extra_body"]["thinking"] == {"type": "enabled"}
def test_map_openai_params_drops_none_reasoning_effort():
def test_map_openai_params_none_reasoning_effort_disables_thinking():
config = TencentChatConfig()
with patch(
"litellm.llms.tencent.chat.transformation.supports_reasoning",
@ -78,6 +80,7 @@ def test_map_openai_params_drops_none_reasoning_effort():
)
assert "thinking" not in result
assert result["extra_body"]["thinking"] == {"type": "disabled"}
assert "reasoning_effort" not in result
@ -97,7 +100,8 @@ def test_map_openai_params_thinking_priority_over_reasoning_effort():
drop_params=False,
)
assert result["thinking"] == {"type": "enabled", "budget_tokens": 2048}
assert "thinking" not in result
assert result["extra_body"]["thinking"] == {"type": "enabled", "budget_tokens": 2048}
def test_map_openai_params_extracts_thinking_and_effort_from_optional_params():
@ -109,10 +113,157 @@ def test_map_openai_params_extracts_thinking_and_effort_from_optional_params():
drop_params=False,
)
assert "thinking" in result
assert "thinking" not in result
assert result["extra_body"]["thinking"] == {"type": "enabled"}
assert "reasoning_effort" not in result
def test_map_openai_params_overwrites_existing_extra_body():
"""The map layer assigns extra_body directly; get_optional_params merges it
with user-supplied extra params downstream (utils.py provider overrides)."""
config = TencentChatConfig()
result = config.map_openai_params(
non_default_params={},
optional_params={
"thinking": {"type": "enabled"},
"extra_body": {"custom_flag": True},
},
model="tencent/deepseek-v4-pro",
drop_params=False,
)
assert result["extra_body"] == {"thinking": {"type": "enabled"}}
def test_get_optional_params_merges_thinking_with_user_extra_body(local_model_cost_map):
"""End-to-end at the get_optional_params layer: a user-supplied extra_body
and the mapped thinking payload must coexist in the final extra_body."""
from litellm.utils import get_optional_params
result = get_optional_params(
model="tencent/deepseek-v4-pro",
custom_llm_provider="tencent",
messages=[{"role": "user", "content": "hi"}],
thinking={"type": "enabled"},
extra_body={"custom_flag": True},
)
assert result["extra_body"]["thinking"] == {"type": "enabled"}
assert result["extra_body"]["custom_flag"] is True
def test_transform_request_never_passes_thinking_as_top_level_kwarg():
"""
Regression test: tencent routes through the OpenAI SDK's
chat.completions.create(**data), which raises TypeError on unknown kwargs.
`thinking` must be nested inside extra_body, never top-level.
"""
config = TencentChatConfig()
optional_params = config.map_openai_params(
non_default_params={"thinking": {"type": "enabled", "budget_tokens": 1024}},
optional_params={},
model="tencent/deepseek-v4-pro",
drop_params=False,
)
data = config.transform_request(
model="deepseek-v4-pro",
messages=[{"role": "user", "content": "hi"}],
optional_params=optional_params,
litellm_params={},
headers={},
)
assert "thinking" not in data
assert data["extra_body"]["thinking"] == {"type": "enabled", "budget_tokens": 1024}
class TestAdaptiveThinkingCoercion:
"""
Models flagged `supports_adaptive_thinking` in the cost map (e.g.
tencent/minimax-m3) only accept thinking.type "adaptive"/"disabled"
"enabled" returns a 400 from TokenHub.
Ref: https://www.tencentcloud.com/document/product/1300/82345
"""
def test_reasoning_effort_maps_to_adaptive_for_adaptive_only_model(self, local_model_cost_map):
config = TencentChatConfig()
result = config.map_openai_params(
non_default_params={"reasoning_effort": "medium"},
optional_params={},
model="tencent/minimax-m3",
drop_params=False,
)
assert result["extra_body"]["thinking"] == {"type": "adaptive"}
def test_explicit_enabled_thinking_coerced_to_adaptive(self, local_model_cost_map):
config = TencentChatConfig()
result = config.map_openai_params(
non_default_params={"thinking": {"type": "enabled", "budget_tokens": 4096}},
optional_params={},
model="tencent/minimax-m3",
drop_params=False,
)
assert result["extra_body"]["thinking"] == {"type": "adaptive", "budget_tokens": 4096}
def test_disabled_thinking_kept_for_adaptive_only_model(self, local_model_cost_map):
config = TencentChatConfig()
result = config.map_openai_params(
non_default_params={"thinking": {"type": "disabled"}},
optional_params={},
model="tencent/minimax-m3",
drop_params=False,
)
assert result["extra_body"]["thinking"] == {"type": "disabled"}
def test_none_reasoning_effort_disables_thinking_for_adaptive_only_model(self, local_model_cost_map):
config = TencentChatConfig()
result = config.map_openai_params(
non_default_params={"reasoning_effort": "none"},
optional_params={},
model="tencent/minimax-m3",
drop_params=False,
)
assert result["extra_body"]["thinking"] == {"type": "disabled"}
def test_non_adaptive_model_keeps_enabled(self, local_model_cost_map):
config = TencentChatConfig()
result = config.map_openai_params(
non_default_params={"reasoning_effort": "high"},
optional_params={},
model="tencent/deepseek-v4-pro",
drop_params=False,
)
assert result["extra_body"]["thinking"] == {"type": "enabled"}
def test_unmapped_model_keeps_enabled(self):
"""Models absent from the cost map never get coerced."""
config = TencentChatConfig()
assert config._is_adaptive_thinking_model("tencent/no-such-model") is False
def test_minimax_m3_cost_map_entry_marks_adaptive_thinking():
"""The capability flag driving the coercion must exist in the cost map
(and its backup, which is shipped with the package)."""
import json
from pathlib import Path
repo_root = Path(__file__).parents[5]
for filename in ("model_prices_and_context_window.json", "litellm/model_prices_and_context_window_backup.json"):
with open(repo_root / filename) as f:
entry = json.load(f).get("tencent/minimax-m3")
assert entry is not None, f"tencent/minimax-m3 not found in {filename}"
assert entry["litellm_provider"] == "tencent"
assert entry.get("supports_adaptive_thinking") is True
assert entry.get("supports_reasoning") is True
def test_get_complete_url_default():
config = TencentChatConfig()

View file

@ -4271,7 +4271,11 @@ class TestGetOptionalParamsTencent:
"""Tests that tencent provider uses TencentChatConfig for parameter mapping."""
def test_tencent_supports_thinking_param(self):
"""Verify get_optional_params for tencent accepts the 'thinking' param."""
"""Verify get_optional_params for tencent accepts the 'thinking' param.
`thinking` must be nested in extra_body: tencent routes through the
OpenAI SDK's chat.completions.create(), which rejects unknown kwargs.
"""
from unittest.mock import patch
from litellm.utils import get_optional_params
@ -4285,7 +4289,8 @@ class TestGetOptionalParamsTencent:
custom_llm_provider="tencent",
thinking={"type": "enabled"},
)
assert result.get("thinking") == {"type": "enabled"}
assert "thinking" not in result
assert result["extra_body"]["thinking"] == {"type": "enabled"}
def test_tencent_supports_reasoning_effort(self):
"""Verify get_optional_params for tencent converts reasoning_effort to thinking."""
@ -4302,7 +4307,8 @@ class TestGetOptionalParamsTencent:
custom_llm_provider="tencent",
reasoning_effort="medium",
)
assert result.get("thinking") == {"type": "enabled"}
assert "thinking" not in result
assert result["extra_body"]["thinking"] == {"type": "enabled"}
def test_tencent_supported_params_includes_thinking_and_reasoning_effort(self):
"""Verify get_supported_openai_params for tencent includes custom params."""