From 85957e104ccbab9a3883a60d5d73bee1ab723495 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 6 Feb 2026 00:29:38 +0800 Subject: [PATCH] Fix thinking signature reuse for Claude --- src/plugin/request-helpers.ts | 57 ++++++++++++++++++++++++++- src/plugin/request.test.ts | 11 +++++- src/plugin/request.ts | 74 ++++++++++++++--------------------- 3 files changed, 94 insertions(+), 48 deletions(-) diff --git a/src/plugin/request-helpers.ts b/src/plugin/request-helpers.ts index 74ffff0..9102c64 100644 --- a/src/plugin/request-helpers.ts +++ b/src/plugin/request-helpers.ts @@ -1085,6 +1085,31 @@ function sanitizeThinkingPart(part: Record): Record; } +function applySentinelSignature(part: Record): Record { + const updated = { ...part } as Record; + + if ("thoughtSignature" in updated) { + (updated as any).thoughtSignature = SKIP_THOUGHT_SIGNATURE; + } + if ("signature" in updated) { + (updated as any).signature = SKIP_THOUGHT_SIGNATURE; + } + + if ((updated as any).thought === true && (updated as any).thoughtSignature === undefined) { + (updated as any).thoughtSignature = SKIP_THOUGHT_SIGNATURE; + } + if ( + ((updated as any).type === "thinking" + || (updated as any).type === "redacted_thinking" + || (updated as any).type === "reasoning") + && (updated as any).signature === undefined + ) { + (updated as any).signature = SKIP_THOUGHT_SIGNATURE; + } + + return updated; +} + function findLastAssistantIndex(contents: any[], roleValue: "model" | "assistant"): number { for (let i = contents.length - 1; i >= 0; i--) { const content = contents[i]; @@ -1108,6 +1133,37 @@ function filterContentArray( return stripAllThinkingBlocks(contentArray); } + if (isClaudeModel) { + const filtered: any[] = []; + + for (const item of contentArray) { + if (!item || typeof item !== "object") { + filtered.push(item); + continue; + } + + if (isToolBlock(item)) { + filtered.push(item); + continue; + } + + const isThinking = isThinkingPart(item); + const hasSignature = hasSignatureField(item); + + if (!isThinking && !hasSignature) { + filtered.push(item); + continue; + } + + const sanitized = sanitizeThinkingPart(item); + if (sanitized) { + filtered.push(applySentinelSignature(sanitized)); + } + } + + return filtered; + } + const filtered: any[] = []; for (const item of contentArray) { @@ -2814,4 +2870,3 @@ data: ${JSON.stringify({ type: "message_stop" })} }, }); } - diff --git a/src/plugin/request.test.ts b/src/plugin/request.test.ts index f7ae91e..2d3a57f 100644 --- a/src/plugin/request.test.ts +++ b/src/plugin/request.test.ts @@ -236,11 +236,11 @@ describe("request.ts", () => { expect(result.thoughtSignature).toBe("skip_thought_signature_validator"); }); - it("preserves existing thoughtSignature", () => { + it("replaces existing thoughtSignature with sentinel", () => { const existingSignature = "a".repeat(MIN_SIGNATURE_LENGTH + 10); const part = { thought: true, text: "thinking...", thoughtSignature: existingSignature }; const result = ensureThoughtSignature(part, "session-key"); - expect(result.thoughtSignature).toBe(existingSignature); + expect(result.thoughtSignature).toBe("skip_thought_signature_validator"); }); it("does not modify non-thinking parts", () => { @@ -276,6 +276,13 @@ describe("request.ts", () => { expect(hasSignedThinkingPart(part)).toBe(true); }); + it("returns true for sentinel signatures", () => { + const thoughtPart = { thought: true, thoughtSignature: "skip_thought_signature_validator" }; + const thinkingPart = { type: "thinking", signature: "skip_thought_signature_validator" }; + expect(hasSignedThinkingPart(thoughtPart)).toBe(true); + expect(hasSignedThinkingPart(thinkingPart)).toBe(true); + }); + it("returns false for part with short signature", () => { const part = { thought: true, thoughtSignature: "short" }; expect(hasSignedThinkingPart(part)).toBe(false); diff --git a/src/plugin/request.ts b/src/plugin/request.ts index f533499..2f7ede5 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -354,35 +354,24 @@ function isGeminiThinkingPart(part: any): boolean { // Reference: LLM-API-Key-Proxy uses this pattern for Gemini 3 tool calls. const SENTINEL_SIGNATURE = "skip_thought_signature_validator"; -function ensureThoughtSignature(part: any, sessionId: string): any { +function ensureThoughtSignature(part: any, _sessionId: string): any { if (!part || typeof part !== "object") { return part; } - const text = typeof part.text === "string" ? part.text : typeof part.thinking === "string" ? part.thinking : ""; - if (!text) { - return part; - } - if (part.thought === true) { - if (!part.thoughtSignature) { - const cached = getCachedSignature(sessionId, text); - if (cached) { - return { ...part, thoughtSignature: cached }; - } - // Fallback: use sentinel signature to prevent API rejection - // This allows Claude to redact the thinking block instead of failing - return { ...part, thoughtSignature: SENTINEL_SIGNATURE }; + if (part.thoughtSignature === SENTINEL_SIGNATURE) { + return part; } - return part; + // Always use sentinel to avoid invalid signature reuse in history. + return { ...part, thoughtSignature: SENTINEL_SIGNATURE }; } - if ((part.type === "thinking" || part.type === "reasoning") && !part.signature) { - const cached = getCachedSignature(sessionId, text); - if (cached) { - return { ...part, signature: cached }; + if (part.type === "thinking" || part.type === "reasoning") { + if (part.signature === SENTINEL_SIGNATURE) { + return part; } - // Fallback: use sentinel signature to prevent API rejection + // Always use sentinel to avoid invalid signature reuse in history. return { ...part, signature: SENTINEL_SIGNATURE }; } @@ -395,11 +384,13 @@ function hasSignedThinkingPart(part: any): boolean { } if (part.thought === true) { - return typeof part.thoughtSignature === "string" && part.thoughtSignature.length >= MIN_SIGNATURE_LENGTH; + return typeof part.thoughtSignature === "string" + && (part.thoughtSignature === SENTINEL_SIGNATURE || part.thoughtSignature.length >= MIN_SIGNATURE_LENGTH); } if (part.type === "thinking" || part.type === "reasoning") { - return typeof part.signature === "string" && part.signature.length >= MIN_SIGNATURE_LENGTH; + return typeof part.signature === "string" + && (part.signature === SENTINEL_SIGNATURE || part.signature.length >= MIN_SIGNATURE_LENGTH); } return false; @@ -432,24 +423,23 @@ function ensureThinkingBeforeToolUseInContents(contents: any[], signatureSession const lastThinking = defaultSignatureStore.get(signatureSessionKey); if (!lastThinking) { - // No cached signature available - strip thinking blocks entirely - // Claude requires valid signatures, and we can't fake them - // Return only tool_use parts without any thinking to avoid signature validation errors - log.debug("Stripping thinking from tool_use content (no valid cached signature)", { signatureSessionKey }); + // No cached thinking available - strip thinking blocks entirely + // Return only tool_use parts without any thinking to avoid invalid signature reuse + log.debug("Stripping thinking from tool_use content (no cached thinking)", { signatureSessionKey }); return { ...content, parts: otherParts }; } const injected = { thought: true, text: lastThinking.text, - thoughtSignature: lastThinking.signature, + thoughtSignature: SKIP_THOUGHT_SIGNATURE, }; return { ...content, parts: [injected, ...otherParts] }; }); } -function ensureMessageThinkingSignature(block: any, sessionId: string): any { +function ensureMessageThinkingSignature(block: any, _sessionId: string): any { if (!block || typeof block !== "object") { return block; } @@ -458,21 +448,11 @@ function ensureMessageThinkingSignature(block: any, sessionId: string): any { return block; } - if (typeof block.signature === "string" && block.signature.length >= MIN_SIGNATURE_LENGTH) { + if (block.signature === SENTINEL_SIGNATURE) { return block; } - const text = typeof block.thinking === "string" ? block.thinking : typeof block.text === "string" ? block.text : ""; - if (!text) { - return block; - } - - const cached = getCachedSignature(sessionId, text); - if (cached) { - return { ...block, signature: cached }; - } - - return block; + return { ...block, signature: SENTINEL_SIGNATURE }; } function hasToolUseInContents(contents: any[]): boolean { @@ -515,7 +495,7 @@ function hasSignedThinkingInMessages(messages: any[]): boolean { typeof block === "object" && (block.type === "thinking" || block.type === "redacted_thinking") && typeof block.signature === "string" && - block.signature.length >= MIN_SIGNATURE_LENGTH, + (block.signature === SKIP_THOUGHT_SIGNATURE || block.signature.length >= MIN_SIGNATURE_LENGTH), ); }); } @@ -541,7 +521,11 @@ function ensureThinkingBeforeToolUseInMessages(messages: any[], signatureSession .map((b) => ensureMessageThinkingSignature(b, signatureSessionKey)); const otherBlocks = blocks.filter((b) => !(b && typeof b === "object" && (b.type === "thinking" || b.type === "redacted_thinking"))); - const hasSignedThinking = thinkingBlocks.some((b) => typeof b.signature === "string" && b.signature.length >= MIN_SIGNATURE_LENGTH); + const hasSignedThinking = thinkingBlocks.some( + (b) => + typeof b.signature === "string" + && (b.signature === SKIP_THOUGHT_SIGNATURE || b.signature.length >= MIN_SIGNATURE_LENGTH), + ); if (hasSignedThinking) { return { ...message, content: [...thinkingBlocks, ...otherBlocks] }; @@ -565,7 +549,7 @@ function ensureThinkingBeforeToolUseInMessages(messages: any[], signatureSession const injected = { type: "thinking", thinking: lastThinking.text, - signature: lastThinking.signature, + signature: SKIP_THOUGHT_SIGNATURE, }; return { ...message, content: [injected, ...otherBlocks] }; @@ -1555,7 +1539,8 @@ export async function transformAntigravityResponse( try { const headers = new Headers(response.headers); - const text = await response.text(); + // Read from a clone so the original body remains available for callers on failure paths. + const text = await response.clone().text(); if (!response.ok) { let errorBody; @@ -1715,4 +1700,3 @@ export const __testExports = { transformStreamingPayload, createStreamingTransformer, }; -