diff --git a/CHANGELOG.md b/CHANGELOG.md index c750de5e0..8f588aaf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,7 @@ ### Fixes - Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`). - Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4. +- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien. ## 2026.1.12-1 diff --git a/src/agents/pi-embedded-helpers/bootstrap.ts b/src/agents/pi-embedded-helpers/bootstrap.ts index 3e17e35e4..8f49f612e 100644 --- a/src/agents/pi-embedded-helpers/bootstrap.ts +++ b/src/agents/pi-embedded-helpers/bootstrap.ts @@ -9,26 +9,65 @@ import type { EmbeddedContextFile } from "./types.js"; type ContentBlockWithSignature = { thought_signature?: unknown; + thoughtSignature?: unknown; [key: string]: unknown; }; +type ThoughtSignatureSanitizeOptions = { + allowBase64Only?: boolean; + includeCamelCase?: boolean; +}; + +function isBase64Signature(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return false; + const compact = trimmed.replace(/\s+/g, ""); + if (!/^[A-Za-z0-9+/=_-]+$/.test(compact)) return false; + const isUrl = compact.includes("-") || compact.includes("_"); + try { + const buf = Buffer.from(compact, isUrl ? "base64url" : "base64"); + if (buf.length === 0) return false; + const encoded = buf.toString(isUrl ? "base64url" : "base64"); + const normalize = (input: string) => input.replace(/=+$/g, ""); + return normalize(encoded) === normalize(compact); + } catch { + return false; + } +} + /** * Strips Claude-style thought_signature fields from content blocks. * * Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids * like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures. */ -export function stripThoughtSignatures(content: T): T { +export function stripThoughtSignatures( + content: T, + options?: ThoughtSignatureSanitizeOptions, +): T { if (!Array.isArray(content)) return content; + const allowBase64Only = options?.allowBase64Only ?? false; + const includeCamelCase = options?.includeCamelCase ?? false; + const shouldStripSignature = (value: unknown): boolean => { + if (!allowBase64Only) { + return typeof value === "string" && value.startsWith("msg_"); + } + return typeof value !== "string" || !isBase64Signature(value); + }; return content.map((block) => { if (!block || typeof block !== "object") return block; const rec = block as ContentBlockWithSignature; - const signature = rec.thought_signature; - if (typeof signature !== "string" || !signature.startsWith("msg_")) { + const stripSnake = shouldStripSignature(rec.thought_signature); + const stripCamel = includeCamelCase + ? shouldStripSignature(rec.thoughtSignature) + : false; + if (!stripSnake && !stripCamel) { return block; } - const { thought_signature: _signature, ...rest } = rec; - return rest; + const next = { ...rec }; + if (stripSnake) delete next.thought_signature; + if (stripCamel) delete next.thoughtSignature; + return next; }) as T; } diff --git a/src/agents/pi-embedded-helpers/google.ts b/src/agents/pi-embedded-helpers/google.ts index 91443e34b..a5fdee75a 100644 --- a/src/agents/pi-embedded-helpers/google.ts +++ b/src/agents/pi-embedded-helpers/google.ts @@ -23,6 +23,7 @@ export { sanitizeGoogleTurnOrdering }; type GeminiToolCallBlock = { type?: unknown; thought_signature?: unknown; + thoughtSignature?: unknown; id?: unknown; toolCallId?: unknown; name?: unknown; @@ -118,7 +119,8 @@ export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[] const blockRecord = block as GeminiToolCallBlock; const type = blockRecord.type; if (type === "toolCall" || type === "functionCall" || type === "toolUse") { - const hasSignature = Boolean(blockRecord.thought_signature); + const signature = blockRecord.thought_signature ?? blockRecord.thoughtSignature; + const hasSignature = Boolean(signature); if (!hasSignature) { const id = typeof blockRecord.id === "string" diff --git a/src/agents/pi-embedded-helpers/images.ts b/src/agents/pi-embedded-helpers/images.ts index bb1ddb260..a53c27a00 100644 --- a/src/agents/pi-embedded-helpers/images.ts +++ b/src/agents/pi-embedded-helpers/images.ts @@ -34,6 +34,10 @@ export async function sanitizeSessionMessagesImages( sanitizeToolCallIds?: boolean; enforceToolCallLast?: boolean; preserveSignatures?: boolean; + sanitizeThoughtSignatures?: { + allowBase64Only?: boolean; + includeCamelCase?: boolean; + }; }, ): Promise { // We sanitize historical session messages because Anthropic can reject a request @@ -82,7 +86,7 @@ export async function sanitizeSessionMessagesImages( if (Array.isArray(content)) { const strippedContent = options?.preserveSignatures ? content // Keep signatures for Antigravity Claude - : stripThoughtSignatures(content); // Strip for Gemini + : stripThoughtSignatures(content, options?.sanitizeThoughtSignatures); // Strip for Gemini const filteredContent = strippedContent.filter((block) => { if (!block || typeof block !== "object") return true; diff --git a/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts b/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts index 17a58230d..0e3776f00 100644 --- a/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts +++ b/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts @@ -145,6 +145,65 @@ describe("sanitizeSessionHistory (google thinking)", () => { expect(assistant.content?.[1]?.text).toBe("internal note"); }); + it("strips non-base64 thought signatures for OpenRouter Gemini", async () => { + const sessionManager = SessionManager.inMemory(); + const input = [ + { + role: "user", + content: "hi", + }, + { + role: "assistant", + content: [ + { type: "text", text: "hello", thought_signature: "msg_abc123" }, + { type: "thinking", thinking: "ok", thought_signature: "c2ln" }, + { + type: "toolCall", + id: "call_1", + name: "read", + arguments: { path: "/tmp/foo" }, + thoughtSignature: "{\"id\":1}", + }, + { + type: "toolCall", + id: "call_2", + name: "read", + arguments: { path: "/tmp/bar" }, + thoughtSignature: "c2ln", + }, + ], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionHistory({ + messages: input, + modelApi: "openrouter", + provider: "openrouter", + modelId: "google/gemini-1.5-pro", + sessionManager, + sessionId: "session:openrouter-gemini", + }); + + const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { + content?: Array<{ type?: string; thought_signature?: string; thoughtSignature?: string }>; + }; + expect(assistant.content).toEqual([ + { type: "text", text: "hello" }, + { type: "text", text: "ok" }, + { + type: "text", + text: "[Tool Call: read (ID: call_1)]\nArguments: {\n \"path\": \"/tmp/foo\"\n}", + }, + { + type: "toolCall", + id: "call_2", + name: "read", + arguments: { path: "/tmp/bar" }, + thoughtSignature: "c2ln", + }, + ]); + }); + it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => { const sessionManager = SessionManager.inMemory(); const input = [ diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index b9ab4a034..6d9e11461 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -308,6 +308,7 @@ export async function compactEmbeddedPiSession(params: { messages: session.messages, modelApi: model.api, modelId, + provider, sessionManager, sessionId: params.sessionId, }); diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index d8e170a08..31ee19a59 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -185,17 +185,26 @@ export async function sanitizeSessionHistory(params: { messages: AgentMessage[]; modelApi?: string | null; modelId?: string; + provider?: string; sessionManager: SessionManager; sessionId: string; }): Promise { const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId); + const provider = (params.provider ?? "").toLowerCase(); + const modelId = (params.modelId ?? "").toLowerCase(); + const isOpenRouterGemini = + (provider === "openrouter" || provider === "opencode") && modelId.includes("gemini"); + const isGeminiLike = isGoogleModelApi(params.modelApi) || isOpenRouterGemini; const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", { sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi), enforceToolCallLast: params.modelApi === "anthropic-messages", preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel, + sanitizeThoughtSignatures: isOpenRouterGemini + ? { allowBase64Only: true, includeCamelCase: true } + : undefined, }); const repairedTools = sanitizeToolUseResultPairing(sanitizedImages); - const shouldDowngradeGemini = isGoogleModelApi(params.modelApi) && !isAntigravityClaudeModel; + const shouldDowngradeGemini = isGeminiLike && !isAntigravityClaudeModel; // Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT. const downgradedThinking = shouldDowngradeGemini ? downgradeGeminiThinkingBlocks(repairedTools) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 065132828..40c11de2f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -323,6 +323,7 @@ export async function runEmbeddedAttempt( messages: activeSession.messages, modelApi: params.model.api, modelId: params.modelId, + provider: params.provider, sessionManager, sessionId: params.sessionId, });