fix(translator): preserve Kimi K3 Responses reasoning

This commit is contained in:
jackjinke
2026-08-05 14:43:17 +08:00
committed by diegosouzapw
parent aae408f585
commit f2ca3f05bc
12 changed files with 535 additions and 143 deletions

View File

@@ -0,0 +1 @@
- fix(translator): preserve Kimi K3 Responses reasoning through Kimi Coding Claude-format and native Moonshot requests, keeping it on the matching assistant tool call or completed turn instead of dropping it or carrying it across a user boundary (#9496)

View File

@@ -40,7 +40,12 @@ export async function handleResponsesCore({
const customToolNames = collectResponsesCustomToolNames(body?.tools, inputItems);
// Convert Responses API format to Chat Completions format
const convertedBody = convertResponsesApiFormat(body, credentials, modelInfo?.provider);
const convertedBody = convertResponsesApiFormat(
body,
credentials,
modelInfo?.provider,
modelInfo?.model
);
// Ensure stream is enabled
convertedBody.stream = true;

View File

@@ -84,6 +84,10 @@ export function hasValidContent(msg: ClaudeMessage): boolean {
return msg.content.some(
(block) =>
(block.type === "text" && block.text?.trim()) ||
(block.type === "thinking" && block.thinking?.trim()) ||
(block.type === "redacted_thinking" &&
typeof block.data === "string" &&
block.data.trim()) ||
block.type === "tool_use" ||
block.type === "tool_result" ||
// #7777: media-only user turns are real content — dropping them

View File

@@ -2,10 +2,16 @@
* Convert OpenAI Responses API format to standard chat completions format.
* Delegates to the canonical translator to avoid logic duplication.
*/
import { shouldPreserveResponsesReasoningContent } from "../../utils/reasoningContentInjector.ts";
import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts";
import { toRecord } from "../request/openai-responses/helpers.ts";
export function convertResponsesApiFormat(body, credentials = null, provider = null) {
export function convertResponsesApiFormat(
body: Record<string, unknown>,
credentials: unknown = null,
provider: unknown = null,
model: unknown = null
): Record<string, unknown> {
const bodyModel = toRecord(body).model;
const requestedModel =
typeof bodyModel === "string" && bodyModel.trim().length > 0
@@ -13,5 +19,21 @@ export function convertResponsesApiFormat(body, credentials = null, provider = n
? bodyModel
: `${provider}/${bodyModel}`
: provider;
return openaiResponsesToOpenAIRequest(requestedModel, body, null, credentials);
const credentialRecord =
credentials && typeof credentials === "object" && !Array.isArray(credentials)
? (credentials as Record<string, unknown>)
: {};
const translationCredentials = shouldPreserveResponsesReasoningContent(provider, model)
? { ...credentialRecord, _preserveReasoningContent: true }
: credentials;
const converted = openaiResponsesToOpenAIRequest(
requestedModel,
body,
null,
translationCredentials
);
if (!converted || typeof converted !== "object" || Array.isArray(converted)) {
throw new TypeError("Responses request conversion must produce an object");
}
return converted as Record<string, unknown>;
}

View File

@@ -13,7 +13,10 @@ import {
providerHonorsOpenAIFormatCacheControl,
resolveConnectionCacheOverride,
} from "../utils/cacheControlPolicy.ts";
import { requiresAuthenticReasoningContent } from "../utils/reasoningContentInjector.ts";
import {
requiresAuthenticReasoningContent,
shouldPreserveResponsesReasoningContent,
} from "../utils/reasoningContentInjector.ts";
import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts";
import {
coerceToolSchemas,
@@ -233,6 +236,17 @@ export function translateRequest(
const connectionCacheOverride = resolveConnectionCacheOverride(
(credentials as { providerSpecificData?: unknown } | null)?.providerSpecificData
);
const normalizedProvider = String(provider ?? "");
const normalizedModel = String(model ?? "");
const isKimiCoding =
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
const requiresAuthenticReasoning = requiresAuthenticReasoningContent(
normalizedProvider,
normalizedModel
);
const preserveResponsesReasoning =
sourceFormat === FORMATS.OPENAI_RESPONSES &&
shouldPreserveResponsesReasoningContent(normalizedProvider, normalizedModel);
// Phase 2: Apply thinking budget control before normalization
result = applyThinkingBudget(result);
@@ -318,12 +332,16 @@ export function translateRequest(
options?.preserveCacheControl === true &&
providerHonorsOpenAIFormatCacheControl(provider, connectionCacheOverride);
const step1Credentials =
options?.copilotClient || hasTargetHint || preserveCacheControl
options?.copilotClient ||
hasTargetHint ||
preserveCacheControl ||
preserveResponsesReasoning
? {
...(credentials && typeof credentials === "object" ? credentials : {}),
...(options?.copilotClient ? { _copilotClient: true } : {}),
...(hasTargetHint ? { _targetFormat: targetFormat } : {}),
...(preserveCacheControl ? { _preserveCacheControl: true } : {}),
...(preserveResponsesReasoning ? { _preserveReasoningContent: true } : {}),
}
: credentials;
result = toOpenAI(model, result, stream, step1Credentials);
@@ -361,14 +379,6 @@ export function translateRequest(
// Resolve reasoning-replay status up-front: it gates both the reasoning_content
// strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for
// replay providers) and the cache re-injection further down.
const normalizedProvider = String(provider ?? "");
const normalizedModel = String(model ?? "");
const isKimiCoding =
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
const requiresAuthenticReasoning = requiresAuthenticReasoningContent(
normalizedProvider,
normalizedModel
);
const resolvedCapabilities = getResolvedModelCapabilities({
provider: normalizedProvider,
model: normalizedModel,

View File

@@ -73,6 +73,19 @@ function toolOutputContentToString(output: unknown): string {
return parts.join("\n");
}
function getReasoningSummaryText(item: JsonRecord): string {
if (!Array.isArray(item.summary)) return "";
return item.summary
.map((part) => toString(toRecord(part).text))
.filter((text) => text.length > 0)
.join("\n\n");
}
function appendReasoningContent(current: unknown, next: string): string {
const existing = typeof current === "string" ? current : "";
return existing ? `${existing}\n\n${next}` : next;
}
/**
* Convert OpenAI Responses API request to OpenAI Chat Completions format
*/
@@ -83,13 +96,13 @@ export function openaiResponsesToOpenAIRequest(
credentials: unknown
): unknown {
void stream;
void credentials;
const collapseToPlainString = requiresPlainStringContent(extractProviderHint(model));
const root = toRecord(body);
if (root.input === undefined) return body;
const credentialRecord = toRecord(credentials);
const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData);
const preserveReasoningContent = credentialRecord._preserveReasoningContent === true;
const rawInputItems = normalizeResponsesInputForChat(root.input);
// Tools may be declared at the Responses top level or in one or more
@@ -204,6 +217,7 @@ export function openaiResponsesToOpenAIRequest(
// Group items by conversation turn
let currentAssistantMsg: JsonRecord | null = null;
let pendingToolResults: JsonRecord[] = [];
let pendingReasoningContent = "";
// Upstream providers reject messages:[] with "400: at least one message is required".
// When the client sends input:[] (empty), inject a placeholder user message — mirrors
@@ -220,11 +234,20 @@ export function openaiResponsesToOpenAIRequest(
const itemType = toString(item.type) || (item.role ? "message" : "");
if (itemType === "message") {
const role = toString(item.role);
// Flush pending assistant message with tool calls
if (currentAssistantMsg) {
messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
if (role !== "assistant" && pendingReasoningContent) {
messages.push({
role: "assistant",
content: null,
reasoning_content: pendingReasoningContent,
});
pendingReasoningContent = "";
}
// Flush pending tool results
if (pendingToolResults.length > 0) {
@@ -269,7 +292,12 @@ export function openaiResponsesToOpenAIRequest(
})
: item.content;
messages.push({ role: toString(item.role), content });
const message: JsonRecord = { role, content };
if (role === "assistant" && pendingReasoningContent) {
message.reasoning_content = pendingReasoningContent;
pendingReasoningContent = "";
}
messages.push(message);
continue;
}
@@ -294,6 +322,10 @@ export function openaiResponsesToOpenAIRequest(
content: null,
tool_calls: [],
};
if (pendingReasoningContent) {
currentAssistantMsg.reasoning_content = pendingReasoningContent;
pendingReasoningContent = "";
}
}
const toolCalls = Array.isArray(currentAssistantMsg.tool_calls)
@@ -353,6 +385,10 @@ export function openaiResponsesToOpenAIRequest(
content: null,
tool_calls: [],
};
if (pendingReasoningContent) {
currentAssistantMsg.reasoning_content = pendingReasoningContent;
pendingReasoningContent = "";
}
}
const toolCalls = Array.isArray(currentAssistantMsg.tool_calls)
? currentAssistantMsg.tool_calls
@@ -401,7 +437,21 @@ export function openaiResponsesToOpenAIRequest(
}
if (itemType === "reasoning") {
// Skip reasoning items - they are display-only metadata
// Responses reasoning summaries are normally display metadata. Preserve them only
// when the routed upstream explicitly requires prior reasoning to continue a turn.
if (preserveReasoningContent) {
const reasoning = getReasoningSummaryText(item);
if (reasoning) {
if (currentAssistantMsg) {
currentAssistantMsg.reasoning_content = appendReasoningContent(
currentAssistantMsg.reasoning_content,
reasoning
);
} else {
pendingReasoningContent = appendReasoningContent(pendingReasoningContent, reasoning);
}
}
}
continue;
}
@@ -430,6 +480,13 @@ export function openaiResponsesToOpenAIRequest(
if (currentAssistantMsg) {
messages.push(currentAssistantMsg);
}
if (pendingReasoningContent) {
messages.push({
role: "assistant",
content: null,
reasoning_content: pendingReasoningContent,
});
}
if (pendingToolResults.length > 0) {
for (const toolResult of pendingToolResults) {
messages.push(toolResult);

View File

@@ -48,6 +48,20 @@ export function requiresAuthenticReasoningContent(provider: unknown, model: unkn
);
}
export function shouldPreserveResponsesReasoningContent(
provider: unknown,
model: unknown
): boolean {
const normalizedProvider = String(provider ?? "")
.trim()
.toLowerCase();
return (
normalizedProvider === "kimi-coding" ||
normalizedProvider === "kimi-coding-apikey" ||
requiresAuthenticReasoningContent(normalizedProvider, model)
);
}
export function isThinkingMessageModel(model: string | undefined | null): boolean {
if (!model || typeof model !== "string") return false;
return THINKING_MODEL_PATTERNS.some((re) => re.test(model));

View File

@@ -79,3 +79,165 @@ test("Kimi Anthropic preserves an explicit empty thinking block", () => {
thinking: "",
});
});
test("Responses history preserves Kimi reasoning before a tool call", () => {
const translated = translateRequest(
FORMATS.OPENAI_RESPONSES,
FORMATS.CLAUDE,
"k3-256k",
{
model: "k3-256k",
max_output_tokens: 4096,
reasoning: { effort: "high" },
input: [
{
role: "user",
content: [{ type: "input_text", text: "Call ping, then answer DONE." }],
},
{
id: "rs_1",
type: "reasoning",
summary: [{ type: "summary_text", text: "I should call ping first." }],
},
{
id: "fc_1",
type: "function_call",
call_id: "call_1",
name: "ping",
arguments: "{}",
},
{
type: "function_call_output",
call_id: "call_1",
output: "pong",
},
],
},
false,
{},
"kimi-coding-apikey"
) as KimiClaudeRequest;
assert.deepEqual(translated.messages[1].content[0], {
type: "thinking",
thinking: "I should call ping first.",
});
assert.deepEqual(translated.messages[1].content[1], {
type: "tool_use",
id: "call_1",
name: "proxy_ping",
input: {},
});
});
test("Responses history preserves Kimi reasoning on completed assistant turns", () => {
const translated = translateRequest(
FORMATS.OPENAI_RESPONSES,
FORMATS.CLAUDE,
"k3-256k",
{
model: "k3-256k",
max_output_tokens: 4096,
reasoning: { effort: "high" },
input: [
{
role: "user",
content: [{ type: "input_text", text: "Remember cobalt-orchid." }],
},
{
id: "rs_1",
type: "reasoning",
summary: [{ type: "summary_text", text: "I should retain the nonce." }],
},
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Noted." }],
},
{
role: "user",
content: [{ type: "input_text", text: "What was it?" }],
},
],
},
false,
{},
"kimi-coding-apikey"
) as KimiClaudeRequest;
assert.deepEqual(translated.messages[1].content, [
{ type: "thinking", thinking: "I should retain the nonce." },
{ type: "text", text: "Noted." },
]);
});
test("Responses history preserves Kimi reasoning before a custom tool call", () => {
const translated = translateRequest(
FORMATS.OPENAI_RESPONSES,
FORMATS.CLAUDE,
"k3-256k",
{
model: "k3-256k",
reasoning: { effort: "high" },
input: [
{
type: "reasoning",
summary: [{ type: "summary_text", text: "I should apply the patch." }],
},
{
type: "custom_tool_call",
call_id: "call_1",
name: "apply_patch",
input: "*** Begin Patch",
},
{
type: "custom_tool_call_output",
call_id: "call_1",
output: "Done",
},
],
},
false,
{},
"kimi-coding-apikey"
) as KimiClaudeRequest;
assert.deepEqual(translated.messages[0].content[0], {
type: "thinking",
thinking: "I should apply the patch.",
});
});
test("Responses history does not carry reasoning across a user boundary", () => {
const translated = translateRequest(
FORMATS.OPENAI_RESPONSES,
FORMATS.CLAUDE,
"k3-256k",
{
model: "k3-256k",
reasoning: { effort: "high" },
input: [
{
role: "user",
content: [{ type: "input_text", text: "First turn" }],
},
{
type: "reasoning",
summary: [{ type: "summary_text", text: "Prior turn reasoning." }],
},
{
role: "user",
content: [{ type: "input_text", text: "Next turn" }],
},
],
},
false,
{},
"kimi-coding-apikey"
) as KimiClaudeRequest;
assert.deepEqual(translated.messages[1].content, [
{ type: "thinking", thinking: "Prior turn reasoning." },
]);
assert.deepEqual(translated.messages[2].content, [{ type: "text", text: "Next turn" }]);
});

View File

@@ -24,12 +24,7 @@ import {
import { translateRequest } from "../../open-sse/translator/index.ts";
import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts";
const EXPECTED_MODELS = [
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.7-code-highspeed",
"kimi-k2.6",
];
const EXPECTED_MODELS = ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6"];
function registryModelIds(provider: string): string[] {
const entry = getRegistryEntry(provider);
@@ -190,6 +185,53 @@ test("Moonshot K3 participates in reasoning replay", () => {
);
});
test("Responses history preserves authentic reasoning for native Moonshot K3", () => {
for (const provider of ["moonshot", "kimi"]) {
const output = translateRequest(
"openai-responses",
"openai",
"kimi-k3",
{
model: "kimi-k3",
reasoning: { effort: "max" },
input: [
{
role: "user",
content: [{ type: "input_text", text: "Call search." }],
},
{
type: "reasoning",
summary: [{ type: "summary_text", text: "I should search first." }],
},
{
type: "function_call",
call_id: "call_1",
name: "search",
arguments: "{}",
},
{
type: "function_call_output",
call_id: "call_1",
output: "found",
},
],
},
false,
null,
provider
) as { messages: Array<Record<string, unknown>> };
assert.equal(output.messages[1].reasoning_content, "I should search first.");
assert.deepEqual(output.messages[1].tool_calls, [
{
id: "call_1",
type: "function",
function: { name: "search", arguments: "{}" },
},
]);
}
});
test("video_url is preserved only for Moonshot's OpenAI-compatible extension", () => {
const moonshot = translateRequest(
"openai",

View File

@@ -225,6 +225,44 @@ test("handleResponsesCore converts Responses API input, instructions, tools, met
assert.equal("store" in call.body, false);
});
test("handleResponsesCore preserves Kimi K3 reasoning through provider translation", async () => {
const body = {
model: "k3-256k",
reasoning: { effort: "high" },
input: [
{ role: "user", content: [{ type: "input_text", text: "Call search." }] },
{
type: "reasoning",
summary: [{ type: "summary_text", text: "I should search first." }],
},
{
type: "function_call",
call_id: "call_1",
name: "search",
arguments: "{}",
},
{ type: "function_call_output", call_id: "call_1", output: "found" },
],
};
const coding = await invokeResponsesCore({
body,
provider: "kimi-coding-apikey",
model: "k3-256k",
});
assert.deepEqual(coding.call.body.messages?.[1]?.content?.[0], {
type: "thinking",
thinking: "I should search first.",
});
const native = await invokeResponsesCore({
body: { ...body, model: "kimi-k3", reasoning: { effort: "max" } },
provider: "moonshot",
model: "kimi-k3",
});
assert.equal(native.call.body.messages?.[1]?.reasoning_content, "I should search first.");
});
test("handleResponsesCore strips previous_response_id by default and handles empty input arrays", async () => {
const { call, result } = await invokeResponsesCore({
body: {

View File

@@ -37,6 +37,42 @@ test("convertResponsesApiFormat skips function_call items with empty names", ()
assert.equal(assistantMsgs.length, 0);
});
test("production Responses conversion preserves Kimi K3 reasoning history", () => {
const body = {
model: "kimi-k3",
input: [
{ role: "user", content: [{ type: "input_text", text: "Call search." }] },
{
type: "reasoning",
summary: [{ type: "summary_text", text: "I should search first." }],
},
{
type: "function_call",
call_id: "call_1",
name: "search",
arguments: "{}",
},
{ type: "function_call_output", call_id: "call_1", output: "found" },
],
};
for (const { provider, model } of [
{ provider: "kimi-coding-apikey", model: "k3-256k" },
{ provider: "moonshot", model: "kimi-k3" },
{ provider: "kimi", model: "kimi-k3" },
]) {
const converted = convertResponsesApiFormat(body, {}, provider, model) as {
messages: Array<Record<string, unknown>>;
};
assert.equal(converted.messages[1].reasoning_content, "I should search first.");
}
const generic = convertResponsesApiFormat(body, {}, "openai", "gpt-5") as {
messages: Array<Record<string, unknown>>;
};
assert.equal(Object.hasOwn(generic.messages[1], "reasoning_content"), false);
});
test("Responses→Chat: input_image converted to image_url with detail", () => {
const body = {
model: "gpt-4",

View File

@@ -259,6 +259,14 @@ test("openaiHelper keeps unmatched tool choices and deletes empty tools arrays",
test("claudeHelper validates content, ordering and request preparation branches", () => {
assert.equal(claudeHelper.hasValidContent({ content: " hello " }), true);
assert.equal(claudeHelper.hasValidContent({ content: [{ type: "tool_use", id: "call" }] }), true);
assert.equal(
claudeHelper.hasValidContent({ content: [{ type: "thinking", thinking: "reasoning" }] }),
true
);
assert.equal(
claudeHelper.hasValidContent({ content: [{ type: "redacted_thinking", data: "opaque" }] }),
true
);
assert.equal(claudeHelper.hasValidContent({ content: [{ type: "text", text: " " }] }), false);
assert.deepEqual(claudeHelper.fixToolUseOrdering([{ role: "user", content: "single" }]), [
@@ -690,138 +698,131 @@ 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", () => {
clearReasoningCacheAll();
cacheReasoningByKey(
"toolu_kimi_claude",
"kimi-coding",
"kimi-for-coding",
"cached thinking for Kimi tool call"
);
test("translateRequest uses Kimi Coding's empty thinking marker instead of cached replay", () => {
clearReasoningCacheAll();
cacheReasoningByKey(
"toolu_kimi_claude",
"kimi-coding",
"kimi-for-coding",
"cached thinking for Kimi tool call"
);
// Claude-format request: assistant has tool_use in content[] but NO thinking block
// This simulates the scenario that causes infinite loops
const result = translateRequest(
FORMATS.OPENAI,
FORMATS.CLAUDE,
"kimi-for-coding",
{
reasoning_effort: "high",
messages: [
{ role: "user", content: "read the file" },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_kimi_claude",
name: "read_file",
input: { path: "test.ts" },
},
],
},
{ role: "tool", tool_call_id: "toolu_kimi_claude", content: "file data" },
],
},
false,
null,
"kimi-coding"
);
// Claude-format request: assistant has tool_use in content[] but NO thinking block
// This simulates the scenario that causes infinite loops
const result = translateRequest(
FORMATS.OPENAI,
FORMATS.CLAUDE,
"kimi-for-coding",
{
reasoning_effort: "high",
messages: [
{ role: "user", content: "read the file" },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_kimi_claude",
name: "read_file",
input: { path: "test.ts" },
},
],
},
{ role: "tool", tool_call_id: "toolu_kimi_claude", content: "file data" },
],
},
false,
null,
"kimi-coding"
);
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");
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, "");
// 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, "");
// 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");
// 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);
clearReasoningCacheAll();
});
assert.equal(getReasoningCacheServiceStats().replays, 0);
clearReasoningCacheAll();
});
test("translateRequest uses an empty Kimi Coding thinking marker on cache miss", () => {
clearReasoningCacheAll();
test("translateRequest uses an empty Kimi Coding thinking marker on cache miss", () => {
clearReasoningCacheAll();
const result = translateRequest(
FORMATS.OPENAI,
FORMATS.CLAUDE,
"kimi-for-coding",
{
reasoning_effort: "high",
messages: [
{ role: "user", content: "do it" },
{
role: "assistant",
content: [
{ type: "tool_use", id: "toolu_miss", name: "bash", input: { command: "ls" } },
],
},
{ role: "tool", tool_call_id: "toolu_miss", content: "output" },
],
},
false,
null,
"kimi-coding"
);
const result = translateRequest(
FORMATS.OPENAI,
FORMATS.CLAUDE,
"kimi-for-coding",
{
reasoning_effort: "high",
messages: [
{ role: "user", content: "do it" },
{
role: "assistant",
content: [{ type: "tool_use", id: "toolu_miss", name: "bash", input: { command: "ls" } }],
},
{ role: "tool", tool_call_id: "toolu_miss", content: "output" },
],
},
false,
null,
"kimi-coding"
);
const assistantMsg = result.messages.find((m) => m.role === "assistant");
assert.ok(assistantMsg, "assistant message should exist");
const assistantMsg = result.messages.find((m) => m.role === "assistant");
assert.ok(assistantMsg, "assistant message should exist");
const thinkingBlock =
Array.isArray(assistantMsg.content) &&
assistantMsg.content.find((b) => b?.type === "thinking");
assert.ok(thinkingBlock, "thinking block should be injected on cache miss");
assert.equal(thinkingBlock.thinking, "");
const thinkingBlock =
Array.isArray(assistantMsg.content) && assistantMsg.content.find((b) => b?.type === "thinking");
assert.ok(thinkingBlock, "thinking block should be injected on cache miss");
assert.equal(thinkingBlock.thinking, "");
clearReasoningCacheAll();
});
clearReasoningCacheAll();
});
test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => {
clearReasoningCacheAll();
test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => {
clearReasoningCacheAll();
const result = translateRequest(
FORMATS.OPENAI,
FORMATS.CLAUDE,
"kimi-for-coding",
{
messages: [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "I already have this" },
{ type: "tool_use", id: "toolu_existing", name: "read", input: {} },
],
},
{ role: "tool", tool_call_id: "toolu_existing", content: "data" },
],
},
false,
null,
"kimi-coding"
);
const result = translateRequest(
FORMATS.OPENAI,
FORMATS.CLAUDE,
"kimi-for-coding",
{
messages: [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "I already have this" },
{ type: "tool_use", id: "toolu_existing", name: "read", input: {} },
],
},
{ role: "tool", tool_call_id: "toolu_existing", content: "data" },
],
},
false,
null,
"kimi-coding"
);
const assistantMsg = result.messages.find((m) => m.role === "assistant");
const thinkingBlocks =
Array.isArray(assistantMsg.content) &&
assistantMsg.content.filter((b) => b?.type === "thinking");
assert.equal(
thinkingBlocks?.length,
1,
"should have exactly one thinking block (no duplicate)"
);
assert.equal(
thinkingBlocks[0].thinking,
"I already have this",
"original thinking should be preserved"
);
const assistantMsg = result.messages.find((m) => m.role === "assistant");
const thinkingBlocks =
Array.isArray(assistantMsg.content) &&
assistantMsg.content.filter((b) => b?.type === "thinking");
assert.equal(thinkingBlocks?.length, 1, "should have exactly one thinking block (no duplicate)");
assert.equal(
thinkingBlocks[0].thinking,
"I already have this",
"original thinking should be preserved"
);
clearReasoningCacheAll();
});
clearReasoningCacheAll();
});