mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
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:
parent
42fdea3b91
commit
9db0487383
4 changed files with 140 additions and 10 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue