fix(logging): warn on the remaining Ollama, DeepSeek and Tencent reasoning drops

The ollama/ completion route now shares the chat route's think mapping, so
a level it turns off logs the same warning. DeepSeek warns when it drops
thinking.budget_tokens, and Tencent warns when an adaptive-only model gets a
non-integer budget. Magistral no longer warns on low, medium, high or
enabled thinking, the values its reasoning prompt stands in for. The Gemini 3
budget warning no longer claims the model uses its default level.
This commit is contained in:
mateo-berri 2026-10-29 15:00:00 +00:00
parent 53414c83b2
commit 949c6365db
11 changed files with 184 additions and 23 deletions

View file

@ -6,10 +6,12 @@ from collections.abc import Coroutine, Mapping, Sequence
from typing import Final, Literal, cast, overload
import litellm
from litellm.litellm_core_utils.core_helpers import as_str_mapping
from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_content_list_to_str,
extract_search_results_text,
)
from litellm.litellm_core_utils.reasoning_effort_utils import OPAQUE_VALUE, warn_dropped_reasoning
from litellm.litellm_core_utils.url_utils import ensure_api_route
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
@ -18,6 +20,16 @@ from litellm.utils import supports_reasoning, supports_vision
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
SUPPORTED_REASONING_EFFORTS: Final = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
_NO_BUDGET_REASON: Final = "DeepSeek takes thinking only as enabled or disabled, with no budget."
def _warn_on_dropped_budget(thinking: object, model: str) -> None:
fields: Final = as_str_mapping(thinking)
if fields is None or fields.get("type") != "enabled" or "budget_tokens" not in fields:
return
warn_dropped_reasoning(
"thinking.budget_tokens", fields["budget_tokens"], model, _NO_BUDGET_REASON, drop_params_gated=False
)
class DeepSeekChatConfig(OpenAIGPTConfig):
@ -53,6 +65,7 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
# Then re-add only if valid for DeepSeek
thinking_value: Final = optional_params.pop("thinking", None)
reasoning_effort: Final = optional_params.pop("reasoning_effort", None)
_warn_on_dropped_budget(OPAQUE_VALUE.validate_python(thinking_value), model)
# Handle thinking parameter - accept both enabled and disabled, ignore budget_tokens
if isinstance(thinking_value, dict) and thinking_value.get("type") in ("enabled", "disabled"):

View file

@ -14,6 +14,7 @@ import httpx
from typing_extensions import TypeIs # noqa: TID251 # TypeIs reaches typing only on 3.13
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.core_helpers import as_str_mapping
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_content_list_to_str,
@ -59,6 +60,17 @@ def _accepted_reasoning_effort(model: str, requested: str, custom_llm_provider:
_NO_TOOL_CALL_TURN: Final[tuple[int, frozenset[str]]] = (-1, frozenset())
_REASONING_PROMPT_REASON: Final = "litellm sends Magistral a reasoning system prompt instead of the requested level."
_DOCUMENTED_MAGISTRAL_EFFORTS: Final = frozenset({"low", "medium", "high"})
_REASONING_ON_THINKING_TYPES: Final = frozenset({"enabled", "adaptive"})
def _warn_unless_reasoning_prompt_honors(param: str, value: object, model: str) -> None:
if param == "reasoning_effort" and isinstance(value, str) and value in _DOCUMENTED_MAGISTRAL_EFFORTS:
return
thinking: Final = as_str_mapping(value)
if param == "thinking" and thinking is not None and thinking.get("type") in _REASONING_ON_THINKING_TYPES:
return
warn_dropped_reasoning(param, value, model, _REASONING_PROMPT_REASON, drop_params_gated=False)
def _is_json_array(value: object) -> TypeIs[Sequence[object]]: # guard-ok: a decoded JSON array is a list
@ -218,9 +230,7 @@ class MistralConfig(OpenAIGPTConfig):
if param in ("reasoning_effort", "thinking") and "magistral" in model.lower():
# Flag that we need to add reasoning system prompt
optional_params["_add_reasoning_prompt"] = True
warn_dropped_reasoning(
param, OPAQUE_VALUE.validate_python(value), model, _REASONING_PROMPT_REASON, drop_params_gated=False
)
_warn_unless_reasoning_prompt_honors(param, OPAQUE_VALUE.validate_python(value), model)
if param == "parallel_tool_calls":
optional_params["parallel_tool_calls"] = value
return optional_params

View file

@ -14,7 +14,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
extract_images_from_message,
extract_reasoning_content,
)
from litellm.litellm_core_utils.reasoning_effort_utils import warn_dropped_reasoning
from litellm.litellm_core_utils.reasoning_effort_utils import OPAQUE_VALUE
from litellm.litellm_core_utils.url_utils import ensure_api_route
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
@ -30,7 +30,7 @@ from litellm.types.llms.openai import (
)
from litellm.types.utils import ModelResponse, ModelResponseStream
from ..common_utils import OllamaError
from ..common_utils import OllamaError, ollama_think
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@ -40,9 +40,6 @@ if TYPE_CHECKING:
else:
LiteLLMLoggingObj = Any
_THINK_ON_EFFORTS: Final = frozenset({"low", "medium", "high"})
_THINK_OFF_REASON: Final = "Ollama takes think only as on or off, and litellm maps this level to off."
class OllamaChatConfig(BaseConfig):
"""
@ -176,14 +173,7 @@ class OllamaChatConfig(BaseConfig):
if value.get("json_schema") and value["json_schema"].get("schema"):
optional_params["format"] = value["json_schema"]["schema"]
if param == "reasoning_effort" and value is not None:
if model.startswith("gpt-oss"):
optional_params["think"] = value
else:
optional_params["think"] = value in _THINK_ON_EFFORTS
if isinstance(value, str) and value not in _THINK_ON_EFFORTS and value != "none":
warn_dropped_reasoning(
"reasoning_effort", value, model, _THINK_OFF_REASON, drop_params_gated=False
)
optional_params["think"] = ollama_think(model, OPAQUE_VALUE.validate_python(value))
### FUNCTION CALLING LOGIC ###
# Ollama 0.4+ supports native tool calling - pass tools directly
# and let Ollama handle model capability detection

View file

@ -8,11 +8,14 @@ from pydantic import BaseModel, TypeAdapter
from litellm import verbose_logger
from litellm._uuid import uuid
from litellm.litellm_core_utils.reasoning_effort_utils import warn_dropped_reasoning
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.types.utils import ModelResponse, ModelResponseStream
_PROVIDER_FIELDS: Final = TypeAdapter(dict[str, object])
_THINK_ON_EFFORTS: Final = frozenset({"low", "medium", "high"})
_THINK_OFF_REASON: Final = "Ollama takes think only as on or off, and litellm maps this level to off."
_DECODED_JSON: Final = TypeAdapter(object)
@ -296,3 +299,12 @@ class OllamaModelInfo(BaseLLMModelInfo):
Return the base model name for Ollama (no-op).
"""
return model
def ollama_think(model: str, reasoning_effort: object) -> object:
if model.startswith("gpt-oss"):
return reasoning_effort
think: Final = reasoning_effort in _THINK_ON_EFFORTS
if isinstance(reasoning_effort, str) and not think and reasoning_effort != "none":
warn_dropped_reasoning("reasoning_effort", reasoning_effort, model, _THINK_OFF_REASON, drop_params_gated=False)
return think

View file

@ -23,6 +23,7 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import (
async_inline_remote_media,
inline_remote_image_urls,
)
from litellm.litellm_core_utils.reasoning_effort_utils import OPAQUE_VALUE
from litellm.litellm_core_utils.url_utils import ensure_api_route
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
@ -43,7 +44,7 @@ from litellm.types.utils import (
generate_id,
)
from ..common_utils import OllamaError, OllamaModelInfo, convert_image
from ..common_utils import OllamaError, OllamaModelInfo, convert_image, ollama_think
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@ -249,10 +250,7 @@ class OllamaConfig(BaseConfig):
elif param == "stop":
optional_params["stop"] = value
elif param == "reasoning_effort" and value is not None:
if model.startswith("gpt-oss"):
optional_params["think"] = value
else:
optional_params["think"] = value in {"low", "medium", "high"}
optional_params["think"] = ollama_think(model, OPAQUE_VALUE.validate_python(value))
elif param == "response_format" and isinstance(value, dict):
if value["type"] == "json_object":
optional_params["format"] = "json"

View file

@ -9,12 +9,15 @@ from typing import Final, TypedDict
from typing_extensions import ReadOnly
import litellm
from litellm.litellm_core_utils.reasoning_effort_utils import warn_dropped_reasoning
from litellm.litellm_core_utils.url_utils import ensure_api_route, join_api_base
from litellm.secret_managers.main import get_secret_str
from litellm.utils import supports_reasoning
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
_ADAPTIVE_BUDGET_REASON: Final = "it is not an integer, so litellm sends adaptive thinking with no budget."
class ThinkingPayload(TypedDict, total=False):
"""Tencent TokenHub `thinking` object.
@ -102,6 +105,10 @@ class TencentChatConfig(OpenAIGPTConfig):
if isinstance(budget, int):
coerced_with_budget: Final[ThinkingPayload] = {"type": "adaptive", "budget_tokens": budget}
return coerced_with_budget
if budget is not None:
warn_dropped_reasoning(
"thinking.budget_tokens", budget, model, _ADAPTIVE_BUDGET_REASON, drop_params_gated=False
)
coerced: Final[ThinkingPayload] = {"type": "adaptive"}
return coerced

View file

@ -206,7 +206,7 @@ _NON_INTEGER_BUDGET_REASON: Final = (
"Gemini takes budget_tokens only as an integer, so it uses its default thinking budget."
)
_LEVEL_ONLY_BUDGET_REASON: Final = (
"Gemini 3 and later set reasoning by thinking level, not budget, so it uses its default level. "
"Gemini 3 and later set reasoning by thinking level, not budget, so litellm does not send the budget. "
"Send reasoning_effort to pick a level."
)
_sampling_deprecation_lock: Final = threading.Lock()

View file

@ -1,4 +1,5 @@
import json
import logging
from collections.abc import Iterator
from typing import Final
@ -6,6 +7,7 @@ import httpx
import litellm
import pytest
import respx
from litellm._logging import verbose_logger
from litellm.caching.llm_caching_handler import LLMClientCache
from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig
@ -951,3 +953,42 @@ def test_get_complete_url_ignores_a_trailing_slash_on_api_base(api_base: str):
)
== "https://gateway.example.com/deepseek/chat/completions"
)
def _budget_warnings(caplog: pytest.LogCaptureFixture) -> tuple[str, ...]:
return tuple(
r.getMessage()
for r in caplog.records
if r.levelno == logging.WARNING and "Dropping thinking.budget_tokens=" in r.getMessage()
)
def test_enabled_thinking_budget_dropped_for_deepseek_warns_once(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
result: Final = DeepSeekChatConfig().map_openai_params(
non_default_params={"thinking": {"type": "enabled", "budget_tokens": 4000}},
optional_params={},
model="deepseek-reasoner",
drop_params=False,
)
warnings: Final = _budget_warnings(caplog)
assert result["thinking"] == {"type": "enabled"}
assert len(warnings) == 1
assert "budget_tokens=4000" in warnings[0]
assert "deepseek-reasoner" in warnings[0]
@pytest.mark.parametrize(
"thinking", ({"type": "enabled"}, {"type": "disabled", "budget_tokens": 4000}), ids=("no-budget", "disabled")
)
def test_deepseek_thinking_without_a_requested_budget_does_not_warn(
thinking: dict[str, object], caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
result: Final = DeepSeekChatConfig().map_openai_params(
non_default_params={"thinking": thinking}, optional_params={}, model="deepseek-reasoner", drop_params=False
)
assert result["thinking"] == {"type": thinking["type"]}
assert _budget_warnings(caplog) == ()

View file

@ -1676,7 +1676,8 @@ def _warnings_naming(caplog: pytest.LogCaptureFixture, param: str) -> tuple[str,
@pytest.mark.parametrize(
"param, value", (("reasoning_effort", "none"), ("thinking", {"type": "enabled", "budget_tokens": 1024}))
"param, value",
(("reasoning_effort", "none"), ("reasoning_effort", "xhigh"), ("thinking", {"type": "disabled"})),
)
def test_magistral_reasoning_request_replaced_by_prompt_warns_once(
param: str, value: object, caplog: pytest.LogCaptureFixture
@ -1695,6 +1696,30 @@ def test_magistral_reasoning_request_replaced_by_prompt_warns_once(
assert "magistral-medium-latest" in warnings[0]
@pytest.mark.parametrize(
"param, value",
(
("reasoning_effort", "low"),
("reasoning_effort", "medium"),
("reasoning_effort", "high"),
("thinking", {"type": "enabled", "budget_tokens": 1024}),
),
)
def test_magistral_reasoning_request_the_prompt_honors_does_not_warn(
param: str, value: object, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
result: Final = MistralConfig().map_openai_params(
non_default_params={param: value},
optional_params={},
model="magistral-medium-latest",
drop_params=False,
)
assert result["_add_reasoning_prompt"] is True
assert _warnings_naming(caplog, param) == ()
def test_non_magistral_reasoning_effort_is_sent_without_warning(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
result: Final = MistralConfig().map_openai_params(

View file

@ -1,6 +1,7 @@
import base64
import io
import json
import logging
import sys
from typing import Final
from unittest.mock import MagicMock, patch
@ -10,6 +11,7 @@ import pytest
import respx
import litellm
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.ollama.completion.transformation import (
@ -990,3 +992,40 @@ def test_get_complete_url_ignores_a_trailing_slash_on_api_base(api_base: str):
)
== "http://gateway.example.com/ollama/api/generate"
)
def _reasoning_effort_warnings(caplog: pytest.LogCaptureFixture) -> tuple[str, ...]:
return tuple(
r.getMessage()
for r in caplog.records
if r.levelno == logging.WARNING and "Dropping reasoning_effort=" in r.getMessage()
)
@pytest.mark.parametrize("effort", ("minimal", "xhigh"))
def test_reasoning_effort_mapped_to_think_off_warns_once(effort: str, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
result: Final = OllamaConfig().map_openai_params(
non_default_params={"reasoning_effort": effort}, optional_params={}, model="qwen3:0.6b", drop_params=False
)
warnings: Final = _reasoning_effort_warnings(caplog)
assert result["think"] is False
assert len(warnings) == 1
assert f"reasoning_effort={effort!r}" in warnings[0]
assert "qwen3:0.6b" in warnings[0]
@pytest.mark.parametrize(
"model, effort, think",
(("qwen3:0.6b", "none", False), ("qwen3:0.6b", "high", True), ("gpt-oss:20b", "xhigh", "xhigh")),
)
def test_reasoning_effort_ollama_honors_does_not_warn(
model: str, effort: str, think: object, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
result: Final = OllamaConfig().map_openai_params(
non_default_params={"reasoning_effort": effort}, optional_params={}, model=model, drop_params=False
)
assert result["think"] == think
assert _reasoning_effort_warnings(caplog) == ()

View file

@ -1,5 +1,10 @@
import logging
from typing import Final
from unittest.mock import patch
import pytest
from litellm._logging import verbose_logger
from litellm.llms.tencent.chat.transformation import TencentChatConfig
@ -208,6 +213,27 @@ class TestAdaptiveThinkingCoercion:
assert result["extra_body"]["thinking"] == {"type": "adaptive", "budget_tokens": 4096}
@pytest.mark.parametrize("budget, warned", (("high", 1), (4096, 0)), ids=("non-integer", "integer"))
def test_enabled_thinking_on_adaptive_only_model_warns_only_when_the_budget_is_dropped(
self, local_model_cost_map, budget: object, warned: int, caplog: pytest.LogCaptureFixture
):
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
result: Final = TencentChatConfig().map_openai_params(
non_default_params={"thinking": {"type": "enabled", "budget_tokens": budget}},
optional_params={},
model="tencent/minimax-m3",
drop_params=False,
)
warnings: Final = tuple(
r.getMessage()
for r in caplog.records
if r.levelno == logging.WARNING and f"Dropping thinking.budget_tokens={budget!r}" in r.getMessage()
)
assert result["extra_body"]["thinking"]["type"] == "adaptive"
assert result["extra_body"]["thinking"].get("budget_tokens") == (None if warned else budget)
assert len(warnings) == warned
def test_disabled_thinking_kept_for_adaptive_only_model(self, local_model_cost_map):
config = TencentChatConfig()
result = config.map_openai_params(