fix(translator): inject thinking placeholder for all Claude-shape upstreams (#2161)

Integrated into release/v3.8.0 — removes redundant provider guard in prepareClaudeRequest, fixing thinking placeholder injection for all Claude-shape upstreams (kimi-coding, glmt, zai).
This commit is contained in:
Anton
2026-05-12 02:01:23 +02:00
committed by GitHub
parent 6d722ecd27
commit d995713e21
2 changed files with 169 additions and 25 deletions

View File

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

View File

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