diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 27a84363f6..2156a4b2ef 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -43,6 +43,14 @@ vitest.mock("@roo-code/telemetry", () => ({ }, })) +const { mockGetModelEndpoints } = vitest.hoisted(() => ({ + mockGetModelEndpoints: vitest.fn(), +})) + +vitest.mock("../fetchers/modelEndpointCache", () => ({ + getModelEndpoints: mockGetModelEndpoints, +})) + vitest.mock("../fetchers/modelCache", () => ({ getModels: vitest.fn().mockImplementation(function () { return Promise.resolve({ @@ -101,6 +109,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, + }, }) }), refreshModels: vitest.fn(async (options) => { @@ -115,7 +136,13 @@ describe("OpenRouterHandler", () => { openRouterModelId: "anthropic/claude-sonnet-4", }) - beforeEach(() => clearAllMocks()) + beforeEach(() => { + clearAllMocks() + // Endpoint records default to "not fetched" (same as the real guard for + // missing options); tests that exercise the specific-provider branch + // override this per test. + mockGetModelEndpoints.mockResolvedValue({}) + }) it("initializes with correct options", () => { const handler = new OpenRouterHandler(mockOptions) @@ -237,6 +264,75 @@ 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).toBe(1) + 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(true) + expect(result.info.defaultTemperature).toBe(1) + }) + + it("applies the Moonshot K3 profile to a stale specific-provider endpoint record", async () => { + // Endpoint records are cached separately from the parent model cache and + // are selected before the profile is applied. A stale endpoint record + // (fabricated max_tokens, boolean supportsReasoningEffort, no reasoning + // default, no temperature default) must be corrected for the selected + // endpoint. + mockGetModelEndpoints.mockResolvedValue({ + moonshotai: { + maxTokens: 209716, + contextWindow: 1000000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.6, + outputPrice: 3, + description: "Kimi K3 (Moonshot endpoint)", + supportsReasoningEffort: true, + }, + }) + + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + openRouterApiKey: "test-key", + openRouterModelId: "moonshotai/kimi-k3", + openRouterSpecificProvider: "moonshotai", + }), + ) + + const result = await handler.fetchModel() + + // The selected endpoint record (not the parent model cache entry) must be + // profiled: a regression that applied the profile before endpoint + // selection would send the stale endpoint values. + expect(result.id).toBe("moonshotai/kimi-k3") + 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(true) + expect(result.info.defaultTemperature).toBe(1) + expect(result.maxTokens).toBe(32768) + expect(result.temperature).toBe(1) + expect(result.reasoningEffort).toBe("high") + expect(result.reasoning).toEqual({ effort: "high" }) + }) }) describe("createMessage", () => { @@ -546,6 +642,44 @@ describe("OpenRouterHandler", () => { expect(endChunks[0].id).toBe("call_openrouter_test") }) + it("sends profiled max_tokens, explicit temperature 1.0, and reasoning effort 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. + const openAiPrototype = OpenAI as unknown as { prototype: { chat?: unknown } } + const originalChat = openAiPrototype.prototype.chat + openAiPrototype.prototype.chat = chatStub + + try { + 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, + temperature: 1, + reasoning: { effort: "high" }, + }) + // K3 is fixed at temperature 1.0 upstream (issue #1316); the request + // must carry it explicitly. + } finally { + // Restore the original prototype property so this stub cannot leak + // into later tests; clearAllMocks does not undo prototype assignment. + openAiPrototype.prototype.chat = originalChat + } + }) + it("emits completion only for identified calls and clears completed IDs", async () => { const toolCall = (id?: string) => ({ id: "stream", diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index 5d1f03bb0b..a72c3e5d0c 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") @@ -478,6 +485,63 @@ 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(true) + expect(result.defaultTemperature).toBe(1) + }) + + 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(true) + expect(result.defaultTemperature).toBe(1) + }) + it("does not override max tokens for other models", () => { const mockModel = { name: "Other Model", @@ -596,4 +660,59 @@ 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: true, + defaultTemperature: 1, + }) + // 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(true) + expect(result.defaultTemperature).toBe(1) + }) + + 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 debbf76967..3049e09554 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -180,6 +180,47 @@ export async function getOpenRouterModelEndpoints( return models } +/** + * Apply the Moonshot K3 profile to an OpenRouter model record. + * + * 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 is fixed at temperature 1.0 + * (issue #1316 expects requests to carry an explicit `temperature: 1.0`), so + * its wire-safe capability flags and temperature default are profiled here + * instead of being derived from the catalogue. + * + * 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. + * + * The model ids and profile values live in the function body on purpose: + * module-scope literals become "static" mutants, and the Stryker/Vitest + * runner combination used by the mutation-diff gate never activates them, + * so they would report as surviving mutants (see .github/workflows/mutation-testing.yml). + * + * `~moonshotai/kimi-latest` is OpenRouter's rolling Kimi alias: the catalogue + * identifier keeps its `~` prefix, and that exact string is what reaches this + * function as `modelId`. Issue #1316 covers the alias in addition to the exact + * `moonshotai/kimi-k3` id. + */ +export const applyOpenRouterMoonshotK3Profile = (modelId: string, modelInfo: ModelInfo): ModelInfo => { + const moonshotK3Models = new Set(["moonshotai/kimi-k3", "~moonshotai/kimi-latest"]) + if (!moonshotK3Models.has(modelId)) { + return modelInfo + } + const moonshotK3Profile: Partial = { + maxTokens: 32_768, + supportsReasoningEffort: ["low", "high", "max"], + reasoningEffort: "high", + supportsTemperature: true, + defaultTemperature: 1.0, // K3 is fixed at 1.0 upstream; send it explicitly (issue #1316) + } + return { ...modelInfo, ...moonshotK3Profile } +} + /** * parseOpenRouterModel */ @@ -317,5 +358,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 ed53c111b5..1bcc8475a6 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -31,6 +31,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" @@ -566,6 +567,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) + }) })