mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
fix(completion_extras): forward non-enum reasoning_effort through the Responses bridge instead of dropping it (#42452)
This commit is contained in:
parent
b0407ad33e
commit
efb93e62f8
5 changed files with 174 additions and 35 deletions
|
|
@ -6,7 +6,7 @@ import json
|
|||
import os
|
||||
from collections.abc import AsyncIterator, Callable, Iterable, Iterator, Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, TypeVar, Union, cast, get_args
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, TypeVar, Union, cast
|
||||
|
||||
from openai.types.chat import ChatCompletion
|
||||
from openai.types.responses import Response
|
||||
|
|
@ -38,7 +38,6 @@ from litellm.responses.sse_output_recovery import (
|
|||
)
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils, normalize_responses_api_stream_options
|
||||
from litellm.types.llms.openai import (
|
||||
REASONING_EFFORT,
|
||||
ChatCompletionAnnotation,
|
||||
ChatCompletionReasoningItem,
|
||||
ChatCompletionToolCallChunk,
|
||||
|
|
@ -1180,10 +1179,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
|
||||
return optional_params
|
||||
|
||||
def _map_reasoning_effort(self, reasoning_effort: str | Reasoning) -> Reasoning | None:
|
||||
def _map_reasoning_effort(self, reasoning_effort: object) -> Reasoning:
|
||||
# If dict is passed, convert it directly to Reasoning object
|
||||
if isinstance(reasoning_effort, dict):
|
||||
return Reasoning(**reasoning_effort)
|
||||
return Reasoning(
|
||||
**cast(Reasoning, reasoning_effort) # cast-ok: dict is forwarded verbatim to the provider
|
||||
)
|
||||
|
||||
# Check if auto-summary is enabled via flag or environment variable
|
||||
# Priority: litellm.reasoning_auto_summary flag > LITELLM_REASONING_AUTO_SUMMARY env var
|
||||
|
|
@ -1191,13 +1192,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"
|
||||
)
|
||||
|
||||
if reasoning_effort in get_args(REASONING_EFFORT):
|
||||
return (
|
||||
Reasoning(effort=reasoning_effort, summary="detailed")
|
||||
if auto_summary_enabled
|
||||
else Reasoning(effort=reasoning_effort)
|
||||
)
|
||||
return None
|
||||
return (
|
||||
Reasoning(effort=reasoning_effort, summary="detailed")
|
||||
if auto_summary_enabled
|
||||
else Reasoning(effort=reasoning_effort)
|
||||
)
|
||||
|
||||
def _add_web_search_tool(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -1313,15 +1313,21 @@ def responses(
|
|||
_raise_responses_compatibility_failure(compatibility_failure, model, custom_llm_provider)
|
||||
|
||||
local_vars.update(kwargs)
|
||||
# Map reasoning_effort (from litellm_params/proxy config) to reasoning when not set
|
||||
if reasoning is None and "reasoning_effort" in local_vars:
|
||||
_mapped = LiteLLMResponsesTransformationHandler()._map_reasoning_effort(local_vars.pop("reasoning_effort"))
|
||||
if _mapped is not None:
|
||||
reasoning = _mapped
|
||||
local_vars["reasoning"] = _mapped
|
||||
# Get ResponsesAPIOptionalRequestParams with only valid parameters
|
||||
current_reasoning: Final = cast( # cast-ok: prompt-managed reasoning arrives as a plain dict
|
||||
Reasoning | None, local_vars.get("reasoning")
|
||||
)
|
||||
reasoning_effort: Final = local_vars.get("reasoning_effort")
|
||||
request_reasoning: Final = (
|
||||
LiteLLMResponsesTransformationHandler()._map_reasoning_effort(reasoning_effort)
|
||||
if current_reasoning is None and reasoning_effort is not None
|
||||
else current_reasoning
|
||||
)
|
||||
response_api_optional_params: Final[ResponsesAPIOptionalRequestParams] = (
|
||||
ResponsesAPIRequestUtils.get_requested_response_api_optional_param(local_vars)
|
||||
ResponsesAPIRequestUtils.get_requested_response_api_optional_param(
|
||||
{ # mutable-ok: callee pops keys off the dict it is given
|
||||
k: v for k, v in {**local_vars, "reasoning": request_reasoning}.items() if k != "reasoning_effort"
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
_file_search_dispatch: Final = _responses_try_dispatch_emulated_file_search(
|
||||
|
|
@ -1337,7 +1343,7 @@ def responses(
|
|||
metadata=metadata,
|
||||
parallel_tool_calls=parallel_tool_calls,
|
||||
previous_response_id=previous_response_id,
|
||||
reasoning=reasoning,
|
||||
reasoning=request_reasoning,
|
||||
store=store,
|
||||
background=background,
|
||||
stream=stream,
|
||||
|
|
@ -2295,9 +2301,11 @@ def _deployment_reasoning_default(kwargs: Mapping[str, object]) -> Reasoning | d
|
|||
if kwargs.get("reasoning") is not None:
|
||||
return None
|
||||
reasoning_effort: Final = kwargs.get("reasoning_effort")
|
||||
if isinstance(reasoning_effort, str):
|
||||
return LiteLLMResponsesTransformationHandler()._map_reasoning_effort(reasoning_effort)
|
||||
return _JSON_OBJECT_ADAPTER.validate_python(reasoning_effort) if isinstance(reasoning_effort, Mapping) else None
|
||||
if reasoning_effort is None:
|
||||
return None
|
||||
if isinstance(reasoning_effort, Mapping):
|
||||
return _JSON_OBJECT_ADAPTER.validate_python(reasoning_effort)
|
||||
return LiteLLMResponsesTransformationHandler()._map_reasoning_effort(reasoning_effort)
|
||||
|
||||
|
||||
_RESPONSES_WS_ROUTING_HINT_KEYS: Final = frozenset({"input", "previous_response_id"})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import datetime
|
|||
import json
|
||||
import os
|
||||
import unittest
|
||||
from typing import TYPE_CHECKING, Final, List, Literal, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Final, List, Literal, Optional, Tuple, get_args
|
||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
|
|
@ -12,6 +12,7 @@ import litellm
|
|||
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
||||
LiteLLMResponsesTransformationHandler,
|
||||
)
|
||||
from litellm.types.llms.openai import REASONING_EFFORT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai.types.responses import ResponseOutputItem
|
||||
|
|
@ -1616,17 +1617,6 @@ def test_map_reasoning_effort_adds_summary_detailed(monkeypatch):
|
|||
assert result_dict["summary"] == "custom_summary"
|
||||
print("✓ Dict input is passed through without modification")
|
||||
|
||||
# Test 5: every REASONING_EFFORT level reaches the provider, and anything else (a typo, an
|
||||
# unshipped level, "default") is dropped so the request still succeeds at the provider default
|
||||
from litellm.types.llms.openai import Reasoning
|
||||
|
||||
for effort in ("max", "xhigh", "none"):
|
||||
result_passthrough = handler._map_reasoning_effort(effort)
|
||||
assert result_passthrough == Reasoning(effort=effort)
|
||||
for dropped in ("ultra", "hgih", "unknown_value", "", "default"):
|
||||
assert handler._map_reasoning_effort(dropped) is None
|
||||
print("✓ Enumerated levels pass through and unknown ones are dropped")
|
||||
|
||||
print(
|
||||
"✓ All reasoning_effort behaviors work correctly with flag/env var control"
|
||||
)
|
||||
|
|
@ -2501,6 +2491,30 @@ def test_transform_request_bedrock_mantle_tools_keeps_reasoning_effort(monkeypat
|
|||
assert result["reasoning"] == {"effort": reasoning_effort}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reasoning_effort",
|
||||
[5, ["low"], "hgih", "", {"effort": 5}, {"effort": "max"}, *get_args(REASONING_EFFORT)],
|
||||
)
|
||||
def test_transform_request_never_drops_reasoning_effort(
|
||||
monkeypatch: pytest.MonkeyPatch, reasoning_effort: int | list[str] | str | dict[str, object]
|
||||
):
|
||||
monkeypatch.setattr(litellm, "reasoning_auto_summary", False)
|
||||
monkeypatch.delenv("LITELLM_REASONING_AUTO_SUMMARY", raising=False)
|
||||
handler: Final = LiteLLMResponsesTransformationHandler()
|
||||
expected_effort: Final = reasoning_effort["effort"] if isinstance(reasoning_effort, dict) else reasoning_effort
|
||||
|
||||
result: Final = handler.transform_request(
|
||||
model="gpt-5.4",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
optional_params={"reasoning_effort": reasoning_effort},
|
||||
litellm_params={"custom_llm_provider": "openai"},
|
||||
headers={},
|
||||
litellm_logging_obj=Mock(),
|
||||
)
|
||||
|
||||
assert result["reasoning"]["effort"] == expected_effort
|
||||
|
||||
|
||||
def test_map_optional_params_tool_choice_chat_nested_to_responses_api():
|
||||
"""Chat tool_choice must become Responses ToolChoiceFunction (top-level name)."""
|
||||
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import copy
|
|||
import json
|
||||
from pathlib import Path
|
||||
from importlib import import_module
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
|
|
@ -221,6 +223,112 @@ async def test_aresponses_drops_stream_options():
|
|||
assert "stream_options" not in request_body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aresponses_forwards_non_enum_reasoning_effort(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
||||
):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
|
||||
return_value=httpx.Response(200, json=_minimal_responses_api_payload("resp_effort_int", "gpt-5.4"))
|
||||
)
|
||||
|
||||
response: Final = await litellm.aresponses(model="openai/gpt-5.4", input="hi", reasoning_effort=5)
|
||||
|
||||
assert upstream.call_count == 1
|
||||
request_body: Final = json.loads(upstream.calls[0].request.read())
|
||||
assert request_body["reasoning"] == {"effort": 5}
|
||||
assert response.output[0].content[0].text == "Done."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_with_tools_forwards_non_enum_reasoning_effort_over_the_bridge(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
||||
):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
|
||||
return_value=httpx.Response(200, json=_minimal_responses_api_payload("resp_bridge_int", "gpt-5.4"))
|
||||
)
|
||||
|
||||
response: Final = await litellm.acompletion(
|
||||
model="openai/gpt-5.4",
|
||||
messages=[{"role": "user", "content": "What is the weather in Paris?"}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
},
|
||||
}
|
||||
],
|
||||
reasoning_effort=5,
|
||||
)
|
||||
|
||||
assert upstream.call_count == 1
|
||||
request_body: Final = json.loads(upstream.calls[0].request.read())
|
||||
assert request_body["reasoning"] == {"effort": 5}
|
||||
assert response.id == "resp_bridge_int"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aresponses_forwards_prompt_managed_reasoning_effort(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
||||
):
|
||||
from litellm.responses.main import _AsyncPromptManagementOutcome
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
|
||||
return_value=httpx.Response(200, json=_minimal_responses_api_payload("resp_prompt_effort", "gpt-5.4"))
|
||||
)
|
||||
|
||||
response: Final = await litellm.aresponses(
|
||||
model="openai/gpt-5.4",
|
||||
input="hi",
|
||||
_async_prompt_merged_params=_AsyncPromptManagementOutcome(
|
||||
merged_optional_params={"reasoning_effort": 5}, deployment_model_info=None
|
||||
),
|
||||
)
|
||||
|
||||
assert upstream.call_count == 1
|
||||
request_body: Final = json.loads(upstream.calls[0].request.read())
|
||||
assert request_body["reasoning"] == {"effort": 5}
|
||||
assert "reasoning_effort" not in request_body
|
||||
assert response.output[0].content[0].text == "Done."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aresponses_forwards_prompt_managed_reasoning_dict(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
||||
):
|
||||
from litellm.responses.main import _AsyncPromptManagementOutcome
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
|
||||
return_value=httpx.Response(200, json=_minimal_responses_api_payload("resp_prompt_reasoning", "gpt-5.4"))
|
||||
)
|
||||
|
||||
response: Final = await litellm.aresponses(
|
||||
model="openai/gpt-5.4",
|
||||
input="hi",
|
||||
_async_prompt_merged_params=_AsyncPromptManagementOutcome(
|
||||
merged_optional_params={"reasoning": {"effort": "high", "summary": "detailed"}}, deployment_model_info=None
|
||||
),
|
||||
)
|
||||
|
||||
assert upstream.call_count == 1
|
||||
request_body: Final = json.loads(upstream.calls[0].request.read())
|
||||
assert request_body["reasoning"] == {"effort": "high", "summary": "detailed"}
|
||||
assert response.output[0].content[0].text == "Done."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aresponses_keeps_include_obfuscation_in_stream_options():
|
||||
"""include_obfuscation is a valid Responses API stream option and must survive the include_usage strip."""
|
||||
|
|
|
|||
|
|
@ -1314,6 +1314,16 @@ class TestNativeWebSocketDeploymentDefaults:
|
|||
|
||||
assert dict(defaults.fill_missing) == {"reasoning": {"effort": "xhigh", "summary": "auto"}}
|
||||
|
||||
@pytest.mark.parametrize("reasoning_effort", [5, ["low"], "hgih"])
|
||||
def test_builder_forwards_non_enum_reasoning_effort_like_the_http_path(
|
||||
self, reasoning_effort: int | list[str] | str
|
||||
):
|
||||
from litellm.responses.main import _build_responses_websocket_request_defaults
|
||||
|
||||
defaults = _build_responses_websocket_request_defaults({"model": "gpt-5-pro", "reasoning_effort": reasoning_effort})
|
||||
|
||||
assert dict(defaults.fill_missing) == {"reasoning": {"effort": reasoning_effort}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extra_body_type_key_never_replaces_the_frame_type(self):
|
||||
from types import MappingProxyType
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue