Avoid cross-provider thought signatures (#654)

* Avoid cross-provider thought signatures

* Fix Google thought signature replay

Filter thought signatures to same provider with base64 validation and rename the transform helper for clarity.
This commit is contained in:
Danila Poyarkov 2026-01-12 18:38:53 +03:00 committed by GitHub
parent 6f3ba88733
commit 934e7e470b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 28 additions and 9 deletions

View file

@ -26,7 +26,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { transformMessages } from "./transorm-messages.js";
import { transformMessages } from "./transform-messages.js";
// Stealth mode: Mimic Claude Code's tool naming exactly
const claudeCodeVersion = "2.1.2";

View file

@ -5,7 +5,7 @@
import { type Content, FinishReason, FunctionCallingConfigMode, type Part, type Schema } from "@google/genai";
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { transformMessages } from "./transorm-messages.js";
import { transformMessages } from "./transform-messages.js";
type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
@ -42,6 +42,22 @@ export function retainThoughtSignature(existing: string | undefined, incoming: s
return existing;
}
// Thought signatures must be base64 for Google APIs (TYPE_BYTES).
const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/;
function isValidThoughtSignature(signature: string | undefined): boolean {
if (!signature) return false;
if (signature.length % 4 !== 0) return false;
return base64SignaturePattern.test(signature);
}
/**
* Only keep signatures from the same provider/model and with valid base64.
*/
function resolveThoughtSignature(isSameProviderAndModel: boolean, signature: string | undefined): string | undefined {
return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined;
}
/**
* Convert internal messages to Gemini Content[] format.
*/
@ -85,9 +101,10 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
if (block.type === "text") {
// Skip empty text blocks - they can cause issues with some models (e.g. Claude via Antigravity)
if (!block.text || block.text.trim() === "") continue;
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.textSignature);
parts.push({
text: sanitizeSurrogates(block.text),
...(block.textSignature && { thoughtSignature: block.textSignature }),
...(thoughtSignature && { thoughtSignature }),
});
} else if (block.type === "thinking") {
// Skip empty thinking blocks
@ -95,10 +112,11 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
// Only keep as thinking block if same provider AND same model
// Otherwise convert to plain text (no tags to avoid model mimicking them)
if (isSameProviderAndModel) {
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thinkingSignature);
parts.push({
thought: true,
text: sanitizeSurrogates(block.thinking),
...(block.thinkingSignature && { thoughtSignature: block.thinkingSignature }),
...(thoughtSignature && { thoughtSignature }),
});
} else {
parts.push({
@ -112,8 +130,9 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
args: block.arguments,
},
};
if (block.thoughtSignature) {
part.thoughtSignature = block.thoughtSignature;
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
if (thoughtSignature) {
part.thoughtSignature = thoughtSignature;
}
parts.push(part);
}

View file

@ -42,7 +42,7 @@ import {
transformRequestBody,
} from "./openai-codex/request-transformer.js";
import { parseCodexError, parseCodexSseStream } from "./openai-codex/response-handler.js";
import { transformMessages } from "./transorm-messages.js";
import { transformMessages } from "./transform-messages.js";
export interface OpenAICodexResponsesOptions extends StreamOptions {
reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";

View file

@ -27,7 +27,7 @@ import type {
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { transformMessages } from "./transorm-messages.js";
import { transformMessages } from "./transform-messages.js";
/**
* Normalize tool call ID for Mistral.

View file

@ -28,7 +28,7 @@ import type {
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { transformMessages } from "./transorm-messages.js";
import { transformMessages } from "./transform-messages.js";
/** Fast deterministic hash to shorten long strings */
function shortHash(str: string): string {