Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/agents/extensions/models/litellm_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ async def _fetch_response(
)

converted_messages = Converter.items_to_messages(
input, preserve_thinking_blocks=preserve_thinking_blocks
input,
preserve_thinking_blocks=preserve_thinking_blocks,
include_reasoning_content=self._should_include_reasoning_content(model_settings),
)

# Fix for interleaved thinking bug: reorder messages to ensure tool_use comes before tool_result # noqa: E501
Expand Down Expand Up @@ -436,6 +438,27 @@ async def _fetch_response(
)
return response, ret

def _should_include_reasoning_content(self, model_settings: ModelSettings) -> bool:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the logic here should be strictly restricted to DeepSeek models only to avoid potential regressions for other providers.

According to the DeepSeek Thinking Mode docs, we should only return True in these two cases:

  1. The model_name contains deepseek-reasoner.
    or
  2. The model_name contains deepseek and the thinking parameter is explicitly set.

Right now your logic will enable this whenever a thinking parameter is passed for non-DeepSeek models, and also for deepseek-chat even when thinking is not set.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the logic here should be strictly restricted to DeepSeek models only to avoid potential regressions for other providers.

According to the DeepSeek Thinking Mode docs, we should only return True in these two cases:

1. The model_name contains `deepseek-reasoner`.
   or

2. The model_name contains `deepseek` and the `thinking` parameter is explicitly set.

Right now your logic will enable this whenever a thinking parameter is passed for non-DeepSeek models, and also for deepseek-chat even when thinking is not set.

Adjusted reasoning_content forwarding per DeepSeek guidance: only for deepseek-reasoner, or any deepseek-*model when thinking is explicitly set. This applies to both LiteLLM and the OpenAI client paths. Added regression coverage (including deepseek-chat with/without thinking) and reran uv run pytest tests/test_reasoning_content.py tests/test_anthropic_thinking_blocks.py.

"""Determine whether to forward reasoning_content on assistant messages.

DeepSeek thinking mode requires reasoning_content to be present on messages with tool
calls, otherwise the API returns a 400. Restrict this to DeepSeek models to avoid
regressions with other providers.
"""
model_name = str(self.model).lower()

thinking_param_enabled = (
isinstance(model_settings.extra_body, dict) and "thinking" in model_settings.extra_body
) or (model_settings.extra_args and "thinking" in model_settings.extra_args)

if "deepseek-reasoner" in model_name:
return True

if "deepseek" in model_name and thinking_param_enabled:
return True

return False

