mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
commit
10cd9259a3
5 changed files with 290 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue