fix(reasoning): warn when Claude drops a non-string reasoning_effort

Claude chat and native /v1/messages dropped a reasoning_effort like 5 or
{"effort": 5} with no log line. Both now log one warn-only WARNING. A
Magistral thinking.type sent as a list no longer raises TypeError.
This commit is contained in:
mateo-berri 2026-10-29 13:40:00 +00:00
parent 6043cb9c4f
commit 5bbb992569
8 changed files with 134 additions and 2 deletions

View file

@ -20,6 +20,9 @@ OpenAIStyleReasoningEffort = Literal["minimal", "low", "medium", "high"]
_DROP_WARNING: Final = "Dropping %s=%s for model=%r because %s"
_DROP_PARAMS_DROP_WARNING: Final = "Dropping %s=%s for model=%r (drop_params=True) because %s"
_NON_NUMERIC_BUDGET_REASON: Final = "it is not a number. Send budget_tokens as an integer to set a reasoning level."
_NON_STRING_EFFORT_REASON: Final = (
"it is not a string. Send reasoning_effort as a level name such as 'high' to set a reasoning level."
)
_REASONING_PARAMS: Final = ("reasoning", "reasoning_effort", "thinking")
OPAQUE_VALUE: Final = TypeAdapter(object)
@ -32,6 +35,14 @@ def warn_dropped_reasoning(
)
def warn_non_string_reasoning_effort(value: object, model: str | None) -> None:
effort: Final = as_str_mapping(value)
requested_level: Final = effort.get("effort") if effort is not None else value
if requested_level is None or isinstance(value, str):
return
warn_dropped_reasoning("reasoning_effort", value, model, _NON_STRING_EFFORT_REASON, drop_params_gated=False)
def warn_dropped_reasoning_params(
dropped: Collection[object],
request: Mapping[str, object],

View file

@ -48,6 +48,7 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import
place_mid_conversation_system,
split_leading_system_run,
)
from litellm.litellm_core_utils.reasoning_effort_utils import OPAQUE_VALUE, warn_non_string_reasoning_effort
from litellm.llms.base_llm.base_utils import (
ResponseFormatSchema,
as_json_object,
@ -1951,6 +1952,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if isinstance(effort_value, dict):
effort_value = effort_value.get("effort")
if not isinstance(effort_value, str):
warn_non_string_reasoning_effort(OPAQUE_VALUE.validate_python(value), model)
continue
mapped_thinking = AnthropicConfig._map_reasoning_effort(
reasoning_effort=effort_value,

View file

@ -21,6 +21,7 @@ from litellm.constants import ANTHROPIC_BILLING_METADATA_PREFIX
from litellm.exceptions import AuthenticationError
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.reasoning_effort_utils import OPAQUE_VALUE, warn_non_string_reasoning_effort
from litellm.litellm_core_utils.url_utils import ensure_api_route
from litellm.llms.anthropic.messages.convertible_blocks import drop_non_string_tool_result_texts
from litellm.llms.anthropic.messages.safeguards import proxy_serves_safeguards
@ -695,12 +696,17 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
):
anthropic_messages_optional_request_params.pop("output_format") # pyright: ignore[reportUnknownMemberType] # the request params arrive as a bare dict
requested_reasoning_effort: Final = OPAQUE_VALUE.validate_python(
anthropic_messages_optional_request_params.get("reasoning_effort") # pyright: ignore[reportUnknownMemberType] # the request params arrive as a bare dict
)
self._translate_reasoning_effort_to_anthropic(
model=model,
optional_params=anthropic_messages_optional_request_params,
max_tokens=max_tokens,
custom_llm_provider=self._resolved_provider,
)
if self._report_reasoning_drops:
warn_non_string_reasoning_effort(requested_reasoning_effort, model)
AnthropicModelInfo.maybe_drop_disabled_thinking(
model=model,

View file

@ -68,7 +68,8 @@ def _warn_unless_reasoning_prompt_honors(param: str, value: object, model: str)
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:
thinking_type: Final = thinking.get("type") if thinking is not None else None
if param == "thinking" and isinstance(thinking_type, str) and thinking_type in _REASONING_ON_THINKING_TYPES:
return
warn_dropped_reasoning(param, value, model, _REASONING_PROMPT_REASON, drop_params_gated=False)

View file

@ -1,5 +1,6 @@
import copy
import json
import logging
from itertools import chain
from typing import Final
from unittest.mock import MagicMock, patch
@ -3189,6 +3190,47 @@ def test_reasoning_effort_accepts_dict_shape_for_non_adaptive_model(
)
def _reasoning_effort_drop_warnings(caplog: pytest.LogCaptureFixture) -> tuple[str, ...]:
return tuple(
record.getMessage() for record in caplog.records if "Dropping reasoning_effort=" in record.getMessage()
)
@pytest.mark.parametrize("reasoning_effort_value", [5, {"effort": 5, "summary": "concise"}])
def test_reasoning_effort_that_is_not_a_level_name_is_dropped_with_one_warning(
reasoning_effort_value: object, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
result: Final = AnthropicConfig().map_openai_params(
non_default_params={"reasoning_effort": reasoning_effort_value},
optional_params={},
model="claude-sonnet-4-6-20260219",
drop_params=False,
)
warnings: Final = _reasoning_effort_drop_warnings(caplog)
assert "thinking" not in result
assert "output_config" not in result
assert len(warnings) == 1
assert f"reasoning_effort={reasoning_effort_value!r}" in warnings[0]
assert "claude-sonnet-4-6-20260219" in warnings[0]
@pytest.mark.parametrize("reasoning_effort_value", [{"summary": "concise"}, {"effort": "low", "summary": "concise"}])
def test_reasoning_effort_with_a_level_name_or_no_level_logs_no_drop(
reasoning_effort_value: object, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
AnthropicConfig().map_openai_params(
non_default_params={"reasoning_effort": reasoning_effort_value},
optional_params={},
model="claude-sonnet-4-6-20260219",
drop_params=False,
)
assert _reasoning_effort_drop_warnings(caplog) == ()
@pytest.mark.parametrize(
"model,budget_tokens,expected",
[

View file

@ -1,5 +1,8 @@
"""Tests for ``reasoning_effort`` translation on the Anthropic /v1/messages route."""
import logging
from typing import Final
import pytest
from litellm.constants import (
@ -46,6 +49,49 @@ def test_reasoning_effort_maps_to_output_config_for_adaptive_model(
assert result.get("output_config") == {"effort": expected_effort}
def _reasoning_effort_drop_warnings(caplog: pytest.LogCaptureFixture) -> tuple[str, ...]:
return tuple(
record.getMessage() for record in caplog.records if "Dropping reasoning_effort=" in record.getMessage()
)
@pytest.mark.parametrize("reasoning_effort", [5, {"effort": "high"}])
def test_reasoning_effort_that_is_not_a_string_is_dropped_with_one_warning(
reasoning_effort: object, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
result: Final = AnthropicMessagesConfig().transform_anthropic_messages_request(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "Hello"}],
anthropic_messages_optional_request_params={"max_tokens": 1024, "reasoning_effort": reasoning_effort},
litellm_params={},
headers={},
)
warnings: Final = _reasoning_effort_drop_warnings(caplog)
assert "reasoning_effort" not in result
assert "output_config" not in result
assert len(warnings) == 1
assert f"reasoning_effort={reasoning_effort!r}" in warnings[0]
assert "claude-opus-4-7" in warnings[0]
@pytest.mark.parametrize("reasoning_effort", ["high", {"summary": "concise"}])
def test_reasoning_effort_that_is_a_string_or_names_no_level_logs_no_drop(
reasoning_effort: object, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
AnthropicMessagesConfig().transform_anthropic_messages_request(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "Hello"}],
anthropic_messages_optional_request_params={"max_tokens": 1024, "reasoning_effort": reasoning_effort},
litellm_params={},
headers={},
)
assert _reasoning_effort_drop_warnings(caplog) == ()
def test_reasoning_effort_none_clears_thinking_and_output_config():
config = AnthropicMessagesConfig()
optional_params = {

View file

@ -618,6 +618,25 @@ def test_native_baseline_body_drops_a_non_numeric_budget_without_logging_a_drop(
assert [record.getMessage() for record in caplog.records if "Dropping" in record.getMessage()] == []
@pytest.mark.usefixtures("local_model_cost_map")
def test_native_baseline_body_drops_a_non_string_reasoning_effort_without_logging_a_drop(
caplog: pytest.LogCaptureFixture,
) -> None:
request: Final = {
"model": "claude-opus-5",
"max_tokens": 64,
"messages": [{"role": "user", "content": "hi"}],
"reasoning_effort": 5,
}
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
body: Final = prepare_native_baseline_body(request, "anthropic/claude-opus-5")
assert body is not None
assert "reasoning_effort" not in body
assert [record.getMessage() for record in caplog.records if "Dropping" in record.getMessage()] == []
def test_native_baseline_body_matches_the_sent_body_when_drop_params_meets_an_auto_added_beta(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -1677,7 +1677,12 @@ def _warnings_naming(caplog: pytest.LogCaptureFixture, param: str) -> tuple[str,
@pytest.mark.parametrize(
"param, value",
(("reasoning_effort", "none"), ("reasoning_effort", "xhigh"), ("thinking", {"type": "disabled"})),
(
("reasoning_effort", "none"),
("reasoning_effort", "xhigh"),
("thinking", {"type": "disabled"}),
("thinking", {"type": ["enabled"]}),
),
)
def test_magistral_reasoning_request_replaced_by_prompt_warns_once(
param: str, value: object, caplog: pytest.LogCaptureFixture