Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/api/providers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
})
}),
}))
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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<string, unknown>]
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", () => {
Expand Down
117 changes: 116 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 @@ -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",
Expand Down Expand Up @@ -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)
})
})
})
38 changes: 37 additions & 1 deletion src/api/providers/fetchers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(["moonshotai/kimi-k3", "moonshotai/kimi-latest"])

const MOONSHOT_K3_OPENROUTER_PROFILE: Partial<ModelInfo> = {
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
*/
Expand Down Expand Up @@ -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)
}
7 changes: 7 additions & 0 deletions src/api/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
27 changes: 27 additions & 0 deletions src/shared/__tests__/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading