fix(completion_extras): forward non-enum reasoning_effort through the Responses bridge instead of dropping it (#42452)

This commit is contained in:
devin-ai-integration[bot] 2026-09-23 11:04:07 -07:00 • committed by GitHub
parent b0407ad33e
commit efb93e62f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 174 additions and 35 deletions

View file

@ -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,

View file

@ -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"})

View file

@ -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 (

View file

@ -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."""

View file

@ -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