Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 42 additions & 26 deletions open-sse/translator/helpers/claudeHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// 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;
// 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;
}

// 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,
});
}
}
}
Expand Down
128 changes: 128 additions & 0 deletions tests/unit/translator-claude-helper-thinking.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});