Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions litellm/llms/anthropic/chat/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1506,9 +1506,21 @@ def map_openai_params( # noqa: PLR0915
optional_params["metadata"] = {"user_id": value}
elif param == "thinking":
optional_params["thinking"] = value
elif param == "reasoning_effort" and isinstance(value, str):
elif param == "reasoning_effort":
# Accept both string ("low") and dict ({"effort": "low",
# "summary": "concise"}). The Responses->Chat parser keeps the
# full dict when `summary` is set (see #25359), so a dict here
# is the standard shape Otto/OpenAI-Responses-Bridge callers
# send. Coerce to the effort string before mapping — same
# shape-tolerance the GPT-5 path already implements in
# `_normalize_reasoning_effort_for_chat_completion`.
effort_value = value
if isinstance(effort_value, dict):
effort_value = effort_value.get("effort")
if not isinstance(effort_value, str):
continue
mapped_thinking = AnthropicConfig._map_reasoning_effort(
reasoning_effort=value,
reasoning_effort=effort_value,
model=model,
llm_provider=self.custom_llm_provider or "anthropic",
)
Expand All @@ -1519,12 +1531,12 @@ def map_openai_params( # noqa: PLR0915
optional_params["thinking"] = mapped_thinking
if AnthropicConfig._is_adaptive_thinking_model(model):
mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(
value
effort_value
)
if mapped_effort is None:
AnthropicConfig._raise_invalid_reasoning_effort(
model=model,
value=value,
value=effort_value,
llm_provider=self.custom_llm_provider or "anthropic",
)
optional_params["output_config"] = {"effort": mapped_effort}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2476,6 +2476,120 @@ def test_reasoning_effort_does_not_set_output_config_for_older_models():
), f"output_config should not be set for {model}"


@pytest.mark.parametrize(
"reasoning_effort_value",
[
# String shape — what callers send when using `reasoning_effort="low"` directly.
"low",
# Dict shape with `effort` only — what the Responses->Chat parser produces
# when `reasoning={"effort": "low"}` is set without `summary`.
{"effort": "low"},
# Dict shape with `effort` AND `summary` — what the Responses->Chat parser
# produces when callers send `Reasoning(effort="low", summary="concise")`.
# PR #25359 added the dict-keeping branch for this case, but the Anthropic
# transformation must coerce the dict back to a string before mapping.
{"effort": "low", "summary": "concise"},
{"effort": "low", "summary": "detailed"},
],
)
def test_reasoning_effort_accepts_dict_shape_for_adaptive_model(reasoning_effort_value):
"""
Adaptive-thinking (Claude 4.6+) branch: dict-shape reasoning_effort must
map to ``thinking.type='adaptive'`` + ``output_config.effort``.

Regression test for the dict-shape ``reasoning_effort`` produced by the
Responses->Chat parser when ``summary`` is set on the request's
``reasoning`` field. Before this fix, the Anthropic transformation guarded
on ``isinstance(value, str)`` and silently dropped the param — disabling
extended thinking entirely.
"""
config = AnthropicConfig()

result = config.map_openai_params(
non_default_params={"reasoning_effort": reasoning_effort_value},
optional_params={},
model="claude-sonnet-4-6-20260219",
drop_params=False,
)

# thinking must be set (adaptive for 4.6+)
assert "thinking" in result, (
f"thinking missing for reasoning_effort={reasoning_effort_value!r}"
)
assert result["thinking"]["type"] == "adaptive"
# output_config must carry the mapped effort
assert "output_config" in result, (
f"output_config missing for reasoning_effort={reasoning_effort_value!r}"
)
assert result["output_config"]["effort"] == "low"


@pytest.mark.parametrize(
"reasoning_effort_value",
[
"low",
{"effort": "low"},
{"effort": "low", "summary": "concise"},
],
)
def test_reasoning_effort_accepts_dict_shape_for_non_adaptive_model(reasoning_effort_value):
"""
Non-adaptive (pre-4.6) branch: dict-shape reasoning_effort must still map
to ``thinking.type='enabled'`` + ``budget_tokens``. ``output_config`` must
NOT be set on these models.
"""
config = AnthropicConfig()

result = config.map_openai_params(
non_default_params={"reasoning_effort": reasoning_effort_value},
optional_params={},
model="claude-sonnet-4-5-20250929",
drop_params=False,
)

assert "thinking" in result, (
f"thinking missing for reasoning_effort={reasoning_effort_value!r}"
)
assert result["thinking"]["type"] == "enabled"
assert "budget_tokens" in result["thinking"]
assert result["thinking"]["budget_tokens"] > 0
# Older models must not get adaptive-thinking output_config
assert "output_config" not in result, (
f"output_config should not be set for non-adaptive model "
f"(reasoning_effort={reasoning_effort_value!r})"
)


@pytest.mark.parametrize(
"bad_value",
[
{"summary": "concise"}, # missing effort
{"effort": None}, # explicit None effort
{"effort": 123}, # non-string effort
],
)
def test_reasoning_effort_unparseable_dict_is_dropped(bad_value):
"""
A dict shape that doesn't carry a usable ``effort`` key (e.g. only
``summary`` is set, or the value is some other unexpected type) should be
silently dropped — not crash, not partially apply.
"""
config = AnthropicConfig()

result = config.map_openai_params(
non_default_params={"reasoning_effort": bad_value},
optional_params={},
model="claude-sonnet-4-6-20260219",
drop_params=False,
)
assert "thinking" not in result, (
f"thinking should not be set for bad value {bad_value!r}"
)
assert "output_config" not in result, (
f"output_config should not be set for bad value {bad_value!r}"
)


@pytest.mark.parametrize(
"model",
[
Expand Down
Loading