This commit is contained in:
HX 2026-09-27 10:40:36 +00:00 • committed by GitHub
commit e9c5a865c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 651 additions and 5 deletions

View file

@ -0,0 +1,238 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
from pydantic import TypeAdapter, ValidationError
_STR_KEYED_MAPPING: Final = TypeAdapter(Mapping[str, object])
_OBJECT_TUPLE: Final = TypeAdapter(tuple[object, ...])
_EMPTY: Final[Mapping[str, object]] = MappingProxyType({})
_SEND_VIA_EXTRA_BODY: Final = "extra_body"
_SEND_VIA_PROVIDER_MAPPED: Final = "provider_mapped"
_SEND_VIA_VALUES: Final = frozenset((_SEND_VIA_EXTRA_BODY, _SEND_VIA_PROVIDER_MAPPED))
_THINKING_ENABLED_STRINGS: Final = frozenset(("enabled", "true", "1", "auto"))
_THINKING_TYPE_ENABLED: Final = frozenset(("enabled", "auto", "true"))
_EFFORT_FALLBACKS: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
{
"xhigh": ("max", "high"),
"max": ("xhigh", "high"),
"medium": ("high", "low"),
"minimal": ("low", "none"),
"none": ("low",),
}
)
@dataclass(frozen=True, slots=True)
class ThinkingParamsState:
thinking: object | None
reasoning_effort: object | None
extra_body: Mapping[str, object]
def str_keyed_mapping_or_none(value: object) -> Mapping[str, object] | None:
if not isinstance(value, Mapping):
return None
try:
return _STR_KEYED_MAPPING.validate_python(value)
except ValidationError:
return None
def _as_str_tuple(value: object) -> tuple[str, ...]:
if not isinstance(value, (list, tuple)):
return ()
return tuple(item for item in _OBJECT_TUPLE.validate_python(value) if isinstance(item, str))
def _thinking_enabled(thinking: object) -> bool:
if isinstance(thinking, bool):
return thinking
if isinstance(thinking, str):
return thinking.lower() in _THINKING_ENABLED_STRINGS
mapping: Final = str_keyed_mapping_or_none(thinking)
if mapping is None:
return False
typ: Final = mapping.get("type")
if isinstance(typ, str):
return typ.lower() in _THINKING_TYPE_ENABLED
enabled: Final = mapping.get("enabled")
return enabled if isinstance(enabled, bool) else False
def _thinking_type_candidate(thinking: object) -> str | None:
match thinking:
case bool():
return "enabled" if thinking else "disabled"
case str():
return thinking
case _:
mapping: Final = str_keyed_mapping_or_none(thinking)
raw: Final = mapping.get("type") if mapping is not None else None
return raw if isinstance(raw, str) else None
def _thinking_type_value(thinking: object, allowed: Sequence[str]) -> str | None:
candidate: Final = _thinking_type_candidate(thinking)
if candidate is None:
return None
if not allowed or candidate in allowed:
return candidate
if candidate == "auto" and "enabled" in allowed:
return "enabled"
return None
def _thinking_payload(thinking: object, typ: str) -> Mapping[str, object]:
mapping: Final = str_keyed_mapping_or_none(thinking)
if mapping is None:
return MappingProxyType({"type": typ})
return MappingProxyType({**mapping, "type": typ})
def _clamp_effort(value: object, allowed: Sequence[str]) -> str | None:
if not isinstance(value, str):
return None
if not allowed:
return None
if value in allowed:
return value
for fallback in _EFFORT_FALLBACKS.get(value, ()):
if fallback in allowed:
return fallback
return None
def _merged_mapping_value(left: Mapping[str, object], right: Mapping[str, object], key: str) -> object:
if key not in right:
return left[key]
if key not in left:
return right[key]
left_map: Final = str_keyed_mapping_or_none(left[key])
right_map: Final = str_keyed_mapping_or_none(right[key])
if left_map is not None and right_map is not None:
return _deep_merge_pair(left_map, right_map)
return right[key]
def _deep_merge_pair(left: Mapping[str, object], right: Mapping[str, object]) -> Mapping[str, object]:
keys: Final = (*left, *(key for key in right if key not in left))
return MappingProxyType({key: _merged_mapping_value(left, right, key) for key in keys})
def _thawed(value: object) -> object:
mapping: Final = str_keyed_mapping_or_none(value)
return value if mapping is None else thaw_mapping(mapping)
def thaw_mapping(mapping: Mapping[str, object]) -> dict[str, object]: # mutable-ok: JSON request body
return {key: _thawed(item) for key, item in mapping.items()} # mutable-ok: JSON request body
def _map_thinking_to_extra_body(
*,
thinking_param: str | None,
thinking: object,
thinking_values: Sequence[str],
) -> Mapping[str, object]:
match thinking_param:
case "thinking.type":
typ: Final = _thinking_type_value(thinking, thinking_values)
if typ is None:
return _EMPTY
return MappingProxyType({"thinking": _thinking_payload(thinking, typ)})
case "thinking":
thinking_mapping: Final = str_keyed_mapping_or_none(thinking)
if thinking_mapping is not None:
return MappingProxyType({"thinking": MappingProxyType(thinking_mapping)})
typ_only: Final = _thinking_type_value(thinking, thinking_values or ("enabled", "disabled"))
if typ_only is None:
return _EMPTY
return MappingProxyType({"thinking": MappingProxyType({"type": typ_only})})
case "enable_thinking":
return MappingProxyType({"enable_thinking": _thinking_enabled(thinking)})
case "chat_template_kwargs":
return MappingProxyType(
{"chat_template_kwargs": MappingProxyType({"enable_thinking": _thinking_enabled(thinking)})}
)
case _:
return _EMPTY
def _map_effort_to_extra_body(
*,
effort: object,
effort_values: Sequence[str],
send_via: object,
) -> Mapping[str, object]:
clamped: Final = _clamp_effort(effort, effort_values)
if clamped is not None:
return MappingProxyType({"reasoning_effort": clamped})
if not effort_values and send_via == _SEND_VIA_EXTRA_BODY and isinstance(effort, str):
return MappingProxyType({"reasoning_effort": effort})
return _EMPTY
def translate_thinking_params(
*,
model_info: Mapping[str, object] | None,
state: ThinkingParamsState,
) -> ThinkingParamsState:
if model_info is None:
return state
send_via: Final = model_info.get("thinking_send_via")
if send_via not in _SEND_VIA_VALUES:
return state
supports_reasoning: Final = model_info.get("supports_reasoning") is True
thinking_param_raw: Final = model_info.get("thinking_param")
thinking_param: Final = thinking_param_raw if isinstance(thinking_param_raw, str) else None
thinking_values: Final = _as_str_tuple(model_info.get("thinking_values"))
effort_values: Final = _as_str_tuple(model_info.get("reasoning_effort_values"))
if not supports_reasoning and send_via != _SEND_VIA_PROVIDER_MAPPED:
return state
thinking: Final = state.thinking
effort: Final = state.reasoning_effort
if thinking is None and effort is None:
return state
keep_thinking: Final = send_via == _SEND_VIA_PROVIDER_MAPPED
thinking_patch: Final = (
_map_thinking_to_extra_body(
thinking_param=thinking_param,
thinking=thinking,
thinking_values=thinking_values,
)
if thinking is not None and send_via == _SEND_VIA_EXTRA_BODY
else _EMPTY
)
effort_patch: Final = (
_map_effort_to_extra_body(
effort=effort,
effort_values=effort_values,
send_via=send_via,
)
if effort is not None
else _EMPTY
)
patch: Final = MappingProxyType({**thinking_patch, **effort_patch})
if not patch:
return state
thinking_mapped: Final = any(key in patch for key in ("thinking", "enable_thinking", "chat_template_kwargs"))
next_thinking: Final = thinking if (keep_thinking or not thinking_mapped) else None
next_effort: Final = None if "reasoning_effort" in patch else effort
merged_extra: Final = _deep_merge_pair(patch, state.extra_body)
return ThinkingParamsState(
thinking=next_thinking,
reasoning_effort=next_effort,
extra_body=merged_extra,
)

