Skip to content

Commit 1611a60

Browse files
committed
Fix DeepSeek reasoning content handling for LiteLLM
1 parent 9fcc68f commit 1611a60

File tree

3 files changed

+101
-2
lines changed

3 files changed

+101
-2
lines changed

src/agents/extensions/models/litellm_model.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,9 @@ async def _fetch_response(
280280
)
281281

282282
converted_messages = Converter.items_to_messages(
283-
input, preserve_thinking_blocks=preserve_thinking_blocks
283+
input,
284+
preserve_thinking_blocks=preserve_thinking_blocks,
285+
include_reasoning_content=self._should_include_reasoning_content(model_settings),
284286
)
285287

286288
# Fix for interleaved thinking bug: reorder messages to ensure tool_use comes before tool_result # noqa: E501
@@ -436,6 +438,25 @@ async def _fetch_response(
436438
)
437439
return response, ret
438440

441+
def _should_include_reasoning_content(self, model_settings: ModelSettings) -> bool:
442+
"""Determine whether to forward reasoning_content on assistant messages.
443+
444+
DeepSeek thinking mode requires reasoning_content to be present on messages with tool
445+
calls, otherwise the API returns a 400.
446+
"""
447+
model_name = str(self.model).lower()
448+
base_url = (self.base_url or "").lower()
449+
450+
if "deepseek" in model_name or "deepseek.com" in base_url:
451+
return True
452+
453+
if isinstance(model_settings.extra_body, dict) and "thinking" in model_settings.extra_body:
454+
return True
455+
if model_settings.extra_args and "thinking" in model_settings.extra_args:
456+
return True
457+
458+
return False
459+
439460
def _fix_tool_message_ordering(
440461
self, messages: list[ChatCompletionMessageParam]
441462
) -> list[ChatCompletionMessageParam]:

src/agents/models/chatcmpl_converter.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ def items_to_messages(
340340
cls,
341341
items: str | Iterable[TResponseInputItem],
342342
preserve_thinking_blocks: bool = False,
343+
include_reasoning_content: bool = False,
343344
) -> list[ChatCompletionMessageParam]:
344345
"""
345346
Convert a sequence of 'Item' objects into a list of ChatCompletionMessageParam.
@@ -372,6 +373,21 @@ def items_to_messages(
372373
result: list[ChatCompletionMessageParam] = []
373374
current_assistant_msg: ChatCompletionAssistantMessageParam | None = None
374375
pending_thinking_blocks: list[dict[str, str]] | None = None
376+
pending_reasoning_content: str | None = None
377+
378+
def apply_pending_reasoning_content(
379+
message: ChatCompletionAssistantMessageParam,
380+
) -> None:
381+
nonlocal pending_reasoning_content
382+
if (
383+
not include_reasoning_content
384+
or pending_reasoning_content is None
385+
or "reasoning_content" in message
386+
):
387+
return
388+
389+
cast(dict[str, Any], message)["reasoning_content"] = pending_reasoning_content
390+
pending_reasoning_content = None
375391

376392
def flush_assistant_message() -> None:
377393
nonlocal current_assistant_msg
@@ -387,6 +403,9 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
387403
if current_assistant_msg is None:
388404
current_assistant_msg = ChatCompletionAssistantMessageParam(role="assistant")
389405
current_assistant_msg["tool_calls"] = []
406+
apply_pending_reasoning_content(current_assistant_msg)
407+
else:
408+
apply_pending_reasoning_content(current_assistant_msg)
390409

391410
return current_assistant_msg
392411

@@ -479,6 +498,7 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
479498
new_asst["content"] = combined
480499

481500
new_asst["tool_calls"] = []
501+
apply_pending_reasoning_content(new_asst)
482502
current_assistant_msg = new_asst
483503

484504
# 4) function/file-search calls => attach to assistant
@@ -556,6 +576,32 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
556576

557577
# 7) reasoning message => extract thinking blocks if present
558578
elif reasoning_item := cls.maybe_reasoning_message(item):
579+
# Capture reasoning content if present so we can attach it to the next assistant
580+
# message (required by some providers for tool calls).
581+
summary_items = reasoning_item.get("summary")
582+
if (
583+
include_reasoning_content
584+
and isinstance(summary_items, list)
585+
and len(summary_items) > 0
586+
):
587+
reasoning_text = summary_items[0].get("text")
588+
if reasoning_text is not None:
589+
pending_reasoning_content = reasoning_text
590+
if (
591+
include_reasoning_content
592+
and pending_reasoning_content is None
593+
and isinstance(reasoning_item.get("content"), list)
594+
):
595+
reasoning_texts = [
596+
content_item.get("text")
597+
for content_item in cast(list[dict[str, Any]], reasoning_item["content"])
598+
if isinstance(content_item, dict)
599+
and content_item.get("type") == "reasoning_text"
600+
and content_item.get("text") is not None
601+
]
602+
if reasoning_texts:
603+
pending_reasoning_content = "".join(cast(list[str], reasoning_texts))
604+
559605
# Reconstruct thinking blocks from content (text) and encrypted_content (signature)
560606
content_items = reasoning_item.get("content", [])
561607
encrypted_content = reasoning_item.get("encrypted_content")

tests/test_anthropic_thinking_blocks.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from openai.types.chat.chat_completion_message_tool_call import Function
1717

1818
from agents.extensions.models.litellm_model import InternalChatCompletionMessage
19+
from agents.items import TResponseInputItem
1920
from agents.models.chatcmpl_converter import Converter
2021

2122

@@ -58,7 +59,7 @@ def test_converter_skips_reasoning_items():
5859
]
5960

6061
# Convert to messages
61-
messages = Converter.items_to_messages(test_items) # type: ignore[arg-type]
62+
messages = Converter.items_to_messages(cast(list[TResponseInputItem], test_items))
6263

6364
# Should have user message and assistant message, but no reasoning content
6465
assert len(messages) == 2
@@ -242,3 +243,34 @@ def test_anthropic_thinking_blocks_with_tool_calls():
242243
tool_calls = assistant_msg.get("tool_calls", [])
243244
assert len(cast(list[Any], tool_calls)) == 1, "Tool calls should be preserved"
244245
assert cast(list[Any], tool_calls)[0]["function"]["name"] == "get_weather"
246+
247+
248+
def test_reasoning_content_added_when_enabled():
249+
"""
250+
Verify reasoning content is attached to the assistant tool-call message when requested.
251+
"""
252+
test_items: list[dict[str, Any]] = [
253+
{"role": "user", "content": "Hello"},
254+
{
255+
"id": "reasoning_123",
256+
"type": "reasoning",
257+
"summary": [{"text": "Thinking about the weather", "type": "summary_text"}],
258+
},
259+
{
260+
"id": "call_123",
261+
"type": "function_call",
262+
"name": "get_weather",
263+
"arguments": '{"city": "Tokyo"}',
264+
"call_id": "call_123",
265+
},
266+
]
267+
268+
messages = Converter.items_to_messages(
269+
cast(list[TResponseInputItem], test_items),
270+
include_reasoning_content=True,
271+
)
272+
273+
assistant_msg = next(msg for msg in messages if msg.get("role") == "assistant")
274+
assert assistant_msg.get("reasoning_content") == "Thinking about the weather"
275+
tool_calls = assistant_msg.get("tool_calls")
276+
assert tool_calls and len(cast(list[Any], tool_calls)) == 1

0 commit comments

Comments
 (0)