mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-02 21:02:14 +03:00
Compare commits
1 Commits
dependabot
...
fix/11510-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e229d597f2 |
@@ -0,0 +1 @@
|
||||
- **fix(sse):** stop dropping resolved `thoughtSignature` values on parallel (multi tool-call) turns sent to Gemini 3.x — the claude→gemini and openai→gemini translators previously kept the signature only on the *first* function call of a message, causing Gemini to reject subsequent calls in the same turn with HTTP 400 "Function call is missing a thought_signature"; each function call now keeps its own resolved signature ([#11510](https://github.com/diegosouzapw/OmniRoute/issues/11510)).
|
||||
@@ -136,7 +136,6 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
|
||||
const omittedToolCallIds = new Set<string>();
|
||||
for (const msg of body.messages) {
|
||||
const parts = [];
|
||||
let shouldUseEmbeddedSignature = true;
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
@@ -160,15 +159,15 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
|
||||
break;
|
||||
}
|
||||
|
||||
const embeddedThoughtSignature = shouldUseEmbeddedSignature
|
||||
? signatureForToolCall
|
||||
: undefined;
|
||||
if (embeddedThoughtSignature) {
|
||||
shouldUseEmbeddedSignature = false;
|
||||
}
|
||||
|
||||
// #11510: each functionCall part carries its OWN resolved
|
||||
// thoughtSignature — a parallel (multi tool_use) turn can have a
|
||||
// real, individually-valid signature per tool call, and Gemini
|
||||
// 3.x rejects the request if any functionCall in the turn is
|
||||
// missing one. Previously only the first functionCall of the
|
||||
// message kept its signature; this dropped valid signatures for
|
||||
// every subsequent parallel tool call in the same turn.
|
||||
parts.push({
|
||||
...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}),
|
||||
...(signatureForToolCall ? { thoughtSignature: signatureForToolCall } : {}),
|
||||
functionCall: {
|
||||
...(stripFunctionCallId ? {} : { id: block.id }),
|
||||
name: sanitizeToolName(block.name),
|
||||
|
||||
@@ -383,7 +383,6 @@ function openaiToGeminiBase(
|
||||
if (toolCalls && Array.isArray(toolCalls)) {
|
||||
const toolCallIds: string[] = [];
|
||||
const resolvedSignatures = new Map<string, string>();
|
||||
let firstPersistedSignature: string | undefined;
|
||||
for (const tc of toolCalls) {
|
||||
const id = tc.id as string;
|
||||
const resolved = resolveGeminiThoughtSignature(
|
||||
@@ -392,11 +391,9 @@ function openaiToGeminiBase(
|
||||
);
|
||||
if (typeof resolved === "string" && resolved.length > 0) {
|
||||
resolvedSignatures.set(id, resolved);
|
||||
firstPersistedSignature ??= resolved;
|
||||
}
|
||||
}
|
||||
|
||||
let shouldUseEmbeddedSignature = !parts.some((p) => p.thoughtSignature);
|
||||
const signaturelessToolCallMode = toolNameOptions.signaturelessToolCallMode;
|
||||
const stringifySignaturelessToolCalls = signaturelessToolCallMode === "text";
|
||||
const contextualizeSignaturelessToolResponses =
|
||||
@@ -433,13 +430,14 @@ function openaiToGeminiBase(
|
||||
}
|
||||
|
||||
const args = tryParseJSON(fn.arguments || "{}");
|
||||
const embeddedThoughtSignature = shouldUseEmbeddedSignature
|
||||
? firstPersistedSignature || signatureForToolCall
|
||||
: undefined;
|
||||
|
||||
if (embeddedThoughtSignature) {
|
||||
shouldUseEmbeddedSignature = false;
|
||||
}
|
||||
// #11510: each functionCall part carries its OWN resolved
|
||||
// thoughtSignature — a parallel (multi tool_calls) turn can have a
|
||||
// real, individually-valid signature per tool call, and Gemini 3.x
|
||||
// rejects the request if any functionCall in the turn is missing
|
||||
// one. Previously only the first functionCall of the message kept
|
||||
// its signature; this dropped valid signatures for every
|
||||
// subsequent parallel tool call in the same turn.
|
||||
const embeddedThoughtSignature = signatureForToolCall;
|
||||
|
||||
// Gemini expects the signature on the functionCall part itself.
|
||||
// If we are in a mode where missing signatures cause 400s (and we couldn't find one),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { openaiToGeminiRequest } from "../../open-sse/translator/request/openai-to-gemini.ts";
|
||||
import {
|
||||
storeGeminiThoughtSignature,
|
||||
buildGeminiThoughtSignatureKey,
|
||||
} from "../../open-sse/services/geminiThoughtSignatureStore.ts";
|
||||
|
||||
// Mirror of tests/unit/issue-11510-parallel-tool-thought-signature.test.ts for the
|
||||
// OpenAI-protocol → Gemini path (openai-to-gemini.ts). The same
|
||||
// shouldUseEmbeddedSignature/firstPersistedSignature gating dropped a resolved,
|
||||
// individually-valid thoughtSignature for every tool_calls[] entry after the
|
||||
// first one in a parallel (multi tool_calls) assistant turn, reproducing
|
||||
// Gemini 3.x's HTTP 400 "Function call is missing a thought_signature in
|
||||
// functionCall parts" (#11510).
|
||||
test("openai→gemini must attach EACH resolved thoughtSignature on a parallel (multi tool_calls) turn (#11510)", () => {
|
||||
const ns = "conn-11510-openai-parallel";
|
||||
const toolId1 = "call_11510_first";
|
||||
const toolId2 = "call_11510_second_webfetch";
|
||||
const sig1 = "SIG_11510_OPENAI_FIRST";
|
||||
const sig2 = "SIG_11510_OPENAI_SECOND";
|
||||
|
||||
storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId1), sig1);
|
||||
storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId2), sig2);
|
||||
|
||||
const result = openaiToGeminiRequest(
|
||||
"gemini-3.5-flash",
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: toolId1,
|
||||
type: "function",
|
||||
function: { name: "default_api:Read", arguments: JSON.stringify({ path: "/a" }) },
|
||||
},
|
||||
{
|
||||
id: toolId2,
|
||||
type: "function",
|
||||
function: {
|
||||
name: "default_api:WebFetch",
|
||||
arguments: JSON.stringify({ url: "https://example.com" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: toolId1, content: "file a" },
|
||||
{ role: "tool", tool_call_id: toolId2, content: "fetched" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
{ _signatureNamespace: ns }
|
||||
) as { contents: Array<{ role: string; parts: Array<Record<string, unknown>> }> };
|
||||
|
||||
const modelTurn = result.contents.find(
|
||||
(c) => c.role === "model" && c.parts?.some((p) => p.functionCall)
|
||||
);
|
||||
assert.ok(modelTurn, "expected a model turn with functionCall parts");
|
||||
|
||||
const fcParts = modelTurn!.parts.filter((p) => p.functionCall) as Array<{
|
||||
thoughtSignature?: string;
|
||||
functionCall: { name: string };
|
||||
}>;
|
||||
assert.equal(fcParts.length, 2, "both tool_calls entries must be emitted as native functionCall");
|
||||
|
||||
const webFetchPart = fcParts.find((p) => p.functionCall.name.includes("WebFetch"));
|
||||
assert.ok(webFetchPart, "WebFetch functionCall part must be present");
|
||||
|
||||
assert.equal(
|
||||
webFetchPart!.thoughtSignature,
|
||||
sig2,
|
||||
"second functionCall in a parallel tool_calls turn must keep its own resolved " +
|
||||
"thoughtSignature, or Gemini 3.x rejects the request with HTTP 400 " +
|
||||
"'Function call is missing a thought_signature in functionCall parts'"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { claudeToGeminiRequest } from "../../open-sse/translator/request/claude-to-gemini.ts";
|
||||
import {
|
||||
storeGeminiThoughtSignature,
|
||||
buildGeminiThoughtSignatureKey,
|
||||
} from "../../open-sse/services/geminiThoughtSignatureStore.ts";
|
||||
|
||||
// Repro for GitHub issue #11510: Claude Code (or any client) issuing a PARALLEL
|
||||
// tool-call turn (2+ tool_use blocks in one assistant message) against a Gemini
|
||||
// 3.x thinking model. Gemini attaches (and requires) an individual
|
||||
// thoughtSignature on EVERY functionCall part in a multi-call turn, and OmniRoute
|
||||
// stores one per tool_use id (gemini-to-claude.ts reads `part.thoughtSignature`
|
||||
// per-part, not just a single pending value). But claude-to-gemini.ts's
|
||||
// `shouldUseEmbeddedSignature` flag strips the signature from every functionCall
|
||||
// after the first one in the SAME assistant message, even when a real resolved
|
||||
// signature exists for it — reproducing Gemini's exact HTTP 400:
|
||||
// "Function call is missing a thought_signature in functionCall parts."
|
||||
test("claude→gemini must attach EACH resolved thoughtSignature on a parallel (multi tool_use) turn (#11510)", () => {
|
||||
const ns = "conn-11510-parallel";
|
||||
const toolId1 = "toolu_11510_first";
|
||||
const toolId2 = "toolu_11510_second_webfetch";
|
||||
const sig1 = "SIG_11510_FIRST";
|
||||
const sig2 = "SIG_11510_SECOND";
|
||||
|
||||
// Simulate what gemini-to-claude.ts really stores today: a distinct,
|
||||
// individually-valid signature per tool_use id, because Gemini attached one
|
||||
// to each functionCall part of the original response turn.
|
||||
storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId1), sig1);
|
||||
storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId2), sig2);
|
||||
|
||||
const result = claudeToGeminiRequest(
|
||||
"gemini-3.5-flash",
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: toolId1, name: "default_api:Read", input: { path: "/a" } },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolId2,
|
||||
name: "default_api:WebFetch",
|
||||
input: { url: "https://example.com" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: toolId1, content: "file a" },
|
||||
{ type: "tool_result", tool_use_id: toolId2, content: "fetched" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
{ _signatureNamespace: ns }
|
||||
) as { contents: Array<{ role: string; parts: Array<Record<string, unknown>> }> };
|
||||
|
||||
const modelTurn = result.contents.find(
|
||||
(c) => c.role === "model" && c.parts?.some((p) => p.functionCall)
|
||||
);
|
||||
assert.ok(modelTurn, "expected a model turn with functionCall parts");
|
||||
|
||||
const fcParts = modelTurn!.parts.filter((p) => p.functionCall) as Array<{
|
||||
thoughtSignature?: string;
|
||||
functionCall: { name: string };
|
||||
}>;
|
||||
assert.equal(fcParts.length, 2, "both tool_use blocks must be emitted as native functionCall");
|
||||
|
||||
const webFetchPart = fcParts.find((p) => p.functionCall.name.includes("WebFetch"));
|
||||
assert.ok(webFetchPart, "WebFetch functionCall part must be present");
|
||||
|
||||
// THIS is the reported bug: the second tool_use in the turn has a real,
|
||||
// resolved thoughtSignature (sig2) available, but the translator drops it
|
||||
// because it is not the first functionCall in the message.
|
||||
assert.equal(
|
||||
webFetchPart!.thoughtSignature,
|
||||
sig2,
|
||||
"second functionCall in a parallel tool-call turn must keep its own resolved " +
|
||||
"thoughtSignature, or Gemini 3.x rejects the request with HTTP 400 " +
|
||||
"'Function call is missing a thought_signature in functionCall parts'"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user