From 4598b0e6ddde000d608f4123ab513da6eb175dfd Mon Sep 17 00:00:00 2001 From: KlayFu11 <226892459+KlayFu11@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:53:35 +0800 Subject: [PATCH] fix: replay Gemini 3 thought signatures on googleoai tool calls The next tool-loop request is rejected when a tool call's extra_content is dropped. Co-authored-by: Cursor --- src/fast_agent/constants.py | 2 + .../llm/provider/openai/llm_openai.py | 36 ++- .../openai/multipart_converter_openai.py | 29 +- .../llm/provider/openai/tool_call_extra.py | 238 ++++++++++++++ .../test_google_oai_thought_signature.py | 302 ++++++++++++++++++ 5 files changed, 588 insertions(+), 19 deletions(-) create mode 100644 src/fast_agent/llm/provider/openai/tool_call_extra.py create mode 100644 tests/unit/fast_agent/llm/providers/test_google_oai_thought_signature.py diff --git a/src/fast_agent/constants.py b/src/fast_agent/constants.py index 7011c9743..a64efd388 100644 --- a/src/fast_agent/constants.py +++ b/src/fast_agent/constants.py @@ -21,6 +21,8 @@ """Anthropic code-execution container metadata for multi-turn request reuse.""" OPENAI_REASONING_ENCRYPTED = "openai-reasoning-encrypted" """Encrypted OpenAI reasoning items for stateless Responses API passback.""" +OPENAI_TOOL_CALL_EXTRA_CONTENT = "openai-tool-call-extra-content" +"""Per-tool-call ``extra_content`` (Gemini thought signatures) for chat replay.""" OPENAI_COMPACTION_ITEMS = "openai-compaction-items" """Opaque OpenAI Responses compaction items carried by a provider-native checkpoint.""" OPENAI_COMPACTION_TRIGGER = "openai-compaction-trigger" diff --git a/src/fast_agent/llm/provider/openai/llm_openai.py b/src/fast_agent/llm/provider/openai/llm_openai.py index 02d29a658..ef4dcedd4 100644 --- a/src/fast_agent/llm/provider/openai/llm_openai.py +++ b/src/fast_agent/llm/provider/openai/llm_openai.py @@ -1,5 +1,6 @@ import asyncio import json +from collections.abc import Mapping from contextlib import suppress from dataclasses import dataclass, field from functools import partial @@ -58,6 +59,14 @@ should_strip_tool_schema_defaults, ) from fast_agent.llm.provider.openai.structured_output import OpenAIStructuredOutputMixin +from fast_agent.llm.provider.openai.tool_call_extra import ( + add_tool_call_extra_channel, + remember_chunk_tool_call_extra, + remember_tool_call_extra, + retain_completion_tool_call_extra, + tool_call_extra_by_id, + write_tool_call_extra_content, +) from fast_agent.llm.provider.openai.tool_notifications import OpenAIToolNotificationMixin from fast_agent.llm.provider.reasoning_config import reasoning_setting_from_config from fast_agent.llm.provider.streaming_timeouts import ( @@ -810,6 +819,7 @@ async def _process_stream( # Track tool call state for stream events tool_call_started: dict[int, dict[str, Any]] = {} notified_tool_indices: set[int] = set() + tool_call_extra_by_index: dict[int, dict[str, Any]] = {} # Process the stream chunks # Cancellation is handled via asyncio.Task.cancel() which raises CancelledError @@ -820,6 +830,7 @@ async def _process_stream( self._raise_stream_chunk_error(chunk) # Handle chunk accumulation state.handle_chunk(chunk) + remember_chunk_tool_call_extra(chunk, tool_call_extra_by_index) # Process streaming events for tool calls cumulative_content, estimated_tokens, reasoning_active, _ = ( self._process_stream_chunk_common( @@ -903,6 +914,7 @@ async def _process_stream( history_reasoning_segments, source="OpenAI streaming response", ) + retain_completion_tool_call_extra(final_completion, tool_call_extra_by_index) return final_completion, history_reasoning_segments @@ -965,6 +977,7 @@ def _record_manual_tool_call_delta( if delta_tool_call.id: tool_call["id"] = delta_tool_call.id + remember_tool_call_extra(tool_call, delta_tool_call) if not delta_tool_call.function: return if delta_tool_call.function.name: @@ -1033,16 +1046,18 @@ def _manual_stream_tool_calls( tool_call_data = state.tool_calls_map[idx] if not tool_call_data["id"] or not tool_call_data["function"]["name"]: continue - tool_calls.append( - ChatCompletionMessageToolCall( - id=tool_call_data["id"], - type=tool_call_data["type"], - function=Function( - name=tool_call_data["function"]["name"], - arguments=tool_call_data["function"]["arguments"], - ), - ) + tool_call = ChatCompletionMessageToolCall( + id=tool_call_data["id"], + type=tool_call_data["type"], + function=Function( + name=tool_call_data["function"]["name"], + arguments=tool_call_data["function"]["arguments"], + ), ) + extra_content = tool_call_data.get("extra_content") + if isinstance(extra_content, dict) and extra_content: + write_tool_call_extra_content(tool_call, extra_content) + tool_calls.append(tool_call) return tool_calls or None def _raise_for_incomplete_manual_tools( @@ -1461,10 +1476,12 @@ def _openai_response_channels( reasoning_blocks: list[ContentBlock] | None, choice: Any, stop_result: _OpenAIStopResult, + tool_call_extra: Mapping[str, Mapping[str, Any]] | None = None, ) -> dict[str, list[ContentBlock]] | None: channels: dict[str, list[ContentBlock]] = {} if reasoning_blocks: channels[REASONING] = reasoning_blocks + add_tool_call_extra_channel(channels, tool_call_extra) if stop_result.stop_reason == LlmStopReason.SAFETY: channels[FAST_AGENT_SAFETY_DETAILS] = [ TextContent( @@ -1514,6 +1531,7 @@ async def _finalize_openai_completion( reasoning_blocks=reasoning_blocks, choice=choice, stop_result=stop_result, + tool_call_extra=tool_call_extra_by_id(message.tool_calls), ), stop_reason=stop_result.stop_reason, ) diff --git a/src/fast_agent/llm/provider/openai/multipart_converter_openai.py b/src/fast_agent/llm/provider/openai/multipart_converter_openai.py index 621c348e6..08f63da17 100644 --- a/src/fast_agent/llm/provider/openai/multipart_converter_openai.py +++ b/src/fast_agent/llm/provider/openai/multipart_converter_openai.py @@ -1,6 +1,6 @@ import json from collections.abc import Iterable, Mapping -from typing import Any +from typing import Any, cast from mcp_types import ( AudioContent, @@ -28,6 +28,7 @@ from openai.types.chat.chat_completion_user_message_param import ChatCompletionContentPartParam from fast_agent.core.logging.logger import get_logger +from fast_agent.llm.provider.openai.tool_call_extra import extra_content_by_tool_id from fast_agent.mcp.helpers.content_helpers import ( canonicalize_tool_result_content_for_llm, get_image_data, @@ -135,7 +136,8 @@ def convert_to_openai( ChatCompletionAssistantMessageParam( role="assistant", tool_calls=OpenAIConverter._convert_tool_calls_to_openai( - multipart_msg.tool_calls + multipart_msg.tool_calls, + extra_content_by_tool_id(multipart_msg.channels), ), content=OpenAIConverter._assistant_tool_call_content(multipart_msg.content), ) @@ -168,18 +170,25 @@ def convert_to_openai( @staticmethod def _convert_tool_calls_to_openai( tool_calls: Mapping[str, CallToolRequest], + extra_content_by_id: Mapping[str, Mapping[str, Any]] | None = None, ) -> list[ChatCompletionMessageToolCallParam]: - return [ - ChatCompletionMessageToolCallParam( - id=tool_id, - type="function", - function={ + # Gemini 3 rejects a follow-up tool call that omits extra_content. + extras = extra_content_by_id or {} + converted: list[ChatCompletionMessageToolCallParam] = [] + for tool_id, request in tool_calls.items(): + payload: dict[str, Any] = { + "id": tool_id, + "type": "function", + "function": { "name": request.params.name, "arguments": json.dumps(request.params.arguments or {}), }, - ) - for tool_id, request in tool_calls.items() - ] + } + extra = extras.get(tool_id) + if extra: + payload["extra_content"] = dict(extra) + converted.append(cast("ChatCompletionMessageToolCallParam", payload)) + return converted @staticmethod def _assistant_tool_call_content(content: list) -> str: diff --git a/src/fast_agent/llm/provider/openai/tool_call_extra.py b/src/fast_agent/llm/provider/openai/tool_call_extra.py new file mode 100644 index 000000000..debdf2274 --- /dev/null +++ b/src/fast_agent/llm/provider/openai/tool_call_extra.py @@ -0,0 +1,238 @@ +"""Keep OpenAI-compatible tool-call ``extra_content`` for replay. + +Gemini 3 puts a thought signature on ``tool_calls[].extra_content`` and rejects +the next tool-loop request when that field is missing. The value is opaque +provider data, stored on the assistant message and keyed by tool call id. +""" + +import json +from collections.abc import Mapping, Sequence +from typing import Any + +from mcp_types import ContentBlock, TextContent +from pydantic import BaseModel + +from fast_agent.constants import OPENAI_TOOL_CALL_EXTRA_CONTENT +from fast_agent.core.logging.logger import get_logger +from fast_agent.mcp.helpers.content_helpers import get_text + +_logger = get_logger(__name__) + + +def remember_tool_call_extra(accumulated: dict[str, Any], delta: object) -> None: + """Merge one streamed tool-call delta's ``extra_content`` into ``accumulated``.""" + incoming = read_tool_call_extra_content(delta) + if incoming is None: + return + current = accumulated.get("extra_content") + merged = merge_tool_call_extra_content( + current if isinstance(current, dict) else None, + incoming, + ) + if merged: + accumulated["extra_content"] = merged + + +def remember_chunk_tool_call_extra( + chunk: object, + extra_by_index: dict[int, dict[str, Any]], +) -> None: + """Record ``extra_content`` from a chat-completions stream chunk, keyed by index.""" + if not isinstance(chunk, BaseModel): + return + choices = chunk.__dict__.get("choices") + if not isinstance(choices, list): + return + for choice in choices: + if not isinstance(choice, BaseModel): + continue + delta = choice.__dict__.get("delta") + if not isinstance(delta, BaseModel): + continue + tool_calls = delta.__dict__.get("tool_calls") + if not isinstance(tool_calls, list): + continue + for tool_call in tool_calls: + index = _tool_call_index(tool_call) + incoming = read_tool_call_extra_content(tool_call) + if index is None or incoming is None: + continue + extra_by_index[index] = ( + merge_tool_call_extra_content(extra_by_index.get(index), incoming) or incoming + ) + + +def retain_completion_tool_call_extra( + completion: object, + extra_by_index: Mapping[int, Mapping[str, Any]], +) -> None: + """Copy streamed ``extra_content`` onto the final tool calls when the snapshot dropped it.""" + if not extra_by_index or not isinstance(completion, BaseModel): + return + choices = completion.__dict__.get("choices") + if not isinstance(choices, list) or not choices: + return + choice = choices[0] + if not isinstance(choice, BaseModel): + return + message = choice.__dict__.get("message") + if not isinstance(message, BaseModel): + return + tool_calls = message.__dict__.get("tool_calls") + if not isinstance(tool_calls, list): + return + for index, tool_call in enumerate(tool_calls): + incoming = extra_by_index.get(index) + if not isinstance(incoming, Mapping) or not isinstance(tool_call, BaseModel): + continue + current = read_tool_call_extra_content(tool_call) + merged = merge_tool_call_extra_content(current, incoming) + if merged is None or merged == current: + continue + write_tool_call_extra_content(tool_call, merged) + + +def write_tool_call_extra_content(tool_call: BaseModel, extra: Mapping[str, Any]) -> None: + """Attach ``extra_content`` to an OpenAI tool call that has no field for it.""" + extra_store = tool_call.__pydantic_extra__ + copied = dict(extra) + if extra_store is None: + object.__setattr__(tool_call, "__pydantic_extra__", {"extra_content": copied}) + return + extra_store["extra_content"] = copied + + +def tool_call_extra_by_id(tool_calls: object) -> dict[str, dict[str, Any]]: + """Return ``extra_content`` for each tool call that has it, keyed by call id.""" + if not isinstance(tool_calls, list): + return {} + extras: dict[str, dict[str, Any]] = {} + for tool_call in tool_calls: + tool_id = _tool_call_id(tool_call) + extra = read_tool_call_extra_content(tool_call) + if tool_id and extra: + extras[tool_id] = extra + return extras + + +def add_tool_call_extra_channel( + channels: dict[str, list[ContentBlock]], + extra_by_id: Mapping[str, Mapping[str, Any]] | None, +) -> None: + """Store provider tool-call extras so history and session save can replay them.""" + if not extra_by_id: + return + channels[OPENAI_TOOL_CALL_EXTRA_CONTENT] = [ + TextContent( + type="text", + text=json.dumps( + {tool_id: dict(extra) for tool_id, extra in extra_by_id.items()}, + ), + ) + ] + + +def extra_content_by_tool_id( + channels: Mapping[str, Sequence[ContentBlock]] | None, +) -> dict[str, dict[str, Any]]: + """Read the tool-call extra channel written by :func:`add_tool_call_extra_channel`.""" + if not channels: + return {} + blocks = channels.get(OPENAI_TOOL_CALL_EXTRA_CONTENT) + if not blocks: + return {} + + extras: dict[str, dict[str, Any]] = {} + for block in blocks: + text = get_text(block) + if not text: + continue + try: + payload = json.loads(text) + except json.JSONDecodeError: + _logger.debug("Skipping malformed OpenAI tool-call extra_content block") + continue + if not isinstance(payload, dict): + _logger.debug("Skipping malformed OpenAI tool-call extra_content block") + continue + for tool_id, extra in payload.items(): + if not isinstance(tool_id, str) or not tool_id: + continue + parsed = _json_object(extra) + if parsed is not None: + extras[tool_id] = parsed + return extras + + +def read_tool_call_extra_content(tool_call: object) -> dict[str, Any] | None: + """Return a tool call's ``extra_content`` object, including SDK model extras.""" + return _json_object(_field(tool_call, "extra_content")) + + +def merge_tool_call_extra_content( + current: Mapping[str, Any] | None, + incoming: Mapping[str, Any] | None, +) -> dict[str, Any] | None: + """Merge streamed ``extra_content`` objects. Later values replace earlier ones.""" + if not incoming: + if not current: + return None + return dict(current) + if not current: + return dict(incoming) + + merged: dict[str, Any] = dict(current) + for key, value in incoming.items(): + previous = merged.get(key) + if isinstance(previous, dict) and isinstance(value, dict): + nested = merge_tool_call_extra_content(previous, value) + if nested is not None: + merged[key] = nested + continue + if value is not None: + merged[key] = value + return merged or None + + +def _tool_call_id(tool_call: object) -> str | None: + tool_id = _field(tool_call, "id") + if isinstance(tool_id, str) and tool_id: + return tool_id + return None + + +def _tool_call_index(tool_call: object) -> int | None: + index = _field(tool_call, "index") + if isinstance(index, int) and not isinstance(index, bool): + return index + return None + + +def _field(value: object, name: str) -> object: + if isinstance(value, Mapping): + return value.get(name) + if isinstance(value, BaseModel): + if name in value.__dict__: + return value.__dict__[name] + extra = value.model_extra + if extra is not None and name in extra: + return extra[name] + return None + try: + attributes = vars(value) + except TypeError: + return None + return attributes.get(name) + + +def _json_object(value: object) -> dict[str, Any] | None: + if not isinstance(value, Mapping): + return None + try: + encoded = json.dumps(value) + except (TypeError, ValueError): + return None + decoded = json.loads(encoded) + if isinstance(decoded, dict) and decoded: + return decoded + return None diff --git a/tests/unit/fast_agent/llm/providers/test_google_oai_thought_signature.py b/tests/unit/fast_agent/llm/providers/test_google_oai_thought_signature.py new file mode 100644 index 000000000..b8cb09edf --- /dev/null +++ b/tests/unit/fast_agent/llm/providers/test_google_oai_thought_signature.py @@ -0,0 +1,302 @@ +"""Gemini 3 thought signatures must survive the googleoai tool loop.""" + +import json +from typing import Any, cast +from unittest.mock import patch + +import httpx2 +import pytest +from mcp_types import CallToolRequest, CallToolRequestParams, TextContent +from openai.types.chat import ChatCompletionChunk, ChatCompletionMessage + +from fast_agent.agents.agent_types import AgentConfig +from fast_agent.agents.tool_agent import ToolAgent +from fast_agent.config import Settings +from fast_agent.constants import OPENAI_TOOL_CALL_EXTRA_CONTENT +from fast_agent.context import Context +from fast_agent.llm.model_factory import ModelFactory +from fast_agent.llm.provider.openai.llm_openai import OpenAILLM +from fast_agent.llm.provider.openai.multipart_converter_openai import OpenAIConverter +from fast_agent.llm.provider.openai.tool_call_extra import tool_call_extra_by_id +from fast_agent.mcp.prompt_message_extended import PromptMessageExtended +from fast_agent.mcp.prompt_serialization import from_json, to_json +from fast_agent.types import RequestParams + +SIGNATURE = "CiQBcsjafE-opaque-thought-signature" + + +def _sse(chunks: list[dict[str, Any]]) -> bytes: + body = "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + return f"{body}data: [DONE]\n\n".encode() + + +def _chunk( + delta: dict[str, Any], + finish: str | None = None, + usage: dict[str, int] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "id": "chatcmpl-1", + "object": "chat.completion.chunk", + "created": 1, + "model": "gemini-3.5-flash", + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], + } + if usage: + payload["usage"] = usage + return payload + + +def _completion_chunk( + delta: dict[str, Any], + *, + finish_reason: str | None = None, +) -> ChatCompletionChunk: + return ChatCompletionChunk.model_validate(_chunk(delta, finish_reason)) + + +async def _stream_chunks(chunks: list[ChatCompletionChunk]): + for chunk in chunks: + yield chunk + + +def _weather_request(city: str = "Paris") -> CallToolRequest: + return CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name="get_weather", arguments={"city": city}), + ) + + +def _replayed_tool_calls(message: object) -> list[dict[str, Any]]: + payload = cast("dict[str, Any]", message) + tool_calls = payload["tool_calls"] + assert isinstance(tool_calls, list) + return [cast("dict[str, Any]", tool_call) for tool_call in tool_calls] + + +def get_weather(city: str) -> str: + """Return the weather for a city.""" + return f"sunny in {city}" + + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model", + ["googleoai.gemini-3.5-flash", "openai.gpt-4.1"], +) +async def test_tool_loop_replays_thought_signature( + monkeypatch: pytest.MonkeyPatch, model: str +) -> None: + monkeypatch.setenv("GOOGLEOAI_API_KEY", "offline-test-key") + monkeypatch.setenv("OPENAI_API_KEY", "offline-test-key") + tool_reply = _sse( + [ + _chunk( + { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "Paris"}'}, + "extra_content": {"google": {"thought_signature": SIGNATURE}}, + } + ], + } + ), + _chunk( + {}, + finish="tool_calls", + usage={"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + ), + ] + ) + final_reply = _sse( + [ + _chunk({"role": "assistant", "content": "It is sunny in Paris."}), + _chunk( + {}, + finish="stop", + usage={"prompt_tokens": 9, "completion_tokens": 6, "total_tokens": 15}, + ), + ] + ) + bodies: list[dict[str, Any]] = [] + + async def respond(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(await request.aread())) + content = tool_reply if len(bodies) == 1 else final_reply + return httpx2.Response(200, headers={"content-type": "text/event-stream"}, content=content) + + def offline_http_client(*args: Any, **kwargs: Any) -> httpx2.AsyncClient: + del args, kwargs + return httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) + + with patch( + "fast_agent.llm.provider.openai.llm_openai.DefaultAioHttpClient", + offline_http_client, + ): + context = Context(config=Settings()) + agent = ToolAgent(AgentConfig("repro"), [get_weather], context=context) + agent._llm = ModelFactory.create_factory(model)(agent, context=context) + await agent.send("What is the weather in Paris?", RequestParams(max_tokens=256)) + + assert len(bodies) == 2 + replayed = [message for message in bodies[1]["messages"] if message.get("role") == "assistant"] + replayed_signature = ( + replayed[0]["tool_calls"][0] + .get("extra_content", {}) + .get("google", {}) + .get("thought_signature") + ) + assert replayed_signature == SIGNATURE + + restored = from_json(to_json(agent.message_history)) + assistant = next(message for message in restored if message.tool_calls) + replayed_call = _replayed_tool_calls(OpenAIConverter.convert_to_openai(assistant)[0])[0] + assert replayed_call["extra_content"]["google"]["thought_signature"] == SIGNATURE + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_manual_stream_keeps_signature_from_a_later_chunk() -> None: + llm = OpenAILLM(context=Context(), model="gemini-3.5-flash") + chunks = [ + _completion_chunk( + { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city":'}, + } + ], + } + ), + _completion_chunk( + { + "tool_calls": [ + { + "index": 0, + "function": {"arguments": ' "Paris"}'}, + "extra_content": {"google": {"thought_signature": SIGNATURE}}, + } + ] + } + ), + _completion_chunk({}, finish_reason="tool_calls"), + ] + + completion, _reasoning = await llm._process_stream_manual( + _stream_chunks(chunks), + "gemini-3.5-flash", + ) + + extras = tool_call_extra_by_id(completion.choices[0].message.tool_calls) + assert extras["call_1"]["google"]["thought_signature"] == SIGNATURE + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_chat_completion_stream_state_keeps_signature_from_a_later_chunk() -> None: + llm = OpenAILLM(context=Context(), model="gpt-4.1") + chunks = [ + _completion_chunk( + { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city":'}, + } + ], + } + ), + _completion_chunk( + { + "tool_calls": [ + { + "index": 0, + "function": {"arguments": ' "Paris"}'}, + "extra_content": {"google": {"thought_signature": SIGNATURE}}, + } + ] + } + ), + _completion_chunk({}, finish_reason="tool_calls"), + ] + + completion, _reasoning = await llm._process_stream(_stream_chunks(chunks), "gpt-4.1") + + extras = tool_call_extra_by_id(completion.choices[0].message.tool_calls) + assert extras["call_1"]["google"]["thought_signature"] == SIGNATURE + + +@pytest.mark.unit +def test_nonstreaming_tool_call_extra_is_addressable_by_id() -> None: + message = ChatCompletionMessage.model_validate( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "Paris"}'}, + "extra_content": {"google": {"thought_signature": SIGNATURE}}, + } + ], + } + ) + + assert ( + tool_call_extra_by_id(message.tool_calls)["call_1"]["google"]["thought_signature"] + == SIGNATURE + ) + + +@pytest.mark.unit +def test_converter_replays_each_tool_call_extra_and_skips_malformed_channel() -> None: + message = PromptMessageExtended( + role="assistant", + content=[], + tool_calls={ + "call_1": _weather_request(), + "call_2": _weather_request("Lyon"), + }, + channels={ + OPENAI_TOOL_CALL_EXTRA_CONTENT: [ + TextContent(type="text", text="not-json"), + TextContent( + type="text", + text=json.dumps( + { + "call_1": {"google": {"thought_signature": "sig-1"}}, + "call_2": {"google": {"thought_signature": "sig-2"}}, + } + ), + ), + ] + }, + ) + + converted = _replayed_tool_calls(OpenAIConverter.convert_to_openai(message)[0]) + signatures = { + tool_call["id"]: tool_call["extra_content"]["google"]["thought_signature"] + for tool_call in converted + } + assert signatures == {"call_1": "sig-1", "call_2": "sig-2"} + + plain = PromptMessageExtended( + role="assistant", + content=[], + tool_calls={"call_1": _weather_request()}, + ) + plain_call = _replayed_tool_calls(OpenAIConverter.convert_to_openai(plain)[0])[0] + assert "extra_content" not in plain_call