Merge pull request #845 from MatthieuBizien/fix/issue-841-openrouter-gemini
Agents: sanitize OpenRouter Gemini thoughtSignature
This commit is contained in:
@@ -144,6 +144,7 @@
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
- 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: 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
|
## 2026.1.12-1
|
||||||
|
|
||||||
|
|||||||
@@ -9,26 +9,65 @@ import type { EmbeddedContextFile } from "./types.js";
|
|||||||
|
|
||||||
type ContentBlockWithSignature = {
|
type ContentBlockWithSignature = {
|
||||||
thought_signature?: unknown;
|
thought_signature?: unknown;
|
||||||
|
thoughtSignature?: unknown;
|
||||||
[key: string]: 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.
|
* Strips Claude-style thought_signature fields from content blocks.
|
||||||
*
|
*
|
||||||
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
|
* 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.
|
* 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;
|
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) => {
|
return content.map((block) => {
|
||||||
if (!block || typeof block !== "object") return block;
|
if (!block || typeof block !== "object") return block;
|
||||||
const rec = block as ContentBlockWithSignature;
|
const rec = block as ContentBlockWithSignature;
|
||||||
const signature = rec.thought_signature;
|
const stripSnake = shouldStripSignature(rec.thought_signature);
|
||||||
if (typeof signature !== "string" || !signature.startsWith("msg_")) {
|
const stripCamel = includeCamelCase
|
||||||
|
? shouldStripSignature(rec.thoughtSignature)
|
||||||
|
: false;
|
||||||
|
if (!stripSnake && !stripCamel) {
|
||||||
return block;
|
return block;
|
||||||
}
|
}
|
||||||
const { thought_signature: _signature, ...rest } = rec;
|
const next = { ...rec };
|
||||||
return rest;
|
if (stripSnake) delete next.thought_signature;
|
||||||
|
if (stripCamel) delete next.thoughtSignature;
|
||||||
|
return next;
|
||||||
}) as T;
|
}) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export { sanitizeGoogleTurnOrdering };
|
|||||||
type GeminiToolCallBlock = {
|
type GeminiToolCallBlock = {
|
||||||
type?: unknown;
|
type?: unknown;
|
||||||
thought_signature?: unknown;
|
thought_signature?: unknown;
|
||||||
|
thoughtSignature?: unknown;
|
||||||
id?: unknown;
|
id?: unknown;
|
||||||
toolCallId?: unknown;
|
toolCallId?: unknown;
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
@@ -118,7 +119,8 @@ export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[]
|
|||||||
const blockRecord = block as GeminiToolCallBlock;
|
const blockRecord = block as GeminiToolCallBlock;
|
||||||
const type = blockRecord.type;
|
const type = blockRecord.type;
|
||||||
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
|
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) {
|
if (!hasSignature) {
|
||||||
const id =
|
const id =
|
||||||
typeof blockRecord.id === "string"
|
typeof blockRecord.id === "string"
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
sanitizeToolCallIds?: boolean;
|
sanitizeToolCallIds?: boolean;
|
||||||
enforceToolCallLast?: boolean;
|
enforceToolCallLast?: boolean;
|
||||||
preserveSignatures?: boolean;
|
preserveSignatures?: boolean;
|
||||||
|
sanitizeThoughtSignatures?: {
|
||||||
|
allowBase64Only?: boolean;
|
||||||
|
includeCamelCase?: boolean;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
): Promise<AgentMessage[]> {
|
): Promise<AgentMessage[]> {
|
||||||
// We sanitize historical session messages because Anthropic can reject a request
|
// We sanitize historical session messages because Anthropic can reject a request
|
||||||
@@ -82,7 +86,7 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
const strippedContent = options?.preserveSignatures
|
const strippedContent = options?.preserveSignatures
|
||||||
? content // Keep signatures for Antigravity Claude
|
? content // Keep signatures for Antigravity Claude
|
||||||
: stripThoughtSignatures(content); // Strip for Gemini
|
: stripThoughtSignatures(content, options?.sanitizeThoughtSignatures); // Strip for Gemini
|
||||||
|
|
||||||
const filteredContent = strippedContent.filter((block) => {
|
const filteredContent = strippedContent.filter((block) => {
|
||||||
if (!block || typeof block !== "object") return true;
|
if (!block || typeof block !== "object") return true;
|
||||||
|
|||||||
@@ -145,6 +145,65 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
expect(assistant.content?.[1]?.text).toBe("internal note");
|
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 () => {
|
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const input = [
|
const input = [
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
modelApi: model.api,
|
modelApi: model.api,
|
||||||
modelId,
|
modelId,
|
||||||
|
provider,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -185,17 +185,26 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
messages: AgentMessage[];
|
messages: AgentMessage[];
|
||||||
modelApi?: string | null;
|
modelApi?: string | null;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
provider?: string;
|
||||||
sessionManager: SessionManager;
|
sessionManager: SessionManager;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}): Promise<AgentMessage[]> {
|
}): Promise<AgentMessage[]> {
|
||||||
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
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", {
|
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
||||||
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
||||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||||
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
||||||
|
sanitizeThoughtSignatures: isOpenRouterGemini
|
||||||
|
? { allowBase64Only: true, includeCamelCase: true }
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
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.
|
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
|
||||||
const downgradedThinking = shouldDowngradeGemini
|
const downgradedThinking = shouldDowngradeGemini
|
||||||
? downgradeGeminiThinkingBlocks(repairedTools)
|
? downgradeGeminiThinkingBlocks(repairedTools)
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ export async function runEmbeddedAttempt(
|
|||||||
messages: activeSession.messages,
|
messages: activeSession.messages,
|
||||||
modelApi: params.model.api,
|
modelApi: params.model.api,
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
|
provider: params.provider,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user