diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index d42c86dc08..49f4f0d5c6 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -250,34 +250,50 @@ export function prepareClaudeRequest( lastAssistantProcessed = true; } - // Handle thinking blocks for Anthropic endpoints (native + compatible) - if (provider === "claude" || provider?.startsWith?.("anthropic-compatible-")) { - let hasToolUse = false; - let hasThinking = false; + // Handle thinking blocks for Anthropic-shape endpoints. + // prepareClaudeRequest is only invoked when targetFormat === claude + // (translator/index.ts:165-168), so any provider that lands here has + // a Claude-format upstream: claude native, anthropic-compatible-*, + // kimi-coding (api.kimi.com/coding/v1/messages), glmt, zai, etc. + // All of these enforce the same body-shape contract for thinking mode: + // when body.thinking.type === "enabled" and an assistant turn contains + // a tool_use, the same content[] must include a thinking (or + // redacted_thinking) block emitted before the tool_use. Without it, + // the upstream rejects with errors like: + // "thinking is enabled but reasoning_content is missing in + // assistant tool call message at index N" (kimi-coding) + // "Invalid signature in thinking block" (claude native, on + // cross-provider replay) + let hasToolUse = false; + let hasThinking = false; - // Convert thinking blocks to redacted_thinking and replace signature. - // When requests cross provider boundaries (e.g., combo fallback), the - // original thinking signature is invalid for the new provider, causing - // "Invalid signature in thinking block" 400 errors. redacted_thinking - // blocks are accepted without signature validation. - for (const block of content) { - if (block.type === "thinking" || block.type === "redacted_thinking") { - block.type = "redacted_thinking"; - block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE; - delete block.thinking; - hasThinking = true; - } - if (block.type === "tool_use") hasToolUse = true; + // Convert thinking blocks to redacted_thinking and replace signature. + // When requests cross provider boundaries (e.g., combo fallback), the + // original thinking signature is invalid for the new provider, causing + // "Invalid signature in thinking block" 400 errors. redacted_thinking + // blocks are accepted without signature validation. + for (const block of content) { + if (block.type === "thinking" || block.type === "redacted_thinking") { + block.type = "redacted_thinking"; + block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE; + delete block.thinking; + hasThinking = true; } + if (block.type === "tool_use") hasToolUse = true; + } - // Add thinking block if thinking enabled + has tool_use but no thinking - if (thinkingEnabled && !hasThinking && hasToolUse) { - content.unshift({ - type: "thinking", - thinking: ".", - signature: DEFAULT_THINKING_CLAUDE_SIGNATURE, - }); - } + // 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. Placeholder content (".") is enough + // to satisfy shape validation; the upstream still runs its own + // reasoning on the current turn. + if (thinkingEnabled && !hasThinking && hasToolUse) { + content.unshift({ + type: "thinking", + thinking: ".", + signature: DEFAULT_THINKING_CLAUDE_SIGNATURE, + }); } } } diff --git a/tests/unit/translator-claude-helper-thinking.test.ts b/tests/unit/translator-claude-helper-thinking.test.ts new file mode 100644 index 0000000000..ca153a44d1 --- /dev/null +++ b/tests/unit/translator-claude-helper-thinking.test.ts @@ -0,0 +1,128 @@ +import test from "node:test"; +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" +); + +function multiTurnBodyWithoutThinkingBlock() { + return { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "call_x", name: "ls", input: { path: "." } }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_x", + content: "README.md\npackage.json", + }, + ], + }, + ], + }; +} + +test("prepareClaudeRequest: claude provider — injects thinking before tool_use (regression)", () => { + 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, "thinking"); + assert.equal(assistantContent[0].thinking, "."); + assert.equal(assistantContent[0].signature, DEFAULT_THINKING_CLAUDE_SIGNATURE); + assert.equal(assistantContent[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". + 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, "thinking"); + assert.equal(assistantContent[0].thinking, "."); + assert.equal(assistantContent[0].signature, DEFAULT_THINKING_CLAUDE_SIGNATURE); +}); + +test("prepareClaudeRequest: existing thinking block — redacted, signature replaced, no double-inject", () => { + const body = { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning here", signature: "old-sig" }, + { type: "tool_use", id: "call_y", 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].signature, DEFAULT_THINKING_CLAUDE_SIGNATURE); + assert.equal(assistantContent[0].thinking, undefined, "thinking 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" }, + ], + }, + ], + }; + 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"); +}); + +test("prepareClaudeRequest: thinking enabled + no tool_use — no inject (single-turn text)", () => { + const body = { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + ], + }; + 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); +});