diff --git a/packages/types/src/__tests__/mimo.test.ts b/packages/types/src/__tests__/mimo.test.ts new file mode 100644 index 0000000000..68a91c3027 --- /dev/null +++ b/packages/types/src/__tests__/mimo.test.ts @@ -0,0 +1,55 @@ +import { + SECRET_STATE_KEYS, + getMimoModelInfo, + isSecretStateKey, + mimoDefaultModelInfo, + mimoModels, + providerIdentifiers, + providerSettingsSchema, +} from "../index.js" + +describe("MiMo provider secret classification", () => { + it("classifies mimoApiKey as a secret state key alongside its sibling provider keys", () => { + expect(SECRET_STATE_KEYS).toContain("mimoApiKey") + expect(isSecretStateKey("mimoApiKey")).toBe(true) + }) + + it("keeps the non-secret MiMo settings out of the secret set", () => { + expect(isSecretStateKey("mimoBaseUrl")).toBe(false) + expect(SECRET_STATE_KEYS).not.toContain("mimoBaseUrl") + }) + + it("still parses and exposes mimoApiKey through the provider settings schema", () => { + const parsed = providerSettingsSchema.parse({ + apiProvider: providerIdentifiers.mimo, + mimoApiKey: "mimo-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }) + expect(parsed.mimoApiKey).toBe("mimo-key") + expect(parsed.mimoBaseUrl).toBe("https://token-plan-sgp.xiaomimimo.com/v1") + }) +}) + +describe("getMimoModelInfo", () => { + it("returns the curated spec for a known model ID", () => { + expect(getMimoModelInfo("mimo-v2.6-pro")).toBe(mimoModels["mimo-v2.6-pro"]) + }) + + it("returns undefined for an unknown model ID", () => { + expect(getMimoModelInfo("mimo-v99-not-real")).toBeUndefined() + }) + + // Regression: model IDs reach this lookup from the provider's /models response, + // so a plain `mimoModels[id]` read resolved Object.prototype members and handed + // callers a function (or `{}` after spreading) instead of the family default. + it.each(["constructor", "toString", "valueOf", "hasOwnProperty", "__proto__"])( + "returns undefined for the inherited Object.prototype key %s", + (key) => { + expect(getMimoModelInfo(key)).toBeUndefined() + }, + ) + + it("keeps the default model resolvable so the fallback path has a spec", () => { + expect(getMimoModelInfo("mimo-v2.6-pro")).toBe(mimoDefaultModelInfo) + }) +}) diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index af4de232f9..e7ee26b999 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -109,6 +109,7 @@ describe("provider identifiers", () => { providerIdentifiers.poe, providerIdentifiers.deepseek, providerIdentifiers.moonshot, + providerIdentifiers.mimo, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.nanogpt, diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index eb51c1d67d..d25b1589ff 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -344,6 +344,7 @@ export const SECRET_STATE_KEYS = [ "openAiNativeApiKey", "deepSeekApiKey", "moonshotApiKey", + "mimoApiKey", "kimiCodeApiKey", "mistralApiKey", "minimaxApiKey", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index ebb48b041b..2b2ce29b6b 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -97,6 +97,7 @@ export const dynamicProviders = [ providerIdentifiers.poe, providerIdentifiers.deepseek, providerIdentifiers.moonshot, + providerIdentifiers.mimo, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.nanogpt, diff --git a/packages/types/src/providers/lite-llm.ts b/packages/types/src/providers/lite-llm.ts index 5303772586..3a59332f64 100644 --- a/packages/types/src/providers/lite-llm.ts +++ b/packages/types/src/providers/lite-llm.ts @@ -37,7 +37,8 @@ export const LITELLM_PRESERVE_REASONING_MODEL_IDS = [ "deepseek-v4-pro", "deepseek-reasoner", - // mimo.ts, opencode-go.ts + // opencode-go.ts (MiMo V2.5 is retired from mimo.ts; the gateway can still + // front opencode-go's V2.5 routes) "mimo-v2.5", "mimo-v2.5-pro", diff --git a/packages/types/src/providers/mimo.ts b/packages/types/src/providers/mimo.ts index 9279c8b1cf..f65e5644f2 100644 --- a/packages/types/src/providers/mimo.ts +++ b/packages/types/src/providers/mimo.ts @@ -9,9 +9,17 @@ import { createProviderDefinition, } from "./common.js" -// https://developer.puter.com/ai/xiaomi/mimo-v2.5-pro/ -// https://developer.puter.com/ai/xiaomi/mimo-v2.5/ -// https://platform.xiaomimimo.com/docs/en-US/quick-start/model-hyperparameters +// https://mimo.mi.com/docs/en-US/quick-start/model-hyperparameters +// https://mimo.mi.com/docs/en-US/quick-start/usage-guide/text-generation/deep-thinking +// https://mimo.mi.com/docs/en-US/price/pay-as-you-go +// +// mimo-v2.5-pro and mimo-v2.5 were retired on 2026-10-21 10:00 (GMT+8) and their +// model names stopped working, so they are gone from this catalog: keeping them +// would leave dead IDs selectable. The V2.6 series below replaces them. A saved +// selection that still names a retired ID keeps working through the documented +// runtime fallback -- getMimoModelInfo(id) ?? mimoDefaultModelInfo in +// src/api/providers/mimo.ts -- which prices and limits the request as the +// default model while sending the stored ID through unchanged. // // NOTE: mimo-v2-flash is not included here. Its thinking mode defaults to // disabled and it doesn't reliably handle reasoning_content passthrough @@ -21,53 +29,65 @@ import { // an agentic provider, and flash just can't do that yet. export type MimoModelId = keyof typeof mimoModels -export const mimoDefaultModelId: MimoModelId = "mimo-v2.5-pro" +export const mimoDefaultModelId: MimoModelId = "mimo-v2.6-pro" export const mimoModels = { - "mimo-v2.5-pro": { + "mimo-v2.6-pro": { maxTokens: 131_072, contextWindow: 1_048_576, - supportsImages: false, // Pro series is text-only + supportsImages: true, // V2.6 series is full-modality (text, image, audio, video) supportsPromptCache: false, preserveReasoning: true, - inputPrice: 1.0, // $1.00/1M tokens (cache miss, ≤256K) - outputPrice: 3.0, // $3.00/1M tokens (≤256K) - cacheReadsPrice: 0.2, // $0.20/1M tokens (cache hit, ≤256K) + inputPrice: 0.435, // $0.435/1M tokens (cache miss) + outputPrice: 0.87, // $0.87/1M tokens + cacheReadsPrice: 0.0036, // $0.0036/1M tokens (cache hit) cacheWritesPrice: 0, // Free for limited time - // MiMo charges 2x above 256K context - longContextPricing: { - thresholdTokens: 256_000, - inputPriceMultiplier: 2, - outputPriceMultiplier: 2, - cacheReadsPriceMultiplier: 2, - }, description: - "MiMo V2.5 Pro - Xiaomi's flagship reasoning model with 1M context, deep thinking, tool calling, and structured output.", + "MiMo V2.6 Pro - Xiaomi's flagship omni-modal reasoning model with 1M context, deep thinking, tool calling, and structured output.", }, - "mimo-v2.5": { + "mimo-v2.6-flash": { maxTokens: 131_072, contextWindow: 1_048_576, - supportsImages: true, // Full-modal: text, image, audio, video input + supportsImages: true, // Full-modality: text, image, audio, video input supportsPromptCache: false, preserveReasoning: true, - inputPrice: 0.4, // $0.40/1M tokens (cache miss, ≤256K) - outputPrice: 2.0, // $2.00/1M tokens (≤256K) - cacheReadsPrice: 0.08, // $0.08/1M tokens (cache hit, ≤256K) + inputPrice: 0.14, // $0.14/1M tokens (cache miss) + outputPrice: 0.28, // $0.28/1M tokens + cacheReadsPrice: 0.0028, // $0.0028/1M tokens (cache hit) cacheWritesPrice: 0, // Free for limited time - // MiMo charges 2x above 256K context - longContextPricing: { - thresholdTokens: 256_000, - inputPriceMultiplier: 2, - outputPriceMultiplier: 2, - cacheReadsPriceMultiplier: 2, - }, description: - "MiMo V2.5 - Full-modal understanding model (text, image, audio, video) with 1M context, deep thinking, tool calling, and structured output.", + "MiMo V2.6 Flash - Full-modality, low-cost reasoning model for high-frequency calls and large-scale tasks.", + }, + "mimo-v2.6-pro-ultraspeed": { + maxTokens: 131_072, + contextWindow: 1_048_576, + supportsImages: true, // Full-modality like the rest of the V2.6 series + supportsPromptCache: false, + preserveReasoning: true, + inputPrice: 4.35, // $4.35/1M tokens (cache miss) + outputPrice: 8.7, // $8.70/1M tokens + cacheReadsPrice: 0.036, // $0.036/1M tokens (cache hit) + cacheWritesPrice: 0, // Free for limited time + description: + "MiMo V2.6 Pro Ultraspeed - V2.6-Pro performance at up to 20x speed for real-time, latency-sensitive workloads.", }, } as const satisfies Record export const mimoDefaultModelInfo: ModelInfo = mimoModels[mimoDefaultModelId] +/** + * Looks up a curated spec by model ID, or `undefined` when the ID is not in the + * catalog. A plain `mimoModels[id]` read is not safe for untrusted IDs: the + * dynamic catalog feeds IDs straight from the provider's `/models` response, so + * an ID like `constructor` or `toString` resolves to an inherited + * `Object.prototype` member instead of a spec, and spreading it yields `{}` -- + * a model with no metadata at all. Callers fall back to family defaults on + * `undefined`. + */ +export function getMimoModelInfo(modelId: string): ModelInfo | undefined { + return Object.hasOwn(mimoModels, modelId) ? mimoModels[modelId as MimoModelId] : undefined +} + export const MIMO_DEFAULT_TEMPERATURE = 1.0 export const mimoProviderDefinition = createProviderDefinition({ @@ -77,6 +97,12 @@ export const mimoProviderDefinition = createProviderDefinition({ getDefaultModelId: () => mimoDefaultModelId, schema: { ...apiModelIdProviderModelShape, + // The four allowed Xiaomi MiMo endpoints. Runtime paths that read this + // value BEFORE schema validation can apply — unsaved webview values, the + // fail-open ContextProxy path, the chat-completion client — all enforce + // the same set via ALLOWED_BASE_URLS in + // src/api/providers/fetchers/mimo.ts; keep these literals in sync with + // that set (fetcher tests cross-check both directions of the mapping). mimoBaseUrl: z .union([ z.literal("https://api.xiaomimimo.com/v1"), diff --git a/src/api/providers/__tests__/mimo-redirect.spec.ts b/src/api/providers/__tests__/mimo-redirect.spec.ts new file mode 100644 index 0000000000..5ca847cc7d --- /dev/null +++ b/src/api/providers/__tests__/mimo-redirect.spec.ts @@ -0,0 +1,106 @@ +// npx vitest run api/providers/__tests__/mimo-redirect.spec.ts + +// Deliberately does NOT mock "openai": this suite proves the redirect policy the +// real SDK client actually sends, which a mocked constructor cannot show. +import type { Anthropic } from "@anthropic-ai/sdk" + +import type { ApiHandlerOptions } from "../../../shared/api" +import { MimoHandler } from "../mimo" +import { collectStream } from "../../../test-utils/stream" + +const ALLOWED_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1" +const REDIRECT_TARGET = "https://attacker.example/collect" + +const options = (): ApiHandlerOptions => ({ + mimoApiKey: "test-api-key", + apiModelId: "mimo-v2.6-pro", + mimoBaseUrl: ALLOWED_BASE_URL, +}) + +const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + +/** + * Stands in for the transport, emulating the fetch redirect contract rather than + * just recording the init: a 3xx is refused when the init says redirect:"error", + * and otherwise replayed against the Location target with the same method, + * headers, and body -- exactly what undici's default redirect:"follow" does. That + * is what makes this a behavioral test: the assertion below fails on the old + * code because the request really does reach the attacker URL. + */ +const redirectingFetch = () => { + const calls: { url: string; init: RequestInit }[] = [] + + const redirect = (status: number, location: string) => + new Response(null, { status, statusText: "Redirect", headers: { location } }) + + const fetchDouble = async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + calls.push({ url, init: init ?? {} }) + + const response = url.startsWith(ALLOWED_BASE_URL) + ? redirect(307, REDIRECT_TARGET) + : new Response('data: {"choices":[{"delta":{"content":"leaked"},"index":0}]}\n\ndata: [DONE]\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + + const location = response.headers.get("location") + if (!location) { + return response + } + + if (init?.redirect === "error") { + throw new TypeError("fetch failed: unexpected redirect") + } + + if (init?.redirect === "manual") { + return response + } + + // Default policy: follow. Re-issue the same request against the target. + return fetchDouble(new URL(location, url).toString(), init) + } + + return { fetchDouble, calls } +} + +describe("MimoHandler inference client redirect policy", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it("pins redirect:'error' on the SDK client it builds", () => { + const handler = new MimoHandler(options()) + + // The SDK spreads client.fetchOptions into every request init, so this is + // the value the transport receives. Asserted directly as well as through + // the request below, so a change to the SDK's plumbing fails here first. + expect(handler["client"].fetchOptions).toEqual({ redirect: "error" }) + }) + + // Regression: the SDK's default fetch follows 3xx, so an allowlisted Xiaomi + // origin answering with a redirect replayed the POST body (system prompt, + // conversation, tool output) to an origin the allowlist never vetted. Before + // the fix the 307 was followed and the request resolved against the attacker + // URL; with the policy pinned, the request must reject instead. + it("rejects a 307 from an allowlisted origin instead of replaying the request cross-origin", async () => { + const { fetchDouble, calls } = redirectingFetch() + globalThis.fetch = fetchDouble as unknown as typeof fetch + + const handler = new MimoHandler(options()) + + await expect(collectStream(handler.createMessage("System prompt", messages))).rejects.toThrow() + + // The transport was told not to follow, and nothing was ever sent to the + // redirect target. + expect(calls.length).toBeGreaterThan(0) + for (const call of calls) { + expect(call.init.redirect).toBe("error") + expect(call.url.startsWith(ALLOWED_BASE_URL)).toBe(true) + } + expect(calls.some((call) => call.url.startsWith(REDIRECT_TARGET))).toBe(false) + }) +}) diff --git a/src/api/providers/__tests__/mimo.spec.ts b/src/api/providers/__tests__/mimo.spec.ts index 062d2fd922..8355263255 100644 --- a/src/api/providers/__tests__/mimo.spec.ts +++ b/src/api/providers/__tests__/mimo.spec.ts @@ -33,9 +33,11 @@ vi.mock("openai", () => { }) import type { Anthropic } from "@anthropic-ai/sdk" -import { mimoDefaultModelId, mimoModels } from "@roo-code/types" +import OpenAI from "openai" +import { mimoDefaultModelId, mimoDefaultModelInfo, mimoModels } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" import { MimoHandler } from "../mimo" +import { ALLOWED_BASE_URLS } from "../fetchers/mimo" import { convertToR1Format } from "../../transform/r1-format" import { sanitizeOpenAiCallId } from "../../../utils/tool-id" @@ -46,7 +48,7 @@ describe("MimoHandler", () => { beforeEach(() => { mockOptions = { mimoApiKey: "test-api-key", - apiModelId: "mimo-v2.5-pro", + apiModelId: "mimo-v2.6-pro", mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", } handler = new MimoHandler(mockOptions) @@ -56,7 +58,7 @@ describe("MimoHandler", () => { describe("constructor", () => { it("should initialize with provided options", () => { expect(handler).toBeInstanceOf(MimoHandler) - expect(handler.getModel().id).toBe("mimo-v2.5-pro") + expect(handler.getModel().id).toBe("mimo-v2.6-pro") }) it("should use default model ID if not provided", () => { @@ -79,30 +81,137 @@ describe("MimoHandler", () => { }) }) + describe("base URL allowlist (chat-completion path)", () => { + // The constructor mock stands in for the real OpenAI client, so its call + // log proves whether a network object was ever built for a given base URL. + const openAiConstructor = vi.mocked(OpenAI) + + // Simulates the fail-open ContextProxy path, which can hand the handler a + // mimoBaseUrl outside the schema's literal union at runtime. + type RuntimeOptions = Omit & { mimoBaseUrl?: string } + const optionsWithBaseUrl = (mimoBaseUrl: string): ApiHandlerOptions => { + const options: RuntimeOptions = { ...mockOptions, mimoBaseUrl } + return options as ApiHandlerOptions + } + + const lastClientBaseUrl = (): string | null | undefined => { + const calls = openAiConstructor.mock.calls + return calls[calls.length - 1]?.[0]?.baseURL + } + + it("rejects an off-list base URL before any network client is constructed", () => { + const callsBefore = openAiConstructor.mock.calls.length + + expect(() => new MimoHandler(optionsWithBaseUrl("https://attacker.example/v1"))).toThrow( + "MIMO/MimoHandler/001", + ) + expect(openAiConstructor.mock.calls.length).toBe(callsBefore) + }) + + it("accepts every allowlisted Xiaomi endpoint", () => { + for (const allowedUrl of ALLOWED_BASE_URLS) { + new MimoHandler(optionsWithBaseUrl(allowedUrl)) + expect(lastClientBaseUrl()).toBe(allowedUrl) + } + }) + + it("normalizes trailing slashes before the allowlist check", () => { + new MimoHandler(optionsWithBaseUrl("https://token-plan-sgp.xiaomimimo.com/v1/")) + expect(lastClientBaseUrl()).toBe("https://token-plan-sgp.xiaomimimo.com/v1/") + }) + + it("treats an empty base URL as unset and falls back to the default cluster", () => { + new MimoHandler(optionsWithBaseUrl("")) + expect(lastClientBaseUrl()).toBe("https://token-plan-sgp.xiaomimimo.com/v1") + }) + + it("never echoes a secret carried in the rejected URL", () => { + let thrown: unknown + try { + new MimoHandler(optionsWithBaseUrl("https://token-plan-sgp.xiaomimimo.com/v1?api_key=SECRET")) + } catch (error) { + thrown = error + } + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toContain("not an allowed Xiaomi MiMo endpoint") + expect((thrown as Error).message).not.toContain("SECRET") + }) + }) + describe("getModel", () => { - it("should return correct model info for mimo-v2.5-pro", () => { - const model = handler.getModel() - expect(model.id).toBe("mimo-v2.5-pro") + // Saved users: a configuration persisted before the V2.5 retirement still carries + // a retired ID, and `apiModelId` is `z.string().optional()`, so nothing rejects it. + // The handler must keep constructing and must price the request as the family + // default -- `getMimoModelInfo` returns undefined for a retired ID, and the + // `?? mimoDefaultModelInfo` fallback supplies the spec. The ID itself is passed + // through unchanged, which is what makes the provider error visible instead of + // silently switching the user's model. + it.each(["mimo-v2.5-pro", "mimo-v2.5"])( + "should fall back to the default spec for the retired model ID %s", + (retiredModelId) => { + const h = new MimoHandler({ ...mockOptions, apiModelId: retiredModelId }) + const model = h.getModel() + expect(model.id).toBe(retiredModelId) + expect(model.info).toBe(mimoDefaultModelInfo) + expect(model.info).toBe(mimoModels[mimoDefaultModelId]) + expect(model.maxTokens).toBe(mimoDefaultModelInfo.maxTokens) + }, + ) + + it("should return correct model info for mimo-v2.6-pro", () => { + const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.6-pro" }) + const model = h.getModel() + expect(model.id).toBe("mimo-v2.6-pro") expect(model.info.contextWindow).toBe(1_048_576) expect(model.info.maxTokens).toBe(131_072) - expect(model.info.inputPrice).toBe(1.0) - expect(model.info.outputPrice).toBe(3.0) + expect(model.info.inputPrice).toBe(0.435) + expect(model.info.outputPrice).toBe(0.87) + expect(model.info.cacheReadsPrice).toBe(0.0036) + }) + + it("should return correct model info for mimo-v2.6-flash", () => { + const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.6-flash" }) + const model = h.getModel() + expect(model.id).toBe("mimo-v2.6-flash") + expect(model.info.supportsImages).toBe(true) + expect(model.info.inputPrice).toBe(0.14) + expect(model.info.outputPrice).toBe(0.28) + }) + + it("should return correct model info for mimo-v2.6-pro-ultraspeed", () => { + const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.6-pro-ultraspeed" }) + const model = h.getModel() + expect(model.id).toBe("mimo-v2.6-pro-ultraspeed") + expect(model.info.inputPrice).toBe(4.35) + expect(model.info.outputPrice).toBe(8.7) }) - it("should return correct model info for mimo-v2.5", () => { - const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.5" }) + it("should default to mimo-v2.6-pro", () => { + const h = new MimoHandler({ ...mockOptions, apiModelId: undefined }) const model = h.getModel() - expect(model.id).toBe("mimo-v2.5") - expect(model.info.inputPrice).toBe(0.4) - expect(model.info.outputPrice).toBe(2.0) + expect(model.id).toBe(mimoDefaultModelId) + expect(model.info).toBe(mimoModels["mimo-v2.6-pro"]) }) it("should fallback to default model for unknown model ID", () => { const h = new MimoHandler({ ...mockOptions, apiModelId: "unknown-model" }) const model = h.getModel() expect(model.id).toBe("unknown-model") - expect(model.info).toBe(mimoModels["mimo-v2.5-pro"]) + expect(model.info).toBe(mimoModels[mimoDefaultModelId]) }) + + // Regression: `mimoModels[id]` resolved Object.prototype members, so a stored + // ID like `constructor` produced a function (or `{}` once spread) as the model + // spec instead of the family default. + it.each(["constructor", "toString", "valueOf", "__proto__"])( + "should fallback to the family default for the prototype-key ID %s", + (prototypeKey) => { + const h = new MimoHandler({ ...mockOptions, apiModelId: prototypeKey }) + const model = h.getModel() + expect(model.id).toBe(prototypeKey) + expect(model.info).toBe(mimoModels[mimoDefaultModelId]) + }, + ) }) describe("convertMessagesForMiMo (via convertToR1Format)", () => { @@ -884,7 +993,7 @@ describe("MimoHandler", () => { await handler.completePrompt("What is 2+2?") const params = mockCreate.mock.calls[0][0] - expect(params.model).toBe("mimo-v2.5-pro") + expect(params.model).toBe("mimo-v2.6-pro") expect(params.messages).toHaveLength(1) expect(params.messages[0].role).toBe("user") expect(params.messages[0].content).toBe("What is 2+2?") @@ -924,8 +1033,28 @@ describe("MimoHandler", () => { await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error:") }) - it("should use correct model ID for mimo-v2.5 variant", async () => { - const v25Handler = new MimoHandler({ + it("should use correct model ID for mimo-v2.6-flash variant", async () => { + const flashHandler = new MimoHandler({ + ...mockOptions, + apiModelId: "mimo-v2.6-flash", + }) + + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "Response" } }], + }) + + await flashHandler.completePrompt("Test") + + const params = mockCreate.mock.calls[0][0] + expect(params.model).toBe("mimo-v2.6-flash") + }) + + // Saved users: a retired ID is not rewritten on the wire. The spec falls back to + // the family default (see getModel), but the request still names the ID the user + // has stored, so the provider's own error reaches the user instead of the + // extension silently answering as a model they did not pick. + it("should pass a retired model ID through unchanged on the wire", async () => { + const retiredHandler = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.5", }) @@ -934,7 +1063,7 @@ describe("MimoHandler", () => { choices: [{ message: { content: "Response" } }], }) - await v25Handler.completePrompt("Test") + await retiredHandler.completePrompt("Test") const params = mockCreate.mock.calls[0][0] expect(params.model).toBe("mimo-v2.5") diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts new file mode 100644 index 0000000000..dfbe57fc32 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -0,0 +1,552 @@ +import { z } from "zod" + +import { mimoModels, providerSettingsSchema } from "@roo-code/types" + +import { getMimoModels, ALLOWED_BASE_URLS, MAX_MIMO_MODEL_ENTRIES, MAX_MIMO_MODEL_ID_LENGTH } from "../mimo" + +describe("getMimoModels", () => { + 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: "mimo-v2.6-pro" }, { id: "mimo-v2.6-flash" }, { id: "mimo-v3-future" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.any(Object), + ) + expect(models["mimo-v2.6-pro"]).toEqual(mimoModels["mimo-v2.6-pro"]) + expect(models["mimo-v2.6-flash"]).toEqual(mimoModels["mimo-v2.6-flash"]) + + // mimo-v3-future exists only in the API response, so it must surface with + // MiMo-family defaults: a mutation that returned the static map directly + // (ignoring the response) would leave it undefined. + expect(models["mimo-v3-future"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: "MiMo model: mimo-v3-future", + }) + + // A model that IS in the static catalog but absent from the API response must + // stay absent from the result: a mutation that returned the static map + // wholesale would surface it here. (mimo-v2.6-pro-ultraspeed is the current + // catalog entry this response omits.) + expect(models["mimo-v2.6-pro-ultraspeed"]).toBeUndefined() + }) + + it("provides MiMo-family defaults for unknown model IDs without pricing", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "mimo-v3-future" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(models["mimo-v3-future"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: "MiMo model: mimo-v3-future", + }) + }) + + // Regression: IDs come straight from the provider's /models response, so an ID + // that names an Object.prototype member resolved the inherited member instead of + // a curated spec and emitted `{}` -- a model with no metadata at all. The family + // defaults are what an unknown ID must get. + it.each(["constructor", "toString", "valueOf", "__proto__"])( + "gives the prototype-key ID %s MiMo-family defaults instead of inherited metadata", + async (prototypeKey) => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [{ id: prototypeKey }] }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(models[prototypeKey]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: `MiMo model: ${prototypeKey}`, + }) + }, + ) + + it("keeps the static spec authoritative for known IDs while still surfacing API-only models", async () => { + // Negative precedence: the /models payload may carry fields that conflict + // with the curated static spec (stale prices, wrong windows, injected + // text). For a known ID every emitted field must come from mimoModels, + // never from the wire payload; an API-only ID must still appear. + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { + id: "mimo-v2.6-pro", + description: "CONFLICTING payload description", + contextWindow: 999, + maxTokens: 999, + inputPrice: 999, + outputPrice: 999, + supportsImages: false, + }, + { id: "mimo-v9-only-in-api", description: "CONFLICTING payload description", contextWindow: 12345 }, + ], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(models["mimo-v2.6-pro"]).toEqual(mimoModels["mimo-v2.6-pro"]) + expect(models["mimo-v2.6-pro"].description).not.toContain("CONFLICTING") + + expect(models["mimo-v9-only-in-api"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: "MiMo model: mimo-v9-only-in-api", + }) + }) + + 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(getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "invalid-key")).rejects.toThrow( + "HTTP 401: Unauthorized", + ) + }) + + it("uses default Singapore base URL when none provided", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMimoModels(undefined, "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/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 getMimoModels("https://token-plan-cn.xiaomimimo.com/v1/", "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-cn.xiaomimimo.com/v1/models", + expect.any(Object), + ) + }) + + it("throws when response data is not an array", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: "not-an-array" }), + }) as unknown as typeof fetch + + await expect(getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key")).rejects.toThrow( + "Unexpected response format", + ) + }) + + it("skips models with empty or non-string ID", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "" }, { id: 123 }, { id: null }, { id: "mimo-v2.6-pro" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toHaveLength(1) + expect(models["mimo-v2.6-pro"]).toBeDefined() + }) + + it("includes Authorization header when apiKey provided", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "my-secret-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer my-secret-key", + }), + }), + ) + }) + + it("omits the Authorization header when no apiKey is provided", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1") + + const fetchInit = fetchSpy.mock.calls[0]?.[1] as RequestInit + expect(fetchInit.headers).not.toHaveProperty("Authorization") + }) + + it("mixes known and unknown models in same response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "mimo-v2.6-pro" }, { id: "some-new-model" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(models["mimo-v2.6-pro"]).toEqual(mimoModels["mimo-v2.6-pro"]) + expect(models["some-new-model"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: "MiMo model: some-new-model", + }) + }) + + it("throws when the base URL is not https and never sends the request", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + // An http:// endpoint fails the exact-match allowlist, which subsumes the + // previous scheme-only guard. + await expect(getMimoModels("http://token-plan-sgp.xiaomimimo.com/v1", "my-secret-key")).rejects.toThrow( + "not an allowed Xiaomi MiMo endpoint", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects an arbitrary https URL outside the allowlist and never sends the bearer key", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await expect(getMimoModels("https://attacker.example/v1", "my-secret-key")).rejects.toThrow( + "not an allowed Xiaomi MiMo endpoint", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects an allowed host reached through userinfo authority-confusion before any request", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + // classic authority trick: credentials make everything before the last @ + // the userinfo, so the real host is attacker.example. + await expect(getMimoModels("https://token-plan-sgp.xiaomimimo.com@attacker.example/v1", "key")).rejects.toThrow( + "must not contain credentials", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects a percent-encoded %40 userinfo form through the allowlist guard", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + // containsUserinfo only scans for a literal "@", so the encoded form + // reaches the allowlist guard, which rejects it as a non-exact match. + await expect( + getMimoModels("https://token-plan-sgp.xiaomimimo.com%40attacker.example/v1", "key"), + ).rejects.toThrow("not an allowed Xiaomi MiMo endpoint") + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it.each([ + ["query string", "https://token-plan-sgp.xiaomimimo.com/v1?api_key=QUERYSECRET"], + ["fragment", "https://token-plan-sgp.xiaomimimo.com/v1#api_key=FRAGMENTSECRET"], + ])("never echoes a secret carried in a URL %s", async (_label, maliciousUrl) => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + const secret = maliciousUrl.split("=")[1] + let thrown: unknown + try { + await getMimoModels(maliciousUrl, "key") + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toContain("not an allowed Xiaomi MiMo endpoint") + expect((thrown as Error).message).not.toContain(secret) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects embedded credentials on an allowed endpoint and never echoes the secret", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + const error = await getMimoModels( + "https://user:hunter2@token-plan-sgp.xiaomimimo.com/v1", + "my-secret-key", + ).catch((thrown: unknown) => (thrown instanceof Error ? thrown : new Error(String(thrown)))) + + expect(error.message).toContain("must not contain credentials") + expect(error.message).not.toContain("hunter2") + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects embedded credentials on an arbitrary host and never echoes the secret", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + const error = await getMimoModels("https://user:hunter2@attacker.example/v1", "my-secret-key").catch( + (thrown: unknown) => (thrown instanceof Error ? thrown : new Error(String(thrown))), + ) + + expect(error.message).toContain("must not contain credentials") + expect(error.message).not.toContain("hunter2") + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("pins the fetch to reject redirects so a 3xx cannot re-scope the bearer key", async () => { + // The mocked fetch layer cannot exercise undici's real redirect handling, + // so this pins the request init: redirect must be "error", which per the + // fetch spec aborts the request on any 3xx instead of following it to an + // origin the allowlist never vetted. + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "my-secret-key") + + expect(fetchSpy).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.objectContaining({ redirect: "error" }), + ) + }) + + // Regression guard for the security gate: every legitimate endpoint must keep + // working exactly as before the allowlist was introduced. + it.each([...ALLOWED_BASE_URLS])("fetches /models normally for allowed endpoint %s", async (allowedUrl) => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await expect(getMimoModels(allowedUrl, "mock-key")).resolves.toEqual({}) + + expect(fetchSpy).toHaveBeenCalledWith(`${allowedUrl}/models`, expect.any(Object)) + }) + + it("accepts every independently defined persisted Xiaomi endpoint", async () => { + // Independent literal list (NOT iterated from ALLOWED_BASE_URLS) so the + // test fails if a legitimate endpoint is ever removed from the allowlist. + const expectedAllowedUrls = [ + "https://api.xiaomimimo.com/v1", + "https://token-plan-cn.xiaomimimo.com/v1", + "https://token-plan-sgp.xiaomimimo.com/v1", + "https://token-plan-ams.xiaomimimo.com/v1", + ] + + expect([...ALLOWED_BASE_URLS].sort()).toEqual([...expectedAllowedUrls].sort()) + + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + for (const baseUrl of expectedAllowedUrls) { + await expect(getMimoModels(baseUrl, "mock-key")).resolves.toEqual({}) + } + + expect(fetchSpy).toHaveBeenCalledTimes(expectedAllowedUrls.length) + for (const baseUrl of expectedAllowedUrls) { + expect(fetchSpy).toHaveBeenCalledWith(`${baseUrl}/models`, expect.any(Object)) + } + }) + + it("keeps the fetcher allowlist in sync with the persisted settings schema", () => { + // The fetcher mirrors the zod literal union from + // packages/types/src/providers/mimo.ts because the definition is + // not re-exported publicly. Enumerating the union's literals and asserting + // set equality in BOTH directions is what makes drift fail here: a + // one-directional "every allowlisted URL parses" check still passes when a + // fifth literal is added to the schema without an allowlist entry, and the + // fetcher would then reject a URL the settings form accepts. + const mimoBaseUrlField = providerSettingsSchema.shape.mimoBaseUrl + + // The field is `.optional()`, so unwrap before reaching the union. Read the + // members off the runtime shape rather than the declared type: the declared + // type is already the literal union, so a narrowing check on it collapses to + // `never` and cannot guard a future change to a non-union schema. + const union = mimoBaseUrlField.unwrap() as unknown + if (!(union instanceof z.ZodUnion)) { + throw new Error("Expected mimoBaseUrl to be a zod union") + } + + const schemaUrls = union.options.map((option) => { + if (!(option instanceof z.ZodLiteral)) { + throw new Error("Expected every mimoBaseUrl union member to be a zod literal") + } + return option.value as string + }) + + expect([...schemaUrls].sort()).toEqual([...ALLOWED_BASE_URLS].sort()) + expect(mimoBaseUrlField.safeParse("https://attacker.example/v1").success).toBe(false) + }) + + it("skips null and non-object entries in the model list", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [null, "mimo-v2.6-pro", 42, { id: "mimo-v2.6-pro" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toEqual(["mimo-v2.6-pro"]) + }) + + it("excludes ASR and TTS model families from the catalog", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { id: "mimo-v2.6-pro" }, + { id: "mimo-v2.5-asr" }, + { id: "mimo-v2.5-tts" }, + { id: "mimo-v2.5-tts-voiceclone" }, + { id: "mimo-v2.5-tts-voicedesign" }, + ], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toEqual(["mimo-v2.6-pro"]) + }) + + it("strips multiple trailing slashes from the base URL", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMimoModels("https://token-plan-cn.xiaomimimo.com/v1///", "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-cn.xiaomimimo.com/v1/models", + expect.any(Object), + ) + }) + + describe("response size caps", () => { + // The model list is remote input: without a ceiling a hostile or broken + // endpoint inflates the cached catalog and every picker that renders it. + const respondWith = (data: unknown[]) => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data }), + }) as unknown as typeof fetch + } + + it("caps the catalog at the entry limit", async () => { + respondWith( + Array.from({ length: MAX_MIMO_MODEL_ENTRIES + 50 }, (_, index) => ({ id: `mimo-model-${index}` })), + ) + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toHaveLength(MAX_MIMO_MODEL_ENTRIES) + // The cap keeps the first entries in response order, so a truncated + // catalog is still a prefix of what the endpoint served. + expect(models["mimo-model-0"]).toBeDefined() + expect(models[`mimo-model-${MAX_MIMO_MODEL_ENTRIES - 1}`]).toBeDefined() + expect(models[`mimo-model-${MAX_MIMO_MODEL_ENTRIES}`]).toBeUndefined() + }) + + it("keeps every entry when the list is exactly at the cap", async () => { + respondWith(Array.from({ length: MAX_MIMO_MODEL_ENTRIES }, (_, index) => ({ id: `mimo-model-${index}` }))) + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toHaveLength(MAX_MIMO_MODEL_ENTRIES) + }) + + it("drops an over-long model ID instead of truncating it", async () => { + const longId = "m" + "x".repeat(MAX_MIMO_MODEL_ID_LENGTH) + respondWith([{ id: longId }, { id: "mimo-v2.6-pro" }]) + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toEqual(["mimo-v2.6-pro"]) + // Truncating would invent an ID the provider does not serve, so no + // shortened variant may appear either. + expect(models[longId.slice(0, MAX_MIMO_MODEL_ID_LENGTH)]).toBeUndefined() + }) + + it("keeps an ID exactly at the length limit", async () => { + const maxLengthId = "m" + "x".repeat(MAX_MIMO_MODEL_ID_LENGTH - 1) + expect(maxLengthId).toHaveLength(MAX_MIMO_MODEL_ID_LENGTH) + respondWith([{ id: maxLengthId }]) + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toEqual([maxLengthId]) + }) + }) + + it("passes the caller's abort signal to the request", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(JSON.stringify({ data: [] }), { status: 200 })) + const controller = new AbortController() + + await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key", { signal: controller.signal }) + + expect(fetchSpy).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.objectContaining({ signal: controller.signal }), + ) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 3081b5f3eb..488258b3cd 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -40,6 +40,23 @@ vi.mock("fs", () => ({ readFileSync: vi.fn().mockReturnValue("{}"), })) +// Wrap (not replace) pbkdf2Sync so tests can count KDF invocations while real digests +// are still produced. +vi.mock("crypto", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, pbkdf2Sync: vi.fn(actual.pbkdf2Sync) } +}) + +// Mock all the model fetchers +vi.mock("../litellm") +vi.mock("../openrouter") +vi.mock("../requesty") +vi.mock("../kenari") +vi.mock("../nanogpt") +vi.mock("../moonshot") +vi.mock("../mimo") +vi.mock("../zoo-gateway") + // Mock ContextProxy with a simple static instance vi.mock("../../../core/config/ContextProxy", () => ({ ContextProxy: { @@ -57,6 +74,7 @@ import type { Mock, Mocked } from "vitest" import type { ModelRecord } from "@roo-code/types" import { providerIdentifiers } from "@roo-code/types" import * as fsSync from "fs" +import { pbkdf2Sync } from "crypto" import NodeCache from "node-cache" import { TelemetryService } from "@roo-code/telemetry" import { getModels, getModelsFromCache } from "../modelCache" @@ -80,9 +98,13 @@ const NANOGPT_SCOPE: ModelCacheScope = { urlScoped: false, keyScoped: true, auth const IOINTELLIGENCE_SCOPE: ModelCacheScope = { urlScoped: false, keyScoped: true, authScoped: false } const ZOO_SCOPE: ModelCacheScope = { urlScoped: true, keyScoped: true, authScoped: true } const MOONSHOT_SCOPE: ModelCacheScope = { urlScoped: true, keyScoped: true, authScoped: false } +// MiMo is url+key scoped and not auth scoped: the same credentials always yield the +// same catalog, so results are cacheable on disk. +const MIMO_SCOPE: ModelCacheScope = { urlScoped: true, keyScoped: true, authScoped: false } const openRouterHandler = makeHandler(OPENROUTER_SCOPE, vi.fn()) const zooGatewayHandler = makeHandler(ZOO_SCOPE, vi.fn()) +const mockPbkdf2Sync = vi.mocked(pbkdf2Sync) const DUMMY_REQUESTY_KEY = "requesty-key-for-testing" const DUMMY_IOINTELLIGENCE_KEY = "io-intelligence-key-for-testing" @@ -289,6 +311,29 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("dispatches MiMo through the handler's fetchModels with the same options", async () => { + const mockModels = { + "mimo-v2.6-pro": { + maxTokens: 16384, + contextWindow: 262144, + supportsPromptCache: false, + description: "MiMo model via dynamic catalog endpoint", + }, + } + const fetchModels = vi.fn().mockResolvedValue(mockModels) + const handler = makeHandler(MIMO_SCOPE, fetchModels) + + const options = { + provider: providerIdentifiers.mimo, + apiKey: "mimo-test-key", + baseUrl: "https://api.mimo.example/v1", + } as const + const result = await getModels(options, handler) + + expect(fetchModels).toHaveBeenCalledWith(options, expect.any(AbortSignal)) + expect(result).toEqual(mockModels) + }) + it("rejects when the handler does not expose a dynamic model catalog", async () => { // A handler without fetchModels (e.g. a non-catalog provider) must fail with a // clear message instead of silently returning an empty catalog. @@ -1231,6 +1276,67 @@ describe("key-scoped cache key derivation", () => { }) }) +describe("cache digest derivation memoization", () => { + // The bounded FIFO memo exists because getCacheKey/deriveApiKeyDiscriminator run on the + // per-message hot path (PoeHandler.getModel -> getModelsFromCache -> getCacheKey): the KDF + // must not run per request, but the memo must not grow unbounded either. Keys are unique + // per test because the memo is module-level state shared across the file. + const memoProvider = providerIdentifiers.requesty + const memoModels = { + "memo/model": { maxTokens: 4096, contextWindow: 200_000, supportsPromptCache: false }, + } + + beforeEach(() => { + vi.clearAllMocks() + const mockCache = vi.mocked(new (vi.mocked(NodeCache))()) + mockCache.get.mockReturnValue(undefined) + }) + + it("reuses the derived digest across repeated calls with the same key", async () => { + const handler = makeHandler(REQUESTY_SCOPE, vi.fn().mockResolvedValue(memoModels)) + + await getModels({ provider: memoProvider, apiKey: "memo-repeat-key" }, handler) + const afterFirst = mockPbkdf2Sync.mock.calls.length + expect(afterFirst).toBeGreaterThan(0) + + // Per-request cache lookups with the same key must not re-run the KDF. + await getModels({ provider: memoProvider, apiKey: "memo-repeat-key" }, handler) + await getModels({ provider: memoProvider, apiKey: "memo-repeat-key" }, handler) + + expect(mockPbkdf2Sync.mock.calls.length).toBe(afterFirst) + }) + + it("derives a fresh digest for each distinct key", async () => { + const handler = makeHandler(REQUESTY_SCOPE, vi.fn().mockResolvedValue(memoModels)) + + await getModels({ provider: memoProvider, apiKey: "memo-distinct-a" }, handler) + const afterA = mockPbkdf2Sync.mock.calls.length + expect(afterA).toBeGreaterThan(0) + + await getModels({ provider: memoProvider, apiKey: "memo-distinct-b" }, handler) + expect(mockPbkdf2Sync.mock.calls.length).toBeGreaterThan(afterA) + }) + + it("bounds the memo and recomputes inputs that fell out of the FIFO window", async () => { + const handler = makeHandler(REQUESTY_SCOPE, vi.fn().mockResolvedValue(memoModels)) + + // Each call introduces two memo inputs (the raw key and its compound cache key), + // so 12 distinct keys overflow the 16-entry cap: the earliest pairs are evicted + // while the most recent ones are retained. + for (let i = 0; i < 12; i++) { + await getModels({ provider: memoProvider, apiKey: `memo-flood-${i}` }, handler) + } + + const beforeEvicted = mockPbkdf2Sync.mock.calls.length + await getModels({ provider: memoProvider, apiKey: "memo-flood-0" }, handler) + expect(mockPbkdf2Sync.mock.calls.length).toBeGreaterThan(beforeEvicted) + + const beforeRecent = mockPbkdf2Sync.mock.calls.length + await getModels({ provider: memoProvider, apiKey: "memo-flood-11" }, handler) + expect(mockPbkdf2Sync.mock.calls.length).toBe(beforeRecent) + }) +}) + describe("NanoGPT key-scoped cache isolation", () => { const nanoGptModels = { "openai/gpt-5.6-sol": { maxTokens: 128000, contextWindow: 1050000, supportsPromptCache: false }, @@ -1258,6 +1364,56 @@ describe("NanoGPT key-scoped cache isolation", () => { }) }) +describe("MiMo url+key-scoped cache isolation", () => { + // MiMo's handler reports urlScoped AND keyScoped (see MimoHandler.getModelCacheScope), so + // distinct base URLs and distinct API keys must never collapse into a shared cache + // identity. Mirrors the NanoGPT key-scoped isolation test above, extended across the + // url dimension that NanoGPT (key-scoped only) does not exercise. + const mimoModels = { + "mimo-v2.6-pro": { maxTokens: 16384, contextWindow: 262144, supportsPromptCache: false }, + } + + let handler: ApiHandler + + beforeEach(() => { + vi.clearAllMocks() + handler = makeHandler(MIMO_SCOPE, vi.fn().mockResolvedValue(mimoModels)) + }) + + it("separates cache identities by base URL and API key without exposing raw keys", async () => { + const mockCache = vi.mocked(new (vi.mocked(NodeCache))()) + mockCache.get.mockReturnValue(undefined) + + await getModels({ provider: providerIdentifiers.mimo }, handler) + await getModels({ provider: providerIdentifiers.mimo, baseUrl: "https://api.mimo.example/v1" }, handler) + await getModels( + { + provider: providerIdentifiers.mimo, + baseUrl: "https://api.mimo.example/v1", + apiKey: "mimo-key-a", + }, + handler, + ) + await getModels( + { + provider: providerIdentifiers.mimo, + baseUrl: "https://api.mimo.example/v1", + apiKey: "mimo-key-b", + }, + handler, + ) + + const cacheKeys = mockCache.set.mock.calls.map(([key]) => key as string) + expect(new Set(cacheKeys).size).toBe(4) + // Bare provider fallback when neither URL nor key is set; url-only component when + // the key is absent (MiMo PAYG vs token-plan visibility differs per key). + expect(cacheKeys).toContain("mimo") + expect(cacheKeys).toContain("mimo:https://api.mimo.example/v1") + // Raw secrets must never appear in the on-disk-bound cache keys. + expect(cacheKeys.every((key) => !key.includes("mimo-key-a") && !key.includes("mimo-key-b"))).toBe(true) + }) +}) + describe("IO Intelligence key-scoped cache isolation", () => { const ioIntelligenceModels = { "meta-llama/Llama-3.3-70B-Instruct": { maxTokens: 8_192, contextWindow: 128_000, supportsPromptCache: false }, diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts new file mode 100644 index 0000000000..a93610a30a --- /dev/null +++ b/src/api/providers/fetchers/mimo.ts @@ -0,0 +1,203 @@ +import type { ModelRecord } from "@roo-code/types" +import { getMimoModelInfo } from "@roo-code/types" + +import { DEFAULT_HEADERS } from "../constants" + +// The /models endpoint also lists ASR/TTS families, which are not text chat +// models — they require modality-specific payloads this provider never +// builds, so discovery excludes them from the catalog. +const NON_TEXT_MODEL_ID = /-(asr|tts)(-|$)/i + +// Ceilings on what a provider response can add to the catalog. The response is +// remote input: without a cap, a hostile or broken endpoint could inflate the +// cached catalog (and every webview model picker that renders it) without +// bound. Both limits are far above any plausible MiMo catalog size. +export const MAX_MIMO_MODEL_ENTRIES = 1000 +export const MAX_MIMO_MODEL_ID_LENGTH = 256 + +// Default cluster (Singapore), used whenever the caller leaves mimoBaseUrl +// unset or empty. Exported so the chat-completion handler resolves the exact +// same fallback instead of drifting a second literal. +export const MIMO_DEFAULT_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1" + +// Network-boundary allowlist: the same four Xiaomi MiMo endpoints the persisted +// settings schema validates `mimoBaseUrl` against via a zod union of literals +// (packages/types/src/providers/mimo.ts). The definition itself is not +// re-exported through the @roo-code/types public entry points, so the literals +// are mirrored here; __tests__/mimo.spec.ts cross-checks this exported set +// against providerSettingsSchema so the two sources cannot silently drift. The +// fetcher also receives unsaved webview values that bypass the schema entirely, +// and MimoHandler (src/api/providers/mimo.ts) re-enforces this same set before +// it builds its OpenAI chat-completion client. +// +// The gate alone only pins the URL each path is BUILT with, so both paths also +// pin redirect:"error" on their requests: the fetcher below, and the SDK client +// in MimoHandler. Without that, an allowlisted origin answering a 3xx would +// re-scope the bearer key and the request body to an origin this set never +// vetted. Together the allowlist plus the redirect policy are what keep the key +// on a Xiaomi origin on every path. +export const ALLOWED_BASE_URLS: ReadonlySet = new Set([ + "https://api.xiaomimimo.com/v1", + "https://token-plan-cn.xiaomimimo.com/v1", + "https://token-plan-sgp.xiaomimimo.com/v1", + "https://token-plan-ams.xiaomimimo.com/v1", +]) + +// True when the authority component carries `user[:pass]@` credentials. Input +// without a `scheme://` prefix is treated as bare authority so credential-like +// strings cannot slip past the userinfo check into the allowlist rejection +// below. Rejection messages never echo the input: query strings and fragments +// can also carry secrets and are not visible to this authority-only check. +function containsUserinfo(rawUrl: string): boolean { + const schemeEnd = rawUrl.indexOf("://") + const rest = schemeEnd === -1 ? rawUrl : rawUrl.slice(schemeEnd + 3) + const authority = rest.split(/[/?#]/)[0] + return authority.includes("@") +} + +// Trailing-slash normalization as a plain string loop, not a regex: CodeQL's +// polynomial-redos family flags input-driven regexes, and a loop is exactly +// equivalent here (every allowlisted literal carries no trailing slash, and the +// empty string terminates the loop). +export function stripTrailingSlashes(value: string): string { + let result = value + while (result.endsWith("/")) { + result = result.slice(0, -1) + } + return result +} + +/** + * Fetches available models from the Xiaomi MiMo API and merges them with known specs. + * + * MiMo's OpenAI-compatible /models endpoint only returns basic model IDs without + * pricing or context window info, so we merge the API response with the static + * `mimoModels` map for known models. Unknown models get MiMo-family defaults with + * `preserveReasoning` enabled — MiMo requires reasoning_content to be passed back + * in multi-turn tool-calling conversations, so reasoning preservation is a + * correctness requirement, not an optimization. + */ +export async function getMimoModels( + baseUrl?: string, + apiKey?: string, + opts?: { signal?: AbortSignal }, +): Promise { + // MiMo API uses OpenAI-compatible /v1/models endpoint. + // The base URL from settings already includes /v1 (e.g. https://token-plan-sgp.xiaomimimo.com/v1), + // so we keep it as-is and append /models directly. + const base = stripTrailingSlashes(baseUrl || MIMO_DEFAULT_BASE_URL) + + // Reject embedded credentials first, then pin the request to the four allowed + // endpoints with an exact match. This subsumes the previous https-only guard + // (every allowed URL is https, so plaintext HTTP, arbitrary hosts, and path + // tricks all fail the allowlist) and runs before any header is built, so a + // partial or off-list request is never sent. Rejection errors are constant + // strings: the raw URL is never echoed because query strings and fragments + // can carry secrets too. + if (containsUserinfo(base)) { + throw new Error("MIMO/getMimoModels/001: MiMo model fetch rejected: base URL must not contain credentials.") + } + if (!ALLOWED_BASE_URLS.has(base)) { + throw new Error( + "MIMO/getMimoModels/002: MiMo model fetch rejected: base URL is not an allowed Xiaomi MiMo endpoint.", + ) + } + + const url = new URL(`${base}/models`) + + const headers: Record = { + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + } + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + const response = await fetch(url.toString(), { + headers, + signal: opts?.signal, + // The exact-match allowlist is enforced on the request URL only; following + // a 3xx would silently re-scope the bearer key to an un-vetted origin. + // Reject redirects instead (per fetch spec, "error" aborts on any 3xx). + redirect: "error", + }) + + if (!response.ok) { + // Metadata only: response bodies can echo credentials or conversation + // fragments back at whatever sinks the logs, so the body is never logged. + console.error(`[getMimoModels] HTTP error:`, { + status: response.status, + statusText: response.statusText, + url: url.toString(), + }) + + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.json() + + if (!data?.data || !Array.isArray(data.data)) { + // Same reason: log the shape mismatch, never the payload. + console.error("[getMimoModels] Unexpected response format:", { + receivedType: Array.isArray(data) ? "array" : typeof data, + dataFieldType: data?.data === null ? "null" : typeof data?.data, + }) + throw new Error("Failed to fetch MiMo models: Unexpected response format.") + } + + // Use null-prototype object to prevent prototype pollution + const models: ModelRecord = Object.create(null) + let entryCount = 0 + + for (const model of data.data) { + // Stop at the entry ceiling rather than truncating silently at the end: + // a response this large is not a catalog, so the excess is dropped here + // with one log line instead of growing the cache past the bound. + if (entryCount >= MAX_MIMO_MODEL_ENTRIES) { + console.error("[getMimoModels] Model list exceeded the entry cap; the remainder was dropped:", { + cap: MAX_MIMO_MODEL_ENTRIES, + }) + break + } + + // Skip non-record elements so a single malformed entry cannot abort + // the whole catalog. + if (typeof model !== "object" || model === null) continue + + const modelId = typeof model.id === "string" && model.id ? model.id : null + if (!modelId || NON_TEXT_MODEL_ID.test(modelId)) continue + + // An over-long ID is dropped instead of truncated: a truncated ID would + // name a model the provider does not serve, and the length cap keeps the + // cached catalog and the pickers that render it bounded. + if (modelId.length > MAX_MIMO_MODEL_ID_LENGTH) continue + + // Prototype-safe: IDs come straight from the provider's response, so an ID + // such as `constructor` must fall through to the family defaults below + // instead of resolving to an inherited Object.prototype member. + const knownSpecs = getMimoModelInfo(modelId) + + if (knownSpecs) { + models[modelId] = { ...knownSpecs } + } else { + // Unknown IDs get MiMo-family defaults on purpose: the /models response + // carries no pricing or limits, the static map is the only price source + // (so a remote entry can never set a cost), and a model Xiaomi adds + // ahead of a static-map update stays usable instead of resolving to an + // empty spec. Known IDs above keep their static spec untouched. + models[modelId] = { + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: `MiMo model: ${modelId}`, + } + } + + entryCount++ + } + + return models +} diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 19ba059da5..f744cbe18b 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -81,8 +81,13 @@ function captureModelCacheEmptyResponseOnce( TelemetryService.instance.captureEvent(TelemetryEventName.MODEL_CACHE_EMPTY_RESPONSE, { provider, ...properties }) } -// Memoize derived digests so the deliberately-structureless KDF runs at most once per -// distinct input per session (getCacheKey / cacheKeyToFilename run on every cache lookup). +// Recently derived digests, so the 10k-iteration KDF does not run per request: +// getCacheKey/deriveApiKeyDiscriminator sit on the per-message hot path (e.g. +// PoeHandler.getModel -> getModelsFromCache -> getCacheKey). Bounded FIFO: once +// more than MAX_CACHED_DIGESTS distinct inputs have been seen, the oldest entry +// is dropped, so at most this many raw key strings are retained beyond their +// original owners. +const MAX_CACHED_DIGESTS = 16 const cacheDigestCache = new Map() // Fixed, non-secret application salt. This is NOT credential storage: it derives short, @@ -105,13 +110,29 @@ const CACHE_DIGEST_ITERATIONS = 10_000 * ~ n^2 / 2^(8*bytes)), while the truncated output is small enough that any preimage search * yields an astronomically large set of candidate inputs -- so a value written to an on-disk * cache filename cannot be reversed to identify the API key it was derived from. + * + * No fast-hash memo key: the map is keyed by the raw input because the API key is + * already resident in memory as handler options, and bounding the map keeps the extra + * retention window tiny. A fast hash of key-tainted input would only re-introduce the + * CodeQL js/insufficient-password-hash sink. PBKDF2 remains the only derivation whose + * output leaves this process (cache keys and on-disk filenames). */ function deriveCacheDigest(value: string, bytes: number): string { const memoKey = `${bytes}:${value}` const cached = cacheDigestCache.get(memoKey) - if (cached) return cached + if (cached) { + return cached + } const digest = pbkdf2Sync(value, CACHE_DIGEST_SALT, CACHE_DIGEST_ITERATIONS, bytes, "sha256").toString("hex") cacheDigestCache.set(memoKey, digest) + // FIFO eviction: bound the retention window so plaintext keys never accumulate + // for the whole session. + if (cacheDigestCache.size > MAX_CACHED_DIGESTS) { + const oldest = cacheDigestCache.keys().next().value + if (oldest !== undefined) { + cacheDigestCache.delete(oldest) + } + } return digest } @@ -189,6 +210,7 @@ async function fetchModelsFromProvider( signal?: AbortSignal, ): Promise { const { provider } = options + if (!handler.fetchModels) { throw new Error(`Provider ${provider} does not expose a dynamic model catalog`) } diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index d2fba1cb48..c9c6585f75 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -1,8 +1,15 @@ import OpenAI from "openai" -import { mimoModels, mimoDefaultModelId, MIMO_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types" +import { + getMimoModelInfo, + mimoDefaultModelInfo, + mimoDefaultModelId, + MIMO_DEFAULT_TEMPERATURE, + type ModelInfo, + type ModelRecord, +} from "@roo-code/types" -import type { ApiHandlerOptions } from "../../shared/api" +import type { ApiHandlerOptions, GetModelsOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" import { convertToR1Format } from "../transform/r1-format" @@ -13,9 +20,11 @@ import { extractReasoningFromDelta } from "./utils/extract-reasoning" import { OpenAiHandler } from "./openai" import { NOT_PROVIDED } from "./constants" -import type { ApiHandlerCreateMessageMetadata } from "../index" +import type { ApiHandlerCreateMessageMetadata, ModelCacheScope } from "../index" import { sanitizeOpenAiCallId } from "../../utils/tool-id" +import { ALLOWED_BASE_URLS, MIMO_DEFAULT_BASE_URL, stripTrailingSlashes, getMimoModels } from "./fetchers/mimo" + /** * MiMoHandler extends OpenAiHandler with MiMo-specific adaptations. * @@ -26,25 +35,68 @@ import { sanitizeOpenAiCallId } from "../../utils/tool-id" */ export class MimoHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { + // Fail closed on the network boundary BEFORE OpenAiHandler constructs its + // OpenAI client. The persisted settings schema pins mimoBaseUrl to four + // zod literals, but ContextProxy.getProviderSettings() fails open on a + // schema rejection and returns the raw stored value — so a value written + // by a pre-allowlist build or a crafted webview message would otherwise + // reach `new OpenAI({ baseURL })` and could exfiltrate the bearer key + // plus the whole conversation to an arbitrary origin. Unset/empty falls + // back to the default cluster; trailing slashes are normalized for the + // check exactly like the model fetcher does. + const mimoBaseUrl = options.mimoBaseUrl || MIMO_DEFAULT_BASE_URL + if (!ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl))) { + throw new Error( + "MIMO/MimoHandler/001: MiMo chat completion rejected: base URL is not an allowed Xiaomi MiMo endpoint.", + ) + } super({ ...options, openAiApiKey: options.mimoApiKey ?? NOT_PROVIDED, openAiModelId: options.apiModelId ?? mimoDefaultModelId, - openAiBaseUrl: options.mimoBaseUrl || "https://token-plan-sgp.xiaomimimo.com/v1", + openAiBaseUrl: mimoBaseUrl, openAiStreamingEnabled: true, includeMaxTokens: false, }) + + // The allowlist above pins the URL this client is BUILT with, not the URLs + // it can end up talking to: the SDK's default fetch follows 3xx, so an + // allowlisted origin answering with a redirect would replay the POST body + // (system prompt, conversation, tool output) to an origin the allowlist + // never vetted, and would re-scope the bearer key wherever the fetch + // implementation forwards it. Pin the same redirect:"error" policy the + // model fetcher uses (fetchers/mimo.ts) so both paths carry the key under + // one transport rule. Set on the client rather than by replacing `fetch` + // so proxy agents and any other transport configuration still apply. + this.client.fetchOptions = { redirect: "error" } + } + + /** + * MiMo's catalog is scoped by BOTH the endpoint (token-plan vs PAYG clusters) and the + * API key (per-key plan visibility), and is never user-account scoped: the same + * credentials always yield the same catalog, so on-disk caching stays safe. + */ + override getModelCacheScope(): ModelCacheScope { + return { urlScoped: true, keyScoped: true, authScoped: false } + } + + async fetchModels(options: GetModelsOptions, signal?: AbortSignal): Promise { + const fetchOpts: [] | [{ signal: AbortSignal }] = signal ? [{ signal }] : [] + return getMimoModels(options.baseUrl, options.apiKey, ...fetchOpts) } /** * Maps the configured model ID to its MiMo model info and parameters. - * Falls back to the default model (mimo-v2.5-pro) if the stored ID + * Falls back to the default model (mimo-v2.6-pro) if the stored ID * doesn't match any known model — this can happen when users manually * type a model name in settings. */ override getModel() { const id = this.options.apiModelId ?? mimoDefaultModelId - const info: ModelInfo = mimoModels[id as keyof typeof mimoModels] || mimoModels[mimoDefaultModelId] + // getMimoModelInfo is prototype-safe: a plain `mimoModels[id]` read would + // resolve an ID like `constructor` to an inherited member and hand + // getModelParams an empty spec instead of the family default. + const info: ModelInfo = getMimoModelInfo(id) ?? mimoDefaultModelInfo const params = getModelParams({ format: "openai", modelId: id, @@ -83,7 +135,7 @@ export class MimoHandler extends OpenAiHandler { const tools = metadata?.tools // Build request per MiMo's OpenAI-compatible API - // https://developer.puter.com/ai/xiaomi/mimo-v2.5-pro/ + // https://mimo.mi.com/docs/en-US/quick-start/usage-guide/text-generation/deep-thinking // Note: temperature is omitted because MiMo forces it to 1.0 when thinking mode // is enabled, regardless of what is passed (see model-hyperparameters docs). const params: Record = { diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 1250a7baca..c327c202e9 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -104,9 +104,48 @@ export class ContextProxy { // Migration: Clear old default condensing prompt so users get the improved v2 default await this.migrateOldDefaultCondensingPrompt() + // Migration: Move a MiMo API key persisted in plaintext globalState (from + // builds before the key joined SECRET_STATE_KEYS) into SecretStorage. + await this.migratePlaintextMimoApiKey() + this._isInitialized = true } + /** + * MiMo shipped with its API key in plaintext globalState. Once the key became a + * secret-state key, globalState stopped being read for it, so an existing + * plaintext value would sit orphaned (and exposed) while SecretStorage stayed + * empty. Move it into SecretStorage once and clear the plaintext copy. + * + * The existence check reads SecretStorage fresh rather than trusting the + * initialize() snapshot: a secret written after the snapshot (or a transient + * secrets.get failure during the load) would otherwise be clobbered by older + * plaintext. Crash-safe: the plaintext is only cleared after the checks and + * store resolve, so a failure leaves both copies intact and the migration + * retries on next launch. + */ + private async migratePlaintextMimoApiKey() { + try { + const plaintextKey = this.originalContext.globalState.get("mimoApiKey") + if (!plaintextKey) { + return + } + const existingSecret = await this.originalContext.secrets.get("mimoApiKey") + if (!existingSecret) { + await this.originalContext.secrets.store("mimoApiKey", plaintextKey) + this.secretCache.mimoApiKey = plaintextKey + logger.info("Migrated mimoApiKey to secrets") + } else { + // Repair the snapshot cache if the fresh read found a secret the + // initial load missed. + this.secretCache.mimoApiKey = existingSecret + } + await this.originalContext.globalState.update("mimoApiKey", undefined) + } catch (error) { + logger.error(`Error during mimoApiKey migration: ${error instanceof Error ? error.message : String(error)}`) + } + } + /** * Migrates the legacy customCondensingPrompt to the new customSupportPrompts structure * and removes the legacy field. @@ -595,6 +634,11 @@ export class ContextProxy { ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), ...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)), ...GLOBAL_SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)), + // mimoApiKey is a SECRET_STATE_KEY, so it is absent from GLOBAL_STATE_KEYS + // and the sweep above leaves any plaintext copy behind. The initialize() + // below would then migrate that survivor into SecretStorage, resurrecting + // a credential this reset is meant to clear. + this.originalContext.globalState.update("mimoApiKey", undefined), ]) await this.initialize() diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index b4d3b1b8aa..0fd4e60291 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -76,11 +76,12 @@ describe("ContextProxy", () => { describe("constructor", () => { it("should initialize state cache with all global state keys", () => { - // +3 for the migration checks: + // +4 for the migration checks: // 1. openRouterImageGenerationSettings // 2. customCondensingPrompt // 3. customSupportPrompts (for migrateOldDefaultCondensingPrompt) - expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 3) + // 4. mimoApiKey (for migratePlaintextMimoApiKey) + expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 4) for (const key of GLOBAL_STATE_KEYS) { expect(mockGlobalState.get).toHaveBeenCalledWith(key) } @@ -88,6 +89,7 @@ describe("ContextProxy", () => { expect(mockGlobalState.get).toHaveBeenCalledWith("openRouterImageGenerationSettings") expect(mockGlobalState.get).toHaveBeenCalledWith("customCondensingPrompt") expect(mockGlobalState.get).toHaveBeenCalledWith("customSupportPrompts") + expect(mockGlobalState.get).toHaveBeenCalledWith("mimoApiKey") }) it("should initialize secret cache with all secret keys", () => { @@ -110,8 +112,8 @@ describe("ContextProxy", () => { const result = proxy.getGlobalState("apiProvider") expect(result).toBe("deepseek") - // Original context should be called once during updateGlobalState (+3 for migration checks) - expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 3) // From initialization + migration checks + // Original context should be called once during updateGlobalState (+4 for migration checks) + expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 4) // From initialization + migration checks }) it("should handle default values correctly", async () => { @@ -247,6 +249,19 @@ describe("ContextProxy", () => { expect(storedValue).toBe("test-api-key") }) + it("routes mimoApiKey to SecretStorage and resolves it through getValues/getProviderSettings", async () => { + const storeSecretSpy = vi.spyOn(proxy, "storeSecret") + const updateGlobalStateSpy = vi.spyOn(proxy, "updateGlobalState") + + await proxy.setValue("mimoApiKey", "mimo-test-key") + + expect(storeSecretSpy).toHaveBeenCalledWith("mimoApiKey", "mimo-test-key") + expect(updateGlobalStateSpy).not.toHaveBeenCalledWith("mimoApiKey", expect.anything()) + expect(proxy.getSecret("mimoApiKey")).toBe("mimo-test-key") + expect(proxy.getValues()["mimoApiKey"]).toBe("mimo-test-key") + expect(proxy.getProviderSettings().mimoApiKey).toBe("mimo-test-key") + }) + it("should route global state keys to updateGlobalState", async () => { // Spy on updateGlobalState const updateGlobalStateSpy = vi.spyOn(proxy, "updateGlobalState") @@ -441,9 +456,38 @@ describe("ContextProxy", () => { expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined) } - // Total calls should include initial setup + reset operations - const expectedUpdateCalls = 2 + GLOBAL_STATE_KEYS.length + // Total calls should include initial setup + reset operations, plus the + // plaintext mimoApiKey purge (the key is a SECRET_STATE_KEY, so it is not + // covered by the GLOBAL_STATE_KEYS sweep). + const expectedUpdateCalls = 3 + GLOBAL_STATE_KEYS.length expect(mockGlobalState.update).toHaveBeenCalledTimes(expectedUpdateCalls) + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + }) + + it("does not resurrect a surviving plaintext mimoApiKey into SecretStorage", async () => { + // Builds before the key joined SECRET_STATE_KEYS could leave a plaintext + // mimoApiKey in globalState. Because the key is a secret-state key it is + // absent from GLOBAL_STATE_KEYS, so the reset sweep never touched the + // plaintext copy; the initialize() that reset runs afterwards then found + // it and migrated it into SecretStorage. Reset therefore resurrected the + // credential it is supposed to clear. + clearAllMocks() + + let plaintextKey: string | undefined = "surviving-plaintext-mimo-key" + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? plaintextKey : undefined)) + mockGlobalState.update.mockImplementation((key: string, value: unknown) => { + if (key === "mimoApiKey" && value === undefined) { + plaintextKey = undefined + } + return Promise.resolve() + }) + mockSecrets.get.mockResolvedValue(undefined) + + const resetProxy = new ContextProxy(mockContext) + await resetProxy.resetAllState() + + expect(mockSecrets.store).not.toHaveBeenCalledWith("mimoApiKey", expect.anything()) + expect(resetProxy.getSecret("mimoApiKey")).toBeUndefined() }) it("should delete all secrets", async () => { @@ -558,6 +602,83 @@ describe("ContextProxy", () => { }) }) + describe("mimoApiKey plaintext migration", () => { + const plaintextKey = "plaintext-mimo-key" + + const setupContext = ({ secretValue }: { secretValue: string | undefined }) => { + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? plaintextKey : undefined)) + mockSecrets.get.mockImplementation((key: string) => + Promise.resolve(key === "mimoApiKey" ? secretValue : "test-secret"), + ) + return mockContext + } + + it("moves a plaintext globalState key into SecretStorage and clears the plaintext copy", async () => { + clearAllMocks() + setupContext({ secretValue: undefined }) + + const migratedProxy = new ContextProxy(mockContext) + await migratedProxy.initialize() + + expect(mockSecrets.store).toHaveBeenCalledWith("mimoApiKey", plaintextKey) + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + expect(migratedProxy.getSecret("mimoApiKey")).toBe(plaintextKey) + // Both the flat values view and the parsed provider settings must resolve + // the key from SecretStorage after the migration. + expect(migratedProxy.getValues()["mimoApiKey"]).toBe(plaintextKey) + expect(migratedProxy.getProviderSettings().mimoApiKey).toBe(plaintextKey) + }) + + it("keeps an existing SecretStorage value and only clears the plaintext copy", async () => { + clearAllMocks() + setupContext({ secretValue: "already-secret-value" }) + + const migratedProxy = new ContextProxy(mockContext) + await migratedProxy.initialize() + + expect(mockSecrets.store).not.toHaveBeenCalledWith("mimoApiKey", expect.anything()) + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + expect(migratedProxy.getSecret("mimoApiKey")).toBe("already-secret-value") + }) + + it("trusts a fresh SecretStorage read over the stale snapshot before deciding to store", async () => { + // Simulates a secret written after the initialize() snapshot (or a transient + // secrets.get failure during the load): the snapshot sees no secret, the + // migration's fresh read sees one. The newer secret must win and the cache + // must be repaired with it. + clearAllMocks() + let mimoGetCount = 0 + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? plaintextKey : undefined)) + mockSecrets.get.mockImplementation((key: string) => { + if (key !== "mimoApiKey") { + return Promise.resolve("test-secret") + } + mimoGetCount += 1 + return Promise.resolve(mimoGetCount === 1 ? undefined : "freshly-written-secret") + }) + + const migratedProxy = new ContextProxy(mockContext) + await migratedProxy.initialize() + + expect(mockSecrets.store).not.toHaveBeenCalledWith("mimoApiKey", expect.anything()) + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + expect(migratedProxy.getSecret("mimoApiKey")).toBe("freshly-written-secret") + expect(migratedProxy.getValues()["mimoApiKey"]).toBe("freshly-written-secret") + }) + + it("does nothing when no plaintext key exists", async () => { + clearAllMocks() + mockGlobalState.get.mockImplementation(() => undefined) + mockSecrets.get.mockResolvedValue("test-secret") + + const freshProxy = new ContextProxy(mockContext) + await freshProxy.initialize() + + expect(mockSecrets.store).not.toHaveBeenCalled() + expect(mockGlobalState.update).not.toHaveBeenCalledWith("mimoApiKey", undefined) + }) + }) + describe("getProviderSettings", () => { it("should sanitize invalid apiProvider before parsing", async () => { // Reset and create a new proxy with an unknown provider in state diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 779313d385..f4e9c3c885 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4626,6 +4626,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, @@ -4684,6 +4685,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, @@ -4790,6 +4792,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 2107a85a9c..8b6d10380f 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -64,24 +64,44 @@ vi.mock("../../../api/providers/fetchers/modelCache", () => ({ // Real handlers eagerly fetch models in their constructors, so use lightweight doubles. // One double per provider lets tests assert each call receives its own provider's handler. -const { handlersByProvider } = vi.hoisted(() => ({ +// +// The MiMo double enforces MimoHandler's real constructor contract: it throws unless the +// base URL it is handed passes the same allowlist the real handler applies. A non-throwing +// double would mask the mixed-state bug where the guard validated the resolved base URL but +// the handler was built from the raw stored config. +const { handlersByProvider, MIMO_HANDLER_REJECTION } = vi.hoisted(() => ({ handlersByProvider: new Map(), + MIMO_HANDLER_REJECTION: + "MIMO/MimoHandler/001: MiMo chat completion rejected: base URL is not an allowed Xiaomi MiMo endpoint.", })) -vi.mock("../../../api", () => ({ - buildApiHandler: vi.fn((configuration: { apiProvider?: string }) => { - const providerName = configuration.apiProvider - let handler = handlersByProvider.get(providerName) - if (!handler) { - handler = { - providerName, - getModel: vi.fn().mockReturnValue({ id: "claude-3-sonnet" }), - } as { providerName: string | undefined } - handlersByProvider.set(providerName, handler) - } - return handler - }), -})) +vi.mock("../../../api", async () => { + const { ALLOWED_BASE_URLS, MIMO_DEFAULT_BASE_URL, stripTrailingSlashes } = + await import("../../../api/providers/fetchers/mimo") + + return { + buildApiHandler: vi.fn((configuration: { apiProvider?: string; mimoBaseUrl?: string }) => { + const providerName = configuration.apiProvider + + if (providerName === providerIdentifiers.mimo) { + const mimoBaseUrl = configuration.mimoBaseUrl || MIMO_DEFAULT_BASE_URL + if (!ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl))) { + throw new Error(MIMO_HANDLER_REJECTION) + } + } + + let handler = handlersByProvider.get(providerName) + if (!handler) { + handler = { + providerName, + getModel: vi.fn().mockReturnValue({ id: "claude-3-sonnet" }), + } as { providerName: string | undefined } + handlersByProvider.set(providerName, handler) + } + return handler + }), + } +}) const handlerForProvider = (providerName: string) => { const handler = handlersByProvider.get(providerName) @@ -174,8 +194,10 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect(routerModels).toHaveProperty(providerIdentifiers.requesty) expect(routerModels).toHaveProperty(providerIdentifiers.deepseek) expect(routerModels).toHaveProperty(providerIdentifiers.moonshot) + expect(routerModels).toHaveProperty(providerIdentifiers.mimo) expect(routerModels.deepseek).toEqual({}) expect(routerModels.moonshot).toEqual({}) + expect(routerModels.mimo).toEqual({}) expect(getModelsMock).not.toHaveBeenCalledWith( expect.objectContaining({ provider: providerIdentifiers.deepseek }), expect.anything(), @@ -184,6 +206,7 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect.objectContaining({ provider: providerIdentifiers.moonshot }), expect.anything(), ) + expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: providerIdentifiers.mimo })) }) it("fetches DeepSeek models when stored DeepSeek credentials exist", async () => { @@ -754,6 +777,568 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) + it("fetches MiMo models when stored MiMo credentials exist", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }, + }) + + getModelsMock.mockImplementation(async (options) => { + if (options?.provider === providerIdentifiers.mimo) { + return { "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false } } + } + + switch (options?.provider) { + case providerIdentifiers.openrouter: + return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } + case providerIdentifiers.requesty: + return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.vercelAiGateway: + return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.litellm: + return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } + default: + return {} + } + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + }) + + expect(getModelsMock).toHaveBeenCalledWith( + { + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }, + handlerForProvider(providerIdentifiers.mimo), + ) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.mimo).toEqual({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + }) + + it("fetches only MiMo for a single-provider request with stored credentials", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }, + }) + + getModelsMock.mockImplementation(async (options?: { provider?: string }) => { + if (options?.provider === providerIdentifiers.mimo) { + return { "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false } } + } + return {} + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.mimo }, + }) + + const mimoCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoCalls).toHaveLength(1) + // The single-provider filter must not dispatch fetches for other providers. + expect(getModelsMock).toHaveBeenCalledTimes(1) + expect(mimoCalls[0][0]).toEqual({ + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + // Filtered responses carry only the requested provider's entry. + expect(Object.keys(response[0].routerModels)).toEqual([providerIdentifiers.mimo]) + expect(response[0].routerModels.mimo).toEqual({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + }) + + it("echoes the request ID in the routerModels response when one was provided", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + }, + }) + + getModelsMock.mockResolvedValue({}) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.mimo, requestId: "req-123" }, + }) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].values).toEqual({ + requestId: "req-123", + provider: providerIdentifiers.mimo, + }) + }) + + it("flushes MiMo cache when explicit credentials are provided via message values", async () => { + getModelsMock.mockResolvedValue({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + mimoApiKey: "new-mimo-key", + mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + }, + }) + + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: "new-mimo-key", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + } + expect(flushModelsMock).toHaveBeenCalledWith(mimoOptions, handlerForProvider(providerIdentifiers.mimo), true) + expect(getModelsMock).toHaveBeenCalledWith(mimoOptions, handlerForProvider(providerIdentifiers.mimo)) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.mimo).toEqual({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + }) + + it("does not flush MiMo cache when using stored credentials", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + }, + }) + + getModelsMock.mockImplementation(async (options) => { + if (options?.provider === providerIdentifiers.mimo) { + return { "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false } } + } + + switch (options?.provider) { + case providerIdentifiers.openrouter: + return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } + case providerIdentifiers.requesty: + return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.vercelAiGateway: + return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.litellm: + return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } + default: + return {} + } + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + }) + + const mimoFlushCalls = flushModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFlushCalls.length).toBe(0) + + const mimoCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoCalls.length).toBe(1) + expect(mimoCalls[0][0]).toEqual({ + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: undefined, + }) + }) + + describe("flushRouterModels", () => { + // flushRouterModels builds its handler eagerly from stored config and has no + // surrounding catch, so an off-list stored mimoBaseUrl made MimoHandler's + // constructor throw and escape webviewMessageHandler entirely. The mock + // buildApiHandler above enforces the real constructor contract, so these + // tests fail if the guard is ever removed. + it("skips the flush for an off-list stored MiMo base URL instead of letting the constructor throw", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://attacker.example/v1", + }, + }) + + await expect( + webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.flushRouterModels, + text: providerIdentifiers.mimo, + }), + ).resolves.toBeUndefined() + + expect(flushModelsMock).not.toHaveBeenCalled() + expect(handlersByProvider.has(providerIdentifiers.mimo)).toBe(false) + }) + + it("flushes MiMo from a stored allowlisted base URL", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.flushRouterModels, + text: providerIdentifiers.mimo, + }) + + expect(flushModelsMock).toHaveBeenCalledWith( + { provider: providerIdentifiers.mimo }, + handlerForProvider(providerIdentifiers.mimo), + true, + ) + }) + + it("flushes MiMo from the default cluster when no stored base URL is set", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { mimoApiKey: "stored-mimo-key" }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.flushRouterModels, + text: providerIdentifiers.mimo, + }) + + expect(flushModelsMock).toHaveBeenCalledWith( + { provider: providerIdentifiers.mimo }, + handlerForProvider(providerIdentifiers.mimo), + true, + ) + }) + + it("still flushes other providers when the stored MiMo base URL is off-list", async () => { + // The guard is MiMo-specific: an off-list MiMo value must not disable the + // generic flush for every other router. + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://attacker.example/v1", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.flushRouterModels, + text: providerIdentifiers.openrouter, + }) + + expect(flushModelsMock).toHaveBeenCalledWith( + { provider: providerIdentifiers.openrouter }, + handlerForProvider(providerIdentifiers.openrouter), + true, + ) + }) + }) + + it("rejects an unsaved MiMo base URL outside the Xiaomi allowlist without dispatching a fetch", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + mimoBaseUrl: "https://attacker.example", + }, + }) + + // The off-list unsaved URL must never reach modelCache: no fetch and no + // cache flush for MiMo is dispatched. + const mimoCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoCalls.length).toBe(0) + const mimoFlushCalls = flushModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFlushCalls.length).toBe(0) + + const errorCall = mockProvider.postMessageToWebview.mock.calls.find( + (call) => + call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse && + call[0]?.values?.provider === providerIdentifiers.mimo, + ) + expect(errorCall).toBeDefined() + if (!errorCall) throw new Error("Expected MiMo failure response") + expect(errorCall[0].success).toBe(false) + expect(errorCall[0].error).toBe( + "MIMO/requestRouterModels/001: MiMo model fetch rejected: the provided base URL is not an allowed Xiaomi MiMo endpoint.", + ) + + // Aggregation for the remaining providers still posts, with MiMo empty. + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.openrouter).toEqual({ + "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false }, + }) + expect(response[0].routerModels.mimo).toEqual({}) + }) + + // Regression: flushModels() gained an eagerly-built handler argument, so an + // off-list STORED mimoBaseUrl made MimoHandler's constructor throw from inside + // this case. That throw escaped requestRouterModels entirely, so the webview got + // neither the MiMo failure response nor the aggregate routerModels response, and + // the Refresh button stuck at Loading indefinitely (Mimo.tsx clears that state + // only on a routerModels response; the 10s timeout lives in fetchRouterModels, + // which this button does not use). A stored value can be off-list because + // ContextProxy.getProviderSettings() fails open on a schema rejection. + it("rejects an off-list STORED MiMo base URL without letting the handler constructor throw", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://attacker.example/v1", + }, + }) + + // Mirrors Mimo.tsx handleRefreshModels: provider + key, no refresh flag. + await expect( + webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.mimo, mimoApiKey: "stored-mimo-key" }, + }), + ).resolves.toBeUndefined() + + // The off-list stored URL must never reach modelCache: no flush (which would + // build the throwing handler) and no fetch. + const mimoFlushCalls = flushModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFlushCalls.length).toBe(0) + const mimoCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoCalls.length).toBe(0) + + const errorCall = mockProvider.postMessageToWebview.mock.calls.find( + (call) => + call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse && + call[0]?.values?.provider === providerIdentifiers.mimo, + ) + expect(errorCall).toBeDefined() + if (!errorCall) throw new Error("Expected MiMo failure response") + expect(errorCall[0].success).toBe(false) + expect(errorCall[0].error).toBe( + "MIMO/requestRouterModels/001: MiMo model fetch rejected: the provided base URL is not an allowed Xiaomi MiMo endpoint.", + ) + + // The aggregate response still reaches the webview, so the refresh resolves. + // A filtered response only carries entries for dispatched candidates, so the + // skipped provider is absent rather than empty; the webview then falls back + // to the static catalog instead of hanging. + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.mimo).toBeUndefined() + }) + + // Regression (mixed state): a VALID unsaved base URL alongside an INVALID stored + // one. The allowlist guard runs on the resolved value, so it accepts the request, + // but handlerFor() spreads the raw stored config -- so the handler was built from + // the off-list stored URL and MimoHandler's constructor threw, escaping the case. + // The mock above enforces the real constructor contract, so this fails if the + // handler is ever built from anything but the guard-validated resolved value. + it("builds the MiMo handler from the resolved base URL when the stored one is off-list", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://attacker.example/v1", + }, + }) + getModelsMock.mockImplementation(async (options: { provider?: string }) => + options?.provider === providerIdentifiers.mimo + ? { "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false } } + : {}, + ) + + await expect( + webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.mimo, + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + }, + }), + ).resolves.toBeUndefined() + + // The accepted unsaved URL is the one that reaches the cache and the fetch. + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + } + expect(flushModelsMock).toHaveBeenCalledWith(mimoOptions, handlerForProvider(providerIdentifiers.mimo), true) + expect(getModelsMock).toHaveBeenCalledWith(mimoOptions, handlerForProvider(providerIdentifiers.mimo)) + + // No failure notification: the guard accepted the request and the handler built. + const errorCall = mockProvider.postMessageToWebview.mock.calls.find( + (call) => + call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse && + call[0]?.values?.provider === providerIdentifiers.mimo, + ) + expect(errorCall).toBeUndefined() + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.mimo).toEqual({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + }) + + // Regression (mixed state + refresh): the same valid-unsaved / off-list-stored + // pair, but through the filtered-flush path. With `refresh: true` and a provider + // filter, the handler flushes the cache before the aggregate fetch, and that + // flush builds its handler eagerly at the call site. The existing mixed-state + // test above only reaches the fetch path, so it would still pass if the flush + // site kept spreading the raw stored config. + it("flushes the MiMo cache from the resolved base URL when refresh is set and the stored one is off-list", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://attacker.example/v1", + }, + }) + getModelsMock.mockImplementation(async (options: { provider?: string }) => + options?.provider === providerIdentifiers.mimo + ? { "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false } } + : {}, + ) + + await expect( + webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.mimo, + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + refresh: true, + }, + }), + ).resolves.toBeUndefined() + + // Two flushes: the unsaved-values flush in the MiMo block and the filtered + // flush at the shared refresh site. Both must receive the guard-validated + // resolved URL and the MiMo handler built from it; spreading the raw stored + // config at the shared site throws MIMO_HANDLER_REJECTION out of the whole + // requestRouterModels case. + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + } + const mimoFlushCalls = flushModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFlushCalls).toHaveLength(2) + for (const [options, handler, refresh] of mimoFlushCalls) { + expect(options).toEqual(mimoOptions) + expect(handler).toBe(handlerForProvider(providerIdentifiers.mimo)) + expect(refresh).toBe(true) + } + + // The fetches use the same resolved options and handler. + const mimoFetchCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFetchCalls).toHaveLength(1) + expect(mimoFetchCalls[0][0]).toEqual(mimoOptions) + expect(mimoFetchCalls[0][1]).toBe(handlerForProvider(providerIdentifiers.mimo)) + + // The request completed: no failure response, and the aggregate response + // carries the refreshed catalog instead of hanging the Refresh button. + const errorCall = mockProvider.postMessageToWebview.mock.calls.find( + (call) => + call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse && + call[0]?.values?.provider === providerIdentifiers.mimo, + ) + expect(errorCall).toBeUndefined() + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.mimo).toEqual({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + }) + + it("treats an unsaved empty mimoBaseUrl as unset and keeps using the stored cluster", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { mimoBaseUrl: "" }, + }) + + // Empty means unset: the stored China cluster must keep applying instead + // of being silently rerouted to the default Singapore endpoint. + expect(getModelsMock).toHaveBeenCalledWith( + { + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + }, + handlerForProvider(providerIdentifiers.mimo), + ) + + // No rejection is posted and no refresh is flushed for the empty value. + const errorCall = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse, + ) + expect(errorCall).toBeUndefined() + const mimoFlushCalls = flushModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFlushCalls.length).toBe(0) + }) + + it("falls back to an undefined base URL when an empty unsaved value has no stored cluster", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { mimoBaseUrl: "" }, + }) + + expect(getModelsMock).toHaveBeenCalledWith( + { + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: undefined, + }, + handlerForProvider(providerIdentifiers.mimo), + ) + }) + it("posts a Moonshot provider error and keeps an empty aggregate entry when fetch fails", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 685a7b11c1..3794878470 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1206,6 +1206,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, @@ -1481,6 +1482,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, @@ -1548,6 +1550,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, @@ -1975,6 +1978,72 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { }) }) +describe("webviewMessageHandler - updateSettings mimoBaseUrl persistence gate", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Simulates a crafted webview message carrying a value outside the schema's + // literal union (the updateSettings boundary is validated at runtime, not by + // the message type). + type WebviewMessageArg = Parameters[1] + const updateSettingsMessageWithBaseUrl = (mimoBaseUrl: string): WebviewMessageArg => + ({ + type: "updateSettings", + updatedSettings: { mimoBaseUrl }, + }) as WebviewMessageArg + + it("persists an allowlisted Xiaomi endpoint", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1" }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith( + "mimoBaseUrl", + "https://token-plan-ams.xiaomimimo.com/v1", + ) + }) + + it("canonicalizes a trailing slash onto the allowlisted form", async () => { + await webviewMessageHandler( + mockClineProvider, + updateSettingsMessageWithBaseUrl("https://token-plan-ams.xiaomimimo.com/v1/"), + ) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith( + "mimoBaseUrl", + "https://token-plan-ams.xiaomimimo.com/v1", + ) + }) + + it("drops an off-list value with a warning and never persists it", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, updateSettingsMessageWithBaseUrl("https://attacker.example/v1")) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledOnce() + + warnSpy.mockRestore() + }) + + it("treats an empty string as unset and persists undefined", async () => { + await webviewMessageHandler(mockClineProvider, updateSettingsMessageWithBaseUrl("")) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("mimoBaseUrl", undefined) + }) + + it("persists an explicit unset", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { mimoBaseUrl: undefined }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("mimoBaseUrl", undefined) + }) +}) + // A plain boolean needs no normalization branch in the updateSettings loop, // so this pins the generic persistence path: every polarity, including an // explicit unset (undefined), must reach ContextProxy verbatim rather than diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index bd7c589958..9f4aec7a77 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -100,6 +100,7 @@ import { generateSystemPrompt } from "./generateSystemPrompt" import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { getCommand } from "../../utils/commands" import { getLMStudioModels } from "../../api/providers/fetchers/lmstudio" +import { ALLOWED_BASE_URLS, stripTrailingSlashes } from "../../api/providers/fetchers/mimo" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) @@ -130,6 +131,15 @@ import { const handlerFor = (apiConfiguration: ProviderSettings, provider: RouterName): ApiHandler => buildApiHandler({ ...apiConfiguration, apiProvider: provider }) +// True when a non-empty mimoBaseUrl is outside the four Xiaomi endpoints the +// fetcher and MimoHandler both enforce. handlerFor() builds its handler eagerly +// from stored config and MimoHandler's constructor throws on such a value, so +// every router-models case that constructs a MiMo handler must consult this +// first: the throw would otherwise escape webviewMessageHandler entirely +// (onReceiveMessage has no catch) and take the whole response with it. +const isOffListMimoBaseUrl = (mimoBaseUrl: string | undefined): boolean => + mimoBaseUrl !== undefined && mimoBaseUrl !== "" && !ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl)) + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -865,6 +875,32 @@ export const webviewMessageHandler = async ( if (!value) { continue } + } else if (key === "mimoBaseUrl") { + // Persistence-boundary gate for the settings form. The settings + // schema pins this key to the four allowed Xiaomi endpoints, but + // ContextProxy fails open on a schema rejection and returns raw + // stored values, so validate again here rather than trusting + // storage. This is one of two write paths, not the only one: the + // exported ZooCodeAPI.setConfiguration applies the same allowlist + // in src/extension/api.ts, and every consumer re-checks anyway + // because a value can also predate this gate. Empty string means + // "unset" (mirroring the handler and router fallbacks) and is + // normalized to undefined; an off-list non-empty value is dropped + // with a warning so it can never reach the chat-completion client + // via persisted state. Trailing slashes are tolerated for the + // check and canonicalized away on save. + if (typeof value === "string" && value !== "") { + const normalized = stripTrailingSlashes(value) + if (!ALLOWED_BASE_URLS.has(normalized)) { + console.warn( + "[webviewMessageHandler] Rejected mimoBaseUrl outside the allowed Xiaomi MiMo endpoints; the value was not persisted.", + ) + continue + } + newValue = normalized + } else { + newValue = undefined + } } await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) @@ -1120,6 +1156,20 @@ export const webviewMessageHandler = async ( case RouterModelsMessageType.flushRouterModels: { const { apiConfiguration } = await provider.getState() const routerNameFlush: RouterName = toRouterName(message.text) + + // Same stored-value gate as requestRouterModels: an off-list mimoBaseUrl + // (written by a pre-allowlist build, or by a caller of the exported + // ZooCodeAPI.setConfiguration, since ContextProxy fails open on a schema + // rejection) makes MimoHandler's constructor throw here. This case has no + // surrounding catch, so the rejection would escape the message handler and + // break the flush for good; skip the dispatch instead. + if (routerNameFlush === providerIdentifiers.mimo && isOffListMimoBaseUrl(apiConfiguration.mimoBaseUrl)) { + console.warn( + "[flushRouterModels] Skipped MiMo model flush: the stored base URL is not an allowed Xiaomi MiMo endpoint.", + ) + break + } + // Note: flushRouterModels is a generic flush without credentials // For providers that need credentials, use their specific handlers await flushModels( @@ -1153,6 +1203,7 @@ export const webviewMessageHandler = async ( [providerIdentifiers.poe]: {}, [providerIdentifiers.deepseek]: {}, [providerIdentifiers.moonshot]: {}, + [providerIdentifiers.mimo]: {}, [providerIdentifiers.opencodeGo]: {}, [providerIdentifiers.kenari]: {}, [providerIdentifiers.ioIntelligence]: {}, @@ -1296,6 +1347,91 @@ export const webviewMessageHandler = async ( }) } + // MiMo is conditional on apiKey. The baseUrl selects the cluster + // (cn/sgp/ams token-plan or pay-as-you-go), so unsaved form values are + // honored the same way as DeepSeek/Moonshot above. + const mimoApiKey = message?.values?.mimoApiKey ?? apiConfiguration.mimoApiKey + + // An unsaved empty string means "unset" (the form's cleared state), + // matching the handler-side fallback: it must not override the stored + // cluster value and silently reroute the stored key to the default + // Singapore endpoint. + const unsavedMimoBaseUrlValue = message?.values?.mimoBaseUrl + const unsavedMimoBaseUrl = + typeof unsavedMimoBaseUrlValue === "string" && unsavedMimoBaseUrlValue !== "" + ? unsavedMimoBaseUrlValue + : undefined + const mimoBaseUrl = unsavedMimoBaseUrl ?? apiConfiguration.mimoBaseUrl + + // handlerFor() spreads the RAW stored config, so a handler built for MiMo + // would re-validate the stored base URL instead of the one this request + // actually uses. In mixed state (valid unsaved URL + off-list stored URL) + // the guard below accepts the resolved value while MimoHandler's + // constructor rejects the stored one, and that throw escapes this case -- + // so every handler built here takes the resolved value. mimoBaseUrl is + // inert for the other providers. + // + // The cast is sound because the value only reaches MimoHandler after the + // allowlist guard below accepts it, and MimoHandler re-applies the same + // stripTrailingSlashes normalization before building its client. The + // declared type is the four-literal union, but an unsaved form value is a + // plain string that the guard (not the schema) is what vets. + const handlerConfiguration: ProviderSettings = { + ...apiConfiguration, + mimoBaseUrl: mimoBaseUrl as ProviderSettings["mimoBaseUrl"], + } + + // Unsaved form values bypass the settings-schema validation that pins + // stored mimoBaseUrl to the four allowed Xiaomi endpoints, so gate them + // on the fetcher's allowlist before they can carry the bearer key into + // modelCache. The exact match subsumes credential-bearing URLs (no + // allowlisted literal contains userinfo), and the raw value is never + // echoed since an unsaved one may embed credentials. + // + // The resolved value is gated too, not just the unsaved one: a stored + // value can be off-list (ContextProxy fails open on a schema rejection, + // so one written by a pre-allowlist build survives), and flushModels() + // now takes a handler that is built eagerly at this call site -- + // MimoHandler's constructor rejects off-list endpoints, and that throw + // escapes the whole requestRouterModels case, skipping both the MiMo + // failure response and the aggregate routerModels response. + const mimoBaseUrlRejected = isOffListMimoBaseUrl(mimoBaseUrl) + + if (mimoApiKey) { + if (mimoBaseUrlRejected) { + // Same surface as a failed refresh: post the MiMo failure + // response and skip the candidate so no fetch is dispatched, + // while router aggregation for other providers continues. + const errorMessage = + "MIMO/requestRouterModels/001: MiMo model fetch rejected: the provided base URL is not an allowed Xiaomi MiMo endpoint." + console.error(`Error refreshing models for ${providerIdentifiers.mimo}: ${errorMessage}`) + + await provider.postMessageToWebview({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + error: errorMessage, + values: { provider: providerIdentifiers.mimo }, + }) + } else { + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: mimoApiKey, + baseUrl: mimoBaseUrl, + } + + if (message?.values?.mimoApiKey || unsavedMimoBaseUrl) { + // Unsaved form values win over stored config: flush refreshes the cache + // with them before the aggregate fetch (same pattern as DeepSeek/Moonshot). + await flushModels(mimoOptions, handlerFor(handlerConfiguration, providerIdentifiers.mimo), true) + } + + candidates.push({ + key: providerIdentifiers.mimo, + options: mimoOptions, + }) + } + } + // 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 @@ -1395,12 +1531,12 @@ export const webviewMessageHandler = async ( // If refresh flag is set and we have a specific provider, flush its cache first if (shouldRefresh && providerFilter && modelFetchPromises.length > 0) { const targetCandidate = modelFetchPromises[0] - await flushModels(targetCandidate.options, handlerFor(apiConfiguration, targetCandidate.key), true) + await flushModels(targetCandidate.options, handlerFor(handlerConfiguration, targetCandidate.key), true) } const results = await Promise.allSettled( modelFetchPromises.map(async ({ key, options }) => { - const models = await safeGetModels(options, handlerFor(apiConfiguration, key)) + const models = await safeGetModels(options, handlerFor(handlerConfiguration, key)) return { key, models } // The key is `ProviderName` here. }), ) @@ -1428,10 +1564,20 @@ export const webviewMessageHandler = async ( } }) + // Echo the webview's request ID when one was provided so + // fetchRouterModels can correlate the response to the exact pending + // request (a remount may re-issue the same provider request while a + // stale response is in flight). Requests without an ID keep the + // previous response shape. + const requestId = message?.values?.requestId await provider.postMessageToWebview({ type: RouterModelsMessageType.routerModels, routerModels, - values: providerFilter ? { provider: requestedProvider } : undefined, + values: requestId + ? { requestId, ...(providerFilter ? { provider: requestedProvider } : {}) } + : providerFilter + ? { provider: requestedProvider } + : undefined, }) break } diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index f4ed8af469..6935351763 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest" import type * as vscode from "vscode" +import { providerIdentifiers, type RooCodeSettings } from "@roo-code/types" + import { API } from "../api" import type { ClineProvider } from "../../core/webview/ClineProvider" import { makeClineProviderFactory } from "../../test-utils/provider" @@ -15,12 +17,14 @@ describe("API - configuration", () => { const saveConfig = vi.fn().mockResolvedValue("default-id") const setModeConfig = vi.fn().mockResolvedValue(undefined) const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const updateGlobalState = vi.fn().mockResolvedValue(undefined) const provider = { context: {}, on: vi.fn(), - contextProxy: { setValues }, + contextProxy: { setValues, updateGlobalState }, providerSettingsManager: { saveConfig, setModeConfig }, postStateToWebview, + getValue: vi.fn(), } as unknown as ClineProvider const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel const api = new API(outputChannel, provider, makeClineProviderFactory()) @@ -48,12 +52,14 @@ describe("API - configuration", () => { const saveConfig = vi.fn().mockResolvedValue("default-id") const setModeConfig = vi.fn().mockResolvedValue(undefined) const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const updateGlobalState = vi.fn().mockResolvedValue(undefined) const provider = { context: {}, on: vi.fn(), - contextProxy: { setValues }, + contextProxy: { setValues, updateGlobalState }, providerSettingsManager: { saveConfig, setModeConfig }, postStateToWebview, + getValue: vi.fn(), } as unknown as ClineProvider const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel const api = new API(outputChannel, provider, makeClineProviderFactory()) @@ -63,4 +69,214 @@ describe("API - configuration", () => { expect(setModeConfig).not.toHaveBeenCalled() expect(postStateToWebview).toHaveBeenCalledOnce() }) + + it("excludes mimoApiKey from getConfiguration while keeping non-secret MiMo settings", () => { + const provider = { + context: {}, + on: vi.fn(), + getValues: () => ({ + apiProvider: providerIdentifiers.mimo, + mimoApiKey: "mimo-secret-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + apiModelId: "mimo-v2.6-pro", + }), + // Double assertion: this filter-focused spec only needs the two members + // the API surface touches; a full ClineProvider test double is out of + // scope (the upstream siblings above use the same pattern). + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + expect(configuration).not.toHaveProperty("mimoApiKey") + expect(configuration.mimoBaseUrl).toBe("https://token-plan-sgp.xiaomimimo.com/v1") + expect(configuration.apiModelId).toBe("mimo-v2.6-pro") + }) + + describe("mimoBaseUrl allowlist at the public API boundary", () => { + // setConfiguration writes straight to ContextProxy, which fails open on a + // schema rejection, so this is one of the write paths where an off-list + // mimoBaseUrl could newly enter storage. Both persistence calls are + // asserted: the proxy write and the saved profile. + // `storedBaseUrl` is deliberately `unknown`: storage is not schema-validated + // on read, so the corrupted-value cases below need to inject a non-string. + const makeApi = (storedBaseUrl?: unknown) => { + const setValues = vi.fn().mockResolvedValue(undefined) + const saveConfig = vi.fn().mockResolvedValue("default-id") + const setModeConfig = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const updateGlobalState = vi.fn().mockResolvedValue(undefined) + const provider = { + context: {}, + on: vi.fn(), + contextProxy: { setValues, updateGlobalState }, + providerSettingsManager: { saveConfig, setModeConfig }, + postStateToWebview, + // The stored-value read goes through the provider-facing accessor, the + // same one every other consumer uses, so the double only needs that. + getValue: vi.fn((key: string) => (key === "mimoBaseUrl" ? storedBaseUrl : undefined)), + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + + return { + api: new API(outputChannel, provider, makeClineProviderFactory()), + setValues, + saveConfig, + updateGlobalState, + } + } + + // The declared type is the four-literal union, so an off-list value needs + // the same widening the guard itself documents: callers of this public API + // are not the schema. `unknown` rather than `string` because the runtime + // value is whatever the caller passed, including non-strings. + const offListConfiguration = (mimoBaseUrl: unknown): RooCodeSettings => + ({ currentApiConfigName: "default", mimoBaseUrl }) as unknown as RooCodeSettings + + it("drops an off-list mimoBaseUrl instead of persisting it", async () => { + const { api, setValues, saveConfig } = makeApi() + + await api.setConfiguration(offListConfiguration("https://attacker.example/v1")) + + expect(setValues).toHaveBeenCalledWith(expect.not.objectContaining({ mimoBaseUrl: expect.anything() })) + expect(saveConfig).toHaveBeenCalledWith( + "default", + expect.not.objectContaining({ mimoBaseUrl: expect.anything() }), + ) + expect(setValues.mock.calls[0][0]).not.toHaveProperty("mimoBaseUrl") + expect(saveConfig.mock.calls[0][1]).not.toHaveProperty("mimoBaseUrl") + }) + + it.each([ + "https://api.xiaomimimo.com/v1", + "https://token-plan-cn.xiaomimimo.com/v1", + "https://token-plan-sgp.xiaomimimo.com/v1", + "https://token-plan-ams.xiaomimimo.com/v1", + ])("persists the allowlisted endpoint %s", async (mimoBaseUrl) => { + const { api, setValues, saveConfig } = makeApi() + + await api.setConfiguration(offListConfiguration(mimoBaseUrl)) + + expect(setValues).toHaveBeenCalledWith(expect.objectContaining({ mimoBaseUrl })) + expect(saveConfig).toHaveBeenCalledWith("default", expect.objectContaining({ mimoBaseUrl })) + }) + + it("persists a trailing-slash variant of an allowlisted endpoint", async () => { + const { api, setValues } = makeApi() + + await api.setConfiguration(offListConfiguration("https://token-plan-sgp.xiaomimimo.com/v1/")) + + expect(setValues).toHaveBeenCalledWith( + expect.objectContaining({ mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1/" }), + ) + }) + + it("keeps an empty mimoBaseUrl so 'unset' still means the default cluster", async () => { + const { api, setValues } = makeApi() + + await api.setConfiguration(offListConfiguration("")) + + expect(setValues).toHaveBeenCalledWith(expect.objectContaining({ mimoBaseUrl: "" })) + }) + + it("leaves configurations without mimoBaseUrl untouched", async () => { + const { api, setValues } = makeApi() + + await api.setConfiguration({ currentApiConfigName: "default", apiModelId: "mimo-v2.6-pro" }) + + expect(setValues).toHaveBeenCalledWith({ + currentApiConfigName: "default", + apiModelId: "mimo-v2.6-pro", + }) + }) + + it.each([[42], [{ nested: "https://attacker.example/v1" }], [true]])( + "drops a non-string incoming mimoBaseUrl (%s) instead of throwing", + async (mimoBaseUrl) => { + // Same hazard as the stored-value read below, on the incoming side: the + // parameter is typed `string | undefined`, but this is a public entry + // point and stripTrailingSlashes calls .endsWith. Without a typeof guard + // the sanitizer throws TypeError, which rejects the whole configuration + // write, so a mistyped caller loses every other setting it sent. + const { api, setValues, saveConfig } = makeApi() + + await expect(api.setConfiguration(offListConfiguration(mimoBaseUrl))).resolves.toBeUndefined() + + expect(setValues).toHaveBeenCalledWith({ currentApiConfigName: "default" }) + expect(saveConfig).toHaveBeenCalledWith("default", { currentApiConfigName: "default" }) + }, + ) + + it("purges a stored off-list mimoBaseUrl that a partial update would otherwise leave behind", async () => { + // The stored value was written by an older build or another extension, so + // the sanitizer never saw it. A partial update that omits mimoBaseUrl does + // not overwrite it, so without an explicit purge the off-list endpoint + // survives the call and keeps steering requests. + const { api, setValues, updateGlobalState } = makeApi("https://attacker.example/v1") + + await api.setConfiguration({ currentApiConfigName: "default", apiModelId: "mimo-v2.6-pro" }) + + expect(updateGlobalState).toHaveBeenCalledWith("mimoBaseUrl", undefined) + expect(setValues).toHaveBeenCalledWith({ + currentApiConfigName: "default", + apiModelId: "mimo-v2.6-pro", + }) + }) + + it("preserves a valid stored mimoBaseUrl during a partial update with rejected input", async () => { + // The rejected input must not take the stored cluster down with it: only + // the incoming value is dropped, and the stored allowlisted endpoint stays. + const { api, setValues, updateGlobalState } = makeApi("https://token-plan-ams.xiaomimimo.com/v1") + + await api.setConfiguration(offListConfiguration("https://attacker.example/v1")) + + expect(updateGlobalState).not.toHaveBeenCalledWith("mimoBaseUrl", undefined) + expect(setValues).toHaveBeenCalledWith(expect.not.objectContaining({ mimoBaseUrl: expect.anything() })) + }) + + it("does not purge a stored allowlisted mimoBaseUrl on a partial update", async () => { + const { api, updateGlobalState } = makeApi("https://api.xiaomimimo.com/v1") + + await api.setConfiguration({ currentApiConfigName: "default", apiModelId: "mimo-v2.6-pro" }) + + expect(updateGlobalState).not.toHaveBeenCalledWith("mimoBaseUrl", undefined) + }) + + it("does not purge a stored allowlisted mimoBaseUrl that carries a trailing slash", async () => { + // setConfiguration persists a trailing-slash variant verbatim (see the + // incoming-value case above), so this exact stored shape is reachable + // through this very API. Only the slash-less literal is on the allowlist, + // so a stored-value check that skipped stripTrailingSlashes would purge + // every such cluster and silently drop the user to the default SGP one. + const { api, updateGlobalState } = makeApi("https://token-plan-ams.xiaomimimo.com/v1/") + + await api.setConfiguration({ currentApiConfigName: "default", apiModelId: "mimo-v2.6-pro" }) + + expect(updateGlobalState).not.toHaveBeenCalledWith("mimoBaseUrl", undefined) + }) + + it.each([[42], [{ nested: "https://attacker.example/v1" }], [true]])( + "treats a non-string stored mimoBaseUrl (%s) as off-list instead of throwing", + async (storedBaseUrl) => { + // The stored read is typed `string | undefined`, but that is compile-time + // only: ContextProxy hands back whatever the cache holds, and this helper + // exists precisely because storage can carry values no schema ever saw. + // stripTrailingSlashes calls .endsWith, so without a typeof guard a + // corrupted value throws TypeError and rejects the whole configuration + // write - a new failure mode in the scenario the helper targets. + const { api, setValues, updateGlobalState } = makeApi(storedBaseUrl) + + await expect( + api.setConfiguration({ currentApiConfigName: "default", apiModelId: "mimo-v2.6-pro" }), + ).resolves.toBeUndefined() + + expect(updateGlobalState).toHaveBeenCalledWith("mimoBaseUrl", undefined) + expect(setValues).toHaveBeenCalledWith({ + currentApiConfigName: "default", + apiModelId: "mimo-v2.6-pro", + }) + }, + ) + }) }) diff --git a/src/extension/api.ts b/src/extension/api.ts index 3a33510346..69d4d14c8f 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -36,6 +36,7 @@ import { readTaskMessages } from "../core/task-persistence/taskMessages" import { Terminal } from "../integrations/terminal/Terminal" import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" import { getCommands } from "../services/command/commands" +import { ALLOWED_BASE_URLS, stripTrailingSlashes } from "../api/providers/fetchers/mimo" export class API extends EventEmitter implements RooCodeAPI { private readonly outputChannel: vscode.OutputChannel @@ -629,8 +630,36 @@ export class API extends EventEmitter implements RooCodeAPI { } public async setConfiguration(values: RooCodeSettings) { - await this.sidebarProvider.contextProxy.setValues(values) - await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values) + // The allowlist lives at the two boundaries that consume this value (the + // updateSettings persistence gate and MimoHandler's constructor), but this + // public API writes straight to ContextProxy and ContextProxy fails open on + // a schema rejection, so a caller could park an off-list mimoBaseUrl in + // storage. Drop it here instead of persisting it. The value is removed + // rather than overwritten so "unset" keeps its existing meaning (fall back + // to the default cluster) instead of pinning a cluster. + // + // This is not the only write path: ClineProvider.createTask persists a + // caller-supplied configuration through setValues without consulting the + // allowlist, so an off-list value can still reach storage that way. Every + // consumer therefore fails closed on its own (MimoHandler's constructor + // throws, the model fetcher and the router-models handlers refuse the + // value) rather than trusting that storage was sanitized on the way in. + const sanitizedValues = this.withoutOffListMimoBaseUrl(values) + + // Dropping the incoming value is not enough on its own: a partial update + // omits mimoBaseUrl, so an off-list value already in storage (written by an + // older build or another extension) survives this call and keeps steering + // requests. Purge it explicitly. A stored allowlisted endpoint is left alone + // so a rejected input cannot take a valid cluster down with it. + if (this.isStoredMimoBaseUrlOffList()) { + await this.sidebarProvider.contextProxy.updateGlobalState("mimoBaseUrl", undefined) + } + + await this.sidebarProvider.contextProxy.setValues(sanitizedValues) + await this.sidebarProvider.providerSettingsManager.saveConfig( + sanitizedValues.currentApiConfigName || "default", + sanitizedValues, + ) if (values.modeApiConfigs) { await Promise.all( Object.entries(values.modeApiConfigs).map(([mode, configId]) => @@ -641,6 +670,60 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.postStateToWebview() } + /** + * Reports whether the mimoBaseUrl currently in storage is a non-empty value + * outside the four allowed Xiaomi endpoints. Empty string means "unset" and + * counts as on-list, matching the sanitizer's convention. + */ + private isStoredMimoBaseUrlOffList(): boolean { + // Typed as the four-literal union, but the type is compile-time only: the + // accessor returns whatever the state cache holds, and this helper exists + // because storage can carry values no schema ever saw. A non-string is + // corrupted storage, not a usable endpoint, so it counts as off-list and + // gets purged instead of throwing out of stripTrailingSlashes. + const storedBaseUrl: unknown = this.sidebarProvider.getValue("mimoBaseUrl") + + if (storedBaseUrl === undefined || storedBaseUrl === "") { + return false + } + + if (typeof storedBaseUrl !== "string") { + return true + } + + return !ALLOWED_BASE_URLS.has(stripTrailingSlashes(storedBaseUrl)) + } + + /** + * Returns `values` unchanged unless it carries a non-empty mimoBaseUrl outside + * the four allowed Xiaomi endpoints, in which case that one key is omitted. + * Empty string means "unset" and passes through untouched. A non-string value + * cannot be an allowed endpoint, so it is dropped like any other off-list value. + */ + private withoutOffListMimoBaseUrl(values: RooCodeSettings): RooCodeSettings { + // Declared as the four-literal union, but this API is a public entry point + // for other extensions and automation: the runtime value is whatever the + // caller passed, and the schema is exactly what it never went through. + // Typed `unknown` for that reason: a caller that sends a number or an object + // must be rejected, not allowed to throw out of stripTrailingSlashes. + const mimoBaseUrl: unknown = values.mimoBaseUrl + + if (mimoBaseUrl === undefined || mimoBaseUrl === "") { + return values + } + + if (typeof mimoBaseUrl === "string" && ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl))) { + return values + } + + console.warn( + "[API] Rejected mimoBaseUrl outside the allowed Xiaomi MiMo endpoints; the value was not persisted.", + ) + + const { mimoBaseUrl: _rejected, ...rest } = values + return rest + } + public setTerminalProfile(name: string | undefined): void { const previousProfile = Terminal.getTerminalProfile() Terminal.setTerminalProfile(name) diff --git a/src/shared/api.ts b/src/shared/api.ts index 065d32a7ea..1cf7e87d35 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -184,6 +184,7 @@ const _dynamicProviderExtras = { [providerIdentifiers.poe]: {} as { apiKey?: string; baseUrl?: string }, [providerIdentifiers.deepseek]: {} as { apiKey?: string; baseUrl?: string }, [providerIdentifiers.moonshot]: {} as { apiKey?: string; baseUrl?: string }, + [providerIdentifiers.mimo]: {} as { apiKey?: string; baseUrl?: string }, [providerIdentifiers.opencodeGo]: {} as { apiKey?: string }, [providerIdentifiers.kenari]: {} as { apiKey?: string }, [providerIdentifiers.ioIntelligence]: {} as { apiKey?: string }, diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 85d6919db4..c5d45bed53 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -269,6 +269,44 @@ export const stories: Record = { ) }, + // The MiMo settings gained a Refresh Models affordance with idle/loading/success/ + // error render states, so the surface needs a pixel receipt: the button sits in a + // vertical stack with the entrypoint dropdown and API key field, and the status + // line changes the block's height. Props stay serializable; the state lives here. + "mimo-settings": async ({ configured = false } = {}) => { + const [{ AppProviders }, { Mimo }, { providerIdentifiers }] = await Promise.all([ + import("../AppProviders"), + import("@/components/settings/providers/Mimo"), + import("@roo-code/types"), + ]) + type ProviderSettings = React.ComponentProps["apiConfiguration"] + + function MimoSettingsStory() { + const [apiConfiguration, setApiConfiguration] = useState({ + apiProvider: providerIdentifiers.mimo, + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + ...(configured ? { mimoApiKey: "mimo-test-key" } : {}), + }) + return ( +
+
+ + setApiConfiguration((current) => ({ ...current, [field]: value })) + } + /> +
+
+ ) + } + + return ( + + + + ) + }, "layout-clipped-text": () => (
diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 6e22859618..e77de030cc 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -773,11 +773,30 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} defaultModelId={getDefaultModelIdForProvider(activeSelectedProvider, apiConfiguration)} - models={getStaticModelsForProvider( - activeSelectedProvider, - t("settings:labels.useCustomArn"), - apiConfiguration, - )} + models={ + // MiMo has no dedicated picker component, so it shares the + // generic static picker with DeepSeek. MiMo ships new model + // generations frequently (the V2.5 line is EOL), so the + // host-fetched catalog is merged in here: the picker stays + // populated before the router response arrives and post-V2.6 + // releases become selectable without an extension update. + // DeepSeek uses this picker without the merge; Moonshot has + // its own router-only picker. + activeSelectedProvider === providerIdentifiers.mimo + ? { + ...getStaticModelsForProvider( + activeSelectedProvider, + t("settings:labels.useCustomArn"), + apiConfiguration, + ), + ...routerModels?.[providerIdentifiers.mimo], + } + : getStaticModelsForProvider( + activeSelectedProvider, + t("settings:labels.useCustomArn"), + apiConfiguration, + ) + } modelIdKey="apiModelId" serviceName={getProviderServiceConfig(activeSelectedProvider).serviceName} serviceUrl={getProviderServiceConfig(activeSelectedProvider).serviceUrl} diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx index b68bee99e6..7fb63ad766 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx @@ -2,7 +2,13 @@ import { screen } from "@testing-library/react" import { renderWithExtensionState } from "@/utils/test-utils" -import { providerIdentifiers, type ProviderSettings, type OrganizationAllowList } from "@roo-code/types" +import { + providerIdentifiers, + mimoDefaultModelId, + type ModelInfo, + type ProviderSettings, + type OrganizationAllowList, +} from "@roo-code/types" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" @@ -11,13 +17,26 @@ import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import ApiOptions from "../ApiOptions" import { MODELS_BY_PROVIDER, PROVIDERS } from "../constants" -// Mock the extension state context +// Mock the extension state context. The hoisted state holder lets the MiMo suite +// reset the allow list to the unfiltered default without re-typing the full +// ExtensionStateContextType (previous suites pin mockReturnValue via `as any`, +// and vi.clearAllMocks() does not undo that). +const { useExtensionStateMock, setOrganizationAllowList } = vi.hoisted(() => { + const mock = vi.fn(() => ({ + organizationAllowList: undefined as OrganizationAllowList | undefined, + cloudIsAuthenticated: false, + })) + return { + useExtensionStateMock: mock, + setOrganizationAllowList: (list: OrganizationAllowList | undefined) => { + mock.mockReturnValue({ organizationAllowList: list, cloudIsAuthenticated: false }) + }, + } +}) + vi.mock("@src/context/ExtensionStateContext", () => ({ ExtensionStateContextProvider: ({ children }: any) => children, - useExtensionState: vi.fn(() => ({ - organizationAllowList: undefined, - cloudIsAuthenticated: false, - })), + useExtensionState: useExtensionStateMock, })) // Mock the translation hook @@ -34,12 +53,20 @@ vi.mock("@src/utils/vscode", () => ({ }, })) -// Mock the router models hook +// Mock the router models hook. The hoisted state holder lets tests drive `data` +// without re-typing the full react-query UseQueryResult at each call site. +const { useRouterModelsMock, setRouterModelsData } = vi.hoisted(() => { + const state = { data: undefined as Record> | undefined } + return { + useRouterModelsMock: vi.fn(() => ({ data: state.data, refetch: vi.fn() })), + setRouterModelsData: (data: typeof state.data) => { + state.data = data + }, + } +}) + vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ - useRouterModels: vi.fn(() => ({ - data: null, - refetch: vi.fn(), - })), + useRouterModels: useRouterModelsMock, })) // Mock the selected model hook @@ -353,4 +380,74 @@ describe("ApiOptions Provider Filtering", () => { delete (MODELS_BY_PROVIDER as any).testEmptyProvider PROVIDERS.pop() }) + + describe("MiMo dynamic model catalog (ApiOptions -> ModelPicker wiring)", () => { + // Regression guard: the generic ModelPicker must receive the host-fetched router + // catalog for MiMo merged on top of the static MODELS_BY_PROVIDER fallback in + // ApiOptions. ModelPicker itself performs no router merging, so if the merge in + // ApiOptions regressed to static-only, router-only models newer than the shipped + // catalog would silently become unselectable and no other suite would catch it. + const routerOnlyMimoModelId = "mimo-v2.7-ultra" + + const routerOnlyMimoModelInfo: ModelInfo = { + maxTokens: 131_072, + contextWindow: 262_144, + supportsPromptCache: false, + } + + const renderWithMimoSelected = (routerData: Record> | undefined) => { + // Earlier allow-list suites leave a restrictive organizationAllowList on this + // mock (clearAllMocks keeps mockReturnValue); reset to the unfiltered default + // so the MiMo catalog reaches the picker in full. + setOrganizationAllowList(undefined) + setRouterModelsData(routerData) + vi.mocked(useSelectedModel).mockReturnValue({ + provider: providerIdentifiers.mimo, + id: mimoDefaultModelId, + info: undefined, + isLoading: false, + isError: false, + }) + + return renderWithProviders({ + ...defaultProps, + apiConfiguration: { + apiProvider: providerIdentifiers.mimo, + apiModelId: mimoDefaultModelId, + } as ProviderSettings, + }) + } + + afterEach(() => { + // Restore the default (no fetched models) for any suites that run later. + setRouterModelsData(undefined) + }) + + it("passes both static and router-fetched MiMo models to the generic ModelPicker", () => { + // The router payload contains ONLY a model id that does not exist in the + // shipped static mimo catalog; it can reach the picker solely via the merge. + renderWithMimoSelected({ [providerIdentifiers.mimo]: { [routerOnlyMimoModelId]: routerOnlyMimoModelInfo } }) + + // Static default from getStaticModelsForProvider(mimo) / MODELS_BY_PROVIDER. + expect(screen.getByTestId(`model-option-${mimoDefaultModelId}`)).toBeInTheDocument() + // Router-only model: present in the picker only if ApiOptions merged routerModels[mimo]. + expect(screen.getByTestId(`model-option-${routerOnlyMimoModelId}`)).toBeInTheDocument() + }) + + it("keeps the static MiMo catalog selectable when no router models are available", () => { + // useRouterModels data is undefined while the fetch is pending/failed; the + // spread of routerModels?.[mimo] must not crash or drop the static defaults. + renderWithMimoSelected(undefined) + + expect(screen.getByTestId(`model-option-${mimoDefaultModelId}`)).toBeInTheDocument() + expect(screen.queryByTestId(`model-option-${routerOnlyMimoModelId}`)).not.toBeInTheDocument() + }) + + it("keeps the static MiMo catalog selectable when the router catalog is empty", () => { + renderWithMimoSelected({}) + + expect(screen.getByTestId(`model-option-${mimoDefaultModelId}`)).toBeInTheDocument() + expect(screen.queryByTestId(`model-option-${routerOnlyMimoModelId}`)).not.toBeInTheDocument() + }) + }) }) diff --git a/webview-ui/src/components/settings/providers/Mimo.tsx b/webview-ui/src/components/settings/providers/Mimo.tsx index 95f668f8a0..04fb96dfe1 100644 --- a/webview-ui/src/components/settings/providers/Mimo.tsx +++ b/webview-ui/src/components/settings/providers/Mimo.tsx @@ -1,10 +1,21 @@ -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, + type ExtensionMessage, + providerIdentifiers, + allRouterModelsProvider, + RouterModelsMessageType, +} from "@roo-code/types" + +import { RouterName } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { vscode } from "@src/utils/vscode" +import { Button } from "@src/components/ui" import { inputEventTransform } from "../transforms" import { cn } from "@/lib/utils" @@ -14,8 +25,50 @@ type MimoProps = { setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const Mimo = ({ apiConfiguration, setApiConfigurationField }: MimoProps) => { const { t } = useAppTranslation() + const queryClient = useQueryClient() + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) + const [refreshError, setRefreshError] = useState() + const mimoErrorJustReceived = useRef(false) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { + const providerName = message.values?.provider as RouterName + if (providerName === providerIdentifiers.mimo && refreshStatus === RefreshStatus.Loading) { + mimoErrorJustReceived.current = true + setRefreshStatus(RefreshStatus.Error) + setRefreshError(message.error) + } + } else if (message.type === RouterModelsMessageType.routerModels) { + if (refreshStatus === RefreshStatus.Loading) { + if (!mimoErrorJustReceived.current) { + setRefreshStatus(RefreshStatus.Success) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.mimo], + }) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], + }) + } + } + } + } + + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + } + }, [refreshStatus, queryClient]) const handleInputChange = useCallback( ( @@ -28,6 +81,29 @@ export const Mimo = ({ apiConfiguration, setApiConfigurationField }: MimoProps) [setApiConfigurationField], ) + const handleRefreshModels = useCallback(() => { + mimoErrorJustReceived.current = false + setRefreshStatus(RefreshStatus.Loading) + setRefreshError(undefined) + + const key = apiConfiguration.mimoApiKey + + if (!key) { + setRefreshStatus(RefreshStatus.Error) + setRefreshError(t("settings:providers.refreshModels.missingConfig")) + return + } + + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.mimo, + mimoApiKey: key, + mimoBaseUrl: apiConfiguration.mimoBaseUrl, + }, + }) + }, [apiConfiguration, t]) + return ( <>
@@ -68,6 +144,40 @@ export const Mimo = ({ apiConfiguration, setApiConfigurationField }: MimoProps) )}
+ + {/* One persistent live region: screen readers only announce content that + changes inside an element that is already in the accessibility tree, + so the conditional status divs mount into this wrapper instead of + appearing and disappearing as the region itself. */} +
+ {refreshStatus === RefreshStatus.Loading && ( +
+ {t("settings:providers.refreshModels.loading")} +
+ )} + {refreshStatus === RefreshStatus.Success && ( +
+ {t("settings:providers.refreshModels.success")} +
+ )} + {refreshStatus === RefreshStatus.Error && ( +
+ {refreshError || t("settings:providers.refreshModels.error")} +
+ )} +
) } diff --git a/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx index ae6f7bc63a..0a561d2a2a 100644 --- a/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx @@ -76,6 +76,7 @@ describe("IOIntelligence", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": {}, kenari: {}, nanogpt: {}, diff --git a/webview-ui/src/components/settings/providers/__tests__/Mimo.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Mimo.spec.tsx new file mode 100644 index 0000000000..7dc6899522 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Mimo.spec.tsx @@ -0,0 +1,375 @@ +// npx vitest src/components/settings/providers/__tests__/Mimo.spec.tsx + +import { + render, + screen, + fireEvent, + waitFor, + act, + renderWithExtensionState, + createTestQueryClient, +} from "@/utils/test-utils" +import { allRouterModelsProvider, providerIdentifiers, type ProviderSettings } from "@roo-code/types" + +import { Mimo } from "../Mimo" + +// Mock the translation hook +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock VSCode webview toolkit components using importOriginal +vi.mock("@vscode/webview-ui-toolkit/react", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + VSCodeTextField: ({ children, value, onInput, type, placeholder }: any) => ( +
+ {children} + onInput?.(e)} + placeholder={placeholder} + data-testid="mimo-api-key-input" + /> +
+ ), + VSCodeDropdown: ({ children, value, onChange }: any) => ( +
+ {children} + +
+ ), + VSCodeOption: ({ children, value }: any) => ( +
+ {children} +
+ ), + } +}) + +// Mock vscode - factory must not reference outer scope variables +vi.mock("@src/utils/vscode", () => { + const mockPostMessage = vi.fn() + return { + vscode: { postMessage: mockPostMessage }, + } +}) + +// Mock @src/components/ui using importOriginal to get all real exports +vi.mock("@src/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Button: ({ children, onClick, disabled, variant }: any) => ( + + ), + } +}) + +// Mock VSCodeButtonLink +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ href, children }: any) => ( + + {children} + + ), +})) + +import { vscode } from "@src/utils/vscode" + +const findRefreshButton = () => + screen.getAllByTestId("button").find((b) => b.getAttribute("data-variant") === "outline") + +describe("Mimo Component", () => { + const mockSetApiConfigurationField = vi.fn() + + const createDefaultApiConfiguration = (overrides?: Partial): ProviderSettings => ({ + apiProvider: providerIdentifiers.mimo, + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + ...overrides, + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders API key input, base URL dropdown, and refresh button", () => { + render( + , + ) + + expect(screen.getByTestId("mimo-api-key-input")).toBeInTheDocument() + expect(screen.getByTestId("vscode-dropdown")).toBeInTheDocument() + expect(findRefreshButton()).toBeInTheDocument() + }) + + it("refresh button is disabled when no API key is provided", () => { + render( + , + ) + + expect(findRefreshButton()).toBeDisabled() + }) + + it("refresh button is enabled when API key is provided", () => { + render( + , + ) + + expect(findRefreshButton()).not.toBeDisabled() + }) + + it("shows loading state when refresh is clicked", () => { + render( + , + ) + + const refreshButton = findRefreshButton()! + fireEvent.click(refreshButton) + + expect(refreshButton).toBeDisabled() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("shows success state after routerModels message received", async () => { + render( + , + ) + + fireEvent.click(findRefreshButton()!) + + window.postMessage( + { + type: "routerModels", + values: { mimo: { "mimo-v2.6-pro": { maxTokens: 131072 } } }, + }, + "*", + ) + + await waitFor(() => { + expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument() + }) + }) + + it("invalidates only the MiMo and shared router-model caches after a successful refresh", async () => { + const queryClient = createTestQueryClient() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + renderWithExtensionState( + , + { queryClient }, + ) + + fireEvent.click(findRefreshButton()!) + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", providerIdentifiers.mimo], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", allRouterModelsProvider], + }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ["routerModels"] }) + }) + }) + + it("shows error state after a MiMo singleRouterModelFetchResponse error message", async () => { + render( + , + ) + + await act(async () => { + fireEvent.click(findRefreshButton()!) + }) + + await waitFor(() => { + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + // Delay so the handler closure captures refreshStatus === "loading". + await act(async () => { + return new Promise((resolve) => { + setTimeout(() => { + window.postMessage( + { + type: "singleRouterModelFetchResponse", + success: false, + error: "API connection failed", + values: { provider: providerIdentifiers.mimo }, + }, + "*", + ) + resolve(undefined) + }, 0) + }) + }) + + await waitFor(() => { + expect(screen.getByText("API connection failed")).toBeInTheDocument() + }) + }) + + it("ignores another provider's failed refresh response while loading", async () => { + render( + , + ) + + fireEvent.click(findRefreshButton()!) + await waitFor(() => expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + error: "Moonshot unavailable", + values: { provider: providerIdentifiers.moonshot }, + }, + }), + ) + }) + + expect(screen.queryByText("Moonshot unavailable")).not.toBeInTheDocument() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("ignores a MiMo failure response before refresh starts", () => { + render( + , + ) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + error: "MiMo unavailable", + values: { provider: providerIdentifiers.mimo }, + }, + }), + ) + }) + + expect(screen.queryByText("MiMo unavailable")).not.toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.loading")).not.toBeInTheDocument() + }) + + it("sends the provider-correlated MiMo credentials when refresh is clicked", () => { + render( + , + ) + + fireEvent.click(findRefreshButton()!) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "requestRouterModels", + values: { + provider: providerIdentifiers.mimo, + mimoApiKey: "test-key", + mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + }, + }), + ) + }) + + it("keeps one persistent polite live region mounted across status changes", async () => { + // Screen readers only announce content that changes inside an element already + // present in the accessibility tree. When each status div mounts and unmounts + // as the region itself, the announcement is lost, so the wrapper has to stay + // mounted and only its children change. + render( + , + ) + + const liveRegion = screen.getByRole("status") + expect(liveRegion).toHaveAttribute("aria-live", "polite") + expect(liveRegion).toBeEmptyDOMElement() + + fireEvent.click(findRefreshButton()!) + + expect(screen.getByRole("status")).toBe(liveRegion) + expect(liveRegion).toHaveTextContent("settings:providers.refreshModels.loading") + + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + await waitFor(() => { + expect(liveRegion).toHaveTextContent("settings:providers.refreshModels.success") + }) + expect(screen.getByRole("status")).toBe(liveRegion) + }) + + it("shows 'Get MiMo API Key' link when no API key is set", () => { + render( + , + ) + + expect(screen.getByTestId("vscode-button-link")).toBeInTheDocument() + expect(screen.getByTestId("vscode-button-link")).toHaveAttribute("href", "https://platform.xiaomimimo.com") + }) + + it("hides 'Get MiMo API Key' link when API key is set", () => { + render( + , + ) + + expect(screen.queryByTestId("vscode-button-link")).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/Mimo.visual.tsx b/webview-ui/src/components/settings/providers/__tests__/Mimo.visual.tsx new file mode 100644 index 0000000000..f8359410ca --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Mimo.visual.tsx @@ -0,0 +1,32 @@ +import { expect, test } from "../../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../../playwright/mounted-story" + +// The MiMo settings surface gained a Refresh Models affordance: a button that swaps +// its icon for a spinner while loading and appends a status line for the loading, +// success, and error states. That changes the block's height and the button's +// disabled appearance, which JSDOM cannot verify — hence the snapshot. + +test("renders the unconfigured MiMo settings in the VS Code dark theme", async ({ mount, page }) => { + await page.setViewportSize({ width: 480, height: 480 }) + const component = mountedStory(await mount("mimo-settings")) + const story = component.getByTestId("mimo-settings-story") + + // Unconfigured: no API key yet, so the "Get MiMo API Key" link renders and the + // Refresh Models button is disabled. + await expect(story.getByRole("button", { name: /Refresh Models/ })).toBeDisabled() + await expect(story.getByText("Get MiMo API Key")).toBeVisible() + + await expect(story).toHaveScreenshot("mimo-settings-unconfigured-dark.png") +}) + +test("renders the configured MiMo settings with an enabled refresh affordance", async ({ mount, page }) => { + await page.setViewportSize({ width: 480, height: 480 }) + const component = mountedStory(await mount("mimo-settings", { configured: true })) + const story = component.getByTestId("mimo-settings-story") + + // Configured: the refresh button is actionable and the API key link is gone. + await expect(story.getByRole("button", { name: /Refresh Models/ })).toBeEnabled() + await expect(story.getByText("Get MiMo API Key")).toHaveCount(0) + + await expect(story).toHaveScreenshot("mimo-settings-configured-dark.png") +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx index 698fa90e29..f2ceefdce6 100644 --- a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx @@ -100,6 +100,7 @@ describe("NanoGPT", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": {}, kenari: {}, nanogpt: { "openai/test": { contextWindow: 1, maxTokens: 1, supportsPromptCache: false } }, diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-configured-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-configured-dark.png new file mode 100644 index 0000000000..ce2a24393b Binary files /dev/null and b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-configured-dark.png differ diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-unconfigured-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-unconfigured-dark.png new file mode 100644 index 0000000000..6c80703d76 Binary files /dev/null and b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-unconfigured-dark.png differ diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts index 3744d9bc28..b370c059cf 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts @@ -1,6 +1,4 @@ -import { RouterModelsMessageType, providerIdentifiers } from "@roo-code/types" - -import { fetchRouterModels } from "../useRouterModels" +// npx vitest src/components/ui/hooks/__tests__/useRouterModels.spec.ts vi.mock("@src/utils/vscode", () => ({ vscode: { @@ -8,79 +6,135 @@ vi.mock("@src/utils/vscode", () => ({ }, })) +import { providerIdentifiers, RouterModelsMessageType } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +import { fetchRouterModels } from "../useRouterModels" + +const postMessageMock = vi.mocked(vscode.postMessage) + +const postResponse = (requestId: string | undefined, routerModels: Record) => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + routerModels, + values: { requestId }, + }, + }), + ) +} + +const startRequest = (provider?: string, signal?: AbortSignal) => { + const promise = fetchRouterModels(provider, signal) + const requestId = postMessageMock.mock.calls.at(-1)?.[0]?.values?.requestId as string + return { promise, requestId } +} + describe("fetchRouterModels", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() }) - const handlerOf = (spy: { mock: { calls: unknown[][] } }) => - spy.mock.calls.find((args) => args[0] === "message")?.[1] as EventListener + it("posts a provider-filtered request with a request ID and resolves on the matching response", async () => { + const { promise, requestId } = startRequest(providerIdentifiers.mimo) - test("resolves with the router models of the matching provider and removes its listener", async () => { - const addSpy = vi.spyOn(window, "addEventListener") - const removeSpy = vi.spyOn(window, "removeEventListener") + expect(requestId).toBeTruthy() + expect(postMessageMock).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { requestId, provider: providerIdentifiers.mimo }, + }) - const promise = fetchRouterModels(providerIdentifiers.openrouter) - const handler = handlerOf(addSpy) + postResponse(requestId, { mimo: {} }) + await expect(promise).resolves.toEqual({ mimo: {} }) + }) - handler( - new MessageEvent("message", { - data: { - type: RouterModelsMessageType.routerModels, - values: { provider: providerIdentifiers.openrouter }, - routerModels: { [providerIdentifiers.openrouter]: {} }, - }, - }), - ) + it("posts an aggregate request without a provider filter", async () => { + const { promise, requestId } = startRequest() - await expect(promise).resolves.toEqual({ [providerIdentifiers.openrouter]: {} }) - expect(removeSpy).toHaveBeenCalledWith("message", handler) + expect(postMessageMock).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { requestId }, + }) + + postResponse(requestId, {}) + await expect(promise).resolves.toEqual({}) }) - test("on abort, rejects, removes the same message listener, and clears the timeout", async () => { - vi.useFakeTimers() - const addSpy = vi.spyOn(window, "addEventListener") - const removeSpy = vi.spyOn(window, "removeEventListener") - const controller = new AbortController() + it("ignores responses carrying a different or missing request ID", async () => { + const { promise, requestId } = startRequest(providerIdentifiers.mimo) - const promise = fetchRouterModels(providerIdentifiers.openrouter, controller.signal) - const handler = handlerOf(addSpy) - expect(typeof handler).toBe("function") - expect(vi.getTimerCount()).toBe(1) + postResponse("stale-request-id", { mimo: { stale: {} } }) + postResponse(undefined, { mimo: {} }) - controller.abort() + postResponse(requestId, { mimo: { fresh: {} } }) + await expect(promise).resolves.toEqual({ mimo: { fresh: {} } }) + }) + + it("resolves concurrent same-provider requests independently by request ID", async () => { + const first = startRequest(providerIdentifiers.mimo) + const second = startRequest(providerIdentifiers.mimo) - await expect(promise).rejects.toMatchObject({ name: "AbortError" }) - expect(removeSpy).toHaveBeenCalledWith("message", handler) - expect(vi.getTimerCount()).toBe(0) + expect(first.requestId).not.toBe(second.requestId) + + postResponse(second.requestId, { mimo: { second: {} } }) + postResponse(first.requestId, { mimo: { first: {} } }) + + await expect(first.promise).resolves.toEqual({ mimo: { first: {} } }) + await expect(second.promise).resolves.toEqual({ mimo: { second: {} } }) }) - test("rejects at once without adding a listener when the signal is already aborted", async () => { + it("rejects on abort and removes the exact registered listener and timer", async () => { vi.useFakeTimers() - const addSpy = vi.spyOn(window, "addEventListener") + try { + const addSpy = vi.spyOn(window, "addEventListener") + const removeSpy = vi.spyOn(window, "removeEventListener") + const controller = new AbortController() + + const { promise, requestId } = startRequest(providerIdentifiers.mimo, controller.signal) + const registeredHandler = addSpy.mock.calls.find(([eventName]) => eventName === "message")?.[1] + + expect(vi.getTimerCount()).toBe(1) + + controller.abort() + + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + expect(removeSpy).toHaveBeenCalledWith("message", registeredHandler) + expect(vi.getTimerCount()).toBe(0) + + // A stale response for the aborted request must find no listener. + postResponse(requestId, { mimo: {} }) + } finally { + vi.useRealTimers() + } + }) + + it("rejects immediately when the signal is already aborted, without posting", async () => { const controller = new AbortController() controller.abort() - await expect(fetchRouterModels(providerIdentifiers.openrouter, controller.signal)).rejects.toMatchObject({ + await expect(fetchRouterModels(providerIdentifiers.mimo, controller.signal)).rejects.toMatchObject({ name: "AbortError", }) - expect(handlerOf(addSpy)).toBeUndefined() - expect(vi.getTimerCount()).toBe(0) + expect(postMessageMock).not.toHaveBeenCalled() }) - test("on timeout, rejects and removes the same message listener", async () => { + it("times out when no response arrives", async () => { vi.useFakeTimers() - const addSpy = vi.spyOn(window, "addEventListener") - const removeSpy = vi.spyOn(window, "removeEventListener") - - const promise = fetchRouterModels(providerIdentifiers.openrouter) - const handler = handlerOf(addSpy) - const assertion = expect(promise).rejects.toThrow("timed out") + try { + const promise = fetchRouterModels(providerIdentifiers.mimo) - await vi.advanceTimersByTimeAsync(10_000) + vi.advanceTimersByTime(10_000) - await assertion - expect(removeSpy).toHaveBeenCalledWith("message", handler) + await expect(promise).rejects.toThrow("Router models request timed out") + } finally { + vi.useRealTimers() + } }) }) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 4955c3c031..cfeb2f56a1 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -45,6 +45,8 @@ import { vscodeLlmDefaultModelId, moonshotDefaultModelId, moonshotModels, + mimoDefaultModelId, + mimoModels, kimiCodeDefaultModelInfo, lMStudioDefaultModelInfo, opencodeGoDefaultModelInfo, @@ -250,11 +252,18 @@ describe("useSelectedModel", () => { expect(result.current.info).toEqual(opencodeGoDefaultModelInfo) }) - it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot])( + it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot, providerIdentifiers.mimo])( "prefers router data over static data for %s", (provider) => { const modelInfo: ModelInfo = { contextWindow: 42_000, supportsPromptCache: false } - const modelId = provider === providerIdentifiers.deepseek ? "deepseek-v4-pro" : moonshotDefaultModelId + let modelId: string + if (provider === providerIdentifiers.deepseek) { + modelId = "deepseek-v4-pro" + } else if (provider === providerIdentifiers.moonshot) { + modelId = moonshotDefaultModelId + } else { + modelId = mimoDefaultModelId + } mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [provider]: { [modelId]: modelInfo } })) mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) @@ -282,10 +291,17 @@ describe("useSelectedModel", () => { expect(result.current.info?.supportsImages).toBe(true) }) - it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot])( + it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot, providerIdentifiers.mimo])( "falls back to static data when the %s router catalog is null", (provider) => { - const modelId = provider === providerIdentifiers.deepseek ? deepSeekDefaultModelId : moonshotDefaultModelId + let modelId: string + if (provider === providerIdentifiers.deepseek) { + modelId = deepSeekDefaultModelId + } else if (provider === providerIdentifiers.moonshot) { + modelId = moonshotDefaultModelId + } else { + modelId = mimoDefaultModelId + } mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [provider]: null })) mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) @@ -296,12 +312,144 @@ describe("useSelectedModel", () => { expect(result.current.id).toBe(modelId) if (provider === providerIdentifiers.deepseek) { expect(result.current.info).toEqual(deepSeekModels[deepSeekDefaultModelId]) - } else { + } else if (provider === providerIdentifiers.moonshot) { expect(result.current.info).toEqual(moonshotModels[modelId as keyof typeof moonshotModels]) + } else { + expect(result.current.info).toEqual(mimoModels[modelId as keyof typeof mimoModels]) } }, ) + it("mimo: selects a router-only model not present in the static catalog", () => { + // Regression guard: the merged catalog `{ ...mimoModels, ...routerModels.mimo }` must admit + // model IDs that exist ONLY in the router response. A static-only regression would validate + // the configured ID against `mimoModels`, miss it, and silently reset the selection to the + // static default — which the existing MiMo tests (both keyed on `mimoDefaultModelId`) cannot + // catch. This is the only possible guard for that regression. + const routerOnlyModelId = "mimo-router-only-preview" + const routerModelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 262144, + supportsImages: false, + supportsPromptCache: true, + description: "Router-only MiMo model", + } + + // Guard the test premise: if this ID were ever added to the static catalog, the test would + // silently degrade into the "prefers router data" coverage and must fail loudly instead. + expect(mimoModels[routerOnlyModelId as keyof typeof mimoModels]).toBeUndefined() + + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ [providerIdentifiers.mimo]: { [routerOnlyModelId]: routerModelInfo } }), + ) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: routerOnlyModelId }), + { wrapper: createWrapper() }, + ) + + // The router-only ID survives validation instead of resetting to the static default. + expect(result.current.id).toBe(routerOnlyModelId) + expect(result.current.id).not.toBe(mimoDefaultModelId) + // The selection carries the ROUTER metadata, not a static fallback. + expect(result.current.info).toEqual(routerModelInfo) + }) + + // Regression: a settled-but-EMPTY router catalog (the provider returned no + // models) reaches the ready path, where the ID was validated against the merged + // catalog. A saved router-only ID is absent from the static catalog, so it was + // reset to the static default — the same overwrite the unsettled path already + // guards against, and it leaked into ApiOptions' apiModelId sync effect. + it("mimo: preserves a router-only configured ID when the router catalog settles empty", () => { + const routerOnlyModelId = "mimo-router-only-settled-empty" + + // Guard the premise: an ID in the static catalog would not exercise the + // preserve branch at all. + expect(mimoModels[routerOnlyModelId as keyof typeof mimoModels]).toBeUndefined() + + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.mimo]: {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: routerOnlyModelId }), + { wrapper: createWrapper() }, + ) + + // The saved ID survives; info stays undefined because no catalog describes it, + // mirroring the unsettled-preservation contract. + expect(result.current.id).toBe(routerOnlyModelId) + expect(result.current.info).toBeUndefined() + }) + + // The preserve branch must not swallow IDs the static catalog already knows: + // those take the normal validation path and keep their curated spec. Without + // that clause the branch would return `{ id, info: undefined }` for a + // perfectly well-described model, blanking capability UI such as the + // context-window display. The ID assertion alone cannot see the difference, + // so the metadata assertion is the one that pins the clause. + it("mimo: keeps the curated spec for a static-catalog ID when the router catalog settles empty", () => { + const staticModelId = "mimo-v2.6-flash" + + // Guard the premise: an ID missing from the static catalog would exercise + // the preserve branch instead of the validation path under test. + expect(Object.hasOwn(mimoModels, staticModelId)).toBe(true) + + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.mimo]: {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: staticModelId }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(staticModelId) + expect(result.current.info).toEqual(mimoModels[staticModelId as keyof typeof mimoModels]) + expect(result.current.info?.contextWindow).toBe(1_048_576) + }) + + // The `configuredId &&` clause keeps the empty-string convention on this + // branch too: "" means "unset", so it resolves to the default instead of + // being preserved as a phantom selection. Unlike the unsettled fixture, this + // one reaches the ready path with a settled-but-empty catalog. + it("mimo: treats an empty configured ID as unset when the router catalog settles empty", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.mimo]: {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: "" }), + { + wrapper: createWrapper(), + }, + ) + + expect(result.current.id).toBe(mimoDefaultModelId) + expect(result.current.info).toEqual(mimoModels[mimoDefaultModelId]) + }) + + // Regression: both the ID validation and the metadata lookup used plain bracket + // reads, so an ID naming an Object.prototype member was "found" in the catalog and + // surfaced as a model with no metadata instead of falling back to the static spec. + it.each(["constructor", "toString", "valueOf", "__proto__"])( + "mimo: falls back to the static default for the prototype-key ID %s", + (prototypeKey) => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.mimo]: {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: prototypeKey }), + { wrapper: createWrapper() }, + ) + + // A settled-but-empty router catalog reaches the ready path, where the ID is + // validated against the merged catalog. The ID is not in it, so validation + // resets to the default and the metadata is the default's curated spec + // (never an inherited Object.prototype member). + expect(result.current.id).toBe(mimoDefaultModelId) + expect(result.current.info).toEqual(mimoModels[mimoDefaultModelId]) + }, + ) + it("uses router data for Poe", () => { const modelInfo: ModelInfo = { contextWindow: 42_000, supportsPromptCache: false } mockUseRouterModels.mockReturnValue( @@ -657,6 +805,109 @@ describe("useSelectedModel", () => { }) }) + describe("static-catalog dynamic providers while router models are unavailable", () => { + beforeEach(() => { + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + }) + + // The extension host answers single-provider router requests with `{}` + // when the provider has no fetch candidate (e.g. no API key saved yet), + // so the response carries no entry for that provider and the hook stays + // not-ready. The selection must still resolve from the shipped static + // catalog: TaskHeader renders `contextWindow || 1` and showed "… / 1" + // while `info` was undefined. + it.each([ + [providerIdentifiers.deepseek, deepSeekDefaultModelId, deepSeekModels[deepSeekDefaultModelId]], + [providerIdentifiers.moonshot, moonshotDefaultModelId, moonshotModels[moonshotDefaultModelId]], + [providerIdentifiers.mimo, mimoDefaultModelId, mimoModels[mimoDefaultModelId]], + ])( + "falls back to the static %s catalog when the router response has no entry", + (provider, expectedId, expectedInfo) => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({})) + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel({ apiProvider: provider }), { wrapper }) + + expect(result.current.id).toBe(expectedId) + expect(result.current.info).toEqual(expectedInfo) + }, + ) + + it("keeps the configured MiMo V2.6 model specs while router models are still loading", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isLoading: true })) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.mimo, + apiModelId: "mimo-v2.6-flash", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe("mimo-v2.6-flash") + expect(result.current.info).toEqual(mimoModels["mimo-v2.6-flash"]) + expect(result.current.info?.contextWindow).toBe(1_048_576) + expect(result.current.info?.maxTokens).toBe(131_072) + }) + + it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot, providerIdentifiers.mimo])( + "%s: preserves a router-only configured ID while the router query is unsettled", + (provider) => { + const routerOnlyModelId = `${provider}-router-only-unsettled` + // Loading (no cached data) and a failed fetch with no cache both leave + // the query unsettled. The configured ID exists only in the router + // catalog, so substituting the static default here would leak into + // ApiOptions' apiModelId sync effect and bake the default into saved + // settings on Save. Info stays undefined until router data lands. + for (const queryState of [{ isLoading: true }, { isError: true }] as const) { + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, queryState)) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: provider, apiModelId: routerOnlyModelId }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(routerOnlyModelId) + expect(result.current.info).toBeUndefined() + } + }, + ) + + it.each([ + [providerIdentifiers.deepseek, deepSeekDefaultModelId], + [providerIdentifiers.moonshot, moonshotDefaultModelId], + [providerIdentifiers.mimo, mimoDefaultModelId], + ])("%s: treats an empty configured ID as unset while the router query is unsettled", (provider, defaultId) => { + // Repo convention (cf. router-provider fallbacks) is `||`, so an + // empty-string apiModelId resolves to the default instead of being + // preserved as a phantom selection. + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isLoading: true })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook(() => useSelectedModel({ apiProvider: provider, apiModelId: "" }), { + wrapper: createWrapper(), + }) + + expect(result.current.id).toBe(defaultId) + }) + + it.each([ + [providerIdentifiers.deepseek, deepSeekDefaultModelId, deepSeekModels[deepSeekDefaultModelId]], + [providerIdentifiers.moonshot, moonshotDefaultModelId, moonshotModels[moonshotDefaultModelId]], + [providerIdentifiers.mimo, mimoDefaultModelId, mimoModels[mimoDefaultModelId]], + ])("keeps the static %s catalog when the router query has errored", (provider, expectedId, expectedInfo) => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isError: true })) + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel({ apiProvider: provider }), { wrapper }) + + expect(result.current.isError).toBe(true) + expect(result.current.id).toBe(expectedId) + expect(result.current.info).toEqual(expectedInfo) + }) + }) + describe("default behavior", () => { it("should return OpenRouter default when no configuration is provided", () => { mockUseRouterModels.mockReturnValue({ @@ -2238,4 +2489,93 @@ describe("useSelectedModel", () => { expect(result.current.info).toEqual(moonshotModels["kimi-k2-turbo-preview"]) }) }) + + describe("mimo provider", () => { + beforeEach(() => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ mimo: {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + }) + + it("should fallback to default when model ID is not in static or router models", () => { + // The router catalog must be POPULATED for this to be an invalid selection: + // a settled-but-empty catalog carries no information about which IDs exist, + // so the hook preserves the saved ID instead (see the router-only + // settled-empty regression above). Listing other models is what makes the + // configured ID provably absent. + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ + [providerIdentifiers.mimo]: { + "mimo-v2.6-flash": { contextWindow: 1_048_576, supportsPromptCache: true, maxTokens: 131_072 }, + }, + }), + ) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.mimo, + apiModelId: "non-existent-model", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe(mimoDefaultModelId) + expect(result.current.info).toEqual(mimoModels[mimoDefaultModelId]) + }) + + // Saved users: a configuration persisted before the V2.5 retirement still names + // a retired ID, and the static catalog no longer describes it. While the router + // catalog is unsettled, the selection is PRESERVED with `info: undefined` -- the + // documented fallback contract of getStaticCatalogSelection. Capability UI falls + // back to its own defaults for that turn instead of showing the default model's + // numbers, and the saved ID is never rewritten behind the user's back. Once the + // router catalog settles, validation takes over (see the populated-catalog test + // below), which is why the ID is not silently migrated here. + it("mimo: preserves a retired V2.5 ID with undefined info while the router catalog is unsettled", () => { + const retiredModelId = "mimo-v2.5" + + // Guard the premise: the ID must be absent from the static catalog, which is + // what routes it through the undefined-info path under test. + expect(Object.hasOwn(mimoModels, retiredModelId)).toBe(false) + + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isLoading: true })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: retiredModelId }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(retiredModelId) + expect(result.current.info).toBeUndefined() + }) + + // The settled half of the same saved-user path: once the router catalog answers + // with other IDs, the retired ID is provably not served, so validation resets the + // selection to the family default and capability UI renders the default's real + // numbers. This is what the user sees in the picker and the context-window + // display; the stored ID is only rewritten by ApiOptions' sync effect, and the + // handler still falls back to the same default spec for requests. + it("mimo: resets a retired V2.5 ID to the default once the router catalog lists other models", () => { + const retiredModelId = "mimo-v2.5-pro" + + expect(Object.hasOwn(mimoModels, retiredModelId)).toBe(false) + + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ + [providerIdentifiers.mimo]: { + "mimo-v2.6-flash": { contextWindow: 1_048_576, supportsPromptCache: false, maxTokens: 131_072 }, + }, + }), + ) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: retiredModelId }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(mimoDefaultModelId) + expect(result.current.info).toEqual(mimoModels[mimoDefaultModelId]) + }) + }) }) diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts index 7557562583..f0bfa0137b 100644 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ b/webview-ui/src/components/ui/hooks/useRouterModels.ts @@ -16,7 +16,15 @@ type UseRouterModelsOptions = { export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) => new Promise((resolve, reject) => { + // Correlate the response by a unique request ID instead of the provider + // alone: a remount can issue a replacement request for the same provider + // while a stale response to an aborted request is still in flight, and + // provider-only matching would let that stale response resolve the new + // query. The extension host echoes the ID in the routerModels response. + const requestId = crypto.randomUUID() + const cleanup = () => { + clearTimeout(timeout) signal?.removeEventListener("abort", onAbort) if (typeof window !== "undefined") { window.removeEventListener("message", handler) @@ -24,7 +32,6 @@ export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) } const onAbort = () => { - clearTimeout(timeout) cleanup() reject(new DOMException("Router models request aborted", "AbortError")) } @@ -38,15 +45,13 @@ export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) const message: ExtensionMessage = event.data if (message.type === RouterModelsMessageType.routerModels) { - const msgProvider = message?.values?.provider as string | undefined + const msgRequestId = message?.values?.requestId as string | undefined - // Verify response matches request - if (provider !== msgProvider) { - // Not our response; ignore and wait for the matching one + // Verify the response belongs to this exact request. + if (msgRequestId !== requestId) { return } - clearTimeout(timeout) cleanup() if (message.routerModels) { @@ -57,6 +62,10 @@ export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) } } + // React Query cancels the queryFn when the consuming component unmounts or + // the query is removed. Honour that signal so the window listener and the + // timeout do not outlive the request (and a stale response can never + // resolve a disposed query). if (signal?.aborted) { onAbort() return @@ -64,11 +73,10 @@ export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) signal?.addEventListener("abort", onAbort, { once: true }) window.addEventListener("message", handler) - if (provider) { - vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, values: { provider } }) - } else { - vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels }) - } + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + values: { requestId, ...(provider ? { provider } : {}) }, + }) }) export const useRouterModels = (opts: UseRouterModelsOptions = {}) => { diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 9eca0cc0f3..8ef91cc9b5 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -7,9 +7,13 @@ import { anthropicModels, bedrockModels, deepSeekModels, + deepSeekDefaultModelId, moonshotModels, + moonshotDefaultModelId, minimaxModels, mimoModels, + mimoDefaultModelId, + getMimoModelInfo, geminiModels, mistralModels, openAiModelInfoSaneDefaults, @@ -55,13 +59,71 @@ import { useVsCodeLmModels, type VsCodeLmModels } from "./useVsCodeLmModels" /** * Helper to get a validated model ID for dynamic providers. * Returns the configured model ID if it exists in the available models, otherwise returns the default. + * + * Uses an own-property check rather than a truthy read: router catalogs carry model + * IDs straight from provider responses, so a configured ID such as `constructor` + * would otherwise be "found" via Object.prototype and kept as a valid selection. */ function getValidatedModelId( configuredId: string | undefined, availableModels: ModelRecord | undefined, defaultModelId: string, ): string { - return configuredId && availableModels?.[configuredId] ? configuredId : defaultModelId + return configuredId && availableModels && Object.hasOwn(availableModels, configuredId) + ? configuredId + : defaultModelId +} + +/** + * Dynamic providers whose shipped static catalog carries complete specs + * (context window, max tokens). For these, resolving from the static catalog + * while router models are unavailable is safe: the ready-path merge in + * getSelectedModel already prefers router data and falls back to the same + * static entries. + */ +type StaticCatalogDynamicProvider = + typeof providerIdentifiers.deepseek | typeof providerIdentifiers.moonshot | typeof providerIdentifiers.mimo + +const isStaticCatalogDynamicProvider = (provider: ProviderName): provider is StaticCatalogDynamicProvider => + provider === providerIdentifiers.deepseek || + provider === providerIdentifiers.moonshot || + provider === providerIdentifiers.mimo + +/** + * Resolves a selection from the shipped static catalog while router models are + * unavailable (first load, missing API key, or a query-layer fetch failure). + * Without this fallback these providers returned `info: undefined` until the + * router response arrived, and capability-driven UI such as TaskHeader's + * context window rendered a bogus window size of 1 (`contextWindow || 1`). + * Once router data arrives, getSelectedModel revalidates against the merged + * catalog and takes over. + * + * The configured ID is preserved even when it is absent from the static + * catalog (a router-only model): while the query is unsettled, substituting + * the default here would leak into ApiOptions' apiModelId sync effect and + * silently bake the default into saved settings. Info stays undefined for IDs + * the static catalog cannot describe; capability UI falls back to its own + * defaults until router data lands. + */ +function getStaticCatalogSelection( + provider: StaticCatalogDynamicProvider, + apiConfiguration: ProviderSettings, +): { id: string; info: ModelInfo | undefined } { + const configuredId = apiConfiguration.apiModelId + switch (provider) { + case providerIdentifiers.deepseek: { + const id = configuredId || deepSeekDefaultModelId + return { id, info: deepSeekModels[id as keyof typeof deepSeekModels] } + } + case providerIdentifiers.moonshot: { + const id = configuredId || moonshotDefaultModelId + return { id, info: moonshotModels[id as keyof typeof moonshotModels] } + } + case providerIdentifiers.mimo: { + const id = configuredId || mimoDefaultModelId + return { id, info: getMimoModelInfo(id) } + } + } } /** @@ -166,13 +228,15 @@ export const useSelectedModel = (apiConfiguration: ProviderSettings) => { getProviderDefaultModelId(providerIdentifiers.kimiCode, apiConfiguration), info: kimiCodeDefaultModelInfo, } - : { - id: getProviderDefaultModelId( - activeProvider ?? providerIdentifiers.openrouter, - apiConfiguration ?? {}, - ), - info: undefined, - } + : apiConfiguration && activeProvider && isStaticCatalogDynamicProvider(activeProvider) + ? getStaticCatalogSelection(activeProvider, apiConfiguration) + : { + id: getProviderDefaultModelId( + activeProvider ?? providerIdentifiers.openrouter, + apiConfiguration ?? {}, + ), + info: undefined, + } return { provider, @@ -394,9 +458,33 @@ function getSelectedModel({ return { id, info } } case providerIdentifiers.mimo: { - const id = apiConfiguration.apiModelId ?? defaultModelId - const info = mimoModels[id as keyof typeof mimoModels] ?? mimoModels["mimo-v2.5-pro"] - return { id, info } + const routerCatalog = routerModels[providerIdentifiers.mimo] + const availableModels = routerCatalog ? { ...mimoModels, ...routerCatalog } : mimoModels + const configuredId = apiConfiguration.apiModelId + // A settled-but-empty router catalog carries no information about which + // IDs exist: the provider answered, but listed nothing. Validating the + // saved ID against the static catalog alone would reset a router-only + // selection to the default and leak that default into ApiOptions' + // apiModelId sync effect, so preserve it here, mirroring the unsettled + // path. Prototype-safe on both sides: an ID naming an Object.prototype + // member has no own entry in either catalog, so it must still fall back + // to the static spec instead of being preserved as a phantom selection. + const settledEmptyCatalog = !!routerCatalog && Object.keys(routerCatalog).length === 0 + if ( + settledEmptyCatalog && + configuredId && + !Object.hasOwn(mimoModels, configuredId) && + !Object.hasOwn(Object.prototype, configuredId) + ) { + return { id: configuredId, info: undefined } + } + const id = getValidatedModelId(configuredId, availableModels, defaultModelId) + // Prototype-safe on both sides: the router catalog carries IDs straight + // from the provider's /models response, so `catalog["constructor"]` + // would otherwise resolve to an inherited member and surface as a model + // with no metadata instead of falling back to the static spec. + const routerInfo = routerCatalog && Object.hasOwn(routerCatalog, id) ? routerCatalog[id] : undefined + return { id, info: routerInfo ?? getMimoModelInfo(id) } } case providerIdentifiers.zai: { const apiLine = apiConfiguration.zaiApiLine ?? "international_coding" diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index d39d8edd6a..77721c53f4 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -485,7 +485,8 @@ "hint": "Si us plau, torneu a obrir la configuració per veure els models més recents.", "loading": "Actualitzant la llista de models...", "success": "Llista de models actualitzada correctament!", - "error": "No s'ha pogut actualitzar la llista de models. Si us plau, torneu-ho a provar." + "error": "No s'ha pogut actualitzar la llista de models. Si us plau, torneu-ho a provar.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Obtenir clau API de Requesty", "getRequestyBaseUrl": "URL base", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 983db1df5d..598481f8b6 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -485,7 +485,8 @@ "hint": "Bitte öffne die Einstellungen erneut, um die neuesten Modelle zu sehen.", "loading": "Modellliste wird aktualisiert...", "success": "Modellliste erfolgreich aktualisiert!", - "error": "Fehler beim Aktualisieren der Modellliste. Bitte versuche es erneut." + "error": "Fehler beim Aktualisieren der Modellliste. Bitte versuche es erneut.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty API-Schlüssel erhalten", "getRequestyBaseUrl": "Basis-URL", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index ea924ce79e..a7da4ce81f 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -565,7 +565,8 @@ "hint": "Please reopen the settings to see the latest models.", "loading": "Refreshing models list...", "success": "Models list refreshed successfully!", - "error": "Failed to refresh models list. Please try again." + "error": "Failed to refresh models list. Please try again.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Create new Requesty API Key", "getRequestyBaseUrl": "Base URL", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 57612bf3e5..4385fcfb70 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -485,7 +485,8 @@ "hint": "Por favor, vuelve a abrir la configuración para ver los modelos más recientes.", "loading": "Actualizando lista de modelos...", "success": "¡Lista de modelos actualizada correctamente!", - "error": "Error al actualizar la lista de modelos. Por favor, inténtalo de nuevo." + "error": "Error al actualizar la lista de modelos. Por favor, inténtalo de nuevo.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Obtener clave API de Requesty", "getRequestyBaseUrl": "URL base", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index a91a5137d3..1ca2bdae82 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -485,7 +485,8 @@ "hint": "Veuillez rouvrir les paramètres pour voir les modèles les plus récents.", "loading": "Actualisation de la liste des modèles...", "success": "Liste des modèles actualisée avec succès !", - "error": "Échec de l'actualisation de la liste des modèles. Veuillez réessayer." + "error": "Échec de l'actualisation de la liste des modèles. Veuillez réessayer.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Obtenir la clé API Requesty", "getRequestyBaseUrl": "URL de base", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index bc6e973623..75da251144 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -485,7 +485,8 @@ "hint": "नवीनतम मॉडल देखने के लिए कृपया सेटिंग्स को फिर से खोलें।", "loading": "मॉडल सूची अपडेट हो रही है...", "success": "मॉडल सूची सफलतापूर्वक अपडेट की गई!", - "error": "मॉडल सूची अपडेट करने में विफल। कृपया पुनः प्रयास करें।" + "error": "मॉडल सूची अपडेट करने में विफल। कृपया पुनः प्रयास करें।", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty API कुंजी प्राप्त करें", "getRequestyBaseUrl": "बेस URL", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 3ed57f373e..d0cea24473 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -485,7 +485,8 @@ "hint": "Silakan buka kembali pengaturan untuk melihat model terbaru.", "loading": "Merefresh daftar model...", "success": "Daftar model berhasil direfresh!", - "error": "Gagal merefresh daftar model. Silakan coba lagi." + "error": "Gagal merefresh daftar model. Silakan coba lagi.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Dapatkan Requesty API Key", "getRequestyBaseUrl": "Base URL", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index b708304e63..99f1a3a540 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -485,7 +485,8 @@ "hint": "Riapri le impostazioni per vedere i modelli più recenti.", "loading": "Aggiornamento dell'elenco dei modelli...", "success": "Elenco dei modelli aggiornato con successo!", - "error": "Impossibile aggiornare l'elenco dei modelli. Riprova." + "error": "Impossibile aggiornare l'elenco dei modelli. Riprova.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Ottieni chiave API Requesty", "getRequestyBaseUrl": "URL base", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index f6959b50dc..8b22bc3fba 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -485,7 +485,8 @@ "hint": "最新のモデルを表示するには設定を再度開いてください。", "loading": "モデルリストを更新中...", "success": "モデルリストが正常に更新されました!", - "error": "モデルリストの更新に失敗しました。もう一度お試しください。" + "error": "モデルリストの更新に失敗しました。もう一度お試しください。", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty APIキーを取得", "getRequestyBaseUrl": "ベースURL", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 6cadbd8a3b..43bd3a1953 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -485,7 +485,8 @@ "hint": "최신 모델을 보려면 설정을 다시 열어주세요.", "loading": "모델 목록 새로고침 중...", "success": "모델 목록이 성공적으로 새로고침되었습니다!", - "error": "모델 목록 새로고침에 실패했습니다. 다시 시도해 주세요." + "error": "모델 목록 새로고침에 실패했습니다. 다시 시도해 주세요.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty API 키 받기", "getRequestyBaseUrl": "기본 URL", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 992e9c53e0..b528bbd23e 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -485,7 +485,8 @@ "hint": "Open de instellingen opnieuw om de nieuwste modellen te zien.", "loading": "Modellenlijst wordt vernieuwd...", "success": "Modellenlijst succesvol vernieuwd!", - "error": "Kan modellenlijst niet vernieuwen. Probeer het opnieuw." + "error": "Kan modellenlijst niet vernieuwen. Probeer het opnieuw.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty API-sleutel ophalen", "getRequestyBaseUrl": "Basis-URL", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 459675b0e1..bf3f396d8c 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -485,7 +485,8 @@ "hint": "Proszę ponownie otworzyć ustawienia, aby zobaczyć najnowsze modele.", "loading": "Odświeżanie listy modeli...", "success": "Lista modeli została pomyślnie odświeżona!", - "error": "Nie udało się odświeżyć listy modeli. Spróbuj ponownie." + "error": "Nie udało się odświeżyć listy modeli. Spróbuj ponownie.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Uzyskaj klucz API Requesty", "getRequestyBaseUrl": "Bazowy URL", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 500babaaa8..23b99f2d45 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -485,7 +485,8 @@ "hint": "Por favor, reabra as configurações para ver os modelos mais recentes.", "loading": "Atualizando lista de modelos...", "success": "Lista de modelos atualizada com sucesso!", - "error": "Falha ao atualizar a lista de modelos. Por favor, tente novamente." + "error": "Falha ao atualizar a lista de modelos. Por favor, tente novamente.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Obter chave de API Requesty", "getRequestyBaseUrl": "URL Base", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 053ff0684d..b1a44a0f6c 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -485,7 +485,8 @@ "hint": "Пожалуйста, откройте настройки заново, чтобы увидеть последние модели.", "loading": "Обновление списка моделей...", "success": "Список моделей успешно обновлен!", - "error": "Не удалось обновить список моделей. Пожалуйста, попробуйте снова." + "error": "Не удалось обновить список моделей. Пожалуйста, попробуйте снова.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Получить Requesty API-ключ", "getRequestyBaseUrl": "Базовый URL", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index b37f28d44d..2d77f8d71d 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -485,7 +485,8 @@ "hint": "En son modelleri görmek için lütfen ayarları yeniden açın.", "loading": "Model listesi yenileniyor...", "success": "Model listesi başarıyla yenilendi!", - "error": "Model listesi yenilenemedi. Lütfen tekrar deneyin." + "error": "Model listesi yenilenemedi. Lütfen tekrar deneyin.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty API Anahtarı Al", "getRequestyBaseUrl": "Temel URL", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 94843217ae..e962814ced 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -485,7 +485,8 @@ "hint": "Vui lòng mở lại cài đặt để xem các mô hình mới nhất.", "loading": "Đang làm mới danh sách mô hình...", "success": "Danh sách mô hình đã được làm mới thành công!", - "error": "Không thể làm mới danh sách mô hình. Vui lòng thử lại." + "error": "Không thể làm mới danh sách mô hình. Vui lòng thử lại.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Lấy khóa API Requesty", "getRequestyBaseUrl": "URL cơ sở", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 8be0fe1532..2f8da4c3f5 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -485,7 +485,8 @@ "hint": "请重新打开设置以查看最新模型。", "loading": "正在刷新模型列表...", "success": "模型列表刷新成功!", - "error": "刷新模型列表失败。请重试。" + "error": "刷新模型列表失败。请重试。", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "获取 Requesty API 密钥", "getRequestyBaseUrl": "基础 URL", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 65eb5c67a5..a035660460 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -512,7 +512,8 @@ "hint": "請重新開啟設定以查看最新模型。", "loading": "正在重新整理模型列表...", "success": "模型列表重新整理成功!", - "error": "重新整理模型列表失敗。請再試一次。" + "error": "重新整理模型列表失敗。請再試一次。", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "取得 Requesty API 金鑰", "getRequestyBaseUrl": "基礎 URL", diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index bf25faf7f2..5cf3148638 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -64,6 +64,7 @@ describe("Model Validation Functions", () => { "zoo-gateway": {}, "kimi-code": {}, moonshot: {}, + mimo: {}, } const allowAllOrganization: OrganizationAllowList = {