Skip to content

Commit 9a96d9e

Browse files
authored
fix: #2778 keep LiteLLM reasoning_effort portable across providers (#2782)
1 parent 67fa4d8 commit 9a96d9e

2 files changed

Lines changed: 77 additions & 53 deletions

File tree

src/agents/extensions/models/litellm_model.py

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,45 @@ def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice
159159
# Reuse the same normalization to expose retry-after and explicit retry/no-retry hints.
160160
return get_openai_retry_advice(request)
161161

162+
def _get_reasoning_effort(self, model_settings: ModelSettings) -> Any | None:
163+
"""
164+
Resolve the top-level LiteLLM reasoning_effort argument for the chat-completions path.
165+
166+
LiteLLM's public acompletion() surface accepts a scalar reasoning_effort value. Keep the
167+
ModelSettings.reasoning path aligned with that contract and leave extra_body / extra_args as
168+
the explicit escape hatches for advanced provider-specific overrides.
169+
"""
170+
reasoning_effort: Any | None = None
171+
172+
if model_settings.reasoning:
173+
reasoning_effort = model_settings.reasoning.effort
174+
if model_settings.reasoning.summary is not None:
175+
logger.warning(
176+
"LitellmModel does not forward Reasoning.summary on the LiteLLM "
177+
"chat-completions path; ignoring summary and passing reasoning_effort only."
178+
)
179+
180+
# Enable developers to pass non-OpenAI compatible reasoning_effort data like "none".
181+
# Priority order:
182+
# 1. model_settings.reasoning.effort
183+
# 2. model_settings.extra_body["reasoning_effort"]
184+
# 3. model_settings.extra_args["reasoning_effort"]
185+
if (
186+
reasoning_effort is None
187+
and isinstance(model_settings.extra_body, dict)
188+
and "reasoning_effort" in model_settings.extra_body
189+
):
190+
reasoning_effort = model_settings.extra_body["reasoning_effort"]
191+
192+
if (
193+
reasoning_effort is None
194+
and model_settings.extra_args
195+
and "reasoning_effort" in model_settings.extra_args
196+
):
197+
reasoning_effort = model_settings.extra_args["reasoning_effort"]
198+
199+
return reasoning_effort
200+
162201
async def get_response(
163202
self,
164203
system_instructions: str | None,
@@ -456,37 +495,7 @@ async def _fetch_response(
456495
f"Response format: {response_format}\n"
457496
)
458497

459-
# Build reasoning_effort - use dict only when summary is present (OpenAI feature)
460-
# Otherwise pass string for backward compatibility with all providers
461-
reasoning_effort: dict[str, Any] | str | None = None
462-
if model_settings.reasoning:
463-
if model_settings.reasoning.summary is not None:
464-
# Dict format when summary is needed (OpenAI only)
465-
reasoning_effort = {
466-
"effort": model_settings.reasoning.effort,
467-
"summary": model_settings.reasoning.summary,
468-
}
469-
elif model_settings.reasoning.effort is not None:
470-
# String format for compatibility with all providers
471-
reasoning_effort = model_settings.reasoning.effort
472-
473-
# Enable developers to pass non-OpenAI compatible reasoning_effort data like "none"
474-
# Priority order:
475-
# 1. model_settings.reasoning (effort + summary)
476-
# 2. model_settings.extra_body["reasoning_effort"]
477-
# 3. model_settings.extra_args["reasoning_effort"]
478-
if (
479-
reasoning_effort is None # Unset in model_settings
480-
and isinstance(model_settings.extra_body, dict)
481-
and "reasoning_effort" in model_settings.extra_body
482-
):
483-
reasoning_effort = model_settings.extra_body["reasoning_effort"]
484-
if (
485-
reasoning_effort is None # Unset in both model_settings and model_settings.extra_body
486-
and model_settings.extra_args
487-
and "reasoning_effort" in model_settings.extra_args
488-
):
489-
reasoning_effort = model_settings.extra_args["reasoning_effort"]
498+
reasoning_effort = self._get_reasoning_effort(model_settings)
490499

491500
stream_options = None
492501
if stream and model_settings.include_usage is not None:

tests/models/test_litellm_extra_body.py

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import logging
2+
13
import litellm
24
import pytest
35
from litellm.types.utils import Choices, Message, ModelResponse, Usage
@@ -160,15 +162,22 @@ async def fake_acompletion(model, messages=None, **kwargs):
160162

161163
@pytest.mark.allow_call_model_methods
162164
@pytest.mark.asyncio
163-
async def test_reasoning_summary_is_preserved(monkeypatch):
165+
@pytest.mark.parametrize(
166+
"model_name",
167+
[
168+
"openai/gpt-5-mini",
169+
"anthropic/claude-sonnet-4-5",
170+
"gemini/gemini-2.5-pro",
171+
],
172+
)
173+
async def test_reasoning_summary_uses_scalar_effort_and_warns(
174+
monkeypatch, caplog: pytest.LogCaptureFixture, model_name: str
175+
):
164176
"""
165-
Ensure reasoning.summary is preserved when passing ModelSettings.reasoning.
166-
167-
This test verifies the fix for GitHub issue:
168-
https://github.com/BerriAI/litellm/issues/17428
177+
Ensure reasoning.summary does not change the LiteLLM chat-completions argument shape.
169178
170-
Previously, only reasoning.effort was extracted, losing the summary field.
171-
Now we pass a dict with both effort and summary to LiteLLM.
179+
LitellmModel should continue to pass a scalar reasoning_effort value and warn that summary
180+
is ignored on this path, regardless of the provider encoded in the model string.
172181
"""
173182
from openai.types.shared import Reasoning
174183

@@ -184,18 +193,24 @@ async def fake_acompletion(model, messages=None, **kwargs):
184193
settings = ModelSettings(
185194
reasoning=Reasoning(effort="medium", summary="auto"),
186195
)
187-
model = LitellmModel(model="test-model")
188-
189-
await model.get_response(
190-
system_instructions=None,
191-
input=[],
192-
model_settings=settings,
193-
tools=[],
194-
output_schema=None,
195-
handoffs=[],
196-
tracing=ModelTracing.DISABLED,
197-
previous_response_id=None,
198-
)
199-
200-
# Both effort and summary should be preserved in the dict
201-
assert captured["reasoning_effort"] == {"effort": "medium", "summary": "auto"}
196+
model = LitellmModel(model=model_name)
197+
198+
with caplog.at_level(logging.WARNING, logger="openai.agents"):
199+
await model.get_response(
200+
system_instructions=None,
201+
input=[],
202+
model_settings=settings,
203+
tools=[],
204+
output_schema=None,
205+
handoffs=[],
206+
tracing=ModelTracing.DISABLED,
207+
previous_response_id=None,
208+
)
209+
210+
assert captured["reasoning_effort"] == "medium"
211+
warning_messages = [
212+
record.message
213+
for record in caplog.records
214+
if "does not forward Reasoning.summary" in record.message
215+
]
216+
assert len(warning_messages) == 1

0 commit comments

Comments
 (0)