-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat: Support Anthropic extended thinking and interleaved thinking #1744
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
54a9f7a
543d192
8ab9357
c8d5e4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -39,7 +39,7 @@ | |
ResponseReasoningItemParam, | ||
) | ||
from openai.types.responses.response_input_param import FunctionCallOutput, ItemReference, Message | ||
from openai.types.responses.response_reasoning_item import Summary | ||
from openai.types.responses.response_reasoning_item import Content, Summary | ||
|
||
from ..agent_output import AgentOutputSchemaBase | ||
from ..exceptions import AgentsException, UserError | ||
|
@@ -93,24 +93,38 @@ def convert_response_format( | |
def message_to_output_items(cls, message: ChatCompletionMessage) -> list[TResponseOutputItem]: | ||
items: list[TResponseOutputItem] = [] | ||
|
||
# Handle reasoning content if available | ||
# Check if message is agents.extentions.models.litellm_model.InternalChatCompletionMessage | ||
# We can't actually import it here because litellm is an optional dependency | ||
# So we use hasattr to check for reasoning_content and thinking_blocks | ||
if hasattr(message, "reasoning_content") and message.reasoning_content: | ||
reasoning_item = ResponseReasoningItem( | ||
id=FAKE_RESPONSES_ID, | ||
summary=[Summary(text=message.reasoning_content, type="summary_text")], | ||
type="reasoning", | ||
) | ||
|
||
# Store full thinking blocks for Anthropic compatibility | ||
# Store thinking blocks for Anthropic compatibility | ||
if hasattr(message, "thinking_blocks") and message.thinking_blocks: | ||
# Store thinking blocks in the reasoning item's content | ||
# Convert thinking blocks to Content objects | ||
from openai.types.responses.response_reasoning_item import Content | ||
|
||
reasoning_item.content = [ | ||
Content(text=str(block.get("thinking", "")), type="reasoning_text") | ||
for block in message.thinking_blocks | ||
] | ||
# Store thinking text in content and signature in encrypted_content | ||
reasoning_item.content = [] | ||
signature = None | ||
for block in message.thinking_blocks: | ||
if isinstance(block, dict): | ||
thinking_text = block.get("thinking", "") | ||
if thinking_text: | ||
reasoning_item.content.append( | ||
Content(text=thinking_text, type="reasoning_text") | ||
) | ||
# Store the signature if present | ||
if block.get("signature"): | ||
signature = block.get("signature") | ||
|
||
# Store only the last signature in encrypted_content | ||
# If there are multiple thinking blocks, this should be a problem. | ||
# In practice, there should only be one signature for the entire reasoning step. | ||
# Tested with: claude-sonnet-4-20250514 | ||
if signature: | ||
reasoning_item.encrypted_content = signature | ||
|
||
items.append(reasoning_item) | ||
|
||
|
@@ -325,6 +339,7 @@ def items_to_messages( | |
|
||
result: list[ChatCompletionMessageParam] = [] | ||
current_assistant_msg: ChatCompletionAssistantMessageParam | None = None | ||
pending_thinking_blocks: list[dict[str, str]] | None = None | ||
|
||
def flush_assistant_message() -> None: | ||
nonlocal current_assistant_msg | ||
|
@@ -336,10 +351,17 @@ def flush_assistant_message() -> None: | |
current_assistant_msg = None | ||
|
||
def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: | ||
nonlocal current_assistant_msg | ||
nonlocal current_assistant_msg, pending_thinking_blocks | ||
if current_assistant_msg is None: | ||
current_assistant_msg = ChatCompletionAssistantMessageParam(role="assistant") | ||
current_assistant_msg["tool_calls"] = [] | ||
|
||
# If we have pending thinking blocks, use them as the content | ||
# This is required for Anthropic API tool calls with interleaved thinking | ||
if pending_thinking_blocks: | ||
current_assistant_msg["content"] = pending_thinking_blocks # type: ignore | ||
pending_thinking_blocks = None # Clear after using | ||
|
||
return current_assistant_msg | ||
|
||
for item in items: | ||
|
@@ -483,9 +505,28 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: | |
f"Encountered an item_reference, which is not supported: {item_ref}" | ||
) | ||
|
||
# 7) reasoning message => not handled | ||
elif cls.maybe_reasoning_message(item): | ||
pass | ||
# 7) reasoning message => extract thinking blocks if present | ||
elif reasoning_item := cls.maybe_reasoning_message(item): | ||
# Reconstruct thinking blocks from content (text) and encrypted_content (signature) | ||
content_items = reasoning_item.get("content", []) | ||
signature = reasoning_item.get("encrypted_content") | ||
|
||
if content_items: | ||
# Reconstruct thinking blocks from content and signature | ||
pending_thinking_blocks = [] | ||
for content_item in content_items: | ||
if ( | ||
isinstance(content_item, dict) | ||
and content_item.get("type") == "reasoning_text" | ||
): | ||
thinking_block = { | ||
"type": "thinking", | ||
"thinking": content_item.get("text", ""), | ||
} | ||
# Add signature if available | ||
if signature: | ||
thinking_block["signature"] = signature | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Handle redacted thinking blocks when rebuilding assistant messages This code only reconstructs thinking blocks from reasoning items when the content array contains Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @codex My PR currently doesn't support this case since I was unable to test this. The special test prompt mentioned in the documentation didn't work. In practice, I didn't observe any redacted_thinking in the wild. I think this is a very rare condition. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To use Codex here, create an environment for this repo. |
||
pending_thinking_blocks.append(thinking_block) | ||
|
||
# 8) If we haven't recognized it => fail or ignore | ||
else: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -62,6 +62,9 @@ class StreamingState: | |
# Fields for real-time function call streaming | ||
function_call_streaming: dict[int, bool] = field(default_factory=dict) | ||
function_call_output_idx: dict[int, int] = field(default_factory=dict) | ||
# Store accumulated thinking text and signature for Anthropic compatibility | ||
thinking_text: str = "" | ||
thinking_signature: str | None = None | ||
|
||
|
||
class SequenceNumber: | ||
|
@@ -101,6 +104,19 @@ async def handle_stream( | |
|
||
delta = chunk.choices[0].delta | ||
|
||
# Handle thinking blocks from Anthropic (for preserving signatures) | ||
if hasattr(delta, "thinking_blocks") and delta.thinking_blocks: | ||
for block in delta.thinking_blocks: | ||
if isinstance(block, dict): | ||
# Accumulate thinking text | ||
thinking_text = block.get("thinking", "") | ||
if thinking_text: | ||
state.thinking_text += thinking_text | ||
# Store signature if present | ||
signature = block.get("signature") | ||
if signature: | ||
state.thinking_signature = signature | ||
|
||
# Handle reasoning content for reasoning summaries | ||
if hasattr(delta, "reasoning_content"): | ||
reasoning_content = delta.reasoning_content | ||
|
@@ -527,7 +543,19 @@ async def handle_stream( | |
|
||
# include Reasoning item if it exists | ||
if state.reasoning_content_index_and_output: | ||
outputs.append(state.reasoning_content_index_and_output[1]) | ||
reasoning_item = state.reasoning_content_index_and_output[1] | ||
# Store thinking text in content and signature in encrypted_content | ||
if state.thinking_text: | ||
# Add thinking text as a Content object | ||
if not reasoning_item.content: | ||
reasoning_item.content = [] | ||
reasoning_item.content.append( | ||
Content(text=state.thinking_text, type="reasoning_text") | ||
) | ||
# Store signature in encrypted_content | ||
if state.thinking_signature: | ||
reasoning_item.encrypted_content = state.thinking_signature | ||
Comment on lines
545
to
+557
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Streaming path drops thinking block when Anthropic redacts reasoning text The streaming handler only appends a Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @codex This is the same as above. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To use Codex here, create an environment for this repo. |
||
outputs.append(reasoning_item) | ||
|
||
# include text or refusal content if they exist | ||
if state.text_content_index_and_output or state.refusal_content_index_and_output: | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Preserve redacted thinking blocks when building chat messages
The reasoning item is only converted into
thinking
blocks whencontent
containsreasoning_text
. When Anthropic redacts the reasoning text, the SDK still returns a signature inencrypted_content
but leavescontent
empty. In that case this branch never runs, sopending_thinking_blocks
staysNone
and the assistant message is emitted with plain text before the tool call, which continues to violate Anthropic’s requirement that the message start with athinking
/redacted_thinking
block. Consider generating a placeholder thinking block whenever a signature exists even if no text is supplied so that redacted reasoning still surfaces as the first content element.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@codex actually, when the reasoning is redacted the form is completely different. See the example in the official docs:
The second block is the example of a redacted thinking content:
My PR currently doesn't support this case since I was unable to test this. The special test prompt mentioned in the documentation didn't work.