From 2a3eccc3946b4c059d395c80fc2ef6b2787d3c13 Mon Sep 17 00:00:00 2001 From: "@taltas" <6816042+taltas@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:31:13 +0000 Subject: [PATCH] fix(kimi-code): use model-specific reasoning protocols --- packages/types/src/providers/kimi-code.ts | 53 ++++++-- src/api/providers/__tests__/kimi-code.spec.ts | 114 +++++++++++++++++- .../fetchers/__tests__/kimi-code.spec.ts | 38 +++++- src/api/providers/fetchers/kimi-code.ts | 23 +--- src/api/providers/kimi-code.ts | 33 ++++- src/api/providers/openai.ts | 35 ++++-- 6 files changed, 249 insertions(+), 47 deletions(-) diff --git a/packages/types/src/providers/kimi-code.ts b/packages/types/src/providers/kimi-code.ts index 0837e25832..bc7f50f63c 100644 --- a/packages/types/src/providers/kimi-code.ts +++ b/packages/types/src/providers/kimi-code.ts @@ -5,24 +5,57 @@ export const kimiCodeDefaultModelId = "kimi-for-coding" export const kimiCodeReasoningEfforts = ["low", "high", "max"] as const -export const kimiCodeDefaultModelInfo: ModelInfo = { +const kimiCodeK3ModelIds = new Set(["k3", "k3-256k"]) +const kimiCodeK27ModelIds = new Set(["kimi-for-coding", "kimi-for-coding-highspeed"]) + +export type KimiCodeRequestProtocol = "reasoning-effort" | "thinking" + +const kimiCodeBaseModelInfo: ModelInfo = { contextWindow: 262_144, maxTokens: 131_072, supportsImages: false, supportsPromptCache: false, - supportsReasoningEffort: [...kimiCodeReasoningEfforts], - requiredReasoningEffort: true, - reasoningEffort: "max", - description: "Kimi Code's coding model for subscription and API-key access.", + supportsReasoningEffort: false, + requiredReasoningEffort: false, + supportsTemperature: false, + description: "Kimi Code model for subscription and API-key access.", +} + +export function getKimiCodeRequestProtocol(modelId: string): KimiCodeRequestProtocol | undefined { + if (kimiCodeK3ModelIds.has(modelId)) return "reasoning-effort" + if (kimiCodeK27ModelIds.has(modelId)) return "thinking" + return undefined } -export const kimiCodeModelDefaults: Record = { - k3: { maxTokens: 131_072 }, - "k3-256k": { maxTokens: 131_072 }, - "kimi-for-coding": { maxTokens: 131_072 }, - "kimi-for-coding-highspeed": { maxTokens: 131_072 }, +export function getKimiCodeModelInfo(modelId: string): ModelInfo { + const protocol = getKimiCodeRequestProtocol(modelId) + + if (protocol === "reasoning-effort") { + return { + ...kimiCodeBaseModelInfo, + supportsImages: true, + supportsReasoningEffort: [...kimiCodeReasoningEfforts], + requiredReasoningEffort: true, + reasoningEffort: "high", + preserveReasoning: true, + description: "Kimi K3 coding model with configurable reasoning effort.", + } + } + + if (protocol === "thinking") { + return { + ...kimiCodeBaseModelInfo, + supportsImages: true, + preserveReasoning: true, + description: "Kimi K2.7 Code model with preserved thinking.", + } + } + + return { ...kimiCodeBaseModelInfo } } +export const kimiCodeDefaultModelInfo = getKimiCodeModelInfo(kimiCodeDefaultModelId) + export const kimiCodeModels = { [kimiCodeDefaultModelId]: kimiCodeDefaultModelInfo, } as const satisfies Record diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts index fe229910b8..459572e885 100644 --- a/src/api/providers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -129,6 +129,11 @@ describe("KimiCodeHandler", () => { await expect(handler.completePrompt("test")).resolves.toBe("retried") expect(mockForceRefreshAccessToken).toHaveBeenCalledOnce() expect(createCompletion).toHaveBeenCalledTimes(2) + expect(createCompletion).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ thinking: { type: "enabled", keep: "all" } }), + expect.anything(), + ) }) it("does not retry on 401 when using API key auth", async () => { @@ -214,18 +219,33 @@ describe("KimiCodeHandler", () => { expect(model.info.maxTokens).toBe(8000) }) - it("defaults to max reasoning effort and advertises low/high/max support", () => { + it("uses preserved thinking instead of reasoning effort for the default K2.7 model", () => { const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" }) const model = handler.getModel() + expect(model.info.supportsReasoningEffort).toBe(false) + expect(model.info.requiredReasoningEffort).toBe(false) + expect(model.info.preserveReasoning).toBe(true) + expect(model.reasoning).toBeUndefined() + }) + + it("defaults K3 to high reasoning effort and advertises low/high/max support", () => { + const handler = new KimiCodeHandler({ + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "key", + apiModelId: "k3", + }) + const model = handler.getModel() expect(model.info.supportsReasoningEffort).toEqual(["low", "high", "max"]) expect(model.info.requiredReasoningEffort).toBe(true) - expect(model.reasoning).toEqual({ reasoning_effort: "max" }) + expect(model.info.supportsTemperature).toBe(false) + expect(model.reasoning).toEqual({ reasoning_effort: "high" }) }) - it("sends the user-selected reasoning effort", () => { + it("sends the user-selected K3 reasoning effort", () => { const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key", + apiModelId: "k3", reasoningEffort: "low", }) expect(handler.getModel().reasoning).toEqual({ reasoning_effort: "low" }) @@ -235,8 +255,94 @@ describe("KimiCodeHandler", () => { const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key", + apiModelId: "k3", reasoningEffort: "medium", }) - expect(handler.getModel().reasoning).toEqual({ reasoning_effort: "max" }) + expect(handler.getModel().reasoning).toEqual({ reasoning_effort: "high" }) }) + + it.each(["k3", "k3-256k"])( + "sends the K3 reasoning protocol without temperature or thinking for %s", + async (modelId) => { + const handler = new KimiCodeHandler({ + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "key", + apiModelId: modelId, + modelTemperature: 0.7, + }) + const createCompletion = vi.spyOn(handler["client"].chat.completions, "create").mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] } + }, + } as never) + + for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "test" }])) { + // consume + } + + expect(createCompletion).toHaveBeenCalledWith( + expect.objectContaining({ model: modelId, reasoning_effort: "high" }), + expect.anything(), + ) + const request = createCompletion.mock.calls[0][0] + expect(request).not.toHaveProperty("temperature") + expect(request).not.toHaveProperty("thinking") + }, + ) + + it.each(["kimi-for-coding", "kimi-for-coding-highspeed"])( + "sends K2.7 preserved thinking without reasoning effort or temperature for %s", + async (modelId) => { + const handler = new KimiCodeHandler({ + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "key", + apiModelId: modelId, + reasoningEffort: "low", + modelTemperature: 0.7, + }) + const createCompletion = vi.spyOn(handler["client"].chat.completions, "create").mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] } + }, + } as never) + + for await (const _chunk of handler.createMessage("system", [ + { role: "user", content: "inspect the project" }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "I should inspect the files.", summary: [] } as never, + { type: "tool_use", id: "call_1", name: "read_file", input: { path: "README.md" } }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "call_1", content: "# Project" }, + { type: "text", text: "Continue with the result." }, + ], + }, + ])) { + // consume + } + + expect(createCompletion).toHaveBeenCalledWith( + expect.objectContaining({ + model: modelId, + thinking: { type: "enabled", keep: "all" }, + }), + expect.anything(), + ) + const request = createCompletion.mock.calls[0][0] + expect(request).not.toHaveProperty("reasoning_effort") + expect(request).not.toHaveProperty("temperature") + expect(request.messages).toContainEqual( + expect.objectContaining({ role: "assistant", reasoning_content: "I should inspect the files." }), + ) + expect(request.messages).toContainEqual( + expect.objectContaining({ role: "tool", content: "# Project\n\nContinue with the result." }), + ) + expect(request.messages.filter((message) => message.role === "user")).toHaveLength(1) + }, + ) }) diff --git a/src/api/providers/fetchers/__tests__/kimi-code.spec.ts b/src/api/providers/fetchers/__tests__/kimi-code.spec.ts index a96e760253..139812f1af 100644 --- a/src/api/providers/fetchers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/fetchers/__tests__/kimi-code.spec.ts @@ -4,7 +4,7 @@ describe("Kimi Code model discovery", () => { beforeEach(() => vi.restoreAllMocks()) afterEach(() => vi.useRealTimers()) - it("maps official model fields", () => { + it("maps server-reported capacity and display fields without guessing an unknown reasoning protocol", () => { expect( mapKimiCodeModel({ id: "kimi-test", @@ -15,14 +15,42 @@ describe("Kimi Code model discovery", () => { }), ).toMatchObject({ contextWindow: 131072, - supportsReasoningEffort: ["low", "high", "max"], - requiredReasoningEffort: true, - reasoningEffort: "max", + supportsReasoningEffort: false, + requiredReasoningEffort: false, supportsImages: true, displayName: "Kimi Test", }) }) + it.each(["k3", "k3-256k"])("applies the K3 reasoning-effort profile to %s", (modelId) => { + const mapped = mapKimiCodeModel({ id: modelId, supports_reasoning: true }) + + expect(mapped).toMatchObject({ + maxTokens: 131_072, + supportsReasoningEffort: ["low", "high", "max"], + requiredReasoningEffort: true, + reasoningEffort: "high", + supportsTemperature: false, + preserveReasoning: true, + }) + }) + + it.each(["kimi-for-coding", "kimi-for-coding-highspeed"])( + "applies the K2.7 preserved-thinking profile to %s", + (modelId) => { + const mapped = mapKimiCodeModel({ id: modelId, supports_reasoning: true }) + + expect(mapped).toMatchObject({ + maxTokens: 131_072, + supportsReasoningEffort: false, + requiredReasoningEffort: false, + supportsTemperature: false, + preserveReasoning: true, + }) + expect(mapped.reasoningEffort).toBeUndefined() + }, + ) + it("uses bearer auth for GET /models", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ data: [{ id: "kimi-for-coding", context_length: 262144 }] }), { @@ -80,7 +108,7 @@ describe("Kimi Code model discovery", () => { const models = await getKimiCodeModels("token") expect(Object.keys(models)).toHaveLength(2) expect(models["model-a"].contextWindow).toBe(100000) - expect(models["model-b"].supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(models["model-b"].supportsReasoningEffort).toBe(false) }) it("aborts model discovery after its deadline", async () => { diff --git a/src/api/providers/fetchers/kimi-code.ts b/src/api/providers/fetchers/kimi-code.ts index 02bf9180f7..963ffca60a 100644 --- a/src/api/providers/fetchers/kimi-code.ts +++ b/src/api/providers/fetchers/kimi-code.ts @@ -1,13 +1,6 @@ import { z } from "zod" -import { - KIMI_CODE_BASE_URL, - kimiCodeDefaultModelInfo, - kimiCodeModelDefaults, - kimiCodeReasoningEfforts, - type ModelInfo, - type ModelRecord, -} from "@roo-code/types" +import { KIMI_CODE_BASE_URL, getKimiCodeModelInfo, type ModelInfo, type ModelRecord } from "@roo-code/types" export const kimiCodeModelSchema = z.object({ id: z.string().min(1), @@ -23,16 +16,12 @@ const kimiCodeModelsResponseSchema = z.object({ data: z.array(kimiCodeModelSchem const KIMI_CODE_MODELS_TIMEOUT_MS = 10_000 export function mapKimiCodeModel(model: z.infer): ModelInfo { - const supportsReasoning = model.supports_reasoning ?? false - const defaults = kimiCodeModelDefaults[model.id] ?? {} + const profile = getKimiCodeModelInfo(model.id) return { - ...kimiCodeDefaultModelInfo, - contextWindow: model.context_length ?? kimiCodeDefaultModelInfo.contextWindow, - maxTokens: model.max_tokens ?? defaults.maxTokens ?? kimiCodeDefaultModelInfo.maxTokens, - supportsReasoningEffort: supportsReasoning ? [...kimiCodeReasoningEfforts] : false, - requiredReasoningEffort: supportsReasoning, - reasoningEffort: supportsReasoning ? "max" : undefined, - supportsImages: model.supports_image_in ?? false, + ...profile, + contextWindow: model.context_length ?? profile.contextWindow, + maxTokens: model.max_tokens ?? profile.maxTokens, + supportsImages: model.supports_image_in ?? profile.supportsImages, displayName: model.display_name, } } diff --git a/src/api/providers/kimi-code.ts b/src/api/providers/kimi-code.ts index 3f7136806b..217732f0c0 100644 --- a/src/api/providers/kimi-code.ts +++ b/src/api/providers/kimi-code.ts @@ -1,9 +1,11 @@ import type { Anthropic } from "@anthropic-ai/sdk" +import type OpenAI from "openai" import { KIMI_CODE_BASE_URL, + getKimiCodeModelInfo, + getKimiCodeRequestProtocol, kimiCodeDefaultModelId, - kimiCodeDefaultModelInfo, providerIdentifiers, type KimiCodeAuthMethod, type ModelInfo, @@ -14,6 +16,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { kimiCodeOAuthManager } from "../../integrations/kimi-code/oauth" import type { ApiHandlerCreateMessageMetadata } from "../index" +import { convertToOpenAiMessages } from "../transform/openai-format" import type { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -111,7 +114,7 @@ export class KimiCodeHandler extends OpenAiHandler { override getModel() { const id = this.kimiOptions.apiModelId || kimiCodeDefaultModelId - const info: ModelInfo = this.models[id] ?? kimiCodeDefaultModelInfo + const info: ModelInfo = this.models[id] ?? getKimiCodeModelInfo(id) const params = getModelParams({ format: "openai", modelId: id, @@ -121,4 +124,30 @@ export class KimiCodeHandler extends OpenAiHandler { }) return { id, info, ...params } } + + protected override transformChatCompletionRequest( + requestOptions: T, + ): T { + const transformed = { ...requestOptions } as T & { + thinking?: { type: "enabled"; keep: "all" } + } + delete transformed.temperature + + const protocol = getKimiCodeRequestProtocol(requestOptions.model) + if (protocol === "reasoning-effort") { + const effort = this.getModel().reasoning?.reasoning_effort + if (effort) transformed.reasoning_effort = effort + } else if (protocol === "thinking") { + delete transformed.reasoning_effort + transformed.thinking = { type: "enabled", keep: "all" } + } + + return transformed + } + + protected override convertMessagesForRequest( + messages: Anthropic.Messages.MessageParam[], + ): OpenAI.Chat.ChatCompletionMessageParam[] { + return convertToOpenAiMessages(messages, { mergeToolResultText: true }) + } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 5588dd37d6..37cc0e97ff 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -121,7 +121,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)] + convertedMessages = [systemMessage, ...this.convertMessagesForRequest(messages)] if (modelInfo.supportsPromptCache) { // Note: the following logic is copied from openrouter: @@ -174,11 +174,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl // Add max_tokens if needed this.addMaxTokensIfNeeded(requestOptions, modelInfo) + const transformedRequestOptions = this.transformChatCompletionRequest(requestOptions) let stream try { stream = await this.client.chat.completions.create( - requestOptions, + transformedRequestOptions, isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, ) } catch (error) { @@ -231,7 +232,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl model: modelId, messages: deepseekReasoner ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) - : [systemMessage, ...convertToOpenAiMessages(messages)], + : [systemMessage, ...this.convertMessagesForRequest(messages)], // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) tools: this.convertToolsForOpenAI(metadata?.tools), tool_choice: metadata?.tool_choice, @@ -240,11 +241,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl // Add max_tokens if needed this.addMaxTokensIfNeeded(requestOptions, modelInfo) + const transformedRequestOptions = this.transformChatCompletionRequest(requestOptions) let response try { response = await this.client.chat.completions.create( - requestOptions, + transformedRequestOptions, this._isAzureAiInference(modelUrl) ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, ) } catch (error) { @@ -311,11 +313,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl // Add max_tokens if needed this.addMaxTokensIfNeeded(requestOptions, modelInfo) + const transformedRequestOptions = this.transformChatCompletionRequest(requestOptions) let response try { response = await this.client.chat.completions.create( - requestOptions, + transformedRequestOptions, isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, ) } catch (error) { @@ -357,7 +360,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl role: "developer", content: `Formatting re-enabled\n${systemPrompt}`, }, - ...convertToOpenAiMessages(messages), + ...this.convertMessagesForRequest(messages), ], stream: true, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), @@ -373,11 +376,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl // but they do support max_completion_tokens (the modern OpenAI parameter) // This allows O3 models to limit response length when includeMaxTokens is enabled this.addMaxTokensIfNeeded(requestOptions, modelInfo) + const transformedRequestOptions = this.transformChatCompletionRequest(requestOptions) let stream try { stream = await this.client.chat.completions.create( - requestOptions, + transformedRequestOptions, methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, ) } catch (error) { @@ -393,7 +397,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl role: "developer", content: `Formatting re-enabled\n${systemPrompt}`, }, - ...convertToOpenAiMessages(messages), + ...this.convertMessagesForRequest(messages), ], reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined, temperature: undefined, @@ -407,11 +411,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl // but they do support max_completion_tokens (the modern OpenAI parameter) // This allows O3 models to limit response length when includeMaxTokens is enabled this.addMaxTokensIfNeeded(requestOptions, modelInfo) + const transformedRequestOptions = this.transformChatCompletionRequest(requestOptions) let response try { response = await this.client.chat.completions.create( - requestOptions, + transformedRequestOptions, methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, ) } catch (error) { @@ -525,6 +530,18 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl return isAzureAiInferenceBaseUrl(baseUrl) } + protected transformChatCompletionRequest( + requestOptions: T, + ): T { + return requestOptions + } + + protected convertMessagesForRequest( + messages: Anthropic.Messages.MessageParam[], + ): OpenAI.Chat.ChatCompletionMessageParam[] { + return convertToOpenAiMessages(messages) + } + /** * Adds max_completion_tokens to the request body if needed based on provider configuration * Note: max_tokens is deprecated in favor of max_completion_tokens as per OpenAI documentation