Merge pull request #369 from ndycode/fix/issue-368-sentinel-thinking

Fix Claude thinking signature reuse (issue #368)
This commit is contained in:
Noè
2026-02-06 14:47:57 +00:00
committed by GitHub
3 changed files with 94 additions and 48 deletions

View File

@@ -1085,6 +1085,31 @@ function sanitizeThinkingPart(part: Record<string, unknown>): Record<string, unk
return stripCacheControlRecursively(part) as Record<string, unknown>;
}
function applySentinelSignature(part: Record<string, unknown>): Record<string, unknown> {
const updated = { ...part } as Record<string, unknown>;
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" })}
},
});
}

View File

@@ -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);

View File

@@ -355,35 +355,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 };
}
@@ -396,11 +385,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;
@@ -433,24 +424,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;
}
@@ -459,21 +449,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 {
@@ -516,7 +496,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),
);
});
}
@@ -542,7 +522,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] };
@@ -566,7 +550,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] };
@@ -1565,7 +1549,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;
@@ -1736,4 +1721,3 @@ export const __testExports = {
transformStreamingPayload,
createStreamingTransformer,
};