Merge pull request #36363 from joshgarnett/bugfix/responses-reasoning-object-to-effort

fix(responses): translate the reasoning object into a chat-completion reasoning effort
This commit is contained in:
ryan-crabbe-berri 2026-09-12 18:52:25 -07:00 committed by GitHub
commit 67dd150fed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 265 additions and 18 deletions

View file

@ -117,6 +117,14 @@ class ResponsesToolChatForm:
web_search_options: OpenAIWebSearchOptions | None
@dataclass(frozen=True, slots=True)
class ResponsesReasoningChatForm:
"""The Responses ``reasoning`` object as the two params Chat Completions takes."""
effort: str | None
summary: str | None
if TYPE_CHECKING:
from openai.types.responses.response_apply_patch_tool_call import (
ResponseApplyPatchToolCall,
@ -309,6 +317,79 @@ class LiteLLMCompletionResponsesConfig:
)
return supported_params is not None and "web_search_options" not in supported_params
@staticmethod
def _completion_bridges_back_to_responses_api(
model: str,
custom_llm_provider: str | None,
tools: Sequence[ChatCompletionToolParam | OpenAIMcpServerTool] | None,
web_search_options: OpenAIWebSearchOptions | None,
reasoning_effort: str | None,
reasoning_summary: str | None,
api_base: str | None,
) -> bool:
"""
Whether ``litellm.completion`` will route this model back onto the Responses API.
Delegates to the same check ``litellm.completion`` itself runs, and is asked with the
params this transform is about to emit, so the two cannot reach different answers.
"""
from litellm.main import responses_api_bridge_check
try:
model_info, _ = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider or "",
web_search_options=web_search_options,
tools=tools,
reasoning_effort=reasoning_effort,
reasoning_summary=reasoning_summary,
api_base=api_base,
)
except Exception as e: # noqa: BLE001 # a capability probe must never fail the request it probes for
verbose_logger.debug("responses bridge: reasoning effort mode check failed: %s", e)
return False
return model_info.get("mode") == "responses"
@staticmethod
def _transform_reasoning_for_chat_completion(
reasoning_param: Reasoning | str | None,
model: str,
custom_llm_provider: str | None,
tools: Sequence[ChatCompletionToolParam | OpenAIMcpServerTool] | None = None,
web_search_options: OpenAIWebSearchOptions | None = None,
api_base: str | None = None,
) -> ResponsesReasoningChatForm:
"""
Split the Responses ``reasoning`` object into the params Chat Completions understands.
``reasoning_effort`` is a string enum there, so the object is never forwarded whole: a chat
provider either rejects it or silently drops it, and dropping it turns reasoning off while
still billing for the turn. ``summary`` has no chat equivalent, so it rides the
``reasoning_summary`` alias, which ``litellm.completion`` reassembles into ``{effort,
summary}`` when it bridges the model back onto the Responses API, and is sent to nothing
else.
"""
if not reasoning_param:
return ResponsesReasoningChatForm(effort=None, summary=None)
if isinstance(reasoning_param, str):
return ResponsesReasoningChatForm(effort=reasoning_param, summary=None)
effort: Final = reasoning_param.get("effort")
summary: Final = reasoning_param.get("summary")
if summary is None:
return ResponsesReasoningChatForm(effort=effort, summary=None)
bridges_back: Final = LiteLLMCompletionResponsesConfig._completion_bridges_back_to_responses_api(
model=model,
custom_llm_provider=custom_llm_provider,
tools=tools,
web_search_options=web_search_options,
reasoning_effort=effort,
reasoning_summary=summary,
api_base=api_base,
)
return ResponsesReasoningChatForm(effort=effort, summary=summary if bridges_back else None)
@staticmethod
def transform_responses_api_request_to_chat_completion_request(
model: str,
@ -339,23 +420,14 @@ class LiteLLMCompletionResponsesConfig:
if text_param:
response_format = LiteLLMCompletionResponsesConfig._transform_text_format_to_response_format(text_param)
# Extract reasoning_effort from reasoning parameter
reasoning_effort: Reasoning | str | None = None
reasoning_param: Final = responses_api_request.get("reasoning")
if reasoning_param:
if isinstance(reasoning_param, dict):
# reasoning can be {"effort": "low|medium|high", "summary": "detailed"}
# Keep the full dict when summary is set so the responses API bridge can
# forward it; otherwise use the effort string for chat completion (e.g. Gemini).
if "summary" in reasoning_param:
reasoning_effort = reasoning_param
elif "effort" in reasoning_param:
reasoning_effort = reasoning_param.get("effort")
else:
reasoning_effort = reasoning_param
elif isinstance(reasoning_param, str):
# reasoning could be a string directly
reasoning_effort = reasoning_param
reasoning: Final = LiteLLMCompletionResponsesConfig._transform_reasoning_for_chat_completion(
reasoning_param=responses_api_request.get("reasoning"),
model=model,
custom_llm_provider=custom_llm_provider,
tools=tools,
web_search_options=web_search_options,
api_base=kwargs.get("api_base"),
)
litellm_completion_request: dict = {
"messages": LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
@ -378,7 +450,8 @@ class LiteLLMCompletionResponsesConfig:
"service_tier": kwargs.get("service_tier"),
"web_search_options": web_search_options,
"response_format": response_format,
"reasoning_effort": reasoning_effort,
"reasoning_effort": reasoning.effort,
"reasoning_summary": reasoning.summary,
"context_management": responses_api_request.get("context_management"),
# litellm specific params
"custom_llm_provider": custom_llm_provider,

View file

@ -2527,6 +2527,180 @@ class TestToolTransformation:
"type": "object",
}
@pytest.mark.parametrize(
"model, custom_llm_provider",
[
("bedrock/converse/global.anthropic.claude-sonnet-5", "bedrock_converse"),
("anthropic.claude-sonnet-4-5-20250929-v1:0", "bedrock"),
("claude-sonnet-5", "vertex_ai"),
("gemini-3.1-pro-preview", "vertex_ai"),
("moonshotai.kimi-k2-thinking", "bedrock_mantle"),
],
)
def test_reasoning_summary_still_yields_a_string_reasoning_effort(self, model, custom_llm_provider):
"""
A Responses request carrying ``reasoning.summary`` must still reach a chat provider as a
plain ``reasoning_effort`` string. ``summary`` is Responses-only, and forwarding the whole
object turns reasoning off: Bedrock Converse and Vertex silently discard a non-string
``reasoning_effort``, and Bedrock Mantle rejects the request outright.
"""
responses_api_request = {"reasoning": {"effort": "medium", "summary": "auto"}}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model=model,
input="hi",
responses_api_request=responses_api_request,
custom_llm_provider=custom_llm_provider,
)
assert result["reasoning_effort"] == "medium"
@pytest.mark.parametrize(
"model, custom_llm_provider",
[
("gpt-5.4-pro", "azure_ai"),
("gpt-5", "openai"),
("gpt-5.1", "openai"),
("gpt-5", "azure"),
],
)
def test_bridged_model_carries_the_summary_as_an_alias(self, model, custom_llm_provider):
"""
``summary`` reaches a bridged model through the ``reasoning_summary`` alias, never smuggled
inside ``reasoning_effort``. ``litellm.completion`` reads that alias back with
``peek_reasoning_summary_aliases`` and reassembles ``{effort, summary}``, so the far end
gets the same object it always did while no chat provider ever sees a non-string effort.
"""
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model=model,
input="hi",
responses_api_request={"reasoning": {"effort": "medium", "summary": "auto"}},
custom_llm_provider=custom_llm_provider,
)
assert result["reasoning_effort"] == "medium"
assert result["reasoning_summary"] == "auto"
@pytest.mark.parametrize(
"model, custom_llm_provider",
[
("gpt-5", "openai"),
("gpt-5.1", "openai"),
("gpt-5", "azure"),
],
)
def test_gpt_5_summary_survives_the_bridge_it_claims_to_take(self, model, custom_llm_provider):
"""
Regression for the probe disagreeing with the real decision. The transform asked
``responses_api_bridge_check`` with ``reasoning_summary`` taken straight off the Responses
object, but ``litellm.completion`` reads it from ``optional_params`` via
``peek_reasoning_summary_aliases``, which the bridged request never populated. So these
models answered "bridging" to the probe and "not bridging" for real, and the object landed
on Chat Completions, which only takes a string. Emitting the alias makes the two agree.
"""
from litellm.main import responses_api_bridge_check
from litellm.utils import get_optional_params, peek_reasoning_summary_aliases
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model=model,
input="hi",
responses_api_request={"reasoning": {"effort": "medium", "summary": "auto"}},
custom_llm_provider=custom_llm_provider,
)
optional_params = get_optional_params(
model=model,
custom_llm_provider=custom_llm_provider,
reasoning_effort=result["reasoning_effort"],
reasoning_summary=result["reasoning_summary"],
)
model_info, _ = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
reasoning_effort=result["reasoning_effort"],
reasoning_summary=peek_reasoning_summary_aliases(optional_params),
)
assert model_info.get("mode") == "responses"
def test_a_failing_bridge_probe_falls_back_to_the_string_effort(self, monkeypatch):
"""
The probe is a capability question, so a model-info lookup blowing up must not fail the
request. It degrades to the chat-safe form: a string effort and no alias.
"""
import litellm.main
def _boom(**_kwargs):
raise RuntimeError("model info unavailable")
monkeypatch.setattr(litellm.main, "responses_api_bridge_check", _boom)
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="gpt-5.4-pro",
input="hi",
responses_api_request={"reasoning": {"effort": "medium", "summary": "auto"}},
custom_llm_provider="azure_ai",
)
assert result["reasoning_effort"] == "medium"
assert "reasoning_summary" not in result
@pytest.mark.parametrize(
"reasoning, expected",
[
({"effort": "high"}, "high"),
("low", "low"),
({"summary": "auto"}, None),
({}, None),
(None, None),
],
)
def test_reasoning_param_shapes_map_to_reasoning_effort(self, reasoning, expected):
"""
An object without ``effort`` carries nothing Chat Completions can use, so no
``reasoning_effort`` is sent at all (the bridge drops None-valued params).
"""
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
input="hi",
responses_api_request={"reasoning": reasoning},
custom_llm_provider="bedrock",
)
assert result.get("reasoning_effort") == expected
assert ("reasoning_effort" in result) is (expected is not None)
@pytest.mark.parametrize(
"model, expected_thinking",
[
("global.anthropic.claude-sonnet-5", {"type": "adaptive"}),
(
"anthropic.claude-sonnet-4-5-20250929-v1:0",
{"type": "enabled", "budget_tokens": 2048},
),
],
)
def test_reasoning_summary_still_enables_thinking_on_bedrock(self, model, expected_thinking):
"""
End to end through Bedrock Converse's own param mapping: the effort a Responses request asks
for must survive into ``thinking``, whether the model takes an adaptive effort or a legacy
token budget. Forwarding the object instead leaves ``thinking`` unset and the model never
reasons, which is the failure this guards.
"""
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
bridged = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model=model,
input="hi",
responses_api_request={"reasoning": {"effort": "medium", "summary": "auto"}},
custom_llm_provider="bedrock",
)
mapped = AmazonConverseConfig().map_openai_params(
{"reasoning_effort": bridged["reasoning_effort"]}, {}, model, True
)
assert expected_thinking.items() <= mapped["thinking"].items()
def test_bedrock_anthropic_responses_tools_yield_only_function_toolspec(self):
"""
End-to-end (no network) of the LIT-3858 acceptance criterion: the mixed tools array