def _fix_tool_message_ordering(
self, messages: list[ChatCompletionMessageParam]
) -> list[ChatCompletionMessageParam]:
Expand Down
46 changes: 46 additions & 0 deletions src/agents/models/chatcmpl_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ def items_to_messages(
cls,
items: str | Iterable[TResponseInputItem],
preserve_thinking_blocks: bool = False,
include_reasoning_content: bool = False,
) -> list[ChatCompletionMessageParam]:
"""
Convert a sequence of 'Item' objects into a list of ChatCompletionMessageParam.
Expand Down Expand Up @@ -372,6 +373,21 @@ def items_to_messages(
result: list[ChatCompletionMessageParam] = []
current_assistant_msg: ChatCompletionAssistantMessageParam | None = None
pending_thinking_blocks: list[dict[str, str]] | None = None
pending_reasoning_content: str | None = None

def apply_pending_reasoning_content(
message: ChatCompletionAssistantMessageParam,
) -> None:
nonlocal pending_reasoning_content
if (
not include_reasoning_content
or pending_reasoning_content is None
or "reasoning_content" in message
):
return

cast(dict[str, Any], message)["reasoning_content"] = pending_reasoning_content
pending_reasoning_content = None

def flush_assistant_message() -> None:
nonlocal current_assistant_msg
Expand All @@ -387,6 +403,9 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
if current_assistant_msg is None:
current_assistant_msg = ChatCompletionAssistantMessageParam(role="assistant")
current_assistant_msg["tool_calls"] = []
apply_pending_reasoning_content(current_assistant_msg)
else:
apply_pending_reasoning_content(current_assistant_msg)

return current_assistant_msg

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

new_asst["tool_calls"] = []
apply_pending_reasoning_content(new_asst)
current_assistant_msg = new_asst

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

# 7) reasoning message => extract thinking blocks if present
elif reasoning_item := cls.maybe_reasoning_message(item):
# Capture reasoning content if present so we can attach it to the next assistant
# message (required by some providers for tool calls).
summary_items = reasoning_item.get("summary")
if (
include_reasoning_content
and isinstance(summary_items, list)
and len(summary_items) > 0
):
reasoning_text = summary_items[0].get("text")
if reasoning_text is not None:
pending_reasoning_content = reasoning_text
if (
include_reasoning_content
and pending_reasoning_content is None
and isinstance(reasoning_item.get("content"), list)
):
reasoning_texts = [
content_item.get("text")
for content_item in cast(list[dict[str, Any]], reasoning_item["content"])
if isinstance(content_item, dict)
and content_item.get("type") == "reasoning_text"
and content_item.get("text") is not None
]
if reasoning_texts:
pending_reasoning_content = "".join(cast(list[str], reasoning_texts))

# Reconstruct thinking blocks from content (text) and encrypted_content (signature)
content_items = reasoning_item.get("content", [])
encrypted_content = reasoning_item.get("encrypted_content")
Expand Down
21 changes: 20 additions & 1 deletion src/agents/models/openai_chatcompletions.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,10 @@ async def _fetch_response(
stream: bool = False,
prompt: ResponsePromptParam | None = None,
) -> ChatCompletion | tuple[Response, AsyncStream[ChatCompletionChunk]]:
converted_messages = Converter.items_to_messages(input)
include_reasoning_content = self._should_include_reasoning_content(model_settings)
converted_messages = Converter.items_to_messages(
input, include_reasoning_content=include_reasoning_content
)

if system_instructions:
converted_messages.insert(
Expand Down Expand Up @@ -364,6 +367,22 @@ async def _fetch_response(
)
return response, ret

def _should_include_reasoning_content(self, model_settings: ModelSettings) -> bool:
"""Determine whether to forward reasoning_content on assistant messages."""
model_name = str(self.model).lower()

thinking_param_enabled = (
isinstance(model_settings.extra_body, dict) and "thinking" in model_settings.extra_body
) or (model_settings.extra_args and "thinking" in model_settings.extra_args)

if "deepseek-reasoner" in model_name:
return True

if "deepseek" in model_name and thinking_param_enabled:
return True

return False

def _get_client(self) -> AsyncOpenAI:
if self._client is None:
self._client = AsyncOpenAI()
Expand Down
34 changes: 33 additions & 1 deletion tests/test_anthropic_thinking_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from openai.types.chat.chat_completion_message_tool_call import Function

from agents.extensions.models.litellm_model import InternalChatCompletionMessage
from agents.items import TResponseInputItem
from agents.models.chatcmpl_converter import Converter


Expand Down Expand Up @@ -58,7 +59,7 @@ def test_converter_skips_reasoning_items():
]

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

# Should have user message and assistant message, but no reasoning content
assert len(messages) == 2
Expand Down Expand Up @@ -242,3 +243,34 @@ def test_anthropic_thinking_blocks_with_tool_calls():
tool_calls = assistant_msg.get("tool_calls", [])
assert len(cast(list[Any], tool_calls)) == 1, "Tool calls should be preserved"
assert cast(list[Any], tool_calls)[0]["function"]["name"] == "get_weather"


def test_reasoning_content_added_when_enabled():
"""
Verify reasoning content is attached to the assistant tool-call message when requested.
"""
test_items: list[dict[str, Any]] = [
{"role": "user", "content": "Hello"},
{
"id": "reasoning_123",
"type": "reasoning",
"summary": [{"text": "Thinking about the weather", "type": "summary_text"}],
},
{
"id": "call_123",
"type": "function_call",
"name": "get_weather",
"arguments": '{"city": "Tokyo"}',
"call_id": "call_123",
},
]

messages = Converter.items_to_messages(
cast(list[TResponseInputItem], test_items),
include_reasoning_content=True,
)

assistant_msg = next(msg for msg in messages if msg.get("role") == "assistant")
assert assistant_msg.get("reasoning_content") == "Thinking about the weather"
tool_calls = assistant_msg.get("tool_calls")
assert tool_calls and len(cast(list[Any], tool_calls)) == 1
Loading