From f7df474084d72b5a68b19354735d603228d13222 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 30 Apr 2026 21:59:21 +0200 Subject: [PATCH] fix google vertex unsigned tool call replay closes #4032 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/google-shared.ts | 12 +- ...-shared-gemini3-unsigned-tool-call.test.ts | 208 +++++++----------- 3 files changed, 78 insertions(+), 143 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index dbc15862..529c1ad1 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ ### 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)). - 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)). diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index f03a8377..9f0ab180 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -51,11 +51,6 @@ export function retainThoughtSignature(existing: string | undefined, incoming: s // Thought signatures must be base64 for Google APIs (TYPE_BYTES). 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 { if (!signature) return false; if (signature.length % 4 !== 0) return false; @@ -161,18 +156,13 @@ export function convertMessages(model: Model, contex } } else if (block.type === "toolCall") { 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 = { functionCall: { name: block.name, args: block.arguments ?? {}, ...(requiresToolCallId(model.id) ? { id: block.id } : {}), }, - ...(effectiveSignature && { thoughtSignature: effectiveSignature }), + ...(thoughtSignature && { thoughtSignature }), }; parts.push(part); } diff --git a/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts b/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts index b1d09fb4..1822e9dd 100644 --- a/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts +++ b/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts @@ -2,15 +2,17 @@ import { describe, expect, it } from "vitest"; import { convertMessages } from "../src/providers/google-shared.js"; import type { Context, Model } from "../src/types.js"; -const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"; - -function makeGemini3Model(id = "gemini-3-pro-preview"): Model<"google-generative-ai"> { +function makeGemini3Model( + api: TApi, + provider: Model["provider"], + id = "gemini-3-pro-preview", +): Model { return { id, name: "Gemini 3 Pro Preview", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com", + api, + provider, + baseUrl: "https://example.com", reasoning: true, input: ["text"], 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", () => { - it("uses skip_thought_signature_validator for unsigned tool calls on Gemini 3", () => { - const model = makeGemini3Model(); - 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 -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 }, +function makeContext(model: { api: string; provider: string; id: string }, thoughtSignature?: string): Context { + const now = Date.now(); + return { + messages: [ + { role: "user", content: "Hi", timestamp: now }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1", + name: "bash", + arguments: { command: "echo hi" }, + ...(thoughtSignature && { thoughtSignature }), }, - 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"); expect(modelTurn).toBeTruthy(); - // Should be a structured functionCall, NOT text fallback - const fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined); - expect(fcPart).toBeTruthy(); - expect(fcPart?.functionCall?.name).toBe("bash"); - expect(fcPart?.functionCall?.args).toEqual({ command: "ls -la" }); - expect(fcPart?.thoughtSignature).toBe(SKIP_THOUGHT_SIGNATURE); + const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? []; + expect(functionCallParts).toHaveLength(2); + expect(functionCallParts[0]?.thoughtSignature).toBeUndefined(); + expect(functionCallParts[1]?.thoughtSignature).toBeUndefined(); + expect(JSON.stringify(modelTurn)).not.toContain("skip_thought_signature_validator"); - // No text fallback should exist const textParts = modelTurn?.parts?.filter((p) => p.text !== undefined) ?? []; const historicalText = textParts.filter((p) => p.text?.includes("Historical context")); expect(historicalText).toHaveLength(0); }); - it("preserves valid thoughtSignature when present (same provider/model)", () => { - const model = makeGemini3Model(); - const now = Date.now(); - // 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); + it("does not add skip_thought_signature_validator for unsigned Vertex tool calls", () => { + const model = makeGemini3Model("google-vertex", "google-vertex"); + const contents = convertMessages(model, makeContext(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(fcPart?.thoughtSignature).toBe(validSig); + expect(functionCallParts).toHaveLength(2); + 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", () => { - const model: Model<"google-generative-ai"> = { - id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash", - api: "google-generative-ai", - provider: "google", - 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, - }, - ], - }; + it("preserves valid thoughtSignature when present for the same provider and model", () => { + const model = makeGemini3Model("google-generative-ai", "google"); + const validSig = "AAAAAAAAAAAAAAAAAAAAAA=="; + const contents = convertMessages(model, makeContext(model, validSig)); + const modelTurn = contents.find((c) => c.role === "model"); + const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? []; - 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 fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined); expect(fcPart).toBeTruthy(); - // No sentinel, no thoughtSignature at all expect(fcPart?.thoughtSignature).toBeUndefined(); }); });