View file

@ -5621,6 +5621,7 @@ def completion(
"prompt_cache_key": prompt_cache_key,
"allowed_openai_params": allowed_openai_params,
"base_model": base_model,
"model_info": model_info if isinstance(model_info, dict) else None,
}
optional_params = get_optional_params(**optional_param_args, **non_default_params)
processed_non_default_params: Final = pre_process_non_default_params(

View file

@ -294,6 +294,12 @@ from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast, runtime_c
from typing_extensions import assert_never
from litellm import utils as litellm_utils
from litellm.litellm_core_utils.thinking_param_translation import (
ThinkingParamsState,
str_keyed_mapping_or_none,
thaw_mapping,
translate_thinking_params,
)
# These are lazy loaded via __getattr__
from litellm.llms.base_llm.base_utils import (
@ -4321,6 +4327,39 @@ def remove_sensitive_keys_from_dict(d: dict) -> dict:
return d
def _translate_thinking_in_params(
*,
model_info: Mapping[str, object] | None,
passed_params: dict[str, object], # mutable-ok: get_optional_params hands over its legacy mutable params
non_default_params: dict[str, object], # mutable-ok: get_optional_params hands over its legacy mutable params
) -> tuple[dict[str, object], dict[str, object]]: # mutable-ok: get_optional_params keeps mutating both downstream
state: Final = ThinkingParamsState(
thinking=non_default_params.get("thinking"),
reasoning_effort=non_default_params.get("reasoning_effort"),
extra_body=str_keyed_mapping_or_none(passed_params.get("extra_body")) or MappingProxyType({}),
)
translated: Final = translate_thinking_params(model_info=model_info, state=state)
if translated is state:
return passed_params, non_default_params
thinking_values: Final = MappingProxyType(
{"thinking": translated.thinking, "reasoning_effort": translated.reasoning_effort}
)
untouched_non_default: Final = MappingProxyType(
{key: value for key, value in non_default_params.items() if key not in thinking_values}
)
surviving_thinking_values: Final = MappingProxyType(
{key: value for key, value in thinking_values.items() if value is not None}
)
return (
{ # mutable-ok: get_optional_params keeps mutating passed_params downstream
**passed_params,
**thinking_values,
"extra_body": thaw_mapping(translated.extra_body),
},
{**untouched_non_default, **surviving_thinking_values}, # mutable-ok: _check_valid_arg pops unsupported keys
)
def pre_process_optional_params(passed_params: dict, non_default_params: dict, custom_llm_provider: str) -> dict:
"""For .completion(), preprocess optional params"""
optional_params: dict = {}
@ -4443,15 +4482,17 @@ def get_optional_params(
store: bool | None = None,
prompt_cache_key: str | None = None,
base_model: str | None = None,
model_info: Mapping[str, object] | None = None,
**kwargs,
):
drop_params = normalize_drop_params(drop_params) # rebind-ok: config and DB deployments pass "true" as a string
passed_params: Final = locals().copy()
special_params: Final = passed_params.pop("kwargs")
untranslated_passed_params: Final = locals().copy()
special_params: Final = untranslated_passed_params.pop("kwargs")
# Remove base_model from passed_params so it doesn't interfere with
# non_default_params / _check_valid_arg — it's a routing hint, not an
# OpenAI param.
passed_params.pop("base_model", None)
untranslated_passed_params.pop("base_model", None)
untranslated_passed_params.pop("model_info", None)
provider_config: BaseConfig | None = None
if custom_llm_provider is not None and custom_llm_provider in [provider.value for provider in LlmProviders]:
provider_config = ProviderConfigManager.get_provider_chat_config(
@ -4459,14 +4500,19 @@ def get_optional_params(
provider=LlmProviders(custom_llm_provider),
base_model=base_model,
)
non_default_params: Final = pre_process_non_default_params(
passed_params=passed_params,
untranslated_non_default_params: Final = pre_process_non_default_params(
passed_params=untranslated_passed_params,
special_params=special_params,
custom_llm_provider=custom_llm_provider,
additional_drop_params=additional_drop_params,
model=model,
provider_config=provider_config,
)
passed_params, non_default_params = _translate_thinking_in_params(
model_info=model_info,
passed_params=untranslated_passed_params,
non_default_params=untranslated_non_default_params,
)
optional_params = pre_process_optional_params(
passed_params=passed_params,
non_default_params=non_default_params,

View file

@ -0,0 +1,361 @@
import copy
import json
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final
import httpx
import openai
import pytest
import litellm
from litellm.litellm_core_utils.thinking_param_translation import (
ThinkingParamsState,
translate_thinking_params,
)
from litellm.utils import get_optional_params
_EXTRA_BODY_MODEL_INFO: Final = MappingProxyType(
{
"supports_reasoning": True,
"thinking_param": "thinking.type",
"thinking_values": ["enabled", "disabled"],
"reasoning_effort_values": ["low", "high", "max"],
"thinking_send_via": "extra_body",
}
)
_CHAT_COMPLETION_RESPONSE: Final = MappingProxyType(
{
"id": "chatcmpl-thinking",
"object": "chat.completion",
"created": 0,
"model": "deepseek-v4-flash",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
)
def _model_info(**overrides: object) -> Mapping[str, object]:
return MappingProxyType({**_EXTRA_BODY_MODEL_INFO, **overrides})
def _state(
*,
thinking: object = None,
reasoning_effort: object = None,
extra_body: Mapping[str, object] = MappingProxyType({}),
) -> ThinkingParamsState:
return ThinkingParamsState(thinking=thinking, reasoning_effort=reasoning_effort, extra_body=extra_body)
def _as_plain(value: object) -> object:
if isinstance(value, Mapping):
return {key: _as_plain(item) for key, item in value.items()}
return value
def _recording_client(bodies: list[object]) -> openai.OpenAI:
def respond(request: httpx.Request) -> httpx.Response:
bodies.append(json.loads(request.content))
return httpx.Response(200, json=dict(_CHAT_COMPLETION_RESPONSE))
return openai.OpenAI(api_key="test-key", http_client=httpx.Client(transport=httpx.MockTransport(respond)))
@pytest.mark.parametrize(
("model_info", "thinking", "reasoning_effort", "expected_extra_body"),
[
pytest.param(
_model_info(),
{"type": "enabled", "budget_tokens": 1024, "clear_thinking": False},
"high",
{
"thinking": {"type": "enabled", "budget_tokens": 1024, "clear_thinking": False},
"reasoning_effort": "high",
},
id="thinking_type_keeps_caller_thinking_keys",
),
pytest.param(
_model_info(),
{"type": "auto"},
None,
{"thinking": {"type": "enabled"}},
id="thinking_type_auto_falls_back_to_enabled",
),
pytest.param(
_model_info(),
False,
None,
{"thinking": {"type": "disabled"}},
id="thinking_type_from_bool",
),
pytest.param(
_model_info(),
"enabled",
None,
{"thinking": {"type": "enabled"}},
id="thinking_type_from_string",
),
pytest.param(
_model_info(thinking_param="thinking", thinking_values=[]),
{"type": "enabled", "budget_tokens": 2048},
None,
{"thinking": {"type": "enabled", "budget_tokens": 2048}},
id="thinking_dict_passthrough",
),
pytest.param(
_model_info(thinking_param="thinking", thinking_values=[]),
True,
None,
{"thinking": {"type": "enabled"}},
id="thinking_from_bool",
),
pytest.param(
_model_info(thinking_param="enable_thinking"),
{"type": "enabled"},
None,
{"enable_thinking": True},
id="enable_thinking_from_type",
),
pytest.param(
_model_info(thinking_param="enable_thinking"),
{"enabled": True},
None,
{"enable_thinking": True},
id="enable_thinking_from_enabled_flag",
),
pytest.param(
_model_info(thinking_param="enable_thinking"),
"true",
None,
{"enable_thinking": True},
id="enable_thinking_from_string",
),
pytest.param(
_model_info(thinking_param="enable_thinking"),
False,
None,
{"enable_thinking": False},
id="enable_thinking_from_bool",
),
pytest.param(
_model_info(thinking_param="enable_thinking"),
1,
None,
{"enable_thinking": False},
id="enable_thinking_unrecognized_value_is_disabled",
),
pytest.param(
_model_info(thinking_param="chat_template_kwargs", reasoning_effort_values=["low", "medium", "high"]),
{"type": "disabled"},
"medium",
{"chat_template_kwargs": {"enable_thinking": False}, "reasoning_effort": "medium"},
id="chat_template_kwargs",
),
pytest.param(
_model_info(reasoning_effort_values=["low", "high", "max"]),
None,
"xhigh",
{"reasoning_effort": "max"},
id="effort_clamped_to_alias",
),
pytest.param(
_model_info(reasoning_effort_values=["low"]),
None,
"minimal",
{"reasoning_effort": "low"},
id="effort_clamped_down",
),
pytest.param(
_model_info(reasoning_effort_values=[]),
None,
"high",
{"reasoning_effort": "high"},
id="effort_passthrough_without_allowed_values",
),
pytest.param(
_model_info(reasoning_effort_values=None),
None,
"high",
{"reasoning_effort": "high"},
id="effort_passthrough_when_allowed_values_missing",
),
],
)
def test_translate_moves_params_into_extra_body(
model_info: Mapping[str, object],
thinking: object,
reasoning_effort: object,
expected_extra_body: dict[str, object],
):
result = translate_thinking_params(
model_info=model_info, state=_state(thinking=thinking, reasoning_effort=reasoning_effort)
)
assert (result.thinking, result.reasoning_effort) == (None, None)
assert _as_plain(result.extra_body) == expected_extra_body
@pytest.mark.parametrize(
("model_info", "thinking", "reasoning_effort"),
[
pytest.param(None, {"type": "enabled"}, "high", id="no_model_info"),
pytest.param(_model_info(thinking_send_via="n/a"), {"type": "enabled"}, "high", id="send_via_not_applicable"),
pytest.param(_model_info(supports_reasoning=False), {"type": "enabled"}, "high", id="reasoning_unsupported"),
pytest.param(_model_info(), None, None, id="nothing_requested"),
pytest.param(_model_info(thinking_param="unknown"), {"type": "enabled"}, None, id="unknown_thinking_param"),
pytest.param(_model_info(thinking_param=None), {"type": "enabled"}, None, id="thinking_param_missing"),
pytest.param(_model_info(), {"type": "adaptive"}, None, id="thinking_type_not_allowed"),
pytest.param(_model_info(), {"budget_tokens": 1024}, None, id="thinking_type_unreadable"),
pytest.param(_model_info(), 1, None, id="thinking_type_unsupported_value"),
pytest.param(_model_info(), {1: "enabled"}, None, id="thinking_mapping_with_non_string_keys"),
pytest.param(
_model_info(thinking_param="thinking", thinking_values=[]), "adaptive", None, id="thinking_value_unmapped"
),
pytest.param(_model_info(reasoning_effort_values=["low"]), None, "ultra", id="effort_without_fallback"),
pytest.param(_model_info(), None, 5, id="effort_not_a_string"),
],
)
def test_translate_returns_state_unchanged_when_nothing_applies(
model_info: Mapping[str, object] | None,
thinking: object,
reasoning_effort: object,
):
state = _state(thinking=thinking, reasoning_effort=reasoning_effort)
assert translate_thinking_params(model_info=model_info, state=state) is state
def test_translate_provider_mapped_keeps_thinking_and_moves_effort():
thinking = {"type": "enabled"}
result = translate_thinking_params(
model_info=_model_info(thinking_send_via="provider_mapped", supports_reasoning=False),
state=_state(thinking=thinking, reasoning_effort="high"),
)
assert result.thinking is thinking
assert result.reasoning_effort is None
assert _as_plain(result.extra_body) == {"reasoning_effort": "high"}
def test_translate_keeps_caller_extra_body_values_over_translated_ones():
result = translate_thinking_params(
model_info=_model_info(thinking_param="chat_template_kwargs", thinking_values=[]),
state=_state(
thinking={"type": "enabled"},
reasoning_effort="high",
extra_body={"chat_template_kwargs": {"enable_thinking": False, "reasoning_budget": 512}, "top_k": 20},
),
)
assert (result.thinking, result.reasoning_effort) == (None, None)
assert _as_plain(result.extra_body) == {
"chat_template_kwargs": {"enable_thinking": False, "reasoning_budget": 512},
"reasoning_effort": "high",
"top_k": 20,
}
def test_get_optional_params_moves_thinking_into_extra_body():
optional_params = get_optional_params(
model="deepseek-v4-flash",
custom_llm_provider="openai",
drop_params=True,
thinking={"type": "enabled"},
reasoning_effort="high",
model_info=_model_info(),
)
assert "thinking" not in optional_params
assert "reasoning_effort" not in optional_params
assert optional_params["extra_body"] == {"thinking": {"type": "enabled"}, "reasoning_effort": "high"}
def test_get_optional_params_without_model_info_drops_thinking():
optional_params = get_optional_params(
model="gpt-4o",
custom_llm_provider="openai",
drop_params=True,
thinking={"type": "enabled"},
reasoning_effort="high",
)
assert "thinking" not in optional_params
assert "thinking" not in (optional_params.get("extra_body") or {})
def test_get_optional_params_does_not_reintroduce_dropped_thinking():
optional_params = get_optional_params(
model="deepseek-v4-flash",
custom_llm_provider="openai",
drop_params=True,
thinking={"type": "enabled"},
additional_drop_params=["thinking"],
model_info=_model_info(thinking_param="chat_template_kwargs", thinking_values=[]),
)
assert "thinking" not in optional_params
assert optional_params.get("extra_body") in (None, {})
def test_get_optional_params_leaves_caller_extra_body_untouched_and_serializable():
caller_extra_body = {"chat_template_kwargs": {"reasoning_budget": 512}}
optional_params = get_optional_params(
model="deepseek-v4-flash",
custom_llm_provider="openai",
thinking={"type": "enabled"},
extra_body=caller_extra_body,
model_info=_model_info(thinking_param="chat_template_kwargs", thinking_values=[]),
)
expected_extra_body = {"chat_template_kwargs": {"reasoning_budget": 512, "enable_thinking": True}}
assert optional_params["extra_body"] == expected_extra_body
assert json.loads(json.dumps(optional_params["extra_body"])) == expected_extra_body
assert copy.deepcopy(optional_params)["extra_body"] == expected_extra_body
assert caller_extra_body == {"chat_template_kwargs": {"reasoning_budget": 512}}
def test_completion_sends_translated_thinking_on_the_wire():
bodies: list[object] = []
litellm.completion(
model="openai/deepseek-v4-flash",
messages=[{"role": "user", "content": "hi"}],
thinking={"type": "enabled", "budget_tokens": 1024},
reasoning_effort="high",
model_info=dict(_model_info()),
client=_recording_client(bodies),
num_retries=0,
)
assert bodies == [
{
"model": "deepseek-v4-flash",
"messages": [{"role": "user", "content": "hi"}],
"thinking": {"type": "enabled", "budget_tokens": 1024},
"reasoning_effort": "high",
}
]
def test_batch_completion_translates_every_request_like_completion():
bodies: list[object] = []
litellm.batch_completion(
model="openai/deepseek-v4-flash",
messages=[[{"role": "user", "content": "one"}], [{"role": "user", "content": "two"}]],
thinking={"type": "enabled"},
model_info=dict(_model_info(thinking_param="enable_thinking")),
client=_recording_client(bodies),
num_retries=0,
max_workers=1,
)
assert sorted(bodies, key=lambda body: json.dumps(body, sort_keys=True)) == [
{"model": "deepseek-v4-flash", "messages": [{"role": "user", "content": "one"}], "enable_thinking": True},
{"model": "deepseek-v4-flash", "messages": [{"role": "user", "content": "two"}], "enable_thinking": True},
]