diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 26c4dee7e1..82cca0ca38 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -45,6 +45,7 @@ export const dynamicProviders = [ "unbound", "poe", "deepseek", + "moonshot", "opencode-go", ] as const diff --git a/packages/types/src/providers/moonshot.ts b/packages/types/src/providers/moonshot.ts index a825475644..ad729bc2a7 100644 --- a/packages/types/src/providers/moonshot.ts +++ b/packages/types/src/providers/moonshot.ts @@ -56,7 +56,7 @@ export const moonshotModels = { "kimi-k2.5": { maxTokens: 16_384, contextWindow: 262_144, - supportsImages: false, + supportsImages: true, // Supports text, image, and video input supportsPromptCache: true, inputPrice: 0.6, // $0.60 per million tokens (cache miss) outputPrice: 3.0, // $3.00 per million tokens @@ -64,7 +64,43 @@ export const moonshotModels = { supportsTemperature: true, defaultTemperature: 1.0, description: - "Kimi K2.5 is the latest generation of Moonshot AI's Kimi series, featuring improved reasoning capabilities and enhanced performance across diverse tasks.", + "Kimi K2.5 supports text, image, and video input, thinking and non-thinking modes, and dialogue and agent tasks. Context length 256k.", + }, + "kimi-k2.6": { + maxTokens: 16_384, + contextWindow: 262_144, + supportsImages: true, // Native multimodal: text, image, video + supportsPromptCache: true, + inputPrice: 0.95, // $0.95 per million tokens (cache miss) + outputPrice: 4.0, // $4.00 per million tokens + cacheWritesPrice: 0, // $0 per million tokens (cache writes) + cacheReadsPrice: 0.16, // $0.16 per million tokens (cache hit) + description: + "Kimi K2.6 is Kimi's latest and most intelligent model with stronger long-term code writing capabilities, improved instruction compliance, and self-correction. Native multimodal architecture supporting text, image, and video input. Context length 256k.", + }, + "kimi-k2.7-code": { + maxTokens: 16_384, + contextWindow: 262_144, + supportsImages: true, // Native multimodal: text, image, video + supportsPromptCache: true, + inputPrice: 0.95, // $0.95 per million tokens (cache miss) + outputPrice: 4.0, // $4.00 per million tokens + cacheWritesPrice: 0, // $0 per million tokens (cache writes) + cacheReadsPrice: 0.19, // $0.19 per million tokens (cache hit) + description: + "Kimi K2.7 Code is Kimi's most intelligent Coding model for higher success rates in long context programming tasks. Native multimodal architecture supporting text, image, and video input. Context length 256k.", + }, + "kimi-k2.7-code-highspeed": { + maxTokens: 16_384, + contextWindow: 262_144, + supportsImages: true, // Native multimodal: text, image, video + supportsPromptCache: true, + inputPrice: 1.9, // $1.90 per million tokens (cache miss) + outputPrice: 8.0, // $8.00 per million tokens + cacheWritesPrice: 0, // $0 per million tokens (cache writes) + cacheReadsPrice: 0.38, // $0.38 per million tokens (cache hit) + description: + "Kimi K2.7 Code HighSpeed is the high-speed version of Kimi K2.7 Code with output speed of approximately 180 Tokens/s (up to 260 Tokens/s in short context). Same model architecture, faster output. Context length 256k.", }, } as const satisfies Record diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts index c0fd832a19..85ccfaffa2 100644 --- a/src/api/providers/__tests__/moonshot.spec.ts +++ b/src/api/providers/__tests__/moonshot.spec.ts @@ -1,28 +1,3 @@ -// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls -const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ - mockStreamText: vi.fn(), - mockGenerateText: vi.fn(), -})) - -vi.mock("ai", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - streamText: mockStreamText, - generateText: mockGenerateText, - } -}) - -vi.mock("@ai-sdk/openai-compatible", () => ({ - createOpenAICompatible: vi.fn(function () { - // Return a function that returns a mock language model - return vi.fn(() => ({ - modelId: "moonshot-chat", - provider: "moonshot", - })) - }), -})) - import type { Anthropic } from "@anthropic-ai/sdk" import { moonshotDefaultModelId } from "@roo-code/types" @@ -38,7 +13,7 @@ describe("MoonshotHandler", () => { beforeEach(() => { mockOptions = { moonshotApiKey: "test-api-key", - apiModelId: "moonshot-chat", + apiModelId: "kimi-k2-0905-preview", moonshotBaseUrl: "https://api.moonshot.ai/v1", } handler = new MoonshotHandler(mockOptions) @@ -96,9 +71,16 @@ describe("MoonshotHandler", () => { const model = handlerWithInvalidModel.getModel() expect(model.id).toBe("invalid-model") // Returns provided ID expect(model.info).toBeDefined() - // Should have the same base properties as default model + // Should have the same structural properties as default model expect(model.info.contextWindow).toBe(handler.getModel().info.contextWindow) expect(model.info.supportsPromptCache).toBe(true) + // Unknown models should not send a guessed maxTokens to the API + expect(model.info.maxTokens).toBeUndefined() + // Pricing should be unknown for unrecognized models + expect(model.info.inputPrice).toBeUndefined() + expect(model.info.outputPrice).toBeUndefined() + expect(model.info.cacheReadsPrice).toBeUndefined() + expect(model.info.cacheWritesPrice).toBeUndefined() }) it("should return default model if no model ID is provided", () => { @@ -134,23 +116,22 @@ describe("MoonshotHandler", () => { ] it("should handle streaming responses", async () => { - // Mock the fullStream async generator - async function* mockFullStream() { - yield { type: "text-delta", text: "Test response" } + async function* mockStream() { + yield { + choices: [{ delta: { content: "Test response" }, finish_reason: null }], + usage: null, + } } - // Mock usage promise - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - details: { cachedInputTokens: undefined }, - raw: { cached_tokens: 2 }, - }) + const mockClient = { + chat: { + completions: { + create: vi.fn().mockResolvedValue(mockStream()), + }, + }, + } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - }) + ;(handler as any).client = mockClient const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -158,28 +139,28 @@ describe("MoonshotHandler", () => { chunks.push(chunk) } - expect(chunks.length).toBeGreaterThan(0) const textChunks = chunks.filter((chunk) => chunk.type === "text") expect(textChunks).toHaveLength(1) expect(textChunks[0].text).toBe("Test response") }) it("should include usage information", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test response" } + async function* mockStream() { + yield { + choices: [{ delta: { content: "Test response" }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + } } - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - details: {}, - raw: { cached_tokens: 2 }, - }) + const mockClient = { + chat: { + completions: { + create: vi.fn().mockResolvedValue(mockStream()), + }, + }, + } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - }) + ;(handler as any).client = mockClient const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -194,21 +175,26 @@ describe("MoonshotHandler", () => { }) it("should include cache metrics in usage information", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test response" } + async function* mockStream() { + yield { + choices: [{ delta: { content: "Test response" }, finish_reason: "stop" }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + prompt_tokens_details: { cached_tokens: 2 }, + }, + } } - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - details: {}, - raw: { cached_tokens: 2 }, - }) + const mockClient = { + chat: { + completions: { + create: vi.fn().mockResolvedValue(mockStream()), + }, + }, + } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - }) + ;(handler as any).client = mockClient const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -224,25 +210,34 @@ describe("MoonshotHandler", () => { }) describe("completePrompt", () => { - it("should complete a prompt using generateText", async () => { - mockGenerateText.mockResolvedValue({ - text: "Test completion", - }) + it("should complete a prompt using the OpenAI client", async () => { + const mockClient = { + chat: { + completions: { + create: vi.fn().mockResolvedValue({ + choices: [{ message: { content: "Test completion" } }], + }), + }, + }, + } + + ;(handler as any).client = mockClient const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test completion") - expect(mockGenerateText).toHaveBeenCalledWith( + expect(mockClient.chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ - prompt: "Test prompt", + model: mockOptions.apiModelId, + messages: [{ role: "user", content: "Test prompt" }], }), + {}, ) }) }) describe("processUsageMetrics", () => { it("should correctly process usage metrics including cache information", () => { - // We need to access the protected method, so we'll create a test subclass class TestMoonshotHandler extends MoonshotHandler { public testProcessUsageMetrics(usage: any) { return this.processUsageMetrics(usage) @@ -252,10 +247,9 @@ describe("MoonshotHandler", () => { const testHandler = new TestMoonshotHandler(mockOptions) const usage = { - inputTokens: 100, - outputTokens: 50, - details: {}, - raw: { + prompt_tokens: 100, + completion_tokens: 50, + prompt_tokens_details: { cached_tokens: 20, }, } @@ -279,10 +273,8 @@ describe("MoonshotHandler", () => { const testHandler = new TestMoonshotHandler(mockOptions) const usage = { - inputTokens: 100, - outputTokens: 50, - details: {}, - raw: {}, + prompt_tokens: 100, + completion_tokens: 50, } const result = testHandler.testProcessUsageMetrics(usage) @@ -295,25 +287,26 @@ describe("MoonshotHandler", () => { }) }) - describe("getMaxOutputTokens", () => { - it("should return maxTokens from model info", () => { + describe("addMaxTokensIfNeeded", () => { + it("should use max_tokens (not max_completion_tokens) for Moonshot", () => { class TestMoonshotHandler extends MoonshotHandler { - public testGetMaxOutputTokens() { - return this.getMaxOutputTokens() + public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) { + return this.addMaxTokensIfNeeded(requestOptions, modelInfo) } } const testHandler = new TestMoonshotHandler(mockOptions) - const result = testHandler.testGetMaxOutputTokens() + const requestOptions: any = {} + testHandler.testAddMaxTokensIfNeeded(requestOptions, handler.getModel().info) - // Default model maxTokens is 16384 - expect(result).toBe(16384) + expect(requestOptions.max_tokens).toBe(16384) + expect(requestOptions.max_completion_tokens).toBeUndefined() }) - it("should use modelMaxTokens when provided", () => { + it("should use modelMaxTokens override when provided", () => { class TestMoonshotHandler extends MoonshotHandler { - public testGetMaxOutputTokens() { - return this.getMaxOutputTokens() + public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) { + return this.addMaxTokensIfNeeded(requestOptions, modelInfo) } } @@ -322,23 +315,27 @@ describe("MoonshotHandler", () => { ...mockOptions, modelMaxTokens: customMaxTokens, }) + const requestOptions: any = {} + testHandler.testAddMaxTokensIfNeeded(requestOptions, handler.getModel().info) - const result = testHandler.testGetMaxOutputTokens() - expect(result).toBe(customMaxTokens) + expect(requestOptions.max_tokens).toBe(customMaxTokens) }) - it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => { + it("should not send maxTokens for unknown model IDs", () => { class TestMoonshotHandler extends MoonshotHandler { - public testGetMaxOutputTokens() { - return this.getMaxOutputTokens() + public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) { + return this.addMaxTokensIfNeeded(requestOptions, modelInfo) } } - const testHandler = new TestMoonshotHandler(mockOptions) - const result = testHandler.testGetMaxOutputTokens() + const testHandler = new TestMoonshotHandler({ + ...mockOptions, + apiModelId: "future-moonshot-model", + }) + const requestOptions: any = {} + testHandler.testAddMaxTokensIfNeeded(requestOptions, testHandler.getModel().info) - // moonshot-chat has maxTokens of 16384 - expect(result).toBe(16384) + expect(requestOptions.max_tokens).toBeUndefined() }) }) @@ -352,94 +349,39 @@ describe("MoonshotHandler", () => { ] it("should handle tool calls in streaming", async () => { - async function* mockFullStream() { - yield { - type: "tool-input-start", - id: "tool-call-1", - toolName: "read_file", - } - yield { - type: "tool-input-delta", - id: "tool-call-1", - delta: '{"path":"test.ts"}', - } + async function* mockStream() { yield { - type: "tool-input-end", - id: "tool-call-1", - } - } - - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - details: {}, - raw: {}, - }) - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - }) - - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: [ - { - type: "function", - function: { - name: "read_file", - description: "Read a file", - parameters: { - type: "object", - properties: { path: { type: "string" } }, - required: ["path"], + choices: [ + { + delta: { + content: null, + tool_calls: [ + { + index: 0, + id: "tool-call-1", + function: { + name: "read_file", + arguments: '{"path":"test.ts"}', + }, + }, + ], }, + finish_reason: "tool_calls", }, - }, - ], - }) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") - const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") - const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") - - expect(toolCallStartChunks.length).toBe(1) - expect(toolCallStartChunks[0].id).toBe("tool-call-1") - expect(toolCallStartChunks[0].name).toBe("read_file") - - expect(toolCallDeltaChunks.length).toBe(1) - expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') - - expect(toolCallEndChunks.length).toBe(1) - expect(toolCallEndChunks[0].id).toBe("tool-call-1") - }) - - it("should handle complete tool calls", async () => { - async function* mockFullStream() { - yield { - type: "tool-call", - toolCallId: "tool-call-1", - toolName: "read_file", - input: { path: "test.ts" }, + ], + usage: null, } } - const mockUsage = Promise.resolve({ - inputTokens: 10, - outputTokens: 5, - details: {}, - raw: {}, - }) + const mockClient = { + chat: { + completions: { + create: vi.fn().mockResolvedValue(mockStream()), + }, + }, + } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: mockUsage, - }) + ;(handler as any).client = mockClient const stream = handler.createMessage(systemPrompt, messages, { taskId: "test-task", @@ -464,11 +406,16 @@ describe("MoonshotHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((c) => c.type === "tool_call") - expect(toolCallChunks.length).toBe(1) - expect(toolCallChunks[0].id).toBe("tool-call-1") - expect(toolCallChunks[0].name).toBe("read_file") - expect(toolCallChunks[0].arguments).toBe('{"path":"test.ts"}') + const partialChunks = chunks.filter((c) => c.type === "tool_call_partial") + const endChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(partialChunks.length).toBe(1) + expect(partialChunks[0].id).toBe("tool-call-1") + expect(partialChunks[0].name).toBe("read_file") + expect(partialChunks[0].arguments).toBe('{"path":"test.ts"}') + + expect(endChunks.length).toBe(1) + expect(endChunks[0].id).toBe("tool-call-1") }) }) }) diff --git a/src/api/providers/fetchers/__tests__/moonshot.spec.ts b/src/api/providers/fetchers/__tests__/moonshot.spec.ts new file mode 100644 index 0000000000..631948d620 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/moonshot.spec.ts @@ -0,0 +1,81 @@ +import { moonshotModels } from "@roo-code/types" + +import { getMoonshotModels } from "../moonshot" + +describe("getMoonshotModels", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it("merges API response with static model specs for known models", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "kimi-k2-0905-preview" }, { id: "kimi-k2-thinking" }], + }), + }) as unknown as typeof fetch + + const models = await getMoonshotModels("https://api.moonshot.ai/v1", "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith("https://api.moonshot.ai/v1/models", expect.any(Object)) + expect(models["kimi-k2-0905-preview"]).toEqual(moonshotModels["kimi-k2-0905-preview"]) + expect(models["kimi-k2-thinking"]).toEqual(moonshotModels["kimi-k2-thinking"]) + }) + + it("provides sane defaults for unknown model IDs without pricing", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "unknown-model-id" }], + }), + }) as unknown as typeof fetch + + const models = await getMoonshotModels("https://api.moonshot.ai/v1", "mock-key") + + expect(models["unknown-model-id"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: true, + description: "Moonshot model: unknown-model-id", + }) + }) + + it("throws for HTTP errors", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: vi.fn().mockResolvedValue('{"error":{"message":"Invalid API key"}}'), + }) as unknown as typeof fetch + + await expect(getMoonshotModels("https://api.moonshot.ai/v1", "invalid-key")).rejects.toThrow( + "HTTP 401: Unauthorized", + ) + }) + + it("uses default base URL when none provided", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMoonshotModels(undefined, "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith("https://api.moonshot.ai/v1/models", expect.any(Object)) + }) + + it("keeps /v1 in base URL and strips trailing slash", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMoonshotModels("https://api.moonshot.cn/v1/", "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith("https://api.moonshot.cn/v1/models", expect.any(Object)) + }) +}) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 312f4f9382..144d9de2e4 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -28,6 +28,7 @@ import { getOllamaModels } from "./ollama" import { getLMStudioModels } from "./lmstudio" import { getPoeModels } from "./poe" import { getDeepSeekModels } from "./deepseek" +import { getMoonshotModels } from "./moonshot" import { getZooGatewayModels } from "./zoo-gateway" const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) @@ -53,6 +54,7 @@ const URL_SCOPED_PROVIDERS: ReadonlySet = new Set([ "litellm", "poe", "deepseek", + "moonshot", "ollama", "lmstudio", "requesty", @@ -65,6 +67,7 @@ const KEY_SCOPED_PROVIDERS: ReadonlySet = new Set([ "litellm", // Per-key model allowlists are a first-class LiteLLM proxy feature "poe", // Per-account model availability "requesty", // Per-account custom model policies + "moonshot", // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn) ]) function isAuthScopedProvider(provider: RouterName): boolean { @@ -217,6 +220,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { + // Moonshot API uses OpenAI-compatible /v1/models endpoint. + // The base URL from settings already includes /v1 (e.g. https://api.moonshot.ai/v1), + // so we keep it as-is and append /models directly. + const base = (baseUrl || "https://api.moonshot.ai/v1").replace(/\/+$/, "") + const url = `${base}/models` + + const headers: Record = { + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + } + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 10000) + + try { + const response = await fetch(url, { + headers, + signal: controller.signal, + }) + + if (!response.ok) { + let errorBody = "" + try { + errorBody = await response.text() + } catch { + errorBody = "(unable to read response body)" + } + + console.error(`[getMoonshotModels] HTTP error:`, { + status: response.status, + statusText: response.statusText, + url, + body: errorBody, + }) + + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.json() + + if (!data?.data || !Array.isArray(data.data)) { + console.error("[getMoonshotModels] Unexpected response format:", data) + throw new Error("Failed to fetch Moonshot models: Unexpected response format.") + } + + // Use null-prototype object to prevent prototype pollution + const models: ModelRecord = Object.create(null) + + for (const model of data.data) { + const modelId = typeof model.id === "string" && model.id ? model.id : null + if (!modelId) continue + + const knownSpecs = moonshotModels[modelId as keyof typeof moonshotModels] + + if (knownSpecs) { + models[modelId] = { ...knownSpecs } + } else { + models[modelId] = { + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: true, + description: `Moonshot model: ${modelId}`, + } + } + } + + return models + } finally { + clearTimeout(timeoutId) + } +} diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts index 3e90e48f7a..74d3770d72 100644 --- a/src/api/providers/moonshot.ts +++ b/src/api/providers/moonshot.ts @@ -5,30 +5,47 @@ import type { ApiHandlerOptions } from "../../shared/api" import type { ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible" +import { OpenAiHandler } from "./openai" -export class MoonshotHandler extends OpenAICompatibleHandler { +export class MoonshotHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { - const modelId = options.apiModelId ?? moonshotDefaultModelId - const modelInfo = - moonshotModels[modelId as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId] + // Map Moonshot-specific options to the OpenAI-compatible options that + // OpenAiHandler expects. This makes Moonshot use the same battle-tested + // OpenAI Node SDK path as the generic "OpenAI Compatible" provider. + super({ + ...options, + openAiApiKey: options.moonshotApiKey ?? "not-provided", + openAiModelId: options.apiModelId ?? moonshotDefaultModelId, + openAiBaseUrl: options.moonshotBaseUrl || "https://api.moonshot.ai/v1", + }) + } - const config: OpenAICompatibleConfig = { - providerName: "moonshot", - baseURL: options.moonshotBaseUrl || "https://api.moonshot.ai/v1", - apiKey: options.moonshotApiKey ?? "not-provided", - modelId, - modelInfo, - modelMaxTokens: options.modelMaxTokens ?? undefined, - temperature: options.modelTemperature ?? undefined, + /** + * Resolve the ModelInfo for a given Moonshot model ID. + * Unknown IDs (e.g. dynamically fetched future models) keep the configured ID + * but fall back to the default model's structural metadata with pricing stripped + * so cost reporting shows "unknown" instead of charging the default model's rates. + */ + private static resolveModelInfo(modelId: string): ModelInfo { + const knownInfo = moonshotModels[modelId as keyof typeof moonshotModels] + if (knownInfo) { + return knownInfo } - super(options, config) + const defaultInfo = moonshotModels[moonshotDefaultModelId] + return { + ...defaultInfo, + maxTokens: undefined, + inputPrice: undefined, + outputPrice: undefined, + cacheReadsPrice: undefined, + cacheWritesPrice: undefined, + } } override getModel() { - const id = this.options.apiModelId ?? moonshotDefaultModelId - const info = moonshotModels[id as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId] + const id = this.options.openAiModelId ?? moonshotDefaultModelId + const info = MoonshotHandler.resolveModelInfo(id) const params = getModelParams({ format: "openai", modelId: id, @@ -43,24 +60,13 @@ export class MoonshotHandler extends OpenAICompatibleHandler { * Override to handle Moonshot's usage metrics, including caching. * Moonshot returns cached_tokens in a different location than standard OpenAI. */ - protected override processUsageMetrics(usage: { - inputTokens?: number - outputTokens?: number - details?: { - cachedInputTokens?: number - reasoningTokens?: number - } - raw?: Record - }): ApiStreamUsageChunk { - // Moonshot uses cached_tokens at the top level of raw usage data - const rawUsage = usage.raw as { cached_tokens?: number } | undefined - + protected override processUsageMetrics(usage: any, _modelInfo?: ModelInfo): ApiStreamUsageChunk { return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens: usage?.prompt_tokens || 0, + outputTokens: usage?.completion_tokens || 0, cacheWriteTokens: 0, - cacheReadTokens: rawUsage?.cached_tokens ?? usage.details?.cachedInputTokens, + cacheReadTokens: usage?.prompt_tokens_details?.cached_tokens ?? usage?.cached_tokens, } } @@ -68,9 +74,13 @@ export class MoonshotHandler extends OpenAICompatibleHandler { * Override to always include max_tokens for Moonshot (not max_completion_tokens). * Moonshot requires max_tokens parameter to be sent. */ - protected override getMaxOutputTokens(): number | undefined { - const modelInfo = this.config.modelInfo - // Moonshot always requires max_tokens - return this.options.modelMaxTokens || modelInfo.maxTokens || undefined + protected override addMaxTokensIfNeeded( + requestOptions: + | import("openai/resources/chat/completions").ChatCompletionCreateParamsStreaming + | import("openai/resources/chat/completions").ChatCompletionCreateParamsNonStreaming, + modelInfo: ModelInfo, + ): void { + // Moonshot always requires max_tokens (not max_completion_tokens) + requestOptions.max_tokens = this.options.modelMaxTokens || modelInfo.maxTokens || undefined } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1904b46bd4..d96991d8e6 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2701,6 +2701,7 @@ describe("ClineProvider - Router Models", () => { lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, }, values: undefined, @@ -2751,6 +2752,7 @@ describe("ClineProvider - Router Models", () => { litellm: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, }, values: undefined, @@ -2848,6 +2850,7 @@ describe("ClineProvider - Router Models", () => { lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, }, values: undefined, diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 13af478c07..49440fe61c 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -111,8 +111,11 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect(routerModels).toHaveProperty("openrouter") expect(routerModels).toHaveProperty("requesty") expect(routerModels).toHaveProperty("deepseek") + expect(routerModels).toHaveProperty("moonshot") expect(routerModels.deepseek).toEqual({}) + expect(routerModels.moonshot).toEqual({}) expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: "deepseek" })) + expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: "moonshot" })) }) it("fetches DeepSeek models when stored DeepSeek credentials exist", async () => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 335911a7c7..c397ba66ec 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -399,6 +399,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, }, values: undefined, @@ -550,6 +551,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, }, values: undefined, @@ -609,6 +611,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": mockModels, }, values: undefined, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index fdcc53ddc1..b09226cb79 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1041,6 +1041,7 @@ export const webviewMessageHandler = async ( lmstudio: {}, poe: {}, deepseek: {}, + moonshot: {}, "opencode-go": {}, } @@ -1135,6 +1136,21 @@ export const webviewMessageHandler = async ( }) } + // Moonshot is conditional on apiKey + const moonshotApiKey = message?.values?.moonshotApiKey ?? apiConfiguration.moonshotApiKey + const moonshotBaseUrl = message?.values?.moonshotBaseUrl ?? apiConfiguration.moonshotBaseUrl + + if (moonshotApiKey) { + if (message?.values?.moonshotApiKey || message?.values?.moonshotBaseUrl) { + await flushModels({ provider: "moonshot", apiKey: moonshotApiKey, baseUrl: moonshotBaseUrl }, true) + } + + candidates.push({ + key: "moonshot", + options: { provider: "moonshot", apiKey: moonshotApiKey, baseUrl: moonshotBaseUrl }, + }) + } + // Opencode Go's /models endpoint is public — it returns the full model list with no // Authorization header — so it's fetched unconditionally like openrouter/vercel-ai-gateway // above. Gating it behind a key meant the picker stayed empty (and fell back to the default diff --git a/src/shared/api.ts b/src/shared/api.ts index fe2042bd5f..6cbf4c2de2 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -186,6 +186,7 @@ const dynamicProviderExtras = { lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type poe: {} as { apiKey?: string; baseUrl?: string }, deepseek: {} as { apiKey?: string; baseUrl?: string }, + moonshot: {} as { apiKey?: string; baseUrl?: string }, "opencode-go": {} as { apiKey?: string }, } as const satisfies Record diff --git a/webview-ui/src/components/settings/providers/Moonshot.tsx b/webview-ui/src/components/settings/providers/Moonshot.tsx index f9ec8e0272..2d6c7d849a 100644 --- a/webview-ui/src/components/settings/providers/Moonshot.tsx +++ b/webview-ui/src/components/settings/providers/Moonshot.tsx @@ -1,13 +1,22 @@ -import { useCallback } from "react" +import { useCallback, useState, useEffect, useRef } from "react" import { VSCodeTextField, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" +import { useQueryClient } from "@tanstack/react-query" -import type { ProviderSettings } from "@roo-code/types" +import type { ProviderSettings, ExtensionMessage } from "@roo-code/types" +import { moonshotDefaultModelId } from "@roo-code/types" + +import { RouterName } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" +import { useExtensionState } from "@src/context/ExtensionStateContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { vscode } from "@src/utils/vscode" +import { Button } from "@src/components/ui" +import { ModelPicker } from "../ModelPicker" +import { handleModelChangeSideEffects } from "../utils/providerModelConfig" +import type { ProviderName } from "@roo-code/types" import { inputEventTransform } from "../transforms" -import { cn } from "@/lib/utils" type MoonshotProps = { apiConfiguration: ProviderSettings @@ -15,8 +24,39 @@ type MoonshotProps = { simplifySettings?: boolean } -export const Moonshot = ({ apiConfiguration, setApiConfigurationField }: MoonshotProps) => { +export const Moonshot = ({ apiConfiguration, setApiConfigurationField, simplifySettings }: MoonshotProps) => { const { t } = useAppTranslation() + const { routerModels } = useExtensionState() + const queryClient = useQueryClient() + const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshError, setRefreshError] = useState() + const moonshotErrorJustReceived = useRef(false) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "singleRouterModelFetchResponse" && !message.success) { + const providerName = message.values?.provider as RouterName + if (providerName === "moonshot" && refreshStatus === "loading") { + moonshotErrorJustReceived.current = true + setRefreshStatus("error") + setRefreshError(message.error) + } + } else if (message.type === "routerModels") { + if (refreshStatus === "loading") { + if (!moonshotErrorJustReceived.current) { + setRefreshStatus("success") + queryClient.invalidateQueries({ queryKey: ["routerModels"] }) + } + } + } + } + + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + } + }, [refreshStatus, queryClient]) const handleInputChange = useCallback( ( @@ -29,6 +69,25 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField }: Moonsho [setApiConfigurationField], ) + const handleRefreshModels = useCallback(() => { + moonshotErrorJustReceived.current = false + setRefreshStatus("loading") + setRefreshError(undefined) + + const key = apiConfiguration.moonshotApiKey + + if (!key) { + setRefreshStatus("error") + setRefreshError(t("settings:providers.refreshModels.missingConfig")) + return + } + + vscode.postMessage({ + type: "requestRouterModels", + values: { moonshotApiKey: key, moonshotBaseUrl: apiConfiguration.moonshotBaseUrl }, + }) + }, [apiConfiguration, t]) + return ( <>
@@ -36,7 +95,7 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField }: Moonsho + className="w-full"> api.moonshot.ai @@ -69,6 +128,45 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField }: Moonsho )}
+ + handleModelChangeSideEffects("moonshot" as ProviderName, modelId, setApiConfigurationField) + } + /> + + {refreshStatus === "loading" && ( +
+ {t("settings:providers.refreshModels.loading")} +
+ )} + {refreshStatus === "success" && ( +
{t("settings:providers.refreshModels.success")}
+ )} + {refreshStatus === "error" && ( +
+ {refreshError || t("settings:providers.refreshModels.error")} +
+ )} ) } diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index 9cc9dafa01..92bae778ad 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -200,6 +200,7 @@ export const PROVIDERS_WITH_CUSTOM_MODEL_UI: ProviderName[] = [ "ollama", "lmstudio", "vscode-lm", + "moonshot", // Moonshot has custom ModelPicker inside Moonshot.tsx ] /** diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index f86619a1d4..6894f51878 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -251,9 +251,13 @@ function getSelectedModel({ return { id, info: routerInfo ?? staticInfo } } case "moonshot": { - const id = apiConfiguration.apiModelId ?? defaultModelId - const info = moonshotModels[id as keyof typeof moonshotModels] - return { id, info } + const availableModels = routerModels.moonshot + ? { ...moonshotModels, ...routerModels.moonshot } + : moonshotModels + const id = getValidatedModelId(apiConfiguration.apiModelId, availableModels, defaultModelId) + const routerInfo = routerModels.moonshot?.[id] + const staticInfo = moonshotModels[id as keyof typeof moonshotModels] + return { id, info: routerInfo ?? staticInfo } } case "minimax": { const id = apiConfiguration.apiModelId ?? defaultModelId diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 5d4f54b927..597bbfb20c 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -53,6 +53,7 @@ describe("Model Validation Functions", () => { deepseek: {}, "opencode-go": {}, "zoo-gateway": {}, + moonshot: {}, } const allowAllOrganization: OrganizationAllowList = {