From 8be27ac11489c2bbaebd5838c8e16a97821b2303 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 07:17:01 +0900 Subject: [PATCH 01/16] feat(mimo): fetch MiMo model catalog dynamically and add V2.6 series Xiaomi released the MiMo-V2.6 series (mimo-v2.6-pro, mimo-v2.6-flash, mimo-v2.6-pro-ultraspeed) on 2026-09-22, and mimo-v2.5-pro / mimo-v2.5 stop working on 2026-10-21 10:00 (GMT+8). The dedicated MiMo provider only offered a hardcoded catalog, so newer models were invisible unless users reconfigured MiMo as an OpenAI-compatible provider. Wire MiMo into the router-model pipeline so the picker populates from the account's GET {baseUrl}/models response, mirroring DeepSeek/Moonshot: - add mimo to dynamicProviders and dynamicProviderExtras - getMimoModels fetcher merges the API response with static mimoModels specs; unknown models get MiMo-family defaults with preserveReasoning - requestRouterModels fetches MiMo when an API key exists, honoring unsaved key/baseUrl form values (cluster URL selects the cache scope) - settings ModelPicker and useSelectedModel merge the fetched catalog with the static fallback, so post-V2.6 releases appear without an extension update Also update the static catalog: V2.6 entries with official pay-as-you-go pricing (https://mimo.mi.com/docs/en-US/price/pay-as-you-go), the v2.5 entries synced to the same page (flat pricing, 256K long-context multiplier removed), and the default model moved off the EOL'd mimo-v2.5-pro. Context windows (1M) and max output (128K) follow the published V2.5-line specs; the pricing page publishes no V2.6 spec table. --- .../__tests__/provider-identifiers.test.ts | 1 + packages/types/src/provider-settings.ts | 1 + packages/types/src/providers/mimo.ts | 81 ++++++--- src/api/providers/__tests__/mimo.spec.ts | 45 ++++- .../providers/fetchers/__tests__/mimo.spec.ts | 169 ++++++++++++++++++ src/api/providers/fetchers/mimo.ts | 90 ++++++++++ src/api/providers/fetchers/modelCache.ts | 5 + .../webview/__tests__/ClineProvider.spec.ts | 3 + ...webviewMessageHandler.routerModels.spec.ts | 123 +++++++++++++ .../__tests__/webviewMessageHandler.spec.ts | 3 + src/core/webview/webviewMessageHandler.ts | 25 +++ src/shared/api.ts | 1 + .../src/components/settings/ApiOptions.tsx | 25 ++- .../providers/__tests__/NanoGPT.spec.tsx | 1 + .../hooks/__tests__/useSelectedModel.spec.ts | 28 ++- .../components/ui/hooks/useSelectedModel.ts | 10 +- .../src/utils/__tests__/validate.spec.ts | 1 + 17 files changed, 568 insertions(+), 44 deletions(-) create mode 100644 src/api/providers/fetchers/__tests__/mimo.spec.ts create mode 100644 src/api/providers/fetchers/mimo.ts diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index d9ba4fb799..4d82659e4b 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -108,6 +108,7 @@ describe("provider identifiers", () => { providerIdentifiers.poe, providerIdentifiers.deepseek, providerIdentifiers.moonshot, + providerIdentifiers.mimo, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.nanogpt, diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 0b898f1b66..2bc5536fcd 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -70,6 +70,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/mimo.ts b/packages/types/src/providers/mimo.ts index debd0cbefc..664a23d8ff 100644 --- a/packages/types/src/providers/mimo.ts +++ b/packages/types/src/providers/mimo.ts @@ -1,8 +1,12 @@ import type { ModelInfo } from "../model.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 are deprecated on 2026-10-21 10:00 (GMT+8) and +// their model names will stop working. They stay in the catalog as a fallback +// until the EOL date passes; mimo-v2.6-* entries carry the V2.6 series. // // NOTE: mimo-v2-flash is not included here. Its thinking mode defaults to // disabled and it doesn't reliably handle reasoning_content passthrough @@ -12,28 +16,60 @@ import type { ModelInfo } from "../model.js" // 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.6-pro": { + maxTokens: 131_072, + contextWindow: 1_048_576, + supportsImages: true, // V2.6 series is full-modality (text, image, audio, video) + supportsPromptCache: false, + preserveReasoning: true, + 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 + description: + "MiMo V2.6 Pro - Xiaomi's flagship omni-modal reasoning model with 1M context, deep thinking, tool calling, and structured output.", + }, + "mimo-v2.6-flash": { + maxTokens: 131_072, + contextWindow: 1_048_576, + supportsImages: true, // Full-modality: text, image, audio, video input + supportsPromptCache: false, + preserveReasoning: true, + 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 + description: + "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.", + }, "mimo-v2.5-pro": { maxTokens: 131_072, contextWindow: 1_048_576, supportsImages: false, // Pro series is text-only 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.5 Pro - Deprecated on 2026-10-21. Xiaomi's flagship reasoning model with 1M context, deep thinking, tool calling, and structured output.", }, "mimo-v2.5": { maxTokens: 131_072, @@ -41,19 +77,12 @@ export const mimoModels = { supportsImages: true, // Full-modal: 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.5 - Deprecated on 2026-10-21. Full-modal understanding model (text, image, audio, video) with 1M context, deep thinking, tool calling, and structured output.", }, } as const satisfies Record diff --git a/src/api/providers/__tests__/mimo.spec.ts b/src/api/providers/__tests__/mimo.spec.ts index 65e78f4673..a4c1882146 100644 --- a/src/api/providers/__tests__/mimo.spec.ts +++ b/src/api/providers/__tests__/mimo.spec.ts @@ -85,23 +85,58 @@ describe("MimoHandler", () => { expect(model.id).toBe("mimo-v2.5-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) }) it("should return correct model info for mimo-v2.5", () => { const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.5" }) 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.info.inputPrice).toBe(0.14) + expect(model.info.outputPrice).toBe(0.28) + }) + + 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(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 default to mimo-v2.6-pro", () => { + const h = new MimoHandler({ ...mockOptions, apiModelId: undefined }) + const model = h.getModel() + 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]) }) }) 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..ef67922c55 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -0,0 +1,169 @@ +import { mimoModels } from "@roo-code/types" + +import { getMimoModels } 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" }], + }), + }) 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"]) + }) + + 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", + }) + }) + + 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("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("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/mimo.ts b/src/api/providers/fetchers/mimo.ts new file mode 100644 index 0000000000..c3833cfced --- /dev/null +++ b/src/api/providers/fetchers/mimo.ts @@ -0,0 +1,90 @@ +import type { ModelRecord } from "@roo-code/types" +import { mimoModels } from "@roo-code/types" + +import { DEFAULT_HEADERS } from "../constants" + +/** + * 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 = (baseUrl || "https://token-plan-sgp.xiaomimimo.com/v1").replace(/\/+$/, "") + const url = `${base}/models` + + const headers: Record = { + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + } + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + const response = await fetch(url, { + headers, + signal: opts?.signal, + }) + + if (!response.ok) { + let errorBody = "" + try { + errorBody = await response.text() + } catch { + errorBody = "(unable to read response body)" + } + + console.error(`[getMimoModels] HTTP error:`, { + status: response.status, + statusText: response.statusText, + url, + body: errorBody, + }) + + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.json() + + if (!data?.data || !Array.isArray(data.data)) { + console.error("[getMimoModels] Unexpected response format:", 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) + + for (const model of data.data) { + const modelId = typeof model.id === "string" && model.id ? model.id : null + if (!modelId) continue + + const knownSpecs = mimoModels[modelId as keyof typeof mimoModels] + + if (knownSpecs) { + models[modelId] = { ...knownSpecs } + } else { + models[modelId] = { + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: `MiMo model: ${modelId}`, + } + } + } + + return models +} diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 6f0898c71b..fcce580e7f 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -33,6 +33,7 @@ import { getLMStudioModels } from "./lmstudio" import { getPoeModels } from "./poe" import { getDeepSeekModels } from "./deepseek" import { getMoonshotModels } from "./moonshot" +import { getMimoModels } from "./mimo" import { getZooGatewayModels } from "./zoo-gateway" import { getKimiCodeModels } from "./kimi-code" @@ -105,6 +106,7 @@ const URL_SCOPED_PROVIDERS: ReadonlySet = new Set([ providerIdentifiers.poe, providerIdentifiers.deepseek, providerIdentifiers.moonshot, + providerIdentifiers.mimo, providerIdentifiers.ollama, providerIdentifiers.lmstudio, providerIdentifiers.requesty, @@ -304,6 +306,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions, signal?: Abort case providerIdentifiers.moonshot: models = await getMoonshotModels(options.baseUrl, options.apiKey, ...fetchOpts) break + case providerIdentifiers.mimo: + models = await getMimoModels(options.baseUrl, options.apiKey, ...fetchOpts) + break case providerIdentifiers.zooGateway: models = await getZooGatewayModels({ zooSessionToken: options.apiKey, zooGatewayBaseUrl: options.baseUrl }) break diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 97c4dd877e..5fe417b395 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3799,6 +3799,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, @@ -3855,6 +3856,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, @@ -3956,6 +3958,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 5a4b3e7be3..337aba0615 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -132,14 +132,17 @@ 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(getModelsMock).not.toHaveBeenCalledWith( expect.objectContaining({ provider: providerIdentifiers.moonshot }), ) + expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: providerIdentifiers.mimo })) }) it("fetches DeepSeek models when stored DeepSeek credentials exist", async () => { @@ -586,6 +589,126 @@ 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", + }) + + 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("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, true) + expect(getModelsMock).toHaveBeenCalledWith(mimoOptions) + + 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, + }) + }) + 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 4c2a301965..dde4eb3dbe 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -574,6 +574,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, @@ -822,6 +823,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, @@ -887,6 +889,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 34a35ea3ca..76bd2ab6ae 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1130,6 +1130,7 @@ export const webviewMessageHandler = async ( [providerIdentifiers.poe]: {}, [providerIdentifiers.deepseek]: {}, [providerIdentifiers.moonshot]: {}, + [providerIdentifiers.mimo]: {}, [providerIdentifiers.opencodeGo]: {}, [providerIdentifiers.kenari]: {}, [providerIdentifiers.nanogpt]: {}, @@ -1268,6 +1269,30 @@ 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 + const mimoBaseUrl = message?.values?.mimoBaseUrl ?? apiConfiguration.mimoBaseUrl + + if (mimoApiKey) { + if (message?.values?.mimoApiKey || message?.values?.mimoBaseUrl) { + await flushModels( + { provider: providerIdentifiers.mimo, apiKey: mimoApiKey, baseUrl: mimoBaseUrl }, + true, + ) + } + + candidates.push({ + key: providerIdentifiers.mimo, + options: { + provider: providerIdentifiers.mimo, + apiKey: mimoApiKey, + baseUrl: mimoBaseUrl, + }, + }) + } + // Opencode Go's /models endpoint is public — it returns the full model list with no // Authorization header — so it's fetched unconditionally like openrouter/vercel-ai-gateway // above. Gating it behind a key meant the picker stayed empty (and fell back to the default diff --git a/src/shared/api.ts b/src/shared/api.ts index d301ee68b9..e2fbaa2fdf 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -192,6 +192,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.nanogpt]: {} as { apiKey?: string }, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 714b507774..195d0d79a2 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -745,11 +745,26 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} defaultModelId={getDefaultModelIdForProvider(activeSelectedProvider, apiConfiguration)} - models={getStaticModelsForProvider( - activeSelectedProvider, - t("settings:labels.useCustomArn"), - apiConfiguration, - )} + models={ + // MiMo is a dynamic provider: merge the host-fetched model + // catalog into the static fallback so models newer than the + // shipped catalog (e.g. post-V2.6 releases) are selectable + // without an extension update. + 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/providers/__tests__/NanoGPT.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx index bb810caa75..605e6f4997 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/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 64d3067092..2b7360ff06 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -31,6 +31,8 @@ import { vscodeLlmDefaultModelId, moonshotDefaultModelId, moonshotModels, + mimoDefaultModelId, + mimoModels, kimiCodeDefaultModelInfo, lMStudioDefaultModelInfo, opencodeGoDefaultModelInfo, @@ -234,11 +236,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({})) @@ -266,10 +275,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({})) @@ -280,8 +296,10 @@ 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]) } }, ) diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 8d4b70ad4a..822a8d8a2f 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -317,9 +317,13 @@ 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 availableModels = routerModels[providerIdentifiers.mimo] + ? { ...mimoModels, ...routerModels[providerIdentifiers.mimo] } + : mimoModels + const id = getValidatedModelId(apiConfiguration.apiModelId, availableModels, defaultModelId) + const routerInfo = routerModels[providerIdentifiers.mimo]?.[id] + const staticInfo = mimoModels[id as keyof typeof mimoModels] + return { id, info: routerInfo ?? staticInfo } } case providerIdentifiers.zai: { const apiLine = apiConfiguration.zaiApiLine ?? "international_coding" diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index a1936b89d7..b954d641de 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -63,6 +63,7 @@ describe("Model Validation Functions", () => { "zoo-gateway": {}, "kimi-code": {}, moonshot: {}, + mimo: {}, } const allowAllOrganization: OrganizationAllowList = { From a340b6f61d9a6d6fb0af35d1af89d672001b5728 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 08:22:01 +0900 Subject: [PATCH 02/16] fix(mimo): harden model discovery per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - require https: at the fetch boundary so the bearer key is never sent over plaintext HTTP (settings schema allows only https endpoints, but unsaved webview values reach this fetcher too) - skip null/non-object entries in the /models payload so one malformed element cannot abort the whole catalog - exclude ASR/TTS model families from discovery — they are not text chat models and require modality payloads this provider never builds - scope the MiMo model cache by API key so catalogs never leak across credentials on the same cluster URL - catch MiMo refresh failures in requestRouterModels and post the normal failure response instead of exiting before any response is sent --- .../providers/fetchers/__tests__/mimo.spec.ts | 56 +++++++++++++++++++ src/api/providers/fetchers/mimo.ts | 24 ++++++-- src/api/providers/fetchers/modelCache.ts | 1 + ...webviewMessageHandler.routerModels.spec.ts | 28 ++++++++++ src/core/webview/webviewMessageHandler.ts | 32 ++++++++--- 5 files changed, 128 insertions(+), 13 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index ef67922c55..3ae0bafb76 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -153,6 +153,62 @@ describe("getMimoModels", () => { }) }) + 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 + + await expect(getMimoModels("http://token-plan-sgp.xiaomimimo.com/v1", "my-secret-key")).rejects.toThrow( + "requires an https:// base URL", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + 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), + ) + }) + it("passes the caller's abort signal to the request", async () => { const fetchSpy = vi .spyOn(globalThis, "fetch") diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index c3833cfced..5f822fc5a9 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -3,6 +3,11 @@ import { mimoModels } 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 + /** * Fetches available models from the Xiaomi MiMo API and merges them with known specs. * @@ -22,7 +27,14 @@ export async function getMimoModels( // 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 = (baseUrl || "https://token-plan-sgp.xiaomimimo.com/v1").replace(/\/+$/, "") - const url = `${base}/models` + const url = new URL(`${base}/models`) + + // The settings schema only allows https:// endpoints, but this fetcher also + // receives unsaved webview values — enforce the contract at the boundary so + // the bearer key is never sent over plaintext HTTP. + if (url.protocol !== "https:") { + throw new Error(`MiMo model fetch requires an https:// base URL (received "${url.protocol}")`) + } const headers: Record = { "Content-Type": "application/json", @@ -33,7 +45,7 @@ export async function getMimoModels( headers["Authorization"] = `Bearer ${apiKey}` } - const response = await fetch(url, { + const response = await fetch(url.toString(), { headers, signal: opts?.signal, }) @@ -49,7 +61,7 @@ export async function getMimoModels( console.error(`[getMimoModels] HTTP error:`, { status: response.status, statusText: response.statusText, - url, + url: url.toString(), body: errorBody, }) @@ -67,8 +79,12 @@ export async function getMimoModels( const models: ModelRecord = Object.create(null) for (const model of data.data) { + // 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) continue + if (!modelId || NON_TEXT_MODEL_ID.test(modelId)) continue const knownSpecs = mimoModels[modelId as keyof typeof mimoModels] diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index fcce580e7f..19a950285c 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -125,6 +125,7 @@ const KEY_SCOPED_PROVIDERS: ReadonlySet = new Set([ providerIdentifiers.poe, // Per-account model availability providerIdentifiers.requesty, // Per-account custom model policies providerIdentifiers.moonshot, // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn) + providerIdentifiers.mimo, // Token-plan and PAYG catalogs are authenticated per key providerIdentifiers.zooGateway, // Per-session-token account identity providerIdentifiers.kimiCode, // Per-session-token account identity providerIdentifiers.nanogpt, // Public catalog can still vary by API-key allowlist diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 337aba0615..ccd0989efb 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -709,6 +709,34 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) + it("posts a MiMo failure response and still posts routerModels when the refresh rejects", async () => { + flushModelsMock.mockRejectedValue(new Error("MiMo refresh failed")) + getModelsMock.mockResolvedValue({}) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + mimoApiKey: "new-mimo-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }, + }) + + 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 refresh failed") + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + }) + 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/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 76bd2ab6ae..6af0980177 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1276,20 +1276,34 @@ export const webviewMessageHandler = async ( const mimoBaseUrl = message?.values?.mimoBaseUrl ?? apiConfiguration.mimoBaseUrl if (mimoApiKey) { + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: mimoApiKey, + baseUrl: mimoBaseUrl, + } + if (message?.values?.mimoApiKey || message?.values?.mimoBaseUrl) { - await flushModels( - { provider: providerIdentifiers.mimo, apiKey: mimoApiKey, baseUrl: mimoBaseUrl }, - true, - ) + // A refresh failure (bad key/endpoint) must not abort the + // aggregate fetch — surface the normal MiMo failure response + // and continue so routerModels is still posted. + try { + await flushModels(mimoOptions, true) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`Error refreshing models for ${providerIdentifiers.mimo}:`, error) + + await provider.postMessageToWebview({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + error: errorMessage, + values: { provider: providerIdentifiers.mimo }, + }) + } } candidates.push({ key: providerIdentifiers.mimo, - options: { - provider: providerIdentifiers.mimo, - apiKey: mimoApiKey, - baseUrl: mimoBaseUrl, - }, + options: mimoOptions, }) } From 45497706ace4e38b40a8c86e9a53ca2f9ef6821c Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 08:41:27 +0900 Subject: [PATCH 03/16] chore: retrigger automated review and pre-merge checks From a4f26b543dfb9bc974006db2850e025fc90286fd Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 16:37:41 +0900 Subject: [PATCH 04/16] fix(mimo): enforce endpoint allowlist inside getMimoModels before sending credentials CodeRabbit pre-merge ERROR (PR #1748): unsaved webview mimoBaseUrl bypassed the persisted 4-URL Xiaomi allowlist and the fetcher only checked the https scheme, so a crafted requestRouterModels message could exfiltrate the saved API key to an arbitrary HTTPS origin. Enforce the allowlist (mirrored from the zod schema, drift-pinned by a sync test) and reject userinfo-bearing URLs inside getMimoModels as the network boundary, before any request or Authorization header is constructed. Regression tests: attacker origin (fetch not called), userinfo/authority confusion on allowed and attacker hosts, all 4 allowed URLs still fetch, schema-drift sync. --- .../providers/fetchers/__tests__/mimo.spec.ts | 84 ++++++++++++++++++- src/api/providers/fetchers/mimo.ts | 47 +++++++++-- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 3ae0bafb76..28df8ba545 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -1,6 +1,6 @@ -import { mimoModels } from "@roo-code/types" +import { mimoModels, providerSettingsSchema } from "@roo-code/types" -import { getMimoModels } from "../mimo" +import { getMimoModels, ALLOWED_BASE_URLS } from "../mimo" describe("getMimoModels", () => { const originalFetch = globalThis.fetch @@ -157,12 +157,90 @@ describe("getMimoModels", () => { 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( - "requires an https:// base URL", + "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 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() + }) + + // 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("keeps the fetcher allowlist in sync with the persisted settings schema", () => { + // The fetcher mirrors the zod literal union from + // packages/types/src/provider-settings/mimo.ts because the definition is + // not re-exported publicly. If the schema ever rejects one of the fetcher's + // URLs, the two sources have drifted and this test must fail. + const mimoBaseUrlField = providerSettingsSchema.shape.mimoBaseUrl + + for (const allowedUrl of ALLOWED_BASE_URLS) { + expect(mimoBaseUrlField.safeParse(allowedUrl).success).toBe(true) + } + 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, diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index 5f822fc5a9..3ba9a7daba 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -8,6 +8,33 @@ import { DEFAULT_HEADERS } from "../constants" // builds, so discovery excludes them from the catalog. const NON_TEXT_MODEL_ID = /-(asr|tts)(-|$)/i +// 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/provider-settings/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, +// so this exact-match gate is the only thing guaranteeing the bearer key never +// leaves a Xiaomi origin. Exported for that drift test only. +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 +// message below. The raw URL is only echoed for credential-free rejections. +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("@") +} + /** * Fetches available models from the Xiaomi MiMo API and merges them with known specs. * @@ -27,14 +54,22 @@ export async function getMimoModels( // 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 = (baseUrl || "https://token-plan-sgp.xiaomimimo.com/v1").replace(/\/+$/, "") - const url = new URL(`${base}/models`) - // The settings schema only allows https:// endpoints, but this fetcher also - // receives unsaved webview values — enforce the contract at the boundary so - // the bearer key is never sent over plaintext HTTP. - if (url.protocol !== "https:") { - throw new Error(`MiMo model fetch requires an https:// base URL (received "${url.protocol}")`) + // 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. + 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}" is not an allowed Xiaomi MiMo endpoint.`, + ) + } + + const url = new URL(`${base}/models`) const headers: Record = { "Content-Type": "application/json", From 079f9d767040dd6dc2e875c226c27c2d71b9f98c Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 16:43:02 +0900 Subject: [PATCH 05/16] test(webview-ui): guard MiMo router-only model selection in useSelectedModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit Regression Evidence sub-claim 3b (verified): the two existing MiMo rows key on mimoDefaultModelId/null-catalog, so a regression of the merged catalog {...mimoModels, ...routerModels.mimo} to static-only would still pass both. This test selects a router-returned ID absent from the static catalog and asserts the selected model carries router metadata — the only guard for the merged-catalog wiring. Includes a premise assertion so the test fails loudly if the ID is ever added to the static catalog. --- .../hooks/__tests__/useSelectedModel.spec.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 2b7360ff06..0d8c2517d6 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -304,6 +304,42 @@ describe("useSelectedModel", () => { }, ) + 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) + }) + it("uses router data for Poe", () => { const modelInfo: ModelInfo = { contextWindow: 42_000, supportsPromptCache: false } mockUseRouterModels.mockReturnValue( From 0ef947234f165dc9fd6db783aaadbc67187db4d6 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 17:16:17 +0900 Subject: [PATCH 06/16] test(webview-ui): assert ModelPicker receives merged static+fetched MiMo catalog CodeRabbit Regression Evidence sub-claim 2 (verified): ApiOptions.tsx spreads routerModels.mimo into the picker catalog but every ApiOptions suite null-mocked ModelPicker, leaving that spread as the only untested wiring path for fetched MiMo models. Adds 3 tests in the sole suite that renders the real ModelPicker: merged static+router-only delivery, undefined router fallback, and empty-payload fallback. Mutation-verified: reverting the merge to static-only fails the router-only assertion. Also hoists the useRouterModels mock state holder and resets the organization allow-list leak from earlier suites. --- .../ApiOptions.provider-filtering.spec.tsx | 119 ++++++++++++++++-- 1 file changed, 108 insertions(+), 11 deletions(-) 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 58e23511a7..20bb451a66 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 @@ -343,4 +370,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() + }) + }) }) From f719fbf88b8f81ba69bbcaa3fc974b86072f3930 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 17:21:51 +0900 Subject: [PATCH 07/16] test(webview-ui): add MiMo invalid model ID fallback case in useSelectedModel CodeRabbit Regression Evidence sub-claim 3a (verified): the PR changed the configured-ID validation branch for MiMo (merge-base passed IDs through unvalidated) but only the known-default and null-catalog rows were covered. Mirrors the existing moonshot invalid-ID row: a configured ID absent from both static and router catalogs falls back to mimoDefaultModelId with its metadata. Uses the spec's typed helpers instead of the precedent block's as any casts. --- .../hooks/__tests__/useSelectedModel.spec.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 0d8c2517d6..7f1abba2c5 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1632,4 +1632,24 @@ 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", () => { + 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]) + }) + }) }) From 1f6a39b0a5b6071c12b74149760dd7dbd197d5ee Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 17:27:31 +0900 Subject: [PATCH 08/16] test(mimo): cover modelCache dispatch wiring and url+key cache isolation CodeRabbit Regression Evidence sub-claim 1 (verified): modelCache.ts added MiMo to URL_SCOPED_PROVIDERS/KEY_SCOPED_PROVIDERS plus a providerIdentifiers.mimo dispatch case, but modelCache.spec.ts had zero MiMo coverage. Adds a dispatch test pinning getMimoModels(baseUrl, apiKey, {signal}) argument wiring (beyond the exhaustive-never compile guard) and a url+key-scoped cache-isolation test mirroring the NanoGPT pattern across both scoping dimensions. --- .../fetchers/__tests__/modelCache.spec.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 3b95f32234..9f14ae0be5 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -47,6 +47,7 @@ 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 @@ -75,6 +76,7 @@ import { getRequestyModels } from "../requesty" import { getKenariModels } from "../kenari" import { getNanoGptModels } from "../nanogpt" import { getMoonshotModels } from "../moonshot" +import { getMimoModels } from "../mimo" import { getZooGatewayModels } from "../zoo-gateway" const mockGetLiteLLMModels = getLiteLLMModels as Mock @@ -83,6 +85,7 @@ const mockGetRequestyModels = getRequestyModels as Mock const mockGetNanoGptModels = getNanoGptModels as Mock const mockGetMoonshotModels = getMoonshotModels as Mock +const mockGetMimoModels = getMimoModels as Mock const mockGetZooGatewayModels = getZooGatewayModels as Mock const DUMMY_REQUESTY_KEY = "requesty-key-for-testing" @@ -263,6 +266,29 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("calls getMimoModels with correct parameters and forwards the abort signal", async () => { + const mockModels = { + "mimo-v2-omni": { + maxTokens: 16384, + contextWindow: 262144, + supportsPromptCache: false, + description: "MiMo model via dynamic catalog endpoint", + }, + } + mockGetMimoModels.mockResolvedValue(mockModels) + + const result = await getModels({ + provider: providerIdentifiers.mimo, + apiKey: "mimo-test-key", + baseUrl: "https://api.mimo.example/v1", + }) + + expect(mockGetMimoModels).toHaveBeenCalledWith("https://api.mimo.example/v1", "mimo-test-key", { + signal: expect.any(AbortSignal), + }) + expect(result).toEqual(mockModels) + }) + it("validates exhaustive provider checking with unknown provider", async () => { // This test ensures TypeScript catches unknown providers at compile time // In practice, the discriminated union should prevent this at compile time @@ -1136,6 +1162,48 @@ describe("NanoGPT key-scoped cache isolation", () => { }) }) +describe("MiMo url+key-scoped cache isolation", () => { + // MiMo belongs to BOTH URL_SCOPED_PROVIDERS and KEY_SCOPED_PROVIDERS (modelCache.ts), + // 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-omni": { maxTokens: 16384, contextWindow: 262144, supportsPromptCache: false }, + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetMimoModels.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 }) + await getModels({ provider: providerIdentifiers.mimo, baseUrl: "https://api.mimo.example/v1" }) + await getModels({ + provider: providerIdentifiers.mimo, + baseUrl: "https://api.mimo.example/v1", + apiKey: "mimo-key-a", + }) + await getModels({ + provider: providerIdentifiers.mimo, + baseUrl: "https://api.mimo.example/v1", + apiKey: "mimo-key-b", + }) + + 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("compound cache key derivation across scoping dimensions", () => { // Exercises every branch of getCacheKey via the public getModels() entry point. // litellm is url-scoped AND key-scoped; openrouter is neither, so it hits the bare From d06f287663300b5ea9b8c17667241aeb1023648c Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 18:29:56 +0900 Subject: [PATCH 09/16] fix(mimo): reject non-allowlisted unsaved mimoBaseUrl in router models handler --- ...webviewMessageHandler.routerModels.spec.ts | 46 +++++++++++ src/core/webview/webviewMessageHandler.ts | 79 +++++++++++++------ 2 files changed, 100 insertions(+), 25 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index ccd0989efb..f352a1948f 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -737,6 +737,52 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect(response).toBeDefined() }) + 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({}) + }) + 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/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 6af0980177..e4db88a6ce 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -91,6 +91,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 } from "../../api/providers/fetchers/mimo" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) @@ -1275,36 +1276,64 @@ export const webviewMessageHandler = async ( const mimoApiKey = message?.values?.mimoApiKey ?? apiConfiguration.mimoApiKey const mimoBaseUrl = message?.values?.mimoBaseUrl ?? apiConfiguration.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. + const unsavedMimoBaseUrl = message?.values?.mimoBaseUrl + const mimoBaseUrlRejected = + typeof unsavedMimoBaseUrl === "string" && + unsavedMimoBaseUrl !== "" && + !ALLOWED_BASE_URLS.has(unsavedMimoBaseUrl.replace(/\/+$/, "")) + if (mimoApiKey) { - const mimoOptions = { - provider: providerIdentifiers.mimo, - apiKey: mimoApiKey, - baseUrl: mimoBaseUrl, - } + 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}`) - if (message?.values?.mimoApiKey || message?.values?.mimoBaseUrl) { - // A refresh failure (bad key/endpoint) must not abort the - // aggregate fetch — surface the normal MiMo failure response - // and continue so routerModels is still posted. - try { - await flushModels(mimoOptions, true) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`Error refreshing models for ${providerIdentifiers.mimo}:`, error) + await provider.postMessageToWebview({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + error: errorMessage, + values: { provider: providerIdentifiers.mimo }, + }) + } else { + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: mimoApiKey, + baseUrl: mimoBaseUrl, + } - await provider.postMessageToWebview({ - type: RouterModelsMessageType.singleRouterModelFetchResponse, - success: false, - error: errorMessage, - values: { provider: providerIdentifiers.mimo }, - }) + if (message?.values?.mimoApiKey || message?.values?.mimoBaseUrl) { + // A refresh failure (bad key/endpoint) must not abort the + // aggregate fetch — surface the normal MiMo failure response + // and continue so routerModels is still posted. + try { + await flushModels(mimoOptions, true) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`Error refreshing models for ${providerIdentifiers.mimo}:`, error) + + await provider.postMessageToWebview({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + error: errorMessage, + values: { provider: providerIdentifiers.mimo }, + }) + } } - } - candidates.push({ - key: providerIdentifiers.mimo, - options: mimoOptions, - }) + candidates.push({ + key: providerIdentifiers.mimo, + options: mimoOptions, + }) + } } // Opencode Go's /models endpoint is public — it returns the full model list with no From d7a6bc99630dc2b603a8806dc3b85fc073e05415 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 00:47:27 +0900 Subject: [PATCH 10/16] fix(webview-ui): resolve MiMo specs from static catalog while router models are unavailable What: useSelectedModel resolves deepseek/moonshot/mimo from the shipped static catalog when router models are absent (still loading, missing provider entry in the single-provider host response, or query-layer failure), mirroring the existing kimiCode fallback. Why: the extension host answers single-provider router requests with {} when a credential-gated fetch candidate is skipped, so routerModels.data carries no mimo entry and the hook stayed not-ready with info undefined. TaskHeader renders contextWindow || 1, so the task header showed "108.1k / 1" instead of the real 1M MiMo context window. Impact: the task header context window and reserved-output display are correct for MiMo before the router catalog arrives; once router data lands, getSelectedModel revalidates against the merged catalog exactly as before. --- .../hooks/__tests__/useSelectedModel.spec.ts | 46 +++++++++++++ .../components/ui/hooks/useSelectedModel.ts | 64 ++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) 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 7f1abba2c5..21bc5b6498 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -695,6 +695,52 @@ 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) + }) + }) + describe("default behavior", () => { it("should return OpenRouter default when no configuration is provided", () => { mockUseRouterModels.mockReturnValue({ diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 822a8d8a2f..3022459793 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -7,9 +7,12 @@ import { anthropicModels, bedrockModels, deepSeekModels, + deepSeekDefaultModelId, moonshotModels, + moonshotDefaultModelId, minimaxModels, mimoModels, + mimoDefaultModelId, geminiModels, mistralModels, openAiModelInfoSaneDefaults, @@ -57,6 +60,60 @@ function getValidatedModelId( return configuredId && 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. + */ +function getStaticCatalogSelection( + provider: StaticCatalogDynamicProvider, + apiConfiguration: ProviderSettings, +): { id: string; info: ModelInfo | undefined } { + const configuredId = apiConfiguration.apiModelId + switch (provider) { + case providerIdentifiers.deepseek: { + const id = + configuredId && deepSeekModels[configuredId as keyof typeof deepSeekModels] + ? configuredId + : deepSeekDefaultModelId + return { id, info: deepSeekModels[id as keyof typeof deepSeekModels] } + } + case providerIdentifiers.moonshot: { + const id = + configuredId && moonshotModels[configuredId as keyof typeof moonshotModels] + ? configuredId + : moonshotDefaultModelId + return { id, info: moonshotModels[id as keyof typeof moonshotModels] } + } + case providerIdentifiers.mimo: { + const id = + configuredId && mimoModels[configuredId as keyof typeof mimoModels] ? configuredId : mimoDefaultModelId + return { id, info: mimoModels[id as keyof typeof mimoModels] } + } + } +} + /** * Resolves the model currently selected for the active API provider. * @@ -131,7 +188,12 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { id: apiConfiguration.apiModelId || getProviderDefaultModelId(providerIdentifiers.kimiCode), info: kimiCodeDefaultModelInfo, } - : { id: getProviderDefaultModelId(activeProvider ?? providerIdentifiers.openrouter), info: undefined } + : apiConfiguration && activeProvider && isStaticCatalogDynamicProvider(activeProvider) + ? getStaticCatalogSelection(activeProvider, apiConfiguration) + : { + id: getProviderDefaultModelId(activeProvider ?? providerIdentifiers.openrouter), + info: undefined, + } return { provider, From 7d612f631c7acdacc1a6767bb84bb1fce4c36603 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 01:03:28 +0900 Subject: [PATCH 11/16] test(mimo): cover no-Authorization fetch and filtered MiMo router request What: add a fetcher test asserting getMimoModels omits the Authorization header when no apiKey is provided, and a webviewMessageHandler test for a single-provider requestRouterModels call (values.provider: mimo) with stored credentials, asserting only MiMo is fetched and the filtered routerModels response carries the mimo entry. Why: CodeRabbit pre-merge Regression Evidence check flagged both gaps; the production useRouterModels path for a selected MiMo provider issues the filtered request, and the optional apiKey branch had no negative test. Impact: no behavior change, coverage only. eslint suppression counts unchanged (no new any). --- .../providers/fetchers/__tests__/mimo.spec.ts | 13 ++++++ ...webviewMessageHandler.routerModels.spec.ts | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 28df8ba545..18eac7497c 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -132,6 +132,19 @@ describe("getMimoModels", () => { ) }) + 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, diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index f352a1948f..4f830d8fba 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -636,6 +636,46 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) + 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) + 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("flushes MiMo cache when explicit credentials are provided via message values", async () => { getModelsMock.mockResolvedValue({ "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, From 041cd8254ce298afebc22daabd20d65dcef0cac0 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 01:46:25 +0900 Subject: [PATCH 12/16] test(mimo): address CodeRabbit regression-evidence follow-ups What: add an independent literal allowlist test exercising /models for each of the four persisted Xiaomi endpoints, a useSelectedModel test holding deepseek/moonshot/mimo static specs when the router query has errored (isError), and assert the filtered single-provider MiMo request dispatches no other provider fetches. Why: CodeRabbit's review of 7d612f631 kept the Regression Evidence pre-merge check at warning: the error-state path of the new static fallback had no MiMo coverage, and the positive allowlist test was tautological (it iterated ALLOWED_BASE_URLS itself). Impact: coverage only, no behavior change. --- .../providers/fetchers/__tests__/mimo.spec.ts | 28 +++++++++++++++++++ ...webviewMessageHandler.routerModels.spec.ts | 2 ++ .../hooks/__tests__/useSelectedModel.spec.ts | 20 +++++++++++++ 3 files changed, 50 insertions(+) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 18eac7497c..f4e38efd75 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -241,6 +241,34 @@ describe("getMimoModels", () => { 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/provider-settings/mimo.ts because the definition is diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 4f830d8fba..94583d0334 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -658,6 +658,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { 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", 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 21bc5b6498..d1e50c0f20 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -739,6 +739,26 @@ describe("useSelectedModel", () => { 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])( + "keeps the static %s catalog when the router query has errored", + (provider) => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isError: true })) + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel({ apiProvider: provider }), { wrapper }) + + const expectedId = + provider === providerIdentifiers.deepseek + ? deepSeekDefaultModelId + : provider === providerIdentifiers.moonshot + ? moonshotDefaultModelId + : mimoDefaultModelId + expect(result.current.id).toBe(expectedId) + expect(result.current.info).toBeDefined() + expect(result.current.info?.contextWindow).toBeGreaterThan(0) + }, + ) }) describe("default behavior", () => { From 5d0be3e9685f7299afafbee47879b8bda3ec8408 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 03:11:22 +0900 Subject: [PATCH 13/16] test(webview-ui): assert isError in the static-catalog error-state test What: extend the deepseek/moonshot/mimo router-error useSelectedModel test to assert result.current.isError is true alongside the existing static ID/spec assertions. Why: CodeRabbit review of 041cd8254 requested the error flag assertion so the test proves the hook surfaces the router query failure while the static fallback keeps the selection usable. --- .../src/components/ui/hooks/__tests__/useSelectedModel.spec.ts | 1 + 1 file changed, 1 insertion(+) 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 d1e50c0f20..220a2a7fcb 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -754,6 +754,7 @@ describe("useSelectedModel", () => { : provider === providerIdentifiers.moonshot ? moonshotDefaultModelId : mimoDefaultModelId + expect(result.current.isError).toBe(true) expect(result.current.id).toBe(expectedId) expect(result.current.info).toBeDefined() expect(result.current.info?.contextWindow).toBeGreaterThan(0) From d0cda7a70615697013c5167a0733c6387b23c63a Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 03:55:05 +0900 Subject: [PATCH 14/16] fix(mimo,webview-ui): stop echoing rejected URLs and honor query abort signals What: make the MiMo fetcher allowlist rejection a constant error string instead of interpolating the raw base URL (query strings and fragments can carry secrets that the authority-only userinfo check cannot see), and teach fetchRouterModels to accept an AbortSignal from React Query so the window listener and 10s timeout are removed when the consuming component unmounts or the query is cancelled. Why: CodeRabbit pre-merge checks on 5d0be3e96 flagged the URL echo as a Security Boundaries error (a persisted malformed value could leak an api_key through the thrown error, logs, or the webview error response) and the missing cancellation as a Lifecycle Resource Cleanup warning (mount/dispose cycles could retain listeners, timers, and duplicate catalog requests). Impact: rejection errors never contain the input URL; aborted router model queries clean up immediately and stale responses can no longer resolve a disposed query. New regression tests cover query/fragment secret non-echo and abort/timeout behavior. --- .../providers/fetchers/__tests__/mimo.spec.ts | 21 +++++ src/api/providers/fetchers/mimo.ts | 9 +- .../hooks/__tests__/useRouterModels.spec.ts | 89 +++++++++++++++++++ .../components/ui/hooks/useRouterModels.ts | 36 ++++++-- 4 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index f4e38efd75..3f5c567fe0 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -200,6 +200,27 @@ describe("getMimoModels", () => { 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 diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index 3ba9a7daba..df31baf6cc 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -27,7 +27,8 @@ export const ALLOWED_BASE_URLS: ReadonlySet = new Set([ // 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 -// message below. The raw URL is only echoed for credential-free rejections. +// 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) @@ -59,13 +60,15 @@ export async function getMimoModels( // 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. + // 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}" is not an allowed Xiaomi MiMo endpoint.`, + "MIMO/getMimoModels/002: MiMo model fetch rejected: base URL is not an allowed Xiaomi MiMo endpoint.", ) } diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts new file mode 100644 index 0000000000..f5743813c8 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts @@ -0,0 +1,89 @@ +// npx vitest src/components/ui/hooks/__tests__/useRouterModels.spec.ts + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +import { providerIdentifiers, RouterModelsMessageType } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +import { fetchRouterModels } from "../useRouterModels" + +const postResponse = (provider: string | undefined, routerModels: Record) => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + routerModels, + values: provider ? { provider } : undefined, + }, + }), + ) +} + +describe("fetchRouterModels", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("posts a provider-filtered request and resolves on the matching response", async () => { + const promise = fetchRouterModels(providerIdentifiers.mimo) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.mimo }, + }) + + postResponse(providerIdentifiers.mimo, { mimo: {} }) + await expect(promise).resolves.toEqual({ mimo: {} }) + }) + + it("ignores responses for other providers", async () => { + const promise = fetchRouterModels(providerIdentifiers.mimo) + + postResponse("openrouter", { openrouter: {} }) + postResponse(undefined, {}) + postResponse(providerIdentifiers.mimo, { mimo: { "mimo-v2.6-pro": {} } }) + + await expect(promise).resolves.toEqual({ mimo: { "mimo-v2.6-pro": {} } }) + }) + + it("rejects with an abort error when the signal aborts mid-flight", async () => { + const controller = new AbortController() + const promise = fetchRouterModels(providerIdentifiers.mimo, controller.signal) + + controller.abort() + + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + + // A late response must not reach the removed listener or resolve anything. + postResponse(providerIdentifiers.mimo, { mimo: {} }) + await Promise.resolve() + }) + + it("rejects immediately when the signal is already aborted, without posting", async () => { + const controller = new AbortController() + controller.abort() + + await expect(fetchRouterModels(providerIdentifiers.mimo, controller.signal)).rejects.toMatchObject({ + name: "AbortError", + }) + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + it("times out when no response arrives", async () => { + vi.useFakeTimers() + try { + const promise = fetchRouterModels(providerIdentifiers.mimo) + + vi.advanceTimersByTime(10_000) + + await expect(promise).rejects.toThrow("Router models request timed out") + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts index a7d6b36725..7108d4ded0 100644 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ b/webview-ui/src/components/ui/hooks/useRouterModels.ts @@ -14,14 +14,8 @@ type UseRouterModelsOptions = { enabled?: boolean // gate fetching entirely } -export const fetchRouterModels = async (provider?: string) => +export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) => new Promise((resolve, reject) => { - const cleanup = () => { - if (typeof window !== "undefined") { - window.removeEventListener("message", handler) - } - } - const timeout = setTimeout(() => { cleanup() reject(new Error("Router models request timed out")) @@ -39,7 +33,6 @@ export const fetchRouterModels = async (provider?: string) => return } - clearTimeout(timeout) cleanup() if (message.routerModels) { @@ -50,6 +43,31 @@ export const fetchRouterModels = async (provider?: string) => } } + const cleanup = () => { + clearTimeout(timeout) + if (typeof window !== "undefined") { + window.removeEventListener("message", handler) + } + signal?.removeEventListener("abort", onAbort) + } + + const onAbort = () => { + cleanup() + reject(new DOMException("Router models request aborted", "AbortError")) + } + + // 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) { + if (signal.aborted) { + onAbort() + return + } + signal.addEventListener("abort", onAbort, { once: true }) + } + window.addEventListener("message", handler) if (provider) { vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, values: { provider } }) @@ -62,7 +80,7 @@ export const useRouterModels = (opts: UseRouterModelsOptions = {}) => { const provider = opts.provider || undefined return useQuery({ queryKey: [RouterModelsMessageType.routerModels, provider || allRouterModelsProvider], - queryFn: () => fetchRouterModels(provider), + queryFn: ({ signal }) => fetchRouterModels(provider, signal), enabled: opts.enabled !== false, }) } From bc077c290650a66fdbcf411deeab13119c2c97c2 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 04:40:12 +0900 Subject: [PATCH 15/16] fix(webview-ui): correlate routerModels responses by request ID What: fetchRouterModels now generates a unique request ID per call, sends it with the requestRouterModels message, and resolves only on the routerModels response echoing that ID; the extension host echoes the ID for both filtered and aggregate responses. Abort cleanup from the previous commit is preserved. Why: CodeRabbit review of d0cda7a70 flagged provider-only response matching: a remount can re-issue the same provider request while a stale response to the aborted request is still in flight, and the stale response could resolve the replacement query. It also asked the abort test to prove cleanup directly instead of inferring it from a late-response no-op. Impact: stale responses can never resolve a different pending request, even for identical provider filters. The abort spec now asserts the exact registered message handler is removed (add/removeEventListener spy reference equality) and the pending timer is cleared (vi.getTimerCount), plus concurrent same-provider disambiguation. --- src/core/webview/webviewMessageHandler.ts | 8 +- .../hooks/__tests__/useRouterModels.spec.ts | 90 ++++++++++++++----- .../components/ui/hooks/useRouterModels.ts | 23 +++-- 3 files changed, 89 insertions(+), 32 deletions(-) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e4db88a6ce..d87065cc4a 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1443,7 +1443,13 @@ export const webviewMessageHandler = async ( await provider.postMessageToWebview({ type: RouterModelsMessageType.routerModels, routerModels, - values: providerFilter ? { provider: requestedProvider } : undefined, + // Echo the webview's request ID 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). + values: { + requestId: message?.values?.requestId, + ...(providerFilter ? { provider: requestedProvider } : {}), + }, }) break } 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 f5743813c8..9b8d222f4c 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts @@ -12,56 +12,102 @@ import { vscode } from "@src/utils/vscode" import { fetchRouterModels } from "../useRouterModels" -const postResponse = (provider: string | undefined, routerModels: Record) => { +const postMessageMock = vi.mocked(vscode.postMessage) + +const postResponse = (requestId: string | undefined, routerModels: Record) => { window.dispatchEvent( new MessageEvent("message", { data: { type: RouterModelsMessageType.routerModels, routerModels, - values: provider ? { provider } : undefined, + 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() }) - it("posts a provider-filtered request and resolves on the matching response", async () => { - const promise = fetchRouterModels(providerIdentifiers.mimo) + it("posts a provider-filtered request with a request ID and resolves on the matching response", async () => { + const { promise, requestId } = startRequest(providerIdentifiers.mimo) - expect(vscode.postMessage).toHaveBeenCalledWith({ + expect(requestId).toBeTruthy() + expect(postMessageMock).toHaveBeenCalledWith({ type: RouterModelsMessageType.requestRouterModels, - values: { provider: providerIdentifiers.mimo }, + values: { requestId, provider: providerIdentifiers.mimo }, }) - postResponse(providerIdentifiers.mimo, { mimo: {} }) + postResponse(requestId, { mimo: {} }) await expect(promise).resolves.toEqual({ mimo: {} }) }) - it("ignores responses for other providers", async () => { - const promise = fetchRouterModels(providerIdentifiers.mimo) + it("posts an aggregate request without a provider filter", async () => { + const { promise, requestId } = startRequest() - postResponse("openrouter", { openrouter: {} }) - postResponse(undefined, {}) - postResponse(providerIdentifiers.mimo, { mimo: { "mimo-v2.6-pro": {} } }) + expect(postMessageMock).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { requestId }, + }) - await expect(promise).resolves.toEqual({ mimo: { "mimo-v2.6-pro": {} } }) + postResponse(requestId, {}) + await expect(promise).resolves.toEqual({}) }) - it("rejects with an abort error when the signal aborts mid-flight", async () => { - const controller = new AbortController() - const promise = fetchRouterModels(providerIdentifiers.mimo, controller.signal) + it("ignores responses carrying a different or missing request ID", async () => { + const { promise, requestId } = startRequest(providerIdentifiers.mimo) - controller.abort() + postResponse("stale-request-id", { mimo: { stale: {} } }) + postResponse(undefined, { mimo: {} }) + + postResponse(requestId, { mimo: { fresh: {} } }) + await expect(promise).resolves.toEqual({ mimo: { fresh: {} } }) + }) - await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + it("resolves concurrent same-provider requests independently by request ID", async () => { + const first = startRequest(providerIdentifiers.mimo) + const second = startRequest(providerIdentifiers.mimo) - // A late response must not reach the removed listener or resolve anything. - postResponse(providerIdentifiers.mimo, { mimo: {} }) - await Promise.resolve() + 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: {} } }) + }) + + it("rejects on abort and removes the exact registered listener and timer", async () => { + vi.useFakeTimers() + 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 () => { @@ -71,7 +117,7 @@ describe("fetchRouterModels", () => { await expect(fetchRouterModels(providerIdentifiers.mimo, controller.signal)).rejects.toMatchObject({ name: "AbortError", }) - expect(vscode.postMessage).not.toHaveBeenCalled() + expect(postMessageMock).not.toHaveBeenCalled() }) it("times out when no response arrives", async () => { diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts index 7108d4ded0..9073068c0f 100644 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ b/webview-ui/src/components/ui/hooks/useRouterModels.ts @@ -16,6 +16,13 @@ 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 timeout = setTimeout(() => { cleanup() reject(new Error("Router models request timed out")) @@ -25,11 +32,10 @@ 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 } @@ -69,11 +75,10 @@ export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) } 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 = {}) => { From 91a2709c0aa8792c5c659588dd9a8601f9374bbe Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 05:18:02 +0900 Subject: [PATCH 16/16] fix(core): keep legacy routerModels response shape when no request ID What: only include values.requestId in the routerModels response when the request carried one; requests without an ID keep the previous response shape (values undefined for aggregate, { provider } for filtered). Why: the unconditional requestId echo broke six existing requestRouterModels specs in ClineProvider.spec.ts and webviewMessageHandler.spec.ts that assert the exact legacy response payload (values: undefined for aggregate requests), failing the platform-unit-test ubuntu CI job on bc077c290. Impact: CI restoration; the request-ID correlation added for fetchRouterModels is unchanged for new-protocol requests. Added a host-side test asserting the ID echo for filtered requests. --- ...webviewMessageHandler.routerModels.spec.ts | 25 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 18 +++++++------ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 94583d0334..9f2f8671b1 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -678,6 +678,31 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) + 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 }, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index d87065cc4a..7959c6bad7 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1440,16 +1440,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, - // Echo the webview's request ID 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). - values: { - requestId: message?.values?.requestId, - ...(providerFilter ? { provider: requestedProvider } : {}), - }, + values: requestId + ? { requestId, ...(providerFilter ? { provider: requestedProvider } : {}) } + : providerFilter + ? { provider: requestedProvider } + : undefined, }) break }