Skip to content

Commit 1a4995c

Browse files
committed
feat: support Gemini thought signatures and cross-model conversations
- Bump litellm dependency to >= 1.80.7 for Gemini thought signatures support - Add Gemini 3 Pro thought_signature support for function calling - Handle both LiteLLM provider_specific_fields and Gemini extra_content formats - Clean up __thought__ suffix on tool call ids for Gemini models - Attach provider_data to all non-Responses output items - Store model, response_id and provider specific metadata - Store Gemini thought_signature on function call items - Use provider_data.model to decide what data is safe to send per provider - Strip provider_data and fake ids before calling the OpenAI Responses API - Drop provider specific reasoning items for non OpenAI providers - Improve Anthropic thinking reconstruction - Only rebuild thinking blocks when items come from Anthropic models - Keep handoff transcripts stable by hiding provider_data in history output
1 parent 9fcc68f commit 1a4995c

13 files changed

+1071
-181
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Repository = "https://github.com/openai/openai-agents-python"
3737
[project.optional-dependencies]
3838
voice = ["numpy>=2.2.0, <3; python_version>='3.10'", "websockets>=15.0, <16"]
3939
viz = ["graphviz>=0.17"]
40-
litellm = ["litellm>=1.67.4.post1, <2"]
40+
litellm = ["litellm>=1.80.7, <2"]
4141
realtime = ["websockets>=15.0, <16"]
4242
sqlalchemy = ["SQLAlchemy>=2.0", "asyncpg>=0.29.0"]
4343
encrypt = ["cryptography>=45.0, <46"]

src/agents/extensions/models/litellm_model.py

Lines changed: 123 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ class InternalChatCompletionMessage(ChatCompletionMessage):
6262
thinking_blocks: list[dict[str, Any]] | None = None
6363

6464

65+
class InternalToolCall(ChatCompletionMessageFunctionToolCall):
66+
"""
67+
An internal subclass to carry provider-specific metadata (e.g., Gemini thought signatures)
68+
without modifying the original model.
69+
"""
70+
71+
extra_content: dict[str, Any] | None = None
72+
73+
6574
class LitellmModel(Model):
6675
"""This class enables using any model via LiteLLM. LiteLLM allows you to acess OpenAPI,
6776
Anthropic, Gemini, Mistral, and many other models.
@@ -168,9 +177,15 @@ async def get_response(
168177
"output_tokens": usage.output_tokens,
169178
}
170179

180+
# Build provider_data for provider specific fields
181+
provider_data: dict[str, Any] = {"model": self.model}
182+
if message is not None and hasattr(response, "id"):
183+
provider_data["response_id"] = response.id
184+
171185
items = (
172186
Converter.message_to_output_items(
173-
LitellmConverter.convert_message_to_openai(message)
187+
LitellmConverter.convert_message_to_openai(message, model=self.model),
188+
provider_data=provider_data,
174189
)
175190
if message is not None
176191
else []
@@ -215,7 +230,9 @@ async def stream_response(
215230
)
216231

217232
final_response: Response | None = None
218-
async for chunk in ChatCmplStreamHandler.handle_stream(response, stream):
233+
async for chunk in ChatCmplStreamHandler.handle_stream(
234+
response, stream, model=self.model
235+
):
219236
yield chunk
220237

221238
if chunk.type == "response.completed":
@@ -280,13 +297,19 @@ async def _fetch_response(
280297
)
281298

282299
converted_messages = Converter.items_to_messages(
283-
input, preserve_thinking_blocks=preserve_thinking_blocks
300+
input, model=self.model, preserve_thinking_blocks=preserve_thinking_blocks
284301
)
285302

286303
# Fix for interleaved thinking bug: reorder messages to ensure tool_use comes before tool_result # noqa: E501
287304
if "anthropic" in self.model.lower() or "claude" in self.model.lower():
288305
converted_messages = self._fix_tool_message_ordering(converted_messages)
289306

307+
# Convert Google's extra_content to litellm's provider_specific_fields format
308+
if "gemini" in self.model.lower():
309+
converted_messages = self._convert_gemini_extra_content_to_provider_specific_fields(
310+
converted_messages
311+
)
312+
290313
if system_instructions:
291314
converted_messages.insert(
292315
0,
@@ -436,6 +459,65 @@ async def _fetch_response(
436459
)
437460
return response, ret
438461

462+
def _convert_gemini_extra_content_to_provider_specific_fields(
463+
self, messages: list[ChatCompletionMessageParam]
464+
) -> list[ChatCompletionMessageParam]:
465+
"""
466+
Convert Gemini model's extra_content format to provider_specific_fields format for litellm.
467+
468+
Transforms tool calls from internal format:
469+
extra_content={"google": {"thought_signature": "..."}}
470+
To litellm format:
471+
provider_specific_fields={"thought_signature": "..."}
472+
473+
Only processes tool_calls that appear after the last user message.
474+
See: https://ai.google.dev/gemini-api/docs/thought-signatures
475+
"""
476+
477+
# Find the index of the last user message
478+
last_user_index = -1
479+
for i in range(len(messages) - 1, -1, -1):
480+
if isinstance(messages[i], dict) and messages[i].get("role") == "user":
481+
last_user_index = i
482+
break
483+
484+
for i, message in enumerate(messages):
485+
if not isinstance(message, dict):
486+
continue
487+
488+
# Only process assistant messages that come after the last user message
489+
# If no user message found (last_user_index == -1), process all messages
490+
if last_user_index != -1 and i <= last_user_index:
491+
continue
492+
493+
# Check if this is an assistant message with tool calls
494+
if message.get("role") == "assistant" and message.get("tool_calls"):
495+
tool_calls = message.get("tool_calls", [])
496+
497+
for tool_call in tool_calls: # type: ignore[attr-defined]
498+
if not isinstance(tool_call, dict):
499+
continue
500+
501+
# Default to skip validator, overridden if valid thought signature exists
502+
tool_call["provider_specific_fields"] = {
503+
"thought_signature": "skip_thought_signature_validator"
504+
}
505+
506+
# Override with actual thought signature if extra_content exists
507+
if "extra_content" in tool_call:
508+
extra_content = tool_call.pop("extra_content")
509+
if isinstance(extra_content, dict):
510+
# Extract google-specific fields
511+
google_fields = extra_content.get("google")
512+
if google_fields and isinstance(google_fields, dict):
513+
thought_sig = google_fields.get("thought_signature")
514+
if thought_sig:
515+
tool_call["provider_specific_fields"] = {
516+
"thought_signature": thought_sig
517+
}
518+
519+
return messages
520+
439521
def _fix_tool_message_ordering(
440522
self, messages: list[ChatCompletionMessageParam]
441523
) -> list[ChatCompletionMessageParam]:
@@ -563,15 +645,18 @@ def _merge_headers(self, model_settings: ModelSettings):
563645
class LitellmConverter:
564646
@classmethod
565647
def convert_message_to_openai(
566-
cls, message: litellm.types.utils.Message
648+
cls, message: litellm.types.utils.Message, model: str | None = None
567649
) -> ChatCompletionMessage:
568650
if message.role != "assistant":
569651
raise ModelBehaviorError(f"Unsupported role: {message.role}")
570652

571653
tool_calls: (
572654
list[ChatCompletionMessageFunctionToolCall | ChatCompletionMessageCustomToolCall] | None
573655
) = (
574-
[LitellmConverter.convert_tool_call_to_openai(tool) for tool in message.tool_calls]
656+
[
657+
LitellmConverter.convert_tool_call_to_openai(tool, model=model)
658+
for tool in message.tool_calls
659+
]
575660
if message.tool_calls
576661
else None
577662
)
@@ -641,13 +726,43 @@ def convert_annotations_to_openai(
641726

642727
@classmethod
643728
def convert_tool_call_to_openai(
644-
cls, tool_call: litellm.types.utils.ChatCompletionMessageToolCall
729+
cls, tool_call: litellm.types.utils.ChatCompletionMessageToolCall, model: str | None = None
645730
) -> ChatCompletionMessageFunctionToolCall:
646-
return ChatCompletionMessageFunctionToolCall(
647-
id=tool_call.id,
731+
# Clean up litellm's addition of __thought__ suffix to tool_call.id for
732+
# Gemini models. See: https://github.com/BerriAI/litellm/pull/16895
733+
# This suffix is redundant since we can get thought_signature from
734+
# provider_specific_fields, and this hack causes validation errors when
735+
# cross-model passing to other models.
736+
tool_call_id = tool_call.id
737+
if model and "gemini" in model.lower() and "__thought__" in tool_call_id:
738+
tool_call_id = tool_call_id.split("__thought__")[0]
739+
740+
# Convert litellm's tool call format to chat completion message format
741+
base_tool_call = ChatCompletionMessageFunctionToolCall(
742+
id=tool_call_id,
648743
type="function",
649744
function=Function(
650745
name=tool_call.function.name or "",
651746
arguments=tool_call.function.arguments,
652747
),
653748
)
749+
750+
# Preserve provider-specific fields if present (e.g., Gemini thought signatures)
751+
if hasattr(tool_call, "provider_specific_fields") and tool_call.provider_specific_fields:
752+
# Convert to nested extra_content structure
753+
extra_content: dict[str, Any] = {}
754+
provider_fields = tool_call.provider_specific_fields
755+
756+
# Check for thought_signature (Gemini specific)
757+
if model and "gemini" in model.lower():
758+
if "thought_signature" in provider_fields:
759+
extra_content["google"] = {
760+
"thought_signature": provider_fields["thought_signature"]
761+
}
762+
763+
return InternalToolCall(
764+
**base_tool_call.model_dump(),
765+
extra_content=extra_content if extra_content else None,
766+
)
767+
768+
return base_tool_call

src/agents/handoffs/history.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def _format_transcript_item(item: TResponseInputItem) -> str:
144144
return f"{prefix}: {content_str}" if content_str else prefix
145145

146146
item_type = item.get("type", "item")
147-
rest = {k: v for k, v in item.items() if k != "type"}
147+
rest = {k: v for k, v in item.items() if k not in ("type", "provider_data")}
148148
try:
149149
serialized = json.dumps(rest, ensure_ascii=False, default=str)
150150
except TypeError:

src/agents/models/chatcmpl_converter.py

Lines changed: 87 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -94,18 +94,28 @@ def convert_response_format(
9494
}
9595

9696
@classmethod
97-
def message_to_output_items(cls, message: ChatCompletionMessage) -> list[TResponseOutputItem]:
97+
def message_to_output_items(
98+
cls,
99+
message: ChatCompletionMessage,
100+
provider_data: dict[str, Any] | None = None,
101+
) -> list[TResponseOutputItem]:
98102
items: list[TResponseOutputItem] = []
99103

100104
# Check if message is agents.extentions.models.litellm_model.InternalChatCompletionMessage
101105
# We can't actually import it here because litellm is an optional dependency
102106
# So we use hasattr to check for reasoning_content and thinking_blocks
103107
if hasattr(message, "reasoning_content") and message.reasoning_content:
104-
reasoning_item = ResponseReasoningItem(
105-
id=FAKE_RESPONSES_ID,
106-
summary=[Summary(text=message.reasoning_content, type="summary_text")],
107-
type="reasoning",
108-
)
108+
reasoning_kwargs: dict[str, Any] = {
109+
"id": FAKE_RESPONSES_ID,
110+
"summary": [Summary(text=message.reasoning_content, type="summary_text")],
111+
"type": "reasoning",
112+
}
113+
114+
# Add provider_data if available
115+
if provider_data:
116+
reasoning_kwargs["provider_data"] = provider_data
117+
118+
reasoning_item = ResponseReasoningItem(**reasoning_kwargs)
109119

110120
# Store thinking blocks for Anthropic compatibility
111121
if hasattr(message, "thinking_blocks") and message.thinking_blocks:
@@ -129,13 +139,19 @@ def message_to_output_items(cls, message: ChatCompletionMessage) -> list[TRespon
129139

130140
items.append(reasoning_item)
131141

132-
message_item = ResponseOutputMessage(
133-
id=FAKE_RESPONSES_ID,
134-
content=[],
135-
role="assistant",
136-
type="message",
137-
status="completed",
138-
)
142+
message_kwargs: dict[str, Any] = {
143+
"id": FAKE_RESPONSES_ID,
144+
"content": [],
145+
"role": "assistant",
146+
"type": "message",
147+
"status": "completed",
148+
}
149+
150+
# Add provider_data if available
151+
if provider_data:
152+
message_kwargs["provider_data"] = provider_data
153+
154+
message_item = ResponseOutputMessage(**message_kwargs)
139155
if message.content:
140156
message_item.content.append(
141157
ResponseOutputText(
@@ -155,15 +171,35 @@ def message_to_output_items(cls, message: ChatCompletionMessage) -> list[TRespon
155171
if message.tool_calls:
156172
for tool_call in message.tool_calls:
157173
if tool_call.type == "function":
158-
items.append(
159-
ResponseFunctionToolCall(
160-
id=FAKE_RESPONSES_ID,
161-
call_id=tool_call.id,
162-
arguments=tool_call.function.arguments,
163-
name=tool_call.function.name,
164-
type="function_call",
165-
)
166-
)
174+
# Create base function call item
175+
func_call_kwargs: dict[str, Any] = {
176+
"id": FAKE_RESPONSES_ID,
177+
"call_id": tool_call.id,
178+
"arguments": tool_call.function.arguments,
179+
"name": tool_call.function.name,
180+
"type": "function_call",
181+
}
182+
183+
# Build provider_data for function call
184+
func_provider_data: dict[str, Any] = {}
185+
186+
# Start with provider_data (if provided)
187+
if provider_data:
188+
func_provider_data.update(provider_data)
189+
190+
# Convert Google's extra_content field data to item's provider_data field
191+
if hasattr(tool_call, "extra_content") and tool_call.extra_content:
192+
google_fields = tool_call.extra_content.get("google")
193+
if google_fields and isinstance(google_fields, dict):
194+
thought_sig = google_fields.get("thought_signature")
195+
if thought_sig:
196+
func_provider_data["thought_signature"] = thought_sig
197+
198+
# Add provider_data if we have any
199+
if func_provider_data:
200+
func_call_kwargs["provider_data"] = func_provider_data
201+
202+
items.append(ResponseFunctionToolCall(**func_call_kwargs))
167203
elif tool_call.type == "custom":
168204
pass
169205

@@ -339,6 +375,7 @@ def extract_all_content(
339375
def items_to_messages(
340376
cls,
341377
items: str | Iterable[TResponseInputItem],
378+
model: str | None = None,
342379
preserve_thinking_blocks: bool = False,
343380
) -> list[ChatCompletionMessageParam]:
344381
"""
@@ -533,6 +570,20 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
533570
"arguments": arguments,
534571
},
535572
)
573+
574+
# Restore provider_data back to chat completion message for non-OpenAI models
575+
if "provider_data" in func_call:
576+
provider_fields = func_call["provider_data"] # type: ignore[typeddict-item]
577+
if isinstance(provider_fields, dict):
578+
# Restore thought_signature for Gemini in Google's extra_content format
579+
if model and "gemini" in model.lower():
580+
thought_sig = provider_fields.get("thought_signature")
581+
582+
if thought_sig:
583+
new_tool_call["extra_content"] = { # type: ignore[typeddict-unknown-key]
584+
"google": {"thought_signature": thought_sig}
585+
}
586+
536587
tool_calls.append(new_tool_call)
537588
asst["tool_calls"] = tool_calls
538589
# 5) function call output => tool message
@@ -559,9 +610,21 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
559610
# Reconstruct thinking blocks from content (text) and encrypted_content (signature)
560611
content_items = reasoning_item.get("content", [])
561612
encrypted_content = reasoning_item.get("encrypted_content")
562-
signatures = encrypted_content.split("\n") if encrypted_content else []
563613

564-
if content_items and preserve_thinking_blocks:
614+
item_provider_data: dict[str, Any] = reasoning_item.get("provider_data", {}) # type: ignore[assignment]
615+
item_model = item_provider_data.get("model", "")
616+
617+
if (
618+
model
619+
and ("claude" in model.lower() or "anthropic" in model.lower())
620+
and content_items
621+
and preserve_thinking_blocks
622+
# Items may not all originate from Claude, so we need to check for model match.
623+
# For backward compatibility, if provider_data is missing, we ignore the check.
624+
and (model == item_model or item_provider_data == {})
625+
):
626+
signatures = encrypted_content.split("\n") if encrypted_content else []
627+
565628
# Reconstruct thinking blocks from content and signature
566629
reconstructed_thinking_blocks = []
567630
for content_item in content_items:

0 commit comments

Comments
 (0)