mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 14:22:14 +03:00
fix(translator): pair Gemini tool responses per turn to prevent cross-turn ID collision (#13848)
This commit is contained in:
@@ -346,7 +346,8 @@ function openaiToGeminiBase(
|
||||
|
||||
// Convert messages
|
||||
if (messages && Array.isArray(messages)) {
|
||||
for (const msg of messages) {
|
||||
for (let msgIndex = 0; msgIndex < messages.length; msgIndex++) {
|
||||
const msg = messages[msgIndex];
|
||||
const role = msg.role;
|
||||
const content = msg.content;
|
||||
|
||||
@@ -482,20 +483,47 @@ function openaiToGeminiBase(
|
||||
result.contents.push({ role: "model", parts });
|
||||
}
|
||||
|
||||
// Collect turn-specific tool responses: in standard OpenAI chat format, tool responses
|
||||
// immediately follow the assistant message that requested them.
|
||||
const turnToolResponses: Record<string, unknown> = {};
|
||||
for (let j = msgIndex + 1; j < messages.length; j++) {
|
||||
const later = messages[j];
|
||||
if (later.role === "assistant" || later.role === "user") break;
|
||||
if (later.role === "tool" && later.tool_call_id) {
|
||||
turnToolResponses[later.tool_call_id as string] = later.content;
|
||||
}
|
||||
}
|
||||
|
||||
// Build a turn-specific map of tool call IDs to function names from this assistant message's toolCalls.
|
||||
// This prevents cross-turn ID collisions where an identical tool_call_id reused in a later turn
|
||||
// would otherwise overwrite the function name and content of an earlier turn (#e59118).
|
||||
const turnTcID2Name: Record<string, string> = {};
|
||||
for (const tc of toolCalls) {
|
||||
const fn = tc.function as { name?: string } | undefined;
|
||||
if (tc.type === "function" && tc.id && fn?.name) {
|
||||
turnTcID2Name[tc.id as string] = fn.name;
|
||||
}
|
||||
}
|
||||
|
||||
const resolveToolResponse = (id: string): unknown =>
|
||||
turnToolResponses[id] !== undefined ? turnToolResponses[id] : toolResponses[id];
|
||||
const hasToolResponse = (id: string): boolean => resolveToolResponse(id) !== undefined;
|
||||
|
||||
// Check if there are actual tool responses in the next messages
|
||||
const hasSignaturelessTextResponses =
|
||||
contextualizeSignaturelessToolResponses &&
|
||||
toolCalls.some((tc) => {
|
||||
const id = tc.id as string;
|
||||
return tc.type === "function" && !resolvedSignatures.has(id) && toolResponses[id];
|
||||
return tc.type === "function" && !resolvedSignatures.has(id) && hasToolResponse(id);
|
||||
});
|
||||
const hasActualResponses =
|
||||
toolCallIds.some((fid) => toolResponses[fid]) || hasSignaturelessTextResponses;
|
||||
toolCallIds.some((fid) => hasToolResponse(fid)) || hasSignaturelessTextResponses;
|
||||
|
||||
if (hasActualResponses) {
|
||||
const toolParts: GeminiPart[] = [];
|
||||
for (const fid of toolCallIds) {
|
||||
if (!toolResponses[fid]) continue;
|
||||
const resp = resolveToolResponse(fid);
|
||||
if (resp === undefined) continue;
|
||||
if (
|
||||
!toolNameOptions.supportsSignatureBypass &&
|
||||
contextualizeSignaturelessToolResponses &&
|
||||
@@ -503,7 +531,7 @@ function openaiToGeminiBase(
|
||||
)
|
||||
continue;
|
||||
|
||||
let name = tcID2Name[fid];
|
||||
let name = turnTcID2Name[fid] || tcID2Name[fid];
|
||||
if (!name) {
|
||||
const idParts = fid.split("-");
|
||||
if (idParts.length > 2) {
|
||||
@@ -514,8 +542,6 @@ function openaiToGeminiBase(
|
||||
}
|
||||
name = sanitizeToolName(name);
|
||||
|
||||
const resp = toolResponses[fid];
|
||||
|
||||
toolParts.push({
|
||||
functionResponse: {
|
||||
...(toolNameOptions.stripFunctionCallId ? {} : { id: fid }),
|
||||
@@ -538,10 +564,10 @@ function openaiToGeminiBase(
|
||||
for (const tc of toolCalls) {
|
||||
const id = tc.id as string;
|
||||
if (tc.type !== "function" || !id) continue;
|
||||
if (!resolvedSignatures.has(id) && toolResponses[id]) {
|
||||
const resp = resolveToolResponse(id);
|
||||
if (!resolvedSignatures.has(id) && resp !== undefined) {
|
||||
const fn = tc.function as { name?: string } | undefined;
|
||||
const name = tcID2Name[id] || fn?.name || "unknown";
|
||||
const resp = toolResponses[id];
|
||||
const name = turnTcID2Name[id] || tcID2Name[id] || fn?.name || "unknown";
|
||||
toolParts.push({
|
||||
text:
|
||||
signaturelessToolCallMode === "text"
|
||||
|
||||
@@ -1648,3 +1648,161 @@ test("OpenAI -> Gemini allows thinkingConfig for unknown model (no spec)", () =>
|
||||
assert.equal(result.generationConfig.thinkingConfig.thinkingBudget, 5000);
|
||||
assert.equal(result.generationConfig.thinkingConfig.includeThoughts, true);
|
||||
});
|
||||
|
||||
test("OpenAI -> Gemini pairs tool calls and responses per turn without cross-turn ID collision mismatch", () => {
|
||||
const result = openaiToCloudCodeGeminiRequest(
|
||||
"gemini-3.8-flash-high",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "read file" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_collision_123",
|
||||
type: "function",
|
||||
function: { name: "read_file", arguments: '{"path":"a.txt"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: "call_collision_123",
|
||||
content: "file content from turn 1",
|
||||
},
|
||||
{ role: "user", content: "now run terminal command" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_collision_123",
|
||||
type: "function",
|
||||
function: { name: "run_terminal_command", arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: "call_collision_123",
|
||||
content: "terminal output from turn 2",
|
||||
},
|
||||
{ role: "user", content: "done" },
|
||||
],
|
||||
},
|
||||
false
|
||||
) as any;
|
||||
|
||||
// Verify Turn 1 functionCall and functionResponse
|
||||
const turn1Model = result.contents.find((c: any) =>
|
||||
c.parts?.some((p: any) => p.functionCall?.name === "read_file")
|
||||
);
|
||||
assert.ok(turn1Model, "Turn 1 model functionCall must be read_file");
|
||||
|
||||
const turn1User = result.contents.find((c: any) =>
|
||||
c.parts?.some(
|
||||
(p: any) =>
|
||||
p.functionResponse?.response?.result === "file content from turn 1" ||
|
||||
p.functionResponse?.name === "read_file"
|
||||
)
|
||||
);
|
||||
assert.ok(turn1User, "Turn 1 user functionResponse must exist");
|
||||
const turn1Resp = turn1User.parts.find((p: any) => p.functionResponse);
|
||||
assert.equal(
|
||||
turn1Resp.functionResponse.name,
|
||||
"read_file",
|
||||
"Turn 1 functionResponse name must match functionCall name, not be overwritten by turn 2"
|
||||
);
|
||||
assert.equal(
|
||||
turn1Resp.functionResponse.response.result,
|
||||
"file content from turn 1",
|
||||
"Turn 1 functionResponse must contain turn 1 output, not turn 2 output"
|
||||
);
|
||||
|
||||
// Verify Turn 2 functionCall and functionResponse
|
||||
const turn2User = result.contents.find((c: any) =>
|
||||
c.parts?.some(
|
||||
(p: any) =>
|
||||
p.functionResponse?.response?.result === "terminal output from turn 2" ||
|
||||
p.functionResponse?.name === "run_terminal_command"
|
||||
)
|
||||
);
|
||||
assert.ok(turn2User, "Turn 2 user functionResponse must exist");
|
||||
const turn2Resp = turn2User.parts.find((p: any) => p.functionResponse);
|
||||
assert.equal(
|
||||
turn2Resp.functionResponse.name,
|
||||
"run_terminal_command",
|
||||
"Turn 2 functionResponse name must match functionCall name"
|
||||
);
|
||||
assert.equal(
|
||||
turn2Resp.functionResponse.response.result,
|
||||
"terminal output from turn 2",
|
||||
"Turn 2 functionResponse must contain turn 2 output"
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenAI -> Gemini pairs tool calls and responses in context mode without ID collision mismatch", () => {
|
||||
const result = openaiToGeminiRequest(
|
||||
"gemini-2.5-flash",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "read file" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_collision_999",
|
||||
type: "function",
|
||||
function: { name: "read_file", arguments: '{"path":"a.txt"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: "call_collision_999",
|
||||
content: "file content from turn 1",
|
||||
},
|
||||
{ role: "user", content: "now run terminal command" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_collision_999",
|
||||
type: "function",
|
||||
function: { name: "run_terminal_command", arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: "call_collision_999",
|
||||
content: "terminal output from turn 2",
|
||||
},
|
||||
{ role: "user", content: "done" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
{ signaturelessToolCallMode: "context" }
|
||||
) as any;
|
||||
|
||||
// In context mode without thought signatures, tool responses are emitted as context text
|
||||
const textParts = result.contents.flatMap((c: any) =>
|
||||
(c.parts || []).filter((p: any) => typeof p.text === "string").map((p: any) => p.text)
|
||||
);
|
||||
assert.ok(
|
||||
textParts.some(
|
||||
(t: string) => t.includes("read_file") && t.includes("file content from turn 1")
|
||||
),
|
||||
"Turn 1 context text must pair read_file with its own turn 1 output"
|
||||
);
|
||||
assert.ok(
|
||||
textParts.some(
|
||||
(t: string) => t.includes("run_terminal_command") && t.includes("terminal output from turn 2")
|
||||
),
|
||||
"Turn 2 context text must pair run_terminal_command with its own turn 2 output"
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user