diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 1e422e4ba8..ef7ffc5c1a 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -100,6 +100,19 @@ vitest.mock("../fetchers/modelCache", () => ({ excludedTools: ["existing_excluded"], includedTools: ["existing_included"], }, + // Stale cache record simulating what users cached before the Moonshot K3 + // profile existed: the fabricated 0.2 context-window max_tokens and a + // boolean supportsReasoningEffort with no default effort. + "moonshotai/kimi-k3": { + maxTokens: 209716, + contextWindow: 1000000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.6, + outputPrice: 3, + description: "Kimi K3", + supportsReasoningEffort: true, + }, }) }), })) @@ -232,6 +245,30 @@ describe("OpenRouterHandler", () => { expect(result.info.excludedTools).toBeUndefined() expect(result.info.includedTools).toBeUndefined() }) + + it("applies the Moonshot K3 profile to stale cached model info", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + openRouterApiKey: "test-key", + openRouterModelId: "moonshotai/kimi-k3", + }), + ) + + const result = await handler.fetchModel() + + // The stale cache record carried a fabricated max_tokens (209716) and a + // boolean supportsReasoningEffort with no default effort; the profile must + // correct all of that before any request parameters are derived. + expect(result.id).toBe("moonshotai/kimi-k3") + expect(result.maxTokens).toBe(32768) + expect(result.temperature).toBeUndefined() + expect(result.reasoningEffort).toBe("high") + expect(result.reasoning).toEqual({ effort: "high" }) + expect(result.info.maxTokens).toBe(32768) + expect(result.info.supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(result.info.reasoningEffort).toBe("high") + expect(result.info.supportsTemperature).toBe(false) + }) }) describe("createMessage", () => { @@ -539,6 +576,35 @@ describe("OpenRouterHandler", () => { expect(endChunks).toHaveLength(1) expect(endChunks[0].id).toBe("call_openrouter_test") }) + + it("sends profiled max_tokens and reasoning effort, omitting temperature, for moonshotai/kimi-k3", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + openRouterApiKey: "test-key", + openRouterModelId: "moonshotai/kimi-k3", + }), + ) + + const mockStream = asyncStreamFrom([{ id: "test-id", choices: [{ delta: { content: "ok" } }] }]) + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + const chatStub = { completions: { create: mockCreate } } + // The vitest-mocked OpenAI class is structurally incompatible with the narrow + // chat stub; the double assertion routes through unknown (instead of any) to + // keep this file's no-explicit-any budget flat. + ;(OpenAI as unknown as { prototype: { chat: typeof chatStub } }).prototype.chat = chatStub + + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "test message" }] + await collectStream(handler.createMessage("test system prompt", messages)) + + const [requestParams] = mockCreate.mock.calls[0] as [Record] + expect(requestParams).toMatchObject({ + model: "moonshotai/kimi-k3", + max_tokens: 32768, + reasoning: { effort: "high" }, + }) + // K3 ignores temperature (fixed at 1.0 server-side); the request must omit it. + expect(requestParams.temperature).toBeUndefined() + }) }) describe("completePrompt", () => { diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index 89169c9c56..b20fe858fb 100644 --- a/src/api/providers/fetchers/__tests__/openrouter.spec.ts +++ b/src/api/providers/fetchers/__tests__/openrouter.spec.ts @@ -4,7 +4,14 @@ import * as path from "path" import { back as nockBack } from "nock" -import { getOpenRouterModelEndpoints, getOpenRouterModels, parseOpenRouterModel } from "../openrouter" +import type { ModelInfo } from "@roo-code/types" + +import { + applyOpenRouterMoonshotK3Profile, + getOpenRouterModelEndpoints, + getOpenRouterModels, + parseOpenRouterModel, +} from "../openrouter" nockBack.fixtures = path.join(__dirname, "fixtures") nockBack.setMode("lockdown") @@ -422,6 +429,61 @@ describe("OpenRouter API", () => { expect(result.contextWindow).toBe(128000) }) + it("applies the Moonshot K3 profile for moonshotai/kimi-k3", () => { + const mockModel = { + name: "Kimi K3", + description: "Test model", + context_length: 1000000, + max_completion_tokens: null, + pricing: { + prompt: "0.0000006", + completion: "0.000003", + }, + } + + const result = parseOpenRouterModel({ + id: "moonshotai/kimi-k3", + model: mockModel, + inputModality: ["text", "image"], + outputModality: ["text"], + maxTokens: null, + supportedParameters: ["reasoning"], + }) + + expect(result.maxTokens).toBe(32768) + expect(result.contextWindow).toBe(1000000) + expect(result.supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(result.reasoningEffort).toBe("high") + expect(result.supportsTemperature).toBe(false) + }) + + it("applies the Moonshot K3 profile for moonshotai/kimi-latest", () => { + const mockModel = { + name: "Kimi Latest", + description: "Test model", + context_length: 1000000, + max_completion_tokens: null, + pricing: { + prompt: "0.0000006", + completion: "0.000003", + }, + } + + const result = parseOpenRouterModel({ + id: "moonshotai/kimi-latest", + model: mockModel, + inputModality: ["text", "image"], + outputModality: ["text"], + maxTokens: null, + supportedParameters: ["reasoning"], + }) + + expect(result.maxTokens).toBe(32768) + expect(result.supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(result.reasoningEffort).toBe("high") + expect(result.supportsTemperature).toBe(false) + }) + it("does not override max tokens for other models", () => { const mockModel = { name: "Other Model", @@ -540,4 +602,57 @@ describe("OpenRouter API", () => { expect(resultWithoutTools.supportedParameters).toContain("max_tokens") }) }) + + describe("applyOpenRouterMoonshotK3Profile", () => { + it("overrides stale cached values for moonshotai/kimi-k3", () => { + const stale: ModelInfo = { + maxTokens: 209716, + contextWindow: 1000000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.6, + outputPrice: 3, + supportsReasoningEffort: true, + } + + const result = applyOpenRouterMoonshotK3Profile("moonshotai/kimi-k3", stale) + + expect(result).toEqual({ + ...stale, + maxTokens: 32768, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "high", + supportsTemperature: false, + }) + // The original record (e.g. a shared cache entry) must not be mutated. + expect(stale.maxTokens).toBe(209716) + expect(stale.supportsReasoningEffort).toBe(true) + }) + + it("applies the profile to moonshotai/kimi-latest", () => { + const stale: ModelInfo = { + maxTokens: 209716, + contextWindow: 1000000, + supportsPromptCache: true, + supportsReasoningEffort: true, + } + + const result = applyOpenRouterMoonshotK3Profile("moonshotai/kimi-latest", stale) + + expect(result.maxTokens).toBe(32768) + expect(result.supportsReasoningEffort).toEqual(["low", "high", "max"]) + expect(result.reasoningEffort).toBe("high") + expect(result.supportsTemperature).toBe(false) + }) + + it("returns other models unchanged", () => { + const info: ModelInfo = { + maxTokens: 8192, + contextWindow: 200000, + supportsPromptCache: true, + } + + expect(applyOpenRouterMoonshotK3Profile("openai/gpt-4o", info)).toBe(info) + }) + }) }) diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index a25c1b96fa..40e1d8e01c 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -180,6 +180,40 @@ export async function getOpenRouterModelEndpoints( return models } +/** + * Moonshot K3 model ids hosted on OpenRouter. + * + * OpenRouter reports `max_completion_tokens: null` for these models, so the + * generic 0.2 context-window fallback would fabricate an inflated max_tokens + * value (e.g. 209,716 for a 1M context window). K3 also always reasons with a + * low/high/max effort ladder (default "high") and ignores temperature + * (fixed at 1.0), so its wire-safe capability flags are profiled here instead + * of being derived from the catalogue. + */ +export const OPENROUTER_MOONSHOT_K3_MODELS = new Set(["moonshotai/kimi-k3", "moonshotai/kimi-latest"]) + +const MOONSHOT_K3_OPENROUTER_PROFILE: Partial = { + maxTokens: 32_768, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "high", + supportsTemperature: false, +} + +/** + * Apply the Moonshot K3 profile to an OpenRouter model record. + * + * Exported so OpenRouterHandler can re-apply the profile at consumption time: + * parsed records are persisted in the model cache, and records cached before + * this profile existed still carry the fabricated max_tokens value and a + * boolean supportsReasoningEffort with no default effort. + */ +export const applyOpenRouterMoonshotK3Profile = (modelId: string, modelInfo: ModelInfo): ModelInfo => { + if (!OPENROUTER_MOONSHOT_K3_MODELS.has(modelId)) { + return modelInfo + } + return { ...modelInfo, ...MOONSHOT_K3_OPENROUTER_PROFILE } +} + /** * parseOpenRouterModel */ @@ -301,5 +335,7 @@ export const parseOpenRouterModel = ({ modelInfo.maxTokens = 32768 } - return modelInfo + // Profile Moonshot K3 ids so fetched (and later cached) records carry the + // correct max tokens, reasoning effort ladder, and temperature handling. + return applyOpenRouterMoonshotK3Profile(id, modelInfo) } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index f61e007214..00f42bf663 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -33,6 +33,7 @@ import { getModelParams } from "../transform/model-params" import { getModels } from "./fetchers/modelCache" import { getModelEndpoints } from "./fetchers/modelEndpointCache" +import { applyOpenRouterMoonshotK3Profile } from "./fetchers/openrouter" import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { BaseProvider } from "./base-provider" @@ -564,6 +565,12 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH info = this.endpoints[this.options.openRouterSpecificProvider] } + // Re-apply the Moonshot K3 profile at consumption time: model records are + // persisted in the model cache, so records cached before the profile existed + // still carry the fabricated max_tokens value and a boolean + // supportsReasoningEffort with no default effort. + info = applyOpenRouterMoonshotK3Profile(id, info) + // Apply tool preferences for models accessed through routers (OpenAI, Gemini) info = applyRouterToolPreferences(id, info) diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts index 0f8b16f6cf..9a9d10c8dc 100644 --- a/src/shared/__tests__/api.spec.ts +++ b/src/shared/__tests__/api.spec.ts @@ -705,4 +705,31 @@ describe("shouldUseReasoningEffort", () => { expect(shouldUseReasoningEffort({ model, settings: { reasoningEffort: "none" as any } })).toBe(true) expect(shouldUseReasoningEffort({ model, settings: { reasoningEffort: "minimal" as any } })).toBe(true) }) + + test("array capability with model default effort and no settings -> true when default is in the ladder", () => { + const model: ModelInfo = { + contextWindow: 1_000_000, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "high", + } + + expect(shouldUseReasoningEffort({ model })).toBe(true) + expect(shouldUseReasoningEffort({ model, settings: {} })).toBe(true) + expect(shouldUseReasoningEffort({ model, settings: { reasoningEffort: undefined } })).toBe(true) + }) + + test("array capability with model default effort returns false when enableReasoningEffort is false", () => { + const model: ModelInfo = { + contextWindow: 1_000_000, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "high", + } + + expect(shouldUseReasoningEffort({ model, settings: { enableReasoningEffort: false } })).toBe(false) + expect( + shouldUseReasoningEffort({ model, settings: { enableReasoningEffort: false, reasoningEffort: "high" } }), + ).toBe(false) + }) })