fix(anthropic): keep a client's effort none on the unsupported-value path and send minimal as the off level only where confirmed

This commit is contained in:
mateo-berri 2026-11-03 19:53:01 -08:00
parent 42fdea3b91
commit 9db0487383
4 changed files with 140 additions and 10 deletions

View file

@ -20,6 +20,7 @@ from litellm.llms.anthropic.messages.utils import (
maps_bare_output_effort,
prompt_cache_key_from_user_id,
reasoning_off_level,
thinking_off_switch,
)
from litellm.responses.utils import auto_reasoning_summary
@ -1270,15 +1271,16 @@ class LiteLLMAnthropicMessagesAdapter:
reasoning_effort, cast(dict[str, object], thinking), model, custom_llm_provider
)
)
off_switch: Final = thinking_off_switch(anthropic_message_request)
target_effort: Final = (
reasoning_off_level(
model,
custom_llm_provider,
deployment_model_info=deployment_model_info,
drop_params=drop_params,
between_tools=is_between_tools,
between_tools=off_switch == "between_tools",
)
if mirrors_responses_bridge and effort_sent == REASONING_EFFORT_OFF
if mirrors_responses_bridge and off_switch is not None and effort_sent == REASONING_EFFORT_OFF
else effort_sent
)
if target_effort is None:

View file

@ -53,12 +53,14 @@ from ..convertible_blocks import reject_malformed_tool_result_text
from ..safeguards import with_safeguard_results
from ..utils import (
REASONING_EFFORT_OFF,
ThinkingOffSwitch,
deployment_reasoning_efforts,
litellm_logging_obj_from_kwargs,
local_model_name,
maps_bare_output_effort,
normalize_reasoning_effort_value,
reasoning_off_level,
thinking_off_switch,
)
from .streaming_iterator import AnthropicResponsesStreamWrapper
from .transformation import LiteLLMAnthropicToResponsesAPIAdapter
@ -270,19 +272,19 @@ def _reasoning_for_target(
custom_llm_provider: str | None,
deployment_model_info: object,
drop_params: bool,
between_tools: bool,
off_switch: ThinkingOffSwitch | None,
) -> object:
reasoning_params: Final = as_str_mapping(reasoning)
effort: Final = reasoning_params.get("effort") if reasoning_params is not None else None
if reasoning_params is None or not isinstance(effort, str):
return reasoning
if effort == REASONING_EFFORT_OFF:
if effort == REASONING_EFFORT_OFF and off_switch is not None:
off_level: Final = reasoning_off_level(
model,
custom_llm_provider,
deployment_model_info=deployment_model_info,
drop_params=drop_params,
between_tools=between_tools,
between_tools=off_switch == "between_tools",
)
if off_level is None or _responses_api_refuses_reasoning_effort(model, custom_llm_provider):
return None
@ -471,7 +473,7 @@ def _build_responses_kwargs(
custom_llm_provider=provider,
deployment_model_info=forwarded_kwargs.get("model_info"),
drop_params=drop_params,
between_tools=isinstance(thinking, dict) and thinking.get("type") == "between_tools",
off_switch=thinking_off_switch(anthropic_request),
)
if _maps_reasoning(thinking, model, provider, forwarded_kwargs)
else None

View file

@ -3,7 +3,7 @@ import os
from collections.abc import Iterator, Mapping, Sequence
from itertools import chain
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, TypeVar
from typing import TYPE_CHECKING, Final, Literal, TypeVar
from pydantic import ConfigDict, TypeAdapter, ValidationError
from typing_extensions import ReadOnly
@ -31,6 +31,9 @@ _EFFORT_DEGRADATION_CHAIN: Final[Mapping[str, tuple[str, ...]]] = MappingProxyTy
REASONING_EFFORT_OFF: Final = "none"
RESPONSES_API_PROVIDERS: Final = frozenset({"openai"})
_LOWEST_REASONING_TIERS: Final = ("minimal", "low")
_UNCONFIRMED_MINIMAL_LOWEST_TIERS: Final = ("low",)
ThinkingOffSwitch = Literal["disabled", "between_tools"]
_JsonValue: Final = TypeVar("_JsonValue")
@ -232,6 +235,16 @@ def is_between_tool_calls(request: Mapping[str, object]) -> bool:
return any(block.get("type") == "tool_result" for block in blocks)
def thinking_off_switch(request: Mapping[str, object]) -> ThinkingOffSwitch | None:
thinking: Final = as_str_mapping(request.get("thinking"))
thinking_type: Final = thinking.get("type") if thinking is not None else None
if thinking_type == "disabled":
return "disabled"
if thinking_type == "between_tools" and not is_between_tool_calls(request):
return "between_tools"
return None
def _mapped_model_info(model: str, custom_llm_provider: str | None) -> ModelInfo | None:
from litellm.utils import get_model_info
@ -282,10 +295,19 @@ def _declares(model_info: Mapping[str, object] | None, capability: str) -> bool:
return model_info is not None and model_info.get(capability) is True
def _lowest_tier_instead_of_off(levels: tuple[str, ...] | None) -> str | None:
def _confirms_minimal(model_info: Mapping[str, object] | None) -> bool:
from litellm.litellm_core_utils.reasoning_effort_capability import declared_reasoning_efforts
return model_info is not None and (
declared_reasoning_efforts(model_info) is not None or _declares(model_info, "supports_minimal_reasoning_effort")
)
def _lowest_tier_instead_of_off(levels: tuple[str, ...] | None, model_info: Mapping[str, object] | None) -> str | None:
if not levels or REASONING_EFFORT_OFF in levels:
return None
return next((tier for tier in _LOWEST_REASONING_TIERS if tier in levels), None)
tiers: Final = _LOWEST_REASONING_TIERS if _confirms_minimal(model_info) else _UNCONFIRMED_MINIMAL_LOWEST_TIERS
return next((tier for tier in tiers if tier in levels), None)
def reasoning_off_level(
@ -300,7 +322,7 @@ def reasoning_off_level(
levels: Final = _resolved_reasoning_efforts(model_info, model, custom_llm_provider)
if levels == () or (between_tools and levels is None):
return None
lowest_tier: Final = _lowest_tier_instead_of_off(levels)
lowest_tier: Final = _lowest_tier_instead_of_off(levels, model_info)
if lowest_tier is not None:
return lowest_tier
if (

View file

@ -932,3 +932,107 @@ async def test_a_non_object_thinking_still_reaches_the_responses_bridge(
)
assert sent["model"] == "gpt-5-mini"
_CLIENT_EFFORT_NONE: Final = [
{"thinking": {"type": "adaptive"}, "output_config": {"effort": "none"}},
{"output_config": {"effort": "none"}},
]
class TestAClientEffortNoneFollowsTheUnsupportedValueRule:
@pytest.fixture(autouse=True)
def _httpx_transport(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
@pytest.mark.asyncio
@pytest.mark.parametrize("messages_params", _CLIENT_EFFORT_NONE, ids=["adaptive", "bare"])
@pytest.mark.parametrize(
"uses_chat_route, reasoning_key", [(True, "reasoning_effort"), (False, "reasoning")], ids=["chat-route", "responses-bridge"]
)
async def test_drop_params_drops_it_on_a_model_that_refuses_none(
self,
respx_mock: respx.MockRouter,
local_model_cost_map: None,
monkeypatch: pytest.MonkeyPatch,
messages_params: Mapping[str, object],
uses_chat_route: bool,
reasoning_key: str,
) -> None:
monkeypatch.setattr(litellm, "use_chat_completions_url_for_anthropic_messages", uses_chat_route)
sent: Final = await _body_sent_upstream(
respx_mock,
{"model": "openai/gpt-5-mini", "api_key": "fake-key", "drop_params": True},
{},
rf"https://api\.openai\.com/v1/{'chat/completions' if uses_chat_route else 'responses'}",
_CHAT_COMPLETION if uses_chat_route else _RESPONSE,
**messages_params,
)
assert reasoning_key not in sent
@pytest.mark.asyncio
@pytest.mark.parametrize("messages_params", _CLIENT_EFFORT_NONE, ids=["adaptive", "bare"])
@pytest.mark.parametrize("uses_chat_route", [True, False], ids=["chat-route", "responses-bridge"])
async def test_without_drop_params_it_answers_400(
self,
respx_mock: respx.MockRouter,
local_model_cost_map: None,
monkeypatch: pytest.MonkeyPatch,
messages_params: Mapping[str, object],
uses_chat_route: bool,
) -> None:
monkeypatch.setattr(litellm, "use_chat_completions_url_for_anthropic_messages", uses_chat_route)
with pytest.raises(litellm.UnsupportedParamsError, match="effort=none is not supported for gpt-5-mini"):
await _body_sent_upstream(
respx_mock,
{"model": "openai/gpt-5-mini", "api_key": "fake-key"},
{},
rf"https://api\.openai\.com/v1/{'chat/completions' if uses_chat_route else 'responses'}",
_CHAT_COMPLETION if uses_chat_route else _RESPONSE,
**messages_params,
)
class TestTheOffSwitchUsesMinimalOnlyWhereTheDeploymentConfirmsIt:
@pytest.fixture(autouse=True)
def _httpx_transport(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"model_info, expected",
[
({"supports_none_reasoning_effort": False}, "low"),
({"reasoning_effort_levels": ["minimal", "low", "medium", "high"]}, "minimal"),
],
ids=["none-flag-only", "declared-levels"],
)
@pytest.mark.parametrize("thinking", [{"type": "disabled"}, {"type": "between_tools"}], ids=["disabled", "between_tools"])
@pytest.mark.parametrize("uses_chat_route", [True, False], ids=["chat-route", "responses-bridge"])
@pytest.mark.parametrize("drop_params", [False, True], ids=["no-drop_params", "drop_params"])
async def test_an_o3_deployment_gets_the_lowest_level_it_confirms(
self,
respx_mock: respx.MockRouter,
local_model_cost_map: None,
monkeypatch: pytest.MonkeyPatch,
model_info: Mapping[str, object],
expected: str,
thinking: Mapping[str, object],
uses_chat_route: bool,
drop_params: bool,
) -> None:
monkeypatch.setattr(litellm, "use_chat_completions_url_for_anthropic_messages", uses_chat_route)
sent: Final = await _body_sent_upstream(
respx_mock,
{"model": "openai/o3", "api_key": "fake-key", "drop_params": drop_params},
model_info,
rf"https://api\.openai\.com/v1/{'chat/completions' if uses_chat_route else 'responses'}",
_CHAT_COMPLETION if uses_chat_route else _RESPONSE,
thinking=thinking,
)
assert (sent["reasoning_effort"] if uses_chat_route else sent["reasoning"]["effort"]) == expected