fix google vertex unsigned tool call replay closes #4032

This commit is contained in:
Mario Zechner
2026-04-30 21:59:21 +02:00
parent 9600a0c030
commit f7df474084
3 changed files with 78 additions and 143 deletions

View File

@@ -12,6 +12,7 @@
### Fixed ### Fixed
- Fixed Google Vertex Gemini 3 tool call replay by no longer sending the non-Vertex `skip_thought_signature_validator` sentinel for unsigned tool calls ([#4032](https://github.com/badlogic/pi-mono/issues/4032)).
- Updated `@anthropic-ai/sdk` to `^0.91.1` to clear GHSA-p7fg-763f-g4gf audit findings ([#3992](https://github.com/badlogic/pi-mono/issues/3992)). - Updated `@anthropic-ai/sdk` to `^0.91.1` to clear GHSA-p7fg-763f-g4gf audit findings ([#3992](https://github.com/badlogic/pi-mono/issues/3992)).
- Fixed DeepSeek V4 Flash `xhigh` thinking support so requests preserve `xhigh` and map it to DeepSeek's `max` reasoning effort ([#3944](https://github.com/badlogic/pi-mono/issues/3944)). - Fixed DeepSeek V4 Flash `xhigh` thinking support so requests preserve `xhigh` and map it to DeepSeek's `max` reasoning effort ([#3944](https://github.com/badlogic/pi-mono/issues/3944)).
- Fixed Anthropic streams that end before `message_stop` to be treated as errors instead of successful partial responses ([#3936](https://github.com/badlogic/pi-mono/issues/3936)). - Fixed Anthropic streams that end before `message_stop` to be treated as errors instead of successful partial responses ([#3936](https://github.com/badlogic/pi-mono/issues/3936)).

View File

@@ -51,11 +51,6 @@ export function retainThoughtSignature(existing: string | undefined, incoming: s
// Thought signatures must be base64 for Google APIs (TYPE_BYTES). // Thought signatures must be base64 for Google APIs (TYPE_BYTES).
const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/; const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/;
// Sentinel value that tells the Gemini API to skip thought signature validation.
// Used for unsigned function call parts (e.g. replayed from providers without thought signatures).
// See: https://ai.google.dev/gemini-api/docs/thought-signatures
const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
function isValidThoughtSignature(signature: string | undefined): boolean { function isValidThoughtSignature(signature: string | undefined): boolean {
if (!signature) return false; if (!signature) return false;
if (signature.length % 4 !== 0) return false; if (signature.length % 4 !== 0) return false;
@@ -161,18 +156,13 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
} }
} else if (block.type === "toolCall") { } else if (block.type === "toolCall") {
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature); const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
// Gemini 3 requires thoughtSignature on all function calls when thinking mode is enabled.
// Use the skip_thought_signature_validator sentinel for unsigned function calls
// when replayed from providers without thought signatures.
const isGemini3 = model.id.toLowerCase().includes("gemini-3");
const effectiveSignature = thoughtSignature || (isGemini3 ? SKIP_THOUGHT_SIGNATURE : undefined);
const part: Part = { const part: Part = {
functionCall: { functionCall: {
name: block.name, name: block.name,
args: block.arguments ?? {}, args: block.arguments ?? {},
...(requiresToolCallId(model.id) ? { id: block.id } : {}), ...(requiresToolCallId(model.id) ? { id: block.id } : {}),
}, },
...(effectiveSignature && { thoughtSignature: effectiveSignature }), ...(thoughtSignature && { thoughtSignature }),
}; };
parts.push(part); parts.push(part);
} }

View File

@@ -2,15 +2,17 @@ import { describe, expect, it } from "vitest";
import { convertMessages } from "../src/providers/google-shared.js"; import { convertMessages } from "../src/providers/google-shared.js";
import type { Context, Model } from "../src/types.js"; import type { Context, Model } from "../src/types.js";
const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"; function makeGemini3Model<TApi extends "google-generative-ai" | "google-vertex">(
api: TApi,
function makeGemini3Model(id = "gemini-3-pro-preview"): Model<"google-generative-ai"> { provider: Model<TApi>["provider"],
id = "gemini-3-pro-preview",
): Model<TApi> {
return { return {
id, id,
name: "Gemini 3 Pro Preview", name: "Gemini 3 Pro Preview",
api: "google-generative-ai", api,
provider: "google", provider,
baseUrl: "https://generativelanguage.googleapis.com", baseUrl: "https://example.com",
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -19,154 +21,96 @@ function makeGemini3Model(id = "gemini-3-pro-preview"): Model<"google-generative
}; };
} }
describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () => { function makeContext(model: { api: string; provider: string; id: string }, thoughtSignature?: string): Context {
it("uses skip_thought_signature_validator for unsigned tool calls on Gemini 3", () => { const now = Date.now();
const model = makeGemini3Model(); return {
const now = Date.now(); messages: [
const context: Context = { { role: "user", content: "Hi", timestamp: now },
messages: [ {
{ role: "user", content: "Hi", timestamp: now }, role: "assistant",
{ content: [
role: "assistant", {
content: [ type: "toolCall",
{ id: "call_1",
type: "toolCall", name: "bash",
id: "call_1", arguments: { command: "echo hi" },
name: "bash", ...(thoughtSignature && { thoughtSignature }),
arguments: { command: "ls -la" },
// No thoughtSignature: simulates replay from a provider without thought signatures.
},
],
api: "google-generative-ai",
provider: "google",
model: "claude-sonnet-4-6",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
}, },
stopReason: "stop", {
timestamp: now, type: "toolCall",
id: "call_2",
name: "bash",
arguments: { command: "ls -la" },
},
],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
}, },
], stopReason: "toolUse",
}; timestamp: now,
},
],
};
}
const contents = convertMessages(model, context); describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () => {
it("does not add skip_thought_signature_validator for unsigned Google Gen AI tool calls", () => {
const model = makeGemini3Model("google-generative-ai", "google");
const contents = convertMessages(model, makeContext({ ...model, id: "other-model" }));
const modelTurn = contents.find((c) => c.role === "model"); const modelTurn = contents.find((c) => c.role === "model");
expect(modelTurn).toBeTruthy(); expect(modelTurn).toBeTruthy();
// Should be a structured functionCall, NOT text fallback const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? [];
const fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined); expect(functionCallParts).toHaveLength(2);
expect(fcPart).toBeTruthy(); expect(functionCallParts[0]?.thoughtSignature).toBeUndefined();
expect(fcPart?.functionCall?.name).toBe("bash"); expect(functionCallParts[1]?.thoughtSignature).toBeUndefined();
expect(fcPart?.functionCall?.args).toEqual({ command: "ls -la" }); expect(JSON.stringify(modelTurn)).not.toContain("skip_thought_signature_validator");
expect(fcPart?.thoughtSignature).toBe(SKIP_THOUGHT_SIGNATURE);
// No text fallback should exist
const textParts = modelTurn?.parts?.filter((p) => p.text !== undefined) ?? []; const textParts = modelTurn?.parts?.filter((p) => p.text !== undefined) ?? [];
const historicalText = textParts.filter((p) => p.text?.includes("Historical context")); const historicalText = textParts.filter((p) => p.text?.includes("Historical context"));
expect(historicalText).toHaveLength(0); expect(historicalText).toHaveLength(0);
}); });
it("preserves valid thoughtSignature when present (same provider/model)", () => { it("does not add skip_thought_signature_validator for unsigned Vertex tool calls", () => {
const model = makeGemini3Model(); const model = makeGemini3Model("google-vertex", "google-vertex");
const now = Date.now(); const contents = convertMessages(model, makeContext(model));
// Valid base64 signature (16 bytes = 24 chars base64)
const validSig = "AAAAAAAAAAAAAAAAAAAAAA==";
const context: Context = {
messages: [
{ role: "user", content: "Hi", timestamp: now },
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_1",
name: "bash",
arguments: { command: "echo hi" },
thoughtSignature: validSig,
},
],
api: "google-generative-ai",
provider: "google",
model: "gemini-3-pro-preview",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: now,
},
],
};
const contents = convertMessages(model, context);
const modelTurn = contents.find((c) => c.role === "model"); const modelTurn = contents.find((c) => c.role === "model");
const fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined); const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? [];
expect(fcPart).toBeTruthy(); expect(functionCallParts).toHaveLength(2);
expect(fcPart?.thoughtSignature).toBe(validSig); expect(functionCallParts[0]?.thoughtSignature).toBeUndefined();
expect(functionCallParts[1]?.thoughtSignature).toBeUndefined();
expect(JSON.stringify(modelTurn)).not.toContain("skip_thought_signature_validator");
}); });
it("does not add sentinel for non-Gemini-3 models", () => { it("preserves valid thoughtSignature when present for the same provider and model", () => {
const model: Model<"google-generative-ai"> = { const model = makeGemini3Model("google-generative-ai", "google");
id: "gemini-2.5-flash", const validSig = "AAAAAAAAAAAAAAAAAAAAAA==";
name: "Gemini 2.5 Flash", const contents = convertMessages(model, makeContext(model, validSig));
api: "google-generative-ai", const modelTurn = contents.find((c) => c.role === "model");
provider: "google", const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? [];
baseUrl: "https://generativelanguage.googleapis.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
};
const now = Date.now();
const context: Context = {
messages: [
{ role: "user", content: "Hi", timestamp: now },
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_1",
name: "bash",
arguments: { command: "ls" },
// No thoughtSignature
},
],
api: "google-generative-ai",
provider: "google",
model: "claude-sonnet-4-6",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: now,
},
],
};
const contents = convertMessages(model, context); expect(functionCallParts).toHaveLength(2);
expect(functionCallParts[0]?.thoughtSignature).toBe(validSig);
expect(functionCallParts[1]?.thoughtSignature).toBeUndefined();
});
it("does not add a thoughtSignature for non-Gemini-3 models", () => {
const model = makeGemini3Model("google-generative-ai", "google", "gemini-2.5-flash");
const contents = convertMessages(model, makeContext({ ...model, id: "other-model" }));
const modelTurn = contents.find((c) => c.role === "model"); const modelTurn = contents.find((c) => c.role === "model");
const fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined); const fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined);
expect(fcPart).toBeTruthy(); expect(fcPart).toBeTruthy();
// No sentinel, no thoughtSignature at all
expect(fcPart?.thoughtSignature).toBeUndefined(); expect(fcPart?.thoughtSignature).toBeUndefined();
}); });
}); });