diff --git a/changelog.d/fixes/9496-kimi-k3-responses-replay.md b/changelog.d/fixes/9496-kimi-k3-responses-replay.md index 4f7e3723a3..59ee61b0f0 100644 --- a/changelog.d/fixes/9496-kimi-k3-responses-replay.md +++ b/changelog.d/fixes/9496-kimi-k3-responses-replay.md @@ -1 +1 @@ -- fix(translator): preserve authentic K3 Responses reasoning by model across providers, projecting it onto the matching assistant tool call or completed turn instead of dropping it or carrying it across a user boundary (#9496) +- fix(translator): preserve authentic K3 Responses reasoning by model across providers, keep it on the matching assistant turn, and make Kimi Coding prefer client reasoning then cached replay before its empty-marker fallback (#9496) diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index e770e91623..c132b19281 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -501,7 +501,7 @@ export function translateRequest( // isReasoner / normalizedProvider / normalizedModel / resolvedCapabilities were // resolved up-front (before the OpenAI-format filter) so the #4849 reasoning strip // could honor reasoning-replay providers. - if (isReasoner && !isKimiCoding && result.messages && Array.isArray(result.messages)) { + if (isReasoner && result.messages && Array.isArray(result.messages)) { const canReplayReasoningOnly = isReasoningOnlyReplayTarget(normalizedProvider, normalizedModel); for (const [messageIndex, msg] of result.messages.entries()) { @@ -550,29 +550,51 @@ export function translateRequest( // Has tool_use blocks but no thinking block yet. // Reasoning models (Kimi K2, etc.) require a thinking block before tool_use // on multi-turn or they regenerate the same tool call infinitely. - const hasThinkingBlock = msg.content.some( + const thinkingBlock = msg.content.find( (b) => b?.type === "thinking" || b?.type === "redacted_thinking" ); - if (hasThinkingBlock) continue; + const hasNonEmptyClientThinking = + thinkingBlock?.type === "thinking" && + typeof thinkingBlock.thinking === "string" && + thinkingBlock.thinking.trim().length > 0; + if (thinkingBlock && (!isKimiCoding || hasNonEmptyClientThinking)) continue; const toolUseBlocks = msg.content.filter((b) => b?.type === "tool_use"); const firstToolUseId = toolUseBlocks[0]?.id; const firstToolUseIdx = msg.content.findIndex((b) => b?.type === "tool_use"); - // Try reasoning cache first + // Client reasoning wins above. Otherwise try authentic replay before + // retaining Kimi Code's empty protocol marker as the final fallback. if (firstToolUseId) { const cached = lookupReasoning(firstToolUseId); if (cached) { - msg.content.splice(firstToolUseIdx, 0, { - type: "thinking", - thinking: cached, - }); + if (thinkingBlock) { + thinkingBlock.type = "thinking"; + thinkingBlock.thinking = cached; + delete thinkingBlock.data; + delete thinkingBlock.signature; + } else { + msg.content.splice(firstToolUseIdx, 0, { + type: "thinking", + thinking: cached, + }); + } recordReplay(); continue; } } + if (isKimiCoding) { + if (thinkingBlock) { + thinkingBlock.type = "thinking"; + thinkingBlock.thinking = ""; + delete thinkingBlock.data; + delete thinkingBlock.signature; + } else { + msg.content.splice(firstToolUseIdx, 0, { type: "thinking", thinking: "" }); + } + continue; + } if (requiresAuthenticReasoning) continue; - // Fallback: inject placeholder (must be non-empty for kimi-coding) msg.content.splice(firstToolUseIdx, 0, { type: "thinking", thinking: NON_ANTHROPIC_THINKING_PLACEHOLDER, diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index e6630368f2..33871a3766 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -698,12 +698,12 @@ test("translateRequest does not replay reasoning-only messages for non-DeepSeek clearReasoningCacheAll(); }); -test("translateRequest uses Kimi Coding's empty thinking marker instead of cached replay", () => { +test("translateRequest replays cached reasoning before Kimi Coding's empty fallback", () => { clearReasoningCacheAll(); cacheReasoningByKey( "toolu_kimi_claude", - "kimi-coding", - "kimi-for-coding", + "kimi-coding-apikey", + "k3-256k", "cached thinking for Kimi tool call" ); @@ -712,7 +712,7 @@ test("translateRequest uses Kimi Coding's empty thinking marker instead of cache const result = translateRequest( FORMATS.OPENAI, FORMATS.CLAUDE, - "kimi-for-coding", + "k3-256k", { reasoning_effort: "high", messages: [ @@ -733,24 +733,23 @@ test("translateRequest uses Kimi Coding's empty thinking marker instead of cache }, false, null, - "kimi-coding" + "kimi-coding-apikey" ); const assistantMsg = result.messages.find((m) => m.role === "assistant"); assert.ok(assistantMsg, "assistant message should exist"); assert.ok(Array.isArray(assistantMsg.content), "content should be array"); - // Kimi Code CLI 0.26 sends an explicit empty thinking marker before tool_use. const thinkingBlock = assistantMsg.content.find((b) => b?.type === "thinking"); assert.ok(thinkingBlock, "thinking block should be injected"); - assert.equal(thinkingBlock.thinking, ""); + assert.equal(thinkingBlock.thinking, "cached thinking for Kimi tool call"); // Thinking block should appear before tool_use const thinkingIdx = assistantMsg.content.indexOf(thinkingBlock); const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use"); assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use"); - assert.equal(getReasoningCacheServiceStats().replays, 0); + assert.equal(getReasoningCacheServiceStats().replays, 1); clearReasoningCacheAll(); }); @@ -760,7 +759,7 @@ test("translateRequest uses an empty Kimi Coding thinking marker on cache miss", const result = translateRequest( FORMATS.OPENAI, FORMATS.CLAUDE, - "kimi-for-coding", + "k3-256k", { reasoning_effort: "high", messages: [ @@ -774,7 +773,7 @@ test("translateRequest uses an empty Kimi Coding thinking marker on cache miss", }, false, null, - "kimi-coding" + "kimi-coding-apikey" ); const assistantMsg = result.messages.find((m) => m.role === "assistant"); @@ -790,11 +789,17 @@ test("translateRequest uses an empty Kimi Coding thinking marker on cache miss", test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => { clearReasoningCacheAll(); + cacheReasoningByKey( + "toolu_existing", + "kimi-coding-apikey", + "k3-256k", + "cached thinking must not replace client thinking" + ); const result = translateRequest( FORMATS.OPENAI, FORMATS.CLAUDE, - "kimi-for-coding", + "k3-256k", { messages: [ { role: "user", content: "hi" }, @@ -810,7 +815,7 @@ test("translateRequest does NOT inject duplicate thinking for Claude-format mess }, false, null, - "kimi-coding" + "kimi-coding-apikey" ); const assistantMsg = result.messages.find((m) => m.role === "assistant"); @@ -823,6 +828,7 @@ test("translateRequest does NOT inject duplicate thinking for Claude-format mess "I already have this", "original thinking should be preserved" ); + assert.equal(getReasoningCacheServiceStats().replays, 0); clearReasoningCacheAll(); });