Agents: sanitize OpenRouter Gemini thoughtSignature

This commit is contained in:
Matthieu Bizien
2026-01-13 12:25:52 +01:00
committed by Peter Steinberger
parent d42b69df74
commit ef36e24522
11 changed files with 4156 additions and 86 deletions

View File

@@ -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<T>(content: T): T {
export function stripThoughtSignatures<T>(
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;
}

View File

@@ -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"

View File

@@ -34,6 +34,10 @@ export async function sanitizeSessionMessagesImages(
sanitizeToolCallIds?: boolean;
enforceToolCallLast?: boolean;
preserveSignatures?: boolean;
sanitizeThoughtSignatures?: {
allowBase64Only?: boolean;
includeCamelCase?: boolean;
};
},
): Promise<AgentMessage[]> {
// 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;