Skip to content
Open
136 changes: 135 additions & 1 deletion src/api/providers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) => {
Expand All @@ -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)
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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<string, unknown>]
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",
Expand Down
121 changes: 120 additions & 1 deletion src/api/providers/fetchers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
})
})
})
45 changes: 44 additions & 1 deletion src/api/providers/fetchers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(["moonshotai/kimi-k3", "~moonshotai/kimi-latest"])
if (!moonshotK3Models.has(modelId)) {
return modelInfo
}
const moonshotK3Profile: Partial<ModelInfo> = {
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
*/
Expand Down Expand Up @@ -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)
}
7 changes: 7 additions & 0 deletions src/api/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading