From 6f2b360ce873fe7d9e5e2a88ea60149c469b67fd Mon Sep 17 00:00:00 2001 From: OmniRoute Ops Date: Wed, 13 May 2026 11:57:42 +0000 Subject: [PATCH] fix(claudeHelper): preserve latest assistant thinking blocks verbatim Derived from squash commit 161cfcf7 (PR #9). The original squash was fat (316 files) because the source branch was rebased on an old base; this commit applies only the claudeHelper-relevant files surgically onto deploy. Computes latestAssistantIndex once before the message loop and skips the rewrite-to-redacted-thinking transform on the latest assistant message. Symmetric guard for non-Anthropic Claude-shape providers preserves plain thinking.thinking text on the latest message. Co-authored-by: OmniRoute Ops --- open-sse/translator/helpers/claudeHelper.ts | 176 +++++++-- .../translator-claude-helper-thinking.test.ts | 341 ++++++++++++++---- tests/unit/translator-helper-branches.test.ts | 21 +- 3 files changed, 440 insertions(+), 98 deletions(-) diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 8d8f476535..afdf796562 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -1,5 +1,15 @@ // Claude helper functions for translator import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; +import { lookupReasoning, recordReplay } from "../../services/reasoningCache.ts"; + +// Placeholder thinking text used as last-resort fallback when: +// - Target upstream is a non-Anthropic Claude-shape provider +// (kimi-coding, glmt, zai, …) that rejects redacted_thinking blobs +// - Client (e.g. Capy) sent only redacted_thinking on replay +// - reasoningCache has no entry for the corresponding tool_use.id +// Must be non-empty: kimi-coding treats empty `thinking.thinking` as +// `reasoning_content missing` and 400s. +const NON_ANTHROPIC_THINKING_PLACEHOLDER = "(prior reasoning summary unavailable)"; type ClaudeContentBlock = { type?: string; @@ -151,6 +161,17 @@ export function prepareClaudeRequest( const supportsPromptCaching = provider === "claude" || provider?.startsWith?.("anthropic-compatible-"); + // Non-Anthropic Claude-shape providers (kimi-coding, glmt, zai, …) cannot + // validate the synthetic redacted_thinking.data blob — they're not Anthropic + // and don't speak its signature scheme. They expect plain `thinking { text }` + // blocks with the original reasoning text, or fail with: + // "thinking is enabled but reasoning_content is missing in assistant + // tool call message at index N" + // We use the same allowlist as prompt-caching: only Anthropic-native + // upstreams get redacted_thinking. Everything else gets plain thinking blocks + // backed by reasoningCache (real text) or a placeholder (cache miss). + const supportsRedactedThinking = supportsPromptCaching; + const systemBlocks = body.system; if (systemBlocks && Array.isArray(systemBlocks) && !preserveCacheControl) { body.system = systemBlocks.map((block, i) => { @@ -233,6 +254,19 @@ export function prepareClaudeRequest( } // Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic + + // Index of the LAST assistant message in the filtered array. Anthropic + // enforces the latest assistant message's thinking blocks cannot be + // modified — preserve them verbatim. Older assistant messages can be + // rewritten to redacted_thinking { data } as before. + let latestAssistantIndex = -1; + for (let k = filtered.length - 1; k >= 0; k--) { + if (filtered[k]?.role === "assistant") { + latestAssistantIndex = k; + break; + } + } + let lastAssistantProcessed = false; for (let i = filtered.length - 1; i >= 0; i--) { const msg = filtered[i]; @@ -264,46 +298,132 @@ export function prepareClaudeRequest( // assistant tool call message at index N" (kimi-coding) // "Invalid signature in thinking block" (claude native, on // cross-provider replay) + // Guard: never modify EXISTING thinking blocks in the latest assistant + // message when sending to an Anthropic-native upstream. Anthropic returns + // 400 "blocks in the latest assistant message cannot be modified" if any + // field changes. Injecting a NEW thinking block (when none exists) is fine. + // Older assistant messages can still be rewritten. + // For non-Anthropic providers: only the text replacement is skipped + // for the latest assistant (if it already has non-empty thinking text); + // field cleanup (signature strip, type normalization) still runs. + const isLatestAssistant = i === latestAssistantIndex; + const latestHasExistingThinking = + isLatestAssistant && + content.some((b: any) => b.type === "thinking" || b.type === "redacted_thinking"); + if (latestHasExistingThinking && supportsRedactedThinking) { + // Anthropic: skip all thinking-block rewrites entirely — the + // blocks must remain verbatim (type, thinking, signature, data). + continue; + } + let hasToolUse = false; let hasThinking = false; - // Convert thinking blocks to redacted_thinking with synthetic `data`. - // When requests cross provider boundaries (e.g., combo fallback) or - // when client-stored signatures (Capy) replay back to Anthropic, the - // original `thinking.signature` no longer validates: "Invalid signature - // in thinking block" 400. redacted_thinking accepts without signature - // validation — but Anthropic REQUIRES a `data` field on it. Previous - // versions emitted `signature` on redacted_thinking (wrong field, - // belongs on regular `thinking`) and omitted `data`, causing: - // messages.N.content.0.redacted_thinking.data: Field required (400) + // Pre-collect tool_use ids in this content[] for reasoningCache + // lookups when the upstream is a non-Anthropic Claude-shape provider. + // The cache is keyed by tool_call_id which equals tool_use.id for + // Anthropic-shape (the same value is reused across formats — see + // claude-to-openai.ts:63 where openai tool_call.id = claude tool_use.id). + const toolUseIds: string[] = []; + if (!supportsRedactedThinking) { + for (const block of content) { + if (block.type === "tool_use" && typeof block.id === "string") { + toolUseIds.push(block.id); + } + } + } + + // Convert thinking blocks per provider type: // - // Fix: emit only the correct fields per type. - // - redacted_thinking: { type, data } - // - thinking: { type, thinking, signature } - // We use DEFAULT_THINKING_CLAUDE_SIGNATURE as the `data` placeholder - // — it's a proven Anthropic-format base64 blob accepted as a valid - // redacted_thinking payload (replay context). + // Anthropic-native (claude, anthropic-compatible-*): + // Emit redacted_thinking { data } with synthetic blob. Anthropic + // accepts this as a valid placeholder for replay context without + // re-validating the original signature. Previous behavior — keep. + // + // When requests cross provider boundaries (e.g., combo fallback) or + // when client-stored signatures (Capy) replay back to Anthropic, the + // original `thinking.signature` no longer validates: "Invalid + // signature in thinking block" 400. redacted_thinking accepts without + // signature validation — but Anthropic REQUIRES a `data` field. + // Field rules: redacted_thinking={type,data} ; thinking={type,thinking,signature}. + // + // Non-Anthropic Claude-shape (kimi-coding, glmt, zai, …): + // Emit plain thinking { thinking: } using the real reasoning + // text from reasoningCache (captured on the prior assistant + // response). Falls back to NON_ANTHROPIC_THINKING_PLACEHOLDER if the + // cache misses (rare but possible after a process restart or TTL + // eviction). Empty text is treated as "missing" by kimi-coding so + // never emit an empty thinking field. + let thinkingBlockIdx = 0; for (const block of content) { if (block.type === "thinking" || block.type === "redacted_thinking") { - block.type = "redacted_thinking"; - block.data = DEFAULT_THINKING_CLAUDE_SIGNATURE; - delete block.thinking; - delete block.signature; + if (supportsRedactedThinking) { + block.type = "redacted_thinking"; + block.data = DEFAULT_THINKING_CLAUDE_SIGNATURE; + delete block.thinking; + delete block.signature; + } else { + const existing = + typeof block.thinking === "string" && block.thinking.length > 0 + ? block.thinking + : ""; + let text = existing; + // For the latest assistant message on non-Anthropic upstreams, + // preserve the thinking text verbatim when it is already present. + // Cache lookups and the placeholder fallback only apply to older + // messages (or to the latest if the client sent empty text). + if (!text || !latestHasExistingThinking) { + if (!text) { + const pairedToolUseId = toolUseIds[thinkingBlockIdx]; + if (pairedToolUseId) { + const cached = lookupReasoning(pairedToolUseId); + if (cached) { + text = cached; + recordReplay(); + } + } + } + block.type = "thinking"; + block.thinking = text || NON_ANTHROPIC_THINKING_PLACEHOLDER; + } else { + // latestHasExistingThinking + non-empty text: preserve text, still clean up fields + block.type = "thinking"; + } + delete block.data; + delete block.signature; + } hasThinking = true; + thinkingBlockIdx++; } if (block.type === "tool_use") hasToolUse = true; } - // Add thinking block if thinking enabled + has tool_use but no thinking. - // Required for Anthropic-shape thinking-mode upstreams (claude, kimi, - // glm) when the assistant turn's content[] needs a precursor thinking - // block in front of any tool_use. Use redacted_thinking shape (with - // `data`) to match what we emit when converting real thinking blocks. + // Add precursor thinking block if thinking enabled + has tool_use but + // no existing thinking-ish block. Required for Anthropic-shape + // thinking-mode upstreams (claude, kimi-coding, glm, …) when the + // assistant turn's content[] needs a thinking block in front of any + // tool_use. Use the same provider-aware shape selection as above. if (thinkingEnabled && !hasThinking && hasToolUse) { - content.unshift({ - type: "redacted_thinking", - data: DEFAULT_THINKING_CLAUDE_SIGNATURE, - }); + if (supportsRedactedThinking) { + content.unshift({ + type: "redacted_thinking", + data: DEFAULT_THINKING_CLAUDE_SIGNATURE, + }); + } else { + let text = ""; + const firstToolUseId = toolUseIds[0]; + if (firstToolUseId) { + const cached = lookupReasoning(firstToolUseId); + if (cached) { + text = cached; + recordReplay(); + } + } + content.unshift({ + type: "thinking", + thinking: text || NON_ANTHROPIC_THINKING_PLACEHOLDER, + }); + } } } } diff --git a/tests/unit/translator-claude-helper-thinking.test.ts b/tests/unit/translator-claude-helper-thinking.test.ts index f514cb67b8..8237417003 100644 --- a/tests/unit/translator-claude-helper-thinking.test.ts +++ b/tests/unit/translator-claude-helper-thinking.test.ts @@ -4,6 +4,10 @@ import assert from "node:assert/strict"; const { prepareClaudeRequest } = await import("../../open-sse/translator/helpers/claudeHelper.ts"); const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = await import("../../open-sse/config/defaultThinkingSignature.ts"); +const reasoningCache = await import("../../open-sse/services/reasoningCache.ts"); + +// Placeholder string from claudeHelper.ts — kept in sync via direct constant. +const PLACEHOLDER = "(prior reasoning summary unavailable)"; function multiTurnBodyWithoutThinkingBlock() { return { @@ -16,46 +20,149 @@ function multiTurnBodyWithoutThinkingBlock() { }, { role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_x", - content: "README.md\npackage.json", - }, - ], + content: [{ type: "tool_result", tool_use_id: "call_x", content: "README.md" }], }, ], }; } -test("prepareClaudeRequest: claude provider — injects thinking before tool_use (regression)", () => { +function multiTurnBodyWithThinkingBlock(thinkingText: string, toolUseId = "call_y") { + return { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: thinkingText, signature: "client-stored-sig" }, + { type: "tool_use", id: toolUseId, name: "ls", input: {} }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: toolUseId, content: "ok" }], + }, + ], + }; +} + +// ──────────────── Anthropic-native (claude, anthropic-compatible-*) ──────────────── + +test("claude provider — empty content, injects redacted_thinking{data} before tool_use", () => { const body = multiTurnBodyWithoutThinkingBlock(); const result = prepareClaudeRequest(body as any, "claude"); - const assistantContent = (result as any).messages[1].content; - assert.equal(assistantContent.length, 2, "thinking + tool_use"); - assert.equal(assistantContent[0].type, "redacted_thinking"); - assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE); - assert.equal(assistantContent[0].thinking, undefined); - assert.equal(assistantContent[0].signature, undefined); - assert.equal(assistantContent[1].type, "tool_use"); + const content = (result as any).messages[1].content; + assert.equal(content.length, 2); + assert.equal(content[0].type, "redacted_thinking"); + assert.equal(content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE); + assert.equal(content[0].thinking, undefined); + assert.equal(content[0].signature, undefined); + assert.equal(content[1].type, "tool_use"); }); -test("prepareClaudeRequest: kimi-coding provider — injects thinking before tool_use (new behavior)", () => { - // Previously: kimi-coding was excluded by the provider gate and the assistant - // turn shipped to api.kimi.com/coding/v1/messages without a thinking - // precursor, triggering 400 "thinking is enabled but reasoning_content is - // missing in assistant tool call message at index N". +test("claude provider — existing thinking block converted to redacted_thinking{data} on older messages", () => { + // Uses a two-assistant-turn body: the first assistant (with thinking) is an + // older turn; the second (latest) assistant's thinking must stay verbatim. + // This verifies that older assistant thinking blocks ARE rewritten. + const body: any = { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "real thinking text", signature: "client-stored-sig" }, + { type: "tool_use", id: "call_y", name: "ls", input: {} }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_y", content: "ok" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "latest thinking", signature: "latest-sig" }, + { type: "tool_use", id: "call_z", name: "ls", input: {} }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_z", content: "ok" }] }, + ], + }; + prepareClaudeRequest(body, "claude"); + // Older assistant: thinking rewritten to redacted_thinking + const olderContent = body.messages[1].content; + assert.equal(olderContent.length, 2, "no double-inject"); + assert.equal(olderContent[0].type, "redacted_thinking"); + assert.equal(olderContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE); + assert.equal( + olderContent[0].thinking, + undefined, + "plain text stripped (Anthropic does not trust replay text)" + ); + assert.equal(olderContent[0].signature, undefined); + assert.equal(olderContent[1].type, "tool_use"); + // Latest assistant: thinking preserved verbatim + const latestContent = body.messages[3].content; + assert.equal(latestContent[0].type, "thinking"); + assert.equal(latestContent[0].thinking, "latest thinking"); + assert.equal(latestContent[0].signature, "latest-sig"); +}); + +test("anthropic-compatible-* provider — same as claude (redacted_thinking)", () => { + const body = multiTurnBodyWithoutThinkingBlock(); + const result = prepareClaudeRequest(body as any, "anthropic-compatible-abc123"); + const content = (result as any).messages[1].content; + assert.equal(content[0].type, "redacted_thinking"); + assert.equal(content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE); +}); + +// ──────────────── Non-Anthropic Claude-shape (kimi-coding, glmt, zai, …) ──────────────── + +test("kimi-coding provider — empty content, injects plain thinking{text} with placeholder (cache miss)", () => { + reasoningCache.clearReasoningCacheAll(); const body = multiTurnBodyWithoutThinkingBlock(); const result = prepareClaudeRequest(body as any, "kimi-coding"); - const assistantContent = (result as any).messages[1].content; - assert.equal(assistantContent.length, 2, "thinking + tool_use"); - assert.equal(assistantContent[0].type, "redacted_thinking"); - assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE); - assert.equal(assistantContent[0].thinking, undefined); - assert.equal(assistantContent[0].signature, undefined); + const content = (result as any).messages[1].content; + assert.equal(content.length, 2); + assert.equal(content[0].type, "thinking"); + assert.equal(content[0].thinking, PLACEHOLDER); + assert.equal(content[0].data, undefined, "no data field on plain thinking"); + assert.equal(content[0].signature, undefined, "no signature field on cross-provider replay"); + assert.equal(content[1].type, "tool_use"); }); -test("prepareClaudeRequest: existing thinking block — redacted, signature replaced, no double-inject", () => { +test("kimi-coding provider — empty content + cache hit on tool_use.id, injects real reasoning text", () => { + reasoningCache.clearReasoningCacheAll(); + reasoningCache.cacheReasoning( + "call_x", + "kimi-coding", + "kimi-k2.6", + "the model actually thought this" + ); + const body = multiTurnBodyWithoutThinkingBlock(); + const result = prepareClaudeRequest(body as any, "kimi-coding"); + const content = (result as any).messages[1].content; + assert.equal(content[0].type, "thinking"); + assert.equal(content[0].thinking, "the model actually thought this"); +}); + +test("kimi-coding provider — existing thinking block: client text preserved, signature stripped, data NOT added", () => { + reasoningCache.clearReasoningCacheAll(); + const body = multiTurnBodyWithThinkingBlock("client preserved reasoning", "call_y"); + const result = prepareClaudeRequest(body as any, "kimi-coding"); + const content = (result as any).messages[1].content; + assert.equal(content.length, 2); + assert.equal(content[0].type, "thinking"); + assert.equal(content[0].thinking, "client preserved reasoning", "client text preserved"); + assert.equal(content[0].data, undefined); + assert.equal( + content[0].signature, + undefined, + "client-stored signature stripped (no value for kimi)" + ); +}); + +test("kimi-coding provider — existing redacted_thinking block (no text), cache hit injects real text", () => { + reasoningCache.clearReasoningCacheAll(); + reasoningCache.cacheReasoning("call_z", "kimi-coding", "kimi-k2.6", "cached reasoning v2"); const body = { thinking: { type: "enabled", budget_tokens: 4096 }, messages: [ @@ -63,35 +170,10 @@ test("prepareClaudeRequest: existing thinking block — redacted, signature repl { role: "assistant", content: [ - { type: "thinking", thinking: "reasoning here", signature: "old-sig" }, - { type: "tool_use", id: "call_y", name: "ls", input: {} }, + { type: "redacted_thinking", data: "opaque-blob-from-prior-anthropic-turn" }, + { type: "tool_use", id: "call_z", name: "ls", input: {} }, ], }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: "call_y", content: "ok" }], - }, - ], - }; - const result = prepareClaudeRequest(body as any, "kimi-coding"); - const assistantContent = (result as any).messages[1].content; - assert.equal(assistantContent.length, 2, "no double-inject — exactly 2 blocks"); - assert.equal(assistantContent[0].type, "redacted_thinking", "thinking → redacted_thinking"); - assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE); - assert.equal(assistantContent[0].thinking, undefined, "thinking field stripped"); - assert.equal(assistantContent[0].signature, undefined, "signature field stripped"); - assert.equal(assistantContent[1].type, "tool_use"); -}); - -test("prepareClaudeRequest: thinking disabled — no inject regardless of tool_use presence", () => { - const body = { - thinking: { type: "disabled" }, - messages: [ - { role: "user", content: [{ type: "text", text: "hi" }] }, - { - role: "assistant", - content: [{ type: "tool_use", id: "call_z", name: "ls", input: {} }], - }, { role: "user", content: [{ type: "tool_result", tool_use_id: "call_z", content: "ok" }], @@ -99,20 +181,151 @@ test("prepareClaudeRequest: thinking disabled — no inject regardless of tool_u ], }; const result = prepareClaudeRequest(body as any, "kimi-coding"); - const assistantContent = (result as any).messages[1].content; - assert.equal(assistantContent.length, 1, "no thinking injected when thinking is disabled"); - assert.equal(assistantContent[0].type, "tool_use"); + const content = (result as any).messages[1].content; + assert.equal(content[0].type, "thinking"); + assert.equal(content[0].thinking, "cached reasoning v2", "cache substitutes redacted data"); + assert.equal(content[0].data, undefined); }); -test("prepareClaudeRequest: thinking enabled + no tool_use — no inject (single-turn text)", () => { +test("kimi-coding provider — existing redacted_thinking block (no text), cache miss → placeholder", () => { + reasoningCache.clearReasoningCacheAll(); const body = { thinking: { type: "enabled", budget_tokens: 4096 }, - messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "redacted_thinking", data: "opaque-blob" }, + { type: "tool_use", id: "call_z_miss", name: "ls", input: {} }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call_z_miss", content: "ok" }], + }, + ], }; const result = prepareClaudeRequest(body as any, "kimi-coding"); - const userContent = (result as any).messages[0].content; - assert.ok(Array.isArray(userContent)); - assert.equal(userContent[0].type, "text"); - // No new thinking block prepended on user messages - assert.equal(userContent.length, 1); + const content = (result as any).messages[1].content; + assert.equal(content[0].type, "thinking"); + assert.equal(content[0].thinking, PLACEHOLDER); +}); + +// ──────────────── Disabled / no-op paths ──────────────── + +test("thinking disabled — no inject regardless of provider or tool_use", () => { + for (const provider of ["claude", "kimi-coding", "anthropic-compatible-x"]) { + const body = { + thinking: { type: "disabled" }, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [{ type: "tool_use", id: "x", name: "ls", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "x", content: "ok" }] }, + ], + }; + const result = prepareClaudeRequest(body as any, provider); + const content = (result as any).messages[1].content; + assert.equal(content.length, 1, `${provider}: no inject when thinking disabled`); + assert.equal(content[0].type, "tool_use"); + } +}); + +test("thinking enabled + no tool_use — no precursor inject (single-turn text)", () => { + for (const provider of ["claude", "kimi-coding"]) { + const body = { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + }; + const result = prepareClaudeRequest(body as any, provider); + const content = (result as any).messages[0].content; + assert.ok(Array.isArray(content)); + assert.equal(content.length, 1); + assert.equal(content[0].type, "text"); + } +}); + +// ──────────────── Latest-assistant preservation (Anthropic & non-Anthropic) ──────────────── + +test("preserves verbatim thinking on the LATEST assistant message; rewrites only older ones", () => { + const body: any = { + thinking: { type: "enabled", budget_tokens: 4096 }, + model: "claude-opus-4-7", + messages: [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "older thought", signature: "sig-OLD" }, + { type: "tool_use", id: "tool_1", name: "do_x", input: {} }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "tool_1", content: "ok" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "latest thought", signature: "sig-LATEST" }, + { type: "tool_use", id: "tool_2", name: "do_y", input: {} }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "tool_2", content: "ok" }] }, + ], + }; + + prepareClaudeRequest(body, "claude"); + + const olderAssistant = body.messages[0]; + const latestAssistant = body.messages[2]; + + // Older assistant thinking: rewritten to redacted_thinking { data } + assert.equal(olderAssistant.content[0].type, "redacted_thinking"); + assert.ok( + typeof olderAssistant.content[0].data === "string" && olderAssistant.content[0].data.length > 0 + ); + assert.equal(olderAssistant.content[0].thinking, undefined); + assert.equal(olderAssistant.content[0].signature, undefined); + + // Latest assistant thinking: untouched (type, text, signature all preserved) + assert.equal(latestAssistant.content[0].type, "thinking"); + assert.equal(latestAssistant.content[0].thinking, "latest thought"); + assert.equal(latestAssistant.content[0].signature, "sig-LATEST"); + assert.equal(latestAssistant.content[0].data, undefined); +}); + +test("non-Anthropic upstream: preserves latest assistant thinking text verbatim, only fills older from cache/placeholder", () => { + reasoningCache.clearReasoningCacheAll(); + const body: any = { + thinking: { type: "enabled", budget_tokens: 4096 }, + model: "kimi-k2.6-thinking", + messages: [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "" /* stripped on wire */ }, + { type: "tool_use", id: "tool_1", name: "do_x", input: {} }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "tool_1", content: "ok" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "latest reasoning text" }, + { type: "tool_use", id: "tool_2", name: "do_y", input: {} }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "tool_2", content: "ok" }] }, + ], + }; + + prepareClaudeRequest(body, "kimi-coding"); + + // Latest assistant: text preserved verbatim + assert.equal(body.messages[2].content[0].type, "thinking"); + assert.equal(body.messages[2].content[0].thinking, "latest reasoning text"); + + // Older assistant: empty text -> placeholder (cache miss path) + assert.equal(body.messages[0].content[0].type, "thinking"); + assert.ok( + typeof body.messages[0].content[0].thinking === "string" && + body.messages[0].content[0].thinking.length > 0 + ); }); diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index ec9832b62a..9c9003374d 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -6,8 +6,6 @@ const openaiHelper = await import("../../open-sse/translator/helpers/openaiHelpe const claudeHelper = await import("../../open-sse/translator/helpers/claudeHelper.ts"); const geminiHelper = await import("../../open-sse/translator/helpers/geminiHelper.ts"); const toolCallHelper = await import("../../open-sse/translator/helpers/toolCallHelper.ts"); -const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = - await import("../../open-sse/config/defaultThinkingSignature.ts"); const originalMathRandom = Math.random; @@ -306,13 +304,24 @@ test("claudeHelper validates content, ordering and request preparation branches" assert.equal(prepared.messages.length, 6); assert.equal(prepared.messages[2].content.at(-1).cache_control.type, "ephemeral"); assert.equal(prepared.messages[4].content[0].type, "tool_result"); + // messages[5] is the latest (and last) assistant message; Anthropic enforces + // that its thinking blocks must remain verbatim — not rewritten to + // redacted_thinking. The guard in prepareClaudeRequest preserves them. assert.deepEqual( prepared.messages[5].content.map((block) => block.type), - ["redacted_thinking", "text"] + ["thinking", "text"] + ); + assert.equal(prepared.messages[5].content[0].thinking, "old", "thinking text preserved verbatim"); + assert.equal( + prepared.messages[5].content[0].signature, + "replace", + "signature preserved verbatim" + ); + assert.equal( + prepared.messages[5].content[0].data, + undefined, + "no data field on verbatim thinking" ); - assert.equal(prepared.messages[5].content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE); - assert.equal(prepared.messages[5].content[0].signature, undefined); - assert.equal(prepared.messages[5].content[0].thinking, undefined); assert.equal(prepared.tools.length, 2); assert.equal(prepared.tools[0].cache_control, undefined); assert.deepEqual(prepared.tools[1].cache_control, { type: "ephemeral", ttl: "1h" });