mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
fix(translator): preserve Kimi K3 Responses reasoning
This commit is contained in:
1
changelog.d/fixes/9496-kimi-k3-responses-replay.md
Normal file
1
changelog.d/fixes/9496-kimi-k3-responses-replay.md
Normal 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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user