Repository navigation
fix(sse): refine kimi thinking handling and add unit tests #3191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5755dff
4ce22f2
f71b0d7
1882d76
8524bd2
14d3a9a
ed0af12
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,60 +1,85 @@ | ||
|
|
||
| import { DefaultExecutor } from "./default.ts"; | ||
| type JsonRecord = Record<string, unknown>; | ||
| import { | ||
| type ProviderCredentials, | ||
| } from "./base.ts"; | ||
|
|
||
| import { ExecuteInput, type ProviderCredentials } from "./base.ts"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Extra space in import statement between There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CRITICAL: Extra space in import statement - should be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Extra space in import statement between |
||
| import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; | ||
| import { NON_ANTHROPIC_THINKING_PLACEHOLDER } from "../translator/helpers/claudeHelper.ts"; | ||
| type JsonRecord = Record<string, unknown>; | ||
|
|
||
| function hasActiveKimiThinking(body: JsonRecord): boolean { | ||
| const reasoningEffort = body.reasoning_effort; | ||
| if (typeof reasoningEffort === "string") { | ||
| const normalized = reasoningEffort.trim().toLowerCase(); | ||
| if (normalized && normalized !== "off" && normalized !== "none") return true; | ||
| } | ||
|
|
||
| const reasoning = body.reasoning; | ||
| if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) { | ||
| const reasoningRecord = reasoning as JsonRecord; | ||
| const effort = reasoningRecord.effort; | ||
| if (typeof effort === "string") { | ||
| const normalized = effort.trim().toLowerCase(); | ||
| if (normalized && normalized !== "off" && normalized !== "none") return true; | ||
| } | ||
| if (reasoningRecord.enabled === true || reasoningRecord.type === "enabled") return true; | ||
| } | ||
|
|
||
| const thinking = body.thinking; | ||
| if (thinking && typeof thinking === "object" && !Array.isArray(thinking)) { | ||
| const thinkingRecord = thinking as JsonRecord; | ||
| return thinkingRecord.type === "enabled" || thinkingRecord.type === "adaptive"; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| function ensureToolCallReasoningContent(body: JsonRecord): JsonRecord { | ||
| if (!hasActiveKimiThinking(body) || !Array.isArray(body.messages)) return body; | ||
| function hasNonEmptyReasoningContent(message: JsonRecord): boolean { | ||
| return typeof message.reasoning_content === "string" && message.reasoning_content.trim().length > 0; | ||
| } | ||
|
|
||
| function isToolUseBlock(value: unknown): value is JsonRecord { | ||
| return !!value && typeof value === "object" && !Array.isArray(value) && | ||
| (value as JsonRecord).type === "tool_use"; | ||
| } | ||
|
|
||
| let changed = false; | ||
| const messages = body.messages.map((message: unknown) => { | ||
| if (!message || typeof message !== "object" || Array.isArray(message)) return message; | ||
| function isThinkingBlock(value: unknown): boolean { | ||
| return !!value && typeof value === "object" && !Array.isArray(value) && | ||
| ((value as JsonRecord).type === "thinking" || (value as JsonRecord).type === "redacted_thinking"); | ||
| } | ||
|
|
||
| const msg = message as JsonRecord; | ||
| if (msg.role !== "assistant" || !Array.isArray(msg.tool_calls)) return message; | ||
| if (Object.prototype.hasOwnProperty.call(msg, "reasoning_content")) return message; | ||
| function hasAssistantToolCalls(message: JsonRecord): boolean { | ||
| if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return true; | ||
| return Array.isArray(message.content) && message.content.some(isToolUseBlock); | ||
| } | ||
|
|
||
| changed = true; | ||
| return { ...msg, reasoning_content: "" }; | ||
| function isClaudeProtocolBody(body: JsonRecord): boolean { | ||
| if (Array.isArray(body.system)) return true; | ||
|
|
||
| if (!Array.isArray(body.messages)) return false; | ||
| return body.messages.some((message: unknown) => { | ||
| const msg = asRecord(message); | ||
| if (!msg || !Array.isArray(msg.content)) return false; | ||
| return msg.content.some((part) => { | ||
| const block = asRecord(part); | ||
| return block?.type === "text" || block?.type === "tool_use" || block?.type === "tool_result"; | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| function disableKimiPreservedThinking(body: JsonRecord): JsonRecord { | ||
| if (!isClaudeProtocolBody(body)) return body; | ||
|
|
||
| return changed ? { ...body, messages } : body; | ||
| const thinking = asRecord(body.thinking) ?? { type: "enabled" }; | ||
| if (thinking.keep === null) return body; | ||
|
|
||
| return { | ||
| ...body, | ||
| thinking: { | ||
| ...thinking, | ||
| keep: null, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function hasTools(body: unknown): boolean { | ||
| const record = asRecord(body); | ||
| return Array.isArray(record?.tools) && record.tools.length > 0; | ||
| function ensureKimiThinkingContent(message: JsonRecord): JsonRecord { | ||
| const reasoningContent = hasNonEmptyReasoningContent(message) | ||
| ? String(message.reasoning_content) | ||
| : NON_ANTHROPIC_THINKING_PLACEHOLDER; | ||
| let nextMessage = hasNonEmptyReasoningContent(message) | ||
| ? message | ||
| : { ...message, reasoning_content: reasoningContent }; | ||
|
|
||
| if (!Array.isArray(nextMessage.content)) return nextMessage; | ||
| const firstToolUseIndex = nextMessage.content.findIndex(isToolUseBlock); | ||
| if (firstToolUseIndex < 0 || nextMessage.content.some(isThinkingBlock)) return nextMessage; | ||
|
|
||
| const content = [...nextMessage.content]; | ||
| content.splice(firstToolUseIndex, 0, { | ||
| type: "thinking", | ||
| thinking: reasoningContent, | ||
| }); | ||
| return { ...nextMessage, content }; | ||
| } | ||
|
|
||
|
|
||
|
|
@@ -65,12 +90,29 @@ function asRecord(value: unknown): JsonRecord | null { | |
| function applyKimiRequestDefaults(body: unknown, defaults?: JsonRecord | null): unknown { | ||
| const withDefaults = applyProviderRequestDefaults(body, defaults); | ||
| const record = asRecord(withDefaults); | ||
| if (record && hasActiveKimiThinking(record) && hasTools(record)) { | ||
| return ensureToolCallReasoningContent(record); | ||
| if (!record || !Array.isArray(record.messages)) { | ||
| return withDefaults; | ||
| } | ||
| return withDefaults; | ||
|
|
||
| const kimiBody = disableKimiPreservedThinking(record); | ||
|
|
||
| if (!hasActiveKimiThinking(kimiBody)) return kimiBody; | ||
|
|
||
| let modified = false; | ||
| const sourceMessages = Array.isArray(kimiBody.messages) ? kimiBody.messages : record.messages; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: The fallback There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: The fallback There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Redundant fallback There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Redundant fallback |
||
| const messages = sourceMessages.map((message: unknown) => { | ||
| const msg = asRecord(message); | ||
| if (!msg || msg.role !== "assistant" || !hasAssistantToolCalls(msg)) return message; | ||
|
|
||
| const nextMessage = ensureKimiThinkingContent(msg); | ||
| if (nextMessage !== msg) modified = true; | ||
| return nextMessage; | ||
| }); | ||
|
|
||
| return modified ? { ...kimiBody, messages } : kimiBody; | ||
| } | ||
|
|
||
|
|
||
| export class KimiExecutor extends DefaultExecutor { | ||
| constructor(provider = "kimi-coding") { | ||
| super(provider); | ||
|
|
@@ -85,6 +127,7 @@ export class KimiExecutor extends DefaultExecutor { | |
| const cleanedBody = super.transformRequest(model, body, stream, credentials); | ||
| return applyKimiRequestDefaults(cleanedBody); | ||
| } | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Trailing whitespace on this line (before closing brace) - should be removed for clean code. |
||
| } | ||
|
|
||
| export default KimiExecutor; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| import { describe, it } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
|
|
||
| import { KimiExecutor } from "../../open-sse/executors/kimi.ts"; | ||
| import { NON_ANTHROPIC_THINKING_PLACEHOLDER } from "../../open-sse/translator/helpers/claudeHelper.ts"; | ||
|
|
||
| type TransformedBody = { | ||
| thinking?: Record<string, unknown>; | ||
| messages?: Array<Record<string, unknown>>; | ||
| reasoning_effort?: unknown; | ||
| }; | ||
|
|
||
| describe("KimiExecutor", () => { | ||
| it("injects placeholder reasoning_content for assistant tool call messages when thinking is enabled", () => { | ||
| const executor = new KimiExecutor(); | ||
|
|
||
| const transformed = executor.transformRequest( | ||
| "kimi-k2", | ||
| { | ||
| thinking: { type: "enabled" }, | ||
| messages: [ | ||
| { role: "user", content: "hi" }, | ||
| { | ||
| role: "assistant", | ||
| content: null, | ||
| tool_calls: [ | ||
| { | ||
| id: "call_1", | ||
| type: "function", | ||
| function: { name: "search", arguments: "{}" }, | ||
| }, | ||
| ], | ||
| reasoning_content: null, | ||
| }, | ||
| ], | ||
| }, | ||
| false, | ||
| { apiKey: "test" } | ||
| ) as TransformedBody; | ||
|
|
||
| assert.ok(Array.isArray(transformed.messages)); | ||
| assert.equal(transformed.messages?.[1]?.reasoning_content, NON_ANTHROPIC_THINKING_PLACEHOLDER); | ||
| }); | ||
|
|
||
| it("injects Claude thinking content before the first tool_use block", () => { | ||
| const executor = new KimiExecutor(); | ||
|
|
||
| const transformed = executor.transformRequest( | ||
| "kimi-k2", | ||
| { | ||
| thinking: { type: "enabled" }, | ||
| messages: [ | ||
| { role: "user", content: [{ type: "text", text: "hi" }] }, | ||
| { | ||
| role: "assistant", | ||
| reasoning_content: "I should call the search tool.", | ||
| content: [ | ||
| { type: "text", text: "Let me check." }, | ||
| { type: "tool_use", id: "toolu_1", name: "search", input: { query: "kimi" } }, | ||
| { type: "text", text: "Trailing text is preserved by this executor." }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| false, | ||
| { apiKey: "test" } | ||
| ) as TransformedBody; | ||
|
|
||
| const assistant = transformed.messages?.[1]; | ||
| assert.ok(assistant); | ||
| assert.ok(Array.isArray(assistant.content)); | ||
| const content = assistant.content as Array<Record<string, unknown>>; | ||
| assert.deepEqual( | ||
| content.map((block) => block.type), | ||
| ["text", "thinking", "tool_use", "text"] | ||
| ); | ||
| assert.deepEqual(content[1], { | ||
| type: "thinking", | ||
| thinking: "I should call the search tool.", | ||
| }); | ||
| }); | ||
|
|
||
| it("disables Kimi preserved thinking for Claude-protocol bodies", () => { | ||
| const executor = new KimiExecutor(); | ||
|
|
||
| const transformed = executor.transformRequest( | ||
| "kimi-k2", | ||
| { | ||
| thinking: { type: "enabled", keep: "all", budget_tokens: 4096 }, | ||
| messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], | ||
| }, | ||
| false, | ||
| { apiKey: "test" } | ||
| ) as TransformedBody; | ||
|
|
||
| assert.deepEqual(transformed.thinking, { | ||
| type: "enabled", | ||
| keep: null, | ||
| budget_tokens: 4096, | ||
| }); | ||
| }); | ||
|
|
||
| it("does not treat OpenAI reasoning_effort as Kimi thinking enablement", () => { | ||
| const executor = new KimiExecutor(); | ||
|
|
||
| const transformed = executor.transformRequest( | ||
| "kimi-k2", | ||
| { | ||
| reasoning_effort: "high", | ||
| messages: [ | ||
| { role: "user", content: "hi" }, | ||
| { | ||
| role: "assistant", | ||
| content: null, | ||
| tool_calls: [ | ||
| { | ||
| id: "call_1", | ||
| type: "function", | ||
| function: { name: "search", arguments: "{}" }, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| false, | ||
| { apiKey: "test" } | ||
| ) as TransformedBody; | ||
|
|
||
| assert.equal(transformed.messages?.[1]?.reasoning_content, undefined); | ||
| assert.equal(transformed.reasoning_effort, "high"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING: Extra space in import statement between
{andExecuteInput- should be{ ExecuteInputfor consistent formatting