Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
184 changes: 183 additions & 1 deletion src/api/providers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"

import { providerIdentifiers } from "@roo-code/types"
import type { ModelInfo } from "@roo-code/types"

import { applyOpenRouterMoonshotK3Profile } from "../fetchers/openrouter"

import { OpenRouterHandler } from "../openrouter"
import { Package } from "../../../shared/package"
Expand All @@ -43,6 +46,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 +112,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 +139,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,8 +267,122 @@ 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("applyOpenRouterMoonshotK3Profile", () => {
// Stale cache record shape: fabricated 0.2 context-window max_tokens and a
// boolean supportsReasoningEffort with no default effort (pre-profile).
const staleK3Record: ModelInfo = {
maxTokens: 209_716,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.6,
outputPrice: 3,
description: "Kimi K3 (stale cache)",
supportsReasoningEffort: true,
}

it("applies the profile to the exact moonshotai/kimi-k3 id", () => {
const result = applyOpenRouterMoonshotK3Profile("moonshotai/kimi-k3", { ...staleK3Record })

expect(result.maxTokens).toBe(32_768)
expect(result.supportsReasoningEffort).toEqual(["low", "high", "max"])
expect(result.reasoningEffort).toBe("high")
expect(result.supportsTemperature).toBe(true)
expect(result.defaultTemperature).toBe(1.0)
// Unprofiled fields pass through from the (stale) record
expect(result.contextWindow).toBe(1_000_000)
expect(result.description).toBe("Kimi K3 (stale cache)")
})

it("applies the profile to the tilde-prefixed rolling alias", () => {
const result = applyOpenRouterMoonshotK3Profile("~moonshotai/kimi-latest", { ...staleK3Record })

// The catalogue keeps the ~ prefix on the rolling alias id, so the profile
// must recognize the exact string rather than a stripped or bare alias
expect(result.maxTokens).toBe(32_768)
expect(result.supportsReasoningEffort).toEqual(["low", "high", "max"])
expect(result.reasoningEffort).toBe("high")
expect(result.supportsTemperature).toBe(true)
expect(result.defaultTemperature).toBe(1.0)
})

it("leaves non-K3 model records untouched", () => {
const record: ModelInfo = { ...staleK3Record }

expect(applyOpenRouterMoonshotK3Profile("moonshotai/kimi-k2-thinking", record)).toBe(record)
})
})
describe("createMessage", () => {
it("generates correct stream chunks", async () => {
const handler = new OpenRouterHandler(mockOptions)
Expand Down Expand Up @@ -546,6 +690,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 @@ -550,6 +557,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 @@ -668,4 +732,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)
})
})
})
Loading
Loading