Port from earendil-works/pi#7494: preserve Gemini 3 tool call IDs

Gemini 3+ models require explicit tool call IDs on functionCall /
functionResponse parts in replayed history; without them parallel tool
calls can be rejected or mispaired. The native adapter now:
- threads the model id into request building and includes ids for
  Gemini >= 3 (version-gated: 2.x rejects unexpected id fields)
- preserves provider-returned functionCall.id on both non-streaming
  and streaming responses instead of always minting a random one
This commit is contained in:
Teknium 2026-08-06 17:52:54 -07:00
parent 0957277f2f
commit 141a746ddb
No known key found for this signature in database
2 changed files with 176 additions and 11 deletions

View File

@ -20,6 +20,7 @@ import asyncio
import base64
import json
import logging
import re
import time
import uuid
from types import SimpleNamespace
@ -59,6 +60,31 @@ def bare_gemini_model_id(model: str) -> str:
return name
def _gemini_major_version(model: str) -> Optional[int]:
"""Extract the major version from a Gemini model id (``gemini-3.6-flash`` → 3)."""
name = bare_gemini_model_id(model).lower()
match = re.match(r"gemini-(\d+)", name)
if not match:
return None
try:
return int(match.group(1))
except ValueError:
return None
def gemini_requires_tool_call_ids(model: str) -> bool:
"""Whether functionCall/functionResponse parts must carry explicit ids.
Gemini 3+ models require explicit tool call IDs in replayed history
without them, multi-tool turns can be rejected or mismatched. Older
Gemini models (2.x) reject unexpected ``id`` fields, so this is gated on
the major version. Mirrors earendil-works/pi#7494 (their fix for the same
class of bug in the google-shared converter).
"""
version = _gemini_major_version(model)
return version is not None and version >= 3
def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
normalized = str(base_url or "").strip().rstrip("/").lower()
@ -299,7 +325,10 @@ _INTERRUPTED_RESPONSE_PLACEHOLDER = (
)
def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
def _translate_tool_call_to_gemini(
tool_call: Dict[str, Any],
include_ids: bool = False,
) -> Dict[str, Any]:
fn = tool_call.get("function") or {}
args_raw = fn.get("arguments", "")
try:
@ -315,6 +344,12 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
"args": args,
}
}
if include_ids:
# Gemini 3+ requires explicit tool call IDs so replayed parallel tool
# calls pair with their functionResponses (earendil-works/pi#7494).
tool_call_id = str(tool_call.get("id") or tool_call.get("call_id") or "")
if tool_call_id:
part["functionCall"]["id"] = tool_call_id
thought_signature = _tool_call_extra_signature(tool_call)
# Fallback sentinel for cross-provider tool_calls (e.g. fallback from
# xAI/Anthropic to Gemini, where the original tool_call carries no
@ -328,6 +363,7 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
def _translate_tool_result_to_gemini(
message: Dict[str, Any],
tool_name_by_call_id: Optional[Dict[str, str]] = None,
include_ids: bool = False,
) -> Dict[str, Any]:
tool_name_by_call_id = tool_name_by_call_id or {}
tool_call_id = str(message.get("tool_call_id") or "")
@ -347,15 +383,19 @@ def _translate_tool_result_to_gemini(
except json.JSONDecodeError:
parsed = None
response = parsed if isinstance(parsed, dict) else {"output": content}
return {
"functionResponse": {
"name": name,
"response": response,
}
function_response: Dict[str, Any] = {
"name": name,
"response": response,
}
if include_ids and tool_call_id:
function_response["id"] = tool_call_id
return {"functionResponse": function_response}
def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
def _build_gemini_contents(
messages: List[Dict[str, Any]],
include_tool_call_ids: bool = False,
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
system_text_parts: List[str] = []
contents: List[Dict[str, Any]] = []
tool_name_by_call_id: Dict[str, str] = {}
@ -377,6 +417,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
_translate_tool_result_to_gemini(
msg,
tool_name_by_call_id=tool_name_by_call_id,
include_ids=include_tool_call_ids,
)
],
}
@ -397,7 +438,11 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
tool_name = str(((tool_call.get("function") or {}).get("name") or ""))
if tool_call_id and tool_name:
tool_name_by_call_id[tool_call_id] = tool_name
parts.append(_translate_tool_call_to_gemini(tool_call))
parts.append(
_translate_tool_call_to_gemini(
tool_call, include_ids=include_tool_call_ids
)
)
if parts:
contents.append({"role": gemini_role, "parts": parts})
@ -525,8 +570,12 @@ def build_gemini_request(
top_p: Optional[float] = None,
stop: Any = None,
thinking_config: Any = None,
model: str = "",
) -> Dict[str, Any]:
contents, system_instruction = _build_gemini_contents(messages)
contents, system_instruction = _build_gemini_contents(
messages,
include_tool_call_ids=gemini_requires_tool_call_ids(model),
)
request: Dict[str, Any] = {"contents": contents}
if system_instruction:
request["systemInstruction"] = system_instruction
@ -642,7 +691,11 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa
except (TypeError, ValueError):
args_str = "{}"
tool_call = SimpleNamespace(
id=f"call_{uuid.uuid4().hex[:12]}",
id=(
str(fc["id"])
if isinstance(fc.get("id"), str) and fc.get("id")
else f"call_{uuid.uuid4().hex[:12]}"
),
type="function",
index=index,
function=SimpleNamespace(name=str(fc["name"]), arguments=args_str),
@ -793,7 +846,11 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices:
if slot is None:
slot = {
"index": len(tool_call_indices),
"id": f"call_{uuid.uuid4().hex[:12]}",
"id": (
str(fc["id"])
if isinstance(fc.get("id"), str) and fc.get("id")
else f"call_{uuid.uuid4().hex[:12]}"
),
"last_arguments": "",
}
tool_call_indices[call_key] = slot
@ -1048,6 +1105,7 @@ class GeminiNativeClient:
top_p=top_p,
stop=stop,
thinking_config=thinking_config,
model=model,
)
model = bare_gemini_model_id(model)

View File

@ -309,3 +309,110 @@ def test_stream_event_translation_emits_tool_call_delta_with_stable_index():
class TestGemini3ToolCallIds:
"""Gemini 3+ requires explicit tool call IDs in replayed history
(port of earendil-works/pi#7494)."""
def _history(self):
return [
{"role": "user", "content": "Read a.txt and b.txt"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call_1", "type": "function",
"function": {"name": "read_file", "arguments": '{"path": "a.txt"}'}},
{"id": "call_2", "type": "function",
"function": {"name": "read_file", "arguments": '{"path": "b.txt"}'}},
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "AAA"},
{"role": "tool", "tool_call_id": "call_2", "content": "BBB"},
]
def test_requires_ids_gate(self):
from agent.gemini_native_adapter import gemini_requires_tool_call_ids
assert gemini_requires_tool_call_ids("gemini-3.6-flash")
assert gemini_requires_tool_call_ids("google/gemini-3.6-pro")
assert gemini_requires_tool_call_ids("gemini-3-flash-preview")
assert not gemini_requires_tool_call_ids("gemini-2.5-flash")
assert not gemini_requires_tool_call_ids("gemini-1.5-pro")
assert not gemini_requires_tool_call_ids("claude-opus-4.6")
assert not gemini_requires_tool_call_ids("")
def test_ids_preserved_for_gemini3(self):
from agent.gemini_native_adapter import _build_gemini_contents
contents, _ = _build_gemini_contents(
self._history(), include_tool_call_ids=True
)
call_ids = [
p["functionCall"]["id"]
for c in contents for p in c["parts"] if "functionCall" in p
]
response_ids = [
p["functionResponse"]["id"]
for c in contents for p in c["parts"] if "functionResponse" in p
]
assert call_ids == ["call_1", "call_2"]
assert response_ids == ["call_1", "call_2"]
def test_ids_omitted_for_older_gemini(self):
from agent.gemini_native_adapter import _build_gemini_contents
contents, _ = _build_gemini_contents(self._history())
for c in contents:
for p in c["parts"]:
if "functionCall" in p:
assert "id" not in p["functionCall"]
if "functionResponse" in p:
assert "id" not in p["functionResponse"]
def test_build_request_threads_model_gate(self):
from agent.gemini_native_adapter import build_gemini_request
request = build_gemini_request(
messages=self._history(), model="gemini-3.6-flash"
)
parts = [p for c in request["contents"] for p in c["parts"]]
assert any(p.get("functionCall", {}).get("id") == "call_1" for p in parts)
request_old = build_gemini_request(
messages=self._history(), model="gemini-2.5-flash"
)
parts_old = [p for c in request_old["contents"] for p in c["parts"]]
assert all("id" not in p.get("functionCall", {}) for p in parts_old)
def test_response_preserves_provider_tool_call_id(self):
from agent.gemini_native_adapter import translate_gemini_response
resp = {
"candidates": [{
"content": {"parts": [{
"functionCall": {"id": "call_native_7", "name": "read_file",
"args": {"path": "a.txt"}},
}]},
"finishReason": "STOP",
}],
}
result = translate_gemini_response(resp, model="gemini-3.6-flash")
tool_calls = result.choices[0].message.tool_calls
assert tool_calls[0].id == "call_native_7"
def test_response_generates_id_when_absent(self):
from agent.gemini_native_adapter import translate_gemini_response
resp = {
"candidates": [{
"content": {"parts": [{
"functionCall": {"name": "read_file", "args": {}},
}]},
"finishReason": "STOP",
}],
}
result = translate_gemini_response(resp, model="gemini-2.5-flash")
tool_calls = result.choices[0].message.tool_calls
assert tool_calls[0].id.startswith("call_")