feat: auto-translate thinking and reasoning_effort from model_info

When a deployment advertises thinking_send_via, rewrite top-level thinking
and reasoning_effort into the vendor shape (usually extra_body) before
drop_params can strip them on openai-compatible routes

Co-authored-by: HX <HXhlx@users.noreply.github.com>
This commit is contained in:
hx 2026-09-08 11:16:18 +00:00
parent 1af7a403c6
commit 5aefd558d9
4 changed files with 394 additions and 0 deletions

View file

@ -0,0 +1,199 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
_SEND_VIA_EXTRA_BODY: Final = "extra_body"
_SEND_VIA_PROVIDER_MAPPED: Final = "provider_mapped"
_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 _as_str_tuple(value: object) -> tuple[str, ...]:
if not isinstance(value, (list, tuple)):
return ()
return tuple(item for item in 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 {"enabled", "true", "1", "auto"}
if isinstance(thinking, Mapping):
typ: Final = thinking.get("type")
if isinstance(typ, str):
return typ.lower() in {"enabled", "auto", "true"}
enabled: Final = thinking.get("enabled")
if isinstance(enabled, bool):
return enabled
return False
def _thinking_type_value(thinking: object, allowed: Sequence[str]) -> str | None:
if isinstance(thinking, str):
candidate: Final = thinking
elif isinstance(thinking, Mapping):
raw: Final = thinking.get("type")
candidate = raw if isinstance(raw, str) else None
elif isinstance(thinking, bool):
candidate = "enabled" if thinking else "disabled"
else:
candidate = None
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]:
if not isinstance(thinking, Mapping):
return MappingProxyType({"type": typ})
budget: Final = thinking.get("budget_tokens")
if isinstance(budget, int):
return MappingProxyType({"type": typ, "budget_tokens": budget})
return MappingProxyType({"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 _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 MappingProxyType({})
return MappingProxyType({"thinking": dict(_thinking_payload(thinking, typ))})
case "thinking":
if isinstance(thinking, Mapping):
return MappingProxyType({"thinking": dict(thinking)})
typ_only: Final = _thinking_type_value(thinking, thinking_values or ("enabled", "disabled"))
if typ_only is None:
return MappingProxyType({})
return MappingProxyType({"thinking": {"type": typ_only}})
case "enable_thinking":
return MappingProxyType({"enable_thinking": _thinking_enabled(thinking)})
case "chat_template_kwargs":
return MappingProxyType({"chat_template_kwargs": {"enable_thinking": _thinking_enabled(thinking)}})
case None:
return MappingProxyType({})
case _:
return MappingProxyType({})
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_EXTRA_BODY, _SEND_VIA_PROVIDER_MAPPED}:
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
existing_extra: Final = dict(state.extra_body)
patch: dict[str, object] = {}
keep_thinking: Final = send_via == _SEND_VIA_PROVIDER_MAPPED
if thinking is not None and send_via == _SEND_VIA_EXTRA_BODY:
patch.update(
_map_thinking_to_extra_body(
thinking_param=thinking_param,
thinking=thinking,
thinking_values=thinking_values,
)
)
if effort is not None:
clamped: Final = _clamp_effort(effort, effort_values)
if clamped is not None:
patch["reasoning_effort"] = clamped
elif not effort_values and send_via == _SEND_VIA_EXTRA_BODY and isinstance(effort, str):
patch["reasoning_effort"] = effort
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 = MappingProxyType({**existing_extra, **patch})
return ThinkingParamsState(
thinking=next_thinking,
reasoning_effort=next_effort,
extra_body=merged_extra,
)
def apply_thinking_param_translation(
*,
model_info: Mapping[str, object] | None,
thinking: object | None,
reasoning_effort: object | None,
existing_extra_body: Mapping[str, object] | None,
) -> ThinkingParamsState:
base_extra: Final = (
MappingProxyType(dict(existing_extra_body))
if isinstance(existing_extra_body, Mapping)
else MappingProxyType({})
)
return translate_thinking_params(
model_info=model_info,
state=ThinkingParamsState(
thinking=thinking,
reasoning_effort=reasoning_effort,
extra_body=base_extra,
),
)

View file

@ -5444,6 +5444,7 @@ def completion(
"prompt_cache_key": prompt_cache_key,
"allowed_openai_params": kwargs.get("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

@ -281,6 +281,9 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Union, cast, get_args
from litellm import utils as litellm_utils
from litellm.litellm_core_utils.thinking_param_translation import (
apply_thinking_param_translation,
)
# These are lazy loaded via __getattr__
from litellm.llms.base_llm.base_utils import (
@ -4070,6 +4073,49 @@ def remove_sensitive_keys_from_dict(d: dict) -> dict:
return d
def _apply_model_info_thinking_translation(
*,
model_info: Mapping[str, object] | None,
passed_params: dict,
non_default_params: dict,
) -> None:
prior_thinking: Final = non_default_params.get("thinking", passed_params.get("thinking"))
prior_effort: Final = non_default_params.get("reasoning_effort", passed_params.get("reasoning_effort"))
existing_extra_raw: Final = passed_params.get("extra_body")
existing_extra: Final = existing_extra_raw if isinstance(existing_extra_raw, Mapping) else None
translated: Final = apply_thinking_param_translation(
model_info=model_info,
thinking=prior_thinking,
reasoning_effort=prior_effort,
existing_extra_body=existing_extra,
)
prior_extra: Final = dict(existing_extra) if existing_extra is not None else {}
if (
translated.thinking is prior_thinking
and translated.reasoning_effort is prior_effort
and dict(translated.extra_body) == prior_extra
):
return
# mutable-ok: get_optional_params already mutates passed_params / non_default_params in place
if translated.thinking is None:
non_default_params.pop("thinking", None)
passed_params["thinking"] = None
else:
non_default_params["thinking"] = translated.thinking
passed_params["thinking"] = translated.thinking
if translated.reasoning_effort is None:
non_default_params.pop("reasoning_effort", None)
passed_params["reasoning_effort"] = None
else:
non_default_params["reasoning_effort"] = translated.reasoning_effort
passed_params["reasoning_effort"] = translated.reasoning_effort
if translated.extra_body:
passed_params["extra_body"] = dict(translated.extra_body)
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 = {}
@ -4192,6 +4238,7 @@ 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,
):
passed_params: Final = locals().copy()
@ -4200,6 +4247,7 @@ def get_optional_params(
# non_default_params / _check_valid_arg — it's a routing hint, not an
# OpenAI param.
passed_params.pop("base_model", None)
model_info_for_translation: Final = 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(
@ -4215,6 +4263,11 @@ def get_optional_params(
model=model,
provider_config=provider_config,
)
_apply_model_info_thinking_translation(
model_info=model_info_for_translation if isinstance(model_info_for_translation, Mapping) else None,
passed_params=passed_params,
non_default_params=non_default_params,
)
optional_params = pre_process_optional_params(
passed_params=passed_params,
non_default_params=non_default_params,

View file

@ -0,0 +1,141 @@
from types import MappingProxyType
from litellm.litellm_core_utils.thinking_param_translation import (
ThinkingParamsState,
apply_thinking_param_translation,
translate_thinking_params,
)
from litellm.utils import get_optional_params
def _extra_body_model_info(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"supports_reasoning": True,
"thinking_param": "thinking.type",
"thinking_values": ["enabled", "disabled"],
"reasoning_effort_values": ["low", "high", "max"],
"thinking_send_via": "extra_body",
}
return {**base, **overrides}
def test_translate_thinking_type_and_effort_to_extra_body():
result = apply_thinking_param_translation(
model_info=_extra_body_model_info(),
thinking={"type": "enabled", "budget_tokens": 1024},
reasoning_effort="high",
existing_extra_body=None,
)
assert result.thinking is None
assert result.reasoning_effort is None
assert dict(result.extra_body) == {
"thinking": {"type": "enabled", "budget_tokens": 1024},
"reasoning_effort": "high",
}
def test_translate_enable_thinking_bool():
result = apply_thinking_param_translation(
model_info=_extra_body_model_info(
thinking_param="enable_thinking",
thinking_values=["true", "false"],
),
thinking={"type": "enabled"},
reasoning_effort=None,
existing_extra_body=None,
)
assert result.thinking is None
assert dict(result.extra_body) == {"enable_thinking": True}
def test_translate_chat_template_kwargs():
result = apply_thinking_param_translation(
model_info=_extra_body_model_info(
thinking_param="chat_template_kwargs",
thinking_values=[],
reasoning_effort_values=["low", "medium", "high"],
),
thinking={"type": "disabled"},
reasoning_effort="medium",
existing_extra_body=None,
)
assert dict(result.extra_body) == {
"chat_template_kwargs": {"enable_thinking": False},
"reasoning_effort": "medium",
}
def test_translate_clamps_effort_aliases():
result = apply_thinking_param_translation(
model_info=_extra_body_model_info(reasoning_effort_values=["low", "high", "max"]),
thinking=None,
reasoning_effort="xhigh",
existing_extra_body=None,
)
assert result.reasoning_effort is None
assert result.extra_body["reasoning_effort"] == "max"
def test_translate_provider_mapped_keeps_thinking_moves_effort():
result = translate_thinking_params(
model_info=_extra_body_model_info(thinking_send_via="provider_mapped"),
state=ThinkingParamsState(
thinking={"type": "enabled"},
reasoning_effort="high",
extra_body=MappingProxyType({}),
),
)
assert result.thinking == {"type": "enabled"}
assert result.reasoning_effort is None
assert dict(result.extra_body) == {"reasoning_effort": "high"}
def test_translate_noop_without_model_info():
state = ThinkingParamsState(
thinking={"type": "enabled"},
reasoning_effort="high",
extra_body=MappingProxyType({}),
)
assert translate_thinking_params(model_info=None, state=state) is state
def test_translate_noop_when_send_via_na():
result = apply_thinking_param_translation(
model_info=_extra_body_model_info(thinking_send_via="n/a"),
thinking={"type": "enabled"},
reasoning_effort="high",
existing_extra_body=None,
)
assert result.thinking == {"type": "enabled"}
assert result.reasoning_effort == "high"
assert dict(result.extra_body) == {}
def test_get_optional_params_openai_drop_translates_via_model_info():
optional_params = get_optional_params(
model="deepseek-v4-flash",
custom_llm_provider="openai",
drop_params=True,
thinking={"type": "enabled"},
reasoning_effort="high",
model_info=_extra_body_model_info(),
)
assert optional_params.get("thinking") is None
assert optional_params.get("reasoning_effort") is None
assert optional_params["extra_body"]["thinking"] == {"type": "enabled"}
assert optional_params["extra_body"]["reasoning_effort"] == "high"
def test_get_optional_params_openai_drop_without_model_info_drops_params():
optional_params = get_optional_params(
model="gpt-4o",
custom_llm_provider="openai",
drop_params=True,
thinking={"type": "enabled"},
reasoning_effort="high",
)
extra_body = optional_params.get("extra_body") or {}
assert "thinking" not in extra_body
assert "reasoning_effort" not in extra_body
assert optional_params.get("thinking") is None
assert optional_params.get("reasoning_effort") is None