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..891d56db4e --- /dev/null +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -0,0 +1,382 @@ +import { mimoModels, providerSettingsSchema } from "@roo-code/types" + +import { getMimoModels, ALLOWED_BASE_URLS } 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"]) + + // mimo-v2.5-pro is still served today, but this mock response omits it + // (post-EOL world): a model absent from the API response must stay absent + // from the result, guarding against a wholesale-static-map regression. + expect(models["mimo-v2.5-pro"]).toBeUndefined() + }) + + it("provides MiMo-family defaults for unknown model IDs without pricing", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "mimo-v3-future" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(models["mimo-v3-future"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: "MiMo model: mimo-v3-future", + }) + }) + + it("throws for HTTP errors", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: vi.fn().mockResolvedValue('{"error":{"message":"Invalid API key"}}'), + }) as unknown as typeof fetch + + await expect(getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "invalid-key")).rejects.toThrow( + "HTTP 401: Unauthorized", + ) + }) + + it("uses default Singapore base URL when none provided", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMimoModels(undefined, "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.any(Object), + ) + }) + + it("keeps /v1 in base URL and strips trailing slash", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMimoModels("https://token-plan-cn.xiaomimimo.com/v1/", "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-cn.xiaomimimo.com/v1/models", + expect.any(Object), + ) + }) + + it("throws when response data is not an array", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: "not-an-array" }), + }) as unknown as typeof fetch + + await expect(getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key")).rejects.toThrow( + "Unexpected response format", + ) + }) + + it("skips models with empty or non-string ID", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "" }, { id: 123 }, { id: null }, { id: "mimo-v2.6-pro" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toHaveLength(1) + expect(models["mimo-v2.6-pro"]).toBeDefined() + }) + + it("includes Authorization header when apiKey provided", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "my-secret-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer my-secret-key", + }), + }), + ) + }) + + it("omits the Authorization header when no apiKey is provided", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1") + + const fetchInit = fetchSpy.mock.calls[0]?.[1] as RequestInit + expect(fetchInit.headers).not.toHaveProperty("Authorization") + }) + + it("mixes known and unknown models in same response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "mimo-v2.6-pro" }, { id: "some-new-model" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(models["mimo-v2.6-pro"]).toEqual(mimoModels["mimo-v2.6-pro"]) + expect(models["some-new-model"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: "MiMo model: some-new-model", + }) + }) + + it("throws when the base URL is not https and never sends the request", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + // An http:// endpoint fails the exact-match allowlist, which subsumes the + // previous scheme-only guard. + await expect(getMimoModels("http://token-plan-sgp.xiaomimimo.com/v1", "my-secret-key")).rejects.toThrow( + "not an allowed Xiaomi MiMo endpoint", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects an arbitrary https URL outside the allowlist and never sends the bearer key", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await expect(getMimoModels("https://attacker.example/v1", "my-secret-key")).rejects.toThrow( + "not an allowed Xiaomi MiMo endpoint", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects an allowed host reached through userinfo authority-confusion before any request", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + // classic authority trick: credentials make everything before the last @ + // the userinfo, so the real host is attacker.example. + await expect(getMimoModels("https://token-plan-sgp.xiaomimimo.com@attacker.example/v1", "key")).rejects.toThrow( + "must not contain credentials", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects a percent-encoded %40 userinfo form through the allowlist guard", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + // containsUserinfo only scans for a literal "@", so the encoded form + // reaches the allowlist guard, which rejects it as a non-exact match. + await expect( + getMimoModels("https://token-plan-sgp.xiaomimimo.com%40attacker.example/v1", "key"), + ).rejects.toThrow("not an allowed Xiaomi MiMo endpoint") + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it.each([ + ["query string", "https://token-plan-sgp.xiaomimimo.com/v1?api_key=QUERYSECRET"], + ["fragment", "https://token-plan-sgp.xiaomimimo.com/v1#api_key=FRAGMENTSECRET"], + ])("never echoes a secret carried in a URL %s", async (_label, maliciousUrl) => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + const secret = maliciousUrl.split("=")[1] + let thrown: unknown + try { + await getMimoModels(maliciousUrl, "key") + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toContain("not an allowed Xiaomi MiMo endpoint") + expect((thrown as Error).message).not.toContain(secret) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects embedded credentials on an allowed endpoint and never echoes the secret", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + const error = await getMimoModels( + "https://user:hunter2@token-plan-sgp.xiaomimimo.com/v1", + "my-secret-key", + ).catch((thrown: unknown) => (thrown instanceof Error ? thrown : new Error(String(thrown)))) + + expect(error.message).toContain("must not contain credentials") + expect(error.message).not.toContain("hunter2") + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects embedded credentials on an arbitrary host and never echoes the secret", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + const error = await getMimoModels("https://user:hunter2@attacker.example/v1", "my-secret-key").catch( + (thrown: unknown) => (thrown instanceof Error ? thrown : new Error(String(thrown))), + ) + + expect(error.message).toContain("must not contain credentials") + expect(error.message).not.toContain("hunter2") + expect(fetchSpy).not.toHaveBeenCalled() + }) + + // Regression guard for the security gate: every legitimate endpoint must keep + // working exactly as before the allowlist was introduced. + it.each([...ALLOWED_BASE_URLS])("fetches /models normally for allowed endpoint %s", async (allowedUrl) => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await expect(getMimoModels(allowedUrl, "mock-key")).resolves.toEqual({}) + + expect(fetchSpy).toHaveBeenCalledWith(`${allowedUrl}/models`, expect.any(Object)) + }) + + it("accepts every independently defined persisted Xiaomi endpoint", async () => { + // Independent literal list (NOT iterated from ALLOWED_BASE_URLS) so the + // test fails if a legitimate endpoint is ever removed from the allowlist. + const expectedAllowedUrls = [ + "https://api.xiaomimimo.com/v1", + "https://token-plan-cn.xiaomimimo.com/v1", + "https://token-plan-sgp.xiaomimimo.com/v1", + "https://token-plan-ams.xiaomimimo.com/v1", + ] + + expect([...ALLOWED_BASE_URLS].sort()).toEqual([...expectedAllowedUrls].sort()) + + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + for (const baseUrl of expectedAllowedUrls) { + await expect(getMimoModels(baseUrl, "mock-key")).resolves.toEqual({}) + } + + expect(fetchSpy).toHaveBeenCalledTimes(expectedAllowedUrls.length) + for (const baseUrl of expectedAllowedUrls) { + expect(fetchSpy).toHaveBeenCalledWith(`${baseUrl}/models`, expect.any(Object)) + } + }) + + it("keeps the fetcher allowlist in sync with the persisted settings schema", () => { + // The fetcher mirrors the zod literal union from + // packages/types/src/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, + 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") + .mockResolvedValue(new Response(JSON.stringify({ data: [] }), { status: 200 })) + const controller = new AbortController() + + await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key", { signal: controller.signal }) + + expect(fetchSpy).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.objectContaining({ signal: controller.signal }), + ) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 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 diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts new file mode 100644 index 0000000000..df31baf6cc --- /dev/null +++ b/src/api/providers/fetchers/mimo.ts @@ -0,0 +1,144 @@ +import type { ModelRecord } from "@roo-code/types" +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 + +// 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 +// below. Rejection messages never echo the input: query strings and fragments +// can also carry secrets and are not visible to this authority-only check. +function containsUserinfo(rawUrl: string): boolean { + const schemeEnd = rawUrl.indexOf("://") + const rest = schemeEnd === -1 ? rawUrl : rawUrl.slice(schemeEnd + 3) + const authority = rest.split(/[/?#]/)[0] + return authority.includes("@") +} + +/** + * 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(/\/+$/, "") + + // Reject embedded credentials first, then pin the request to the four allowed + // endpoints with an exact match. This subsumes the previous https-only guard + // (every allowed URL is https, so plaintext HTTP, arbitrary hosts, and path + // tricks all fail the allowlist) and runs before any header is built, so a + // partial or off-list request is never sent. Rejection errors are constant + // strings: the raw URL is never echoed because query strings and fragments + // can carry secrets too. + if (containsUserinfo(base)) { + throw new Error("MIMO/getMimoModels/001: MiMo model fetch rejected: base URL must not contain credentials.") + } + if (!ALLOWED_BASE_URLS.has(base)) { + throw new Error( + "MIMO/getMimoModels/002: MiMo model fetch rejected: base URL is not an allowed Xiaomi MiMo endpoint.", + ) + } + + const url = new URL(`${base}/models`) + + const headers: Record = { + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + } + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + const response = await fetch(url.toString(), { + headers, + signal: opts?.signal, + }) + + 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: url.toString(), + 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) { + // Skip non-record elements so a single malformed entry cannot abort + // the whole catalog. + if (typeof model !== "object" || model === null) continue + + const modelId = typeof model.id === "string" && model.id ? model.id : null + if (!modelId || NON_TEXT_MODEL_ID.test(modelId)) continue + + 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..19a950285c 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, @@ -123,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 @@ -304,6 +307,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..0dcb9ae674 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,239 @@ 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("fetches only MiMo for a single-provider request with stored credentials", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }, + }) + + getModelsMock.mockImplementation(async (options?: { provider?: string }) => { + if (options?.provider === providerIdentifiers.mimo) { + return { "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false } } + } + return {} + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.mimo }, + }) + + const mimoCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoCalls).toHaveLength(1) + // The single-provider filter must not dispatch fetches for other providers. + expect(getModelsMock).toHaveBeenCalledTimes(1) + expect(mimoCalls[0][0]).toEqual({ + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + // Filtered responses carry only the requested provider's entry. + expect(Object.keys(response[0].routerModels)).toEqual([providerIdentifiers.mimo]) + expect(response[0].routerModels.mimo).toEqual({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + }) + + it("echoes the request ID in the routerModels response when one was provided", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + }, + }) + + getModelsMock.mockResolvedValue({}) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.mimo, requestId: "req-123" }, + }) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].values).toEqual({ + requestId: "req-123", + provider: providerIdentifiers.mimo, + }) + }) + + it("flushes MiMo cache when explicit credentials are provided via message values", async () => { + getModelsMock.mockResolvedValue({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + mimoApiKey: "new-mimo-key", + mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + }, + }) + + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: "new-mimo-key", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + } + expect(flushModelsMock).toHaveBeenCalledWith(mimoOptions, 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("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/__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..69db328dc8 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"]) @@ -1130,6 +1131,7 @@ export const webviewMessageHandler = async ( [providerIdentifiers.poe]: {}, [providerIdentifiers.deepseek]: {}, [providerIdentifiers.moonshot]: {}, + [providerIdentifiers.mimo]: {}, [providerIdentifiers.opencodeGo]: {}, [providerIdentifiers.kenari]: {}, [providerIdentifiers.nanogpt]: {}, @@ -1268,6 +1270,59 @@ 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 + + // 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) { + if (mimoBaseUrlRejected) { + // Same surface as a failed refresh: post the MiMo failure + // response and skip the candidate so no fetch is dispatched, + // while router aggregation for other providers continues. + const errorMessage = + "MIMO/requestRouterModels/001: MiMo model fetch rejected: the provided base URL is not an allowed Xiaomi MiMo endpoint." + console.error(`Error refreshing models for ${providerIdentifiers.mimo}: ${errorMessage}`) + + await provider.postMessageToWebview({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + error: errorMessage, + values: { provider: providerIdentifiers.mimo }, + }) + } else { + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: mimoApiKey, + baseUrl: mimoBaseUrl, + } + + if (message?.values?.mimoApiKey || message?.values?.mimoBaseUrl) { + // Unsaved form values win over stored config: flush refreshes the cache + // with them before the aggregate fetch (same pattern as DeepSeek/Moonshot). + await flushModels(mimoOptions, true) + } + + candidates.push({ + key: providerIdentifiers.mimo, + options: mimoOptions, + }) + } + } + // Opencode Go's /models endpoint is public — it returns the full model list with no // Authorization header — so it's fetched unconditionally like openrouter/vercel-ai-gateway // above. Gating it behind a key meant the picker stayed empty (and fell back to the default @@ -1372,10 +1427,20 @@ export const webviewMessageHandler = async ( } }) + // Echo the webview's request ID when one was provided so + // fetchRouterModels can correlate the response to the exact pending + // request (a remount may re-issue the same provider request while a + // stale response is in flight). Requests without an ID keep the + // previous response shape. + const requestId = message?.values?.requestId await provider.postMessageToWebview({ type: RouterModelsMessageType.routerModels, routerModels, - values: providerFilter ? { provider: requestedProvider } : undefined, + values: requestId + ? { requestId, ...(providerFilter ? { provider: requestedProvider } : {}) } + : providerFilter + ? { provider: requestedProvider } + : undefined, }) break } diff --git a/src/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..b979dd7bc9 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -745,11 +745,30 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} defaultModelId={getDefaultModelIdForProvider(activeSelectedProvider, apiConfiguration)} - models={getStaticModelsForProvider( - activeSelectedProvider, - t("settings:labels.useCustomArn"), - apiConfiguration, - )} + models={ + // MiMo has no dedicated picker component, so it shares the + // generic static picker with DeepSeek. MiMo ships new model + // generations frequently (the V2.5 line is EOL), so the + // host-fetched catalog is merged in here: the picker stays + // populated before the router response arrives and post-V2.6 + // releases become selectable without an extension update. + // DeepSeek uses this picker without the merge; Moonshot has + // its own router-only picker. + activeSelectedProvider === providerIdentifiers.mimo + ? { + ...getStaticModelsForProvider( + activeSelectedProvider, + t("settings:labels.useCustomArn"), + apiConfiguration, + ), + ...routerModels?.[providerIdentifiers.mimo], + } + : getStaticModelsForProvider( + activeSelectedProvider, + t("settings:labels.useCustomArn"), + apiConfiguration, + ) + } modelIdKey="apiModelId" serviceName={getProviderServiceConfig(activeSelectedProvider).serviceName} serviceUrl={getProviderServiceConfig(activeSelectedProvider).serviceUrl} diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx index 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() + }) + }) }) 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__/useRouterModels.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts new file mode 100644 index 0000000000..9b8d222f4c --- /dev/null +++ b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts @@ -0,0 +1,135 @@ +// 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 postMessageMock = vi.mocked(vscode.postMessage) + +const postResponse = (requestId: string | undefined, routerModels: Record) => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + routerModels, + values: { requestId }, + }, + }), + ) +} + +const startRequest = (provider?: string, signal?: AbortSignal) => { + const promise = fetchRouterModels(provider, signal) + const requestId = postMessageMock.mock.calls.at(-1)?.[0]?.values?.requestId as string + return { promise, requestId } +} + +describe("fetchRouterModels", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("posts a provider-filtered request with a request ID and resolves on the matching response", async () => { + const { promise, requestId } = startRequest(providerIdentifiers.mimo) + + expect(requestId).toBeTruthy() + expect(postMessageMock).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { requestId, provider: providerIdentifiers.mimo }, + }) + + postResponse(requestId, { mimo: {} }) + await expect(promise).resolves.toEqual({ mimo: {} }) + }) + + it("posts an aggregate request without a provider filter", async () => { + const { promise, requestId } = startRequest() + + expect(postMessageMock).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { requestId }, + }) + + postResponse(requestId, {}) + await expect(promise).resolves.toEqual({}) + }) + + it("ignores responses carrying a different or missing request ID", async () => { + const { promise, requestId } = startRequest(providerIdentifiers.mimo) + + postResponse("stale-request-id", { mimo: { stale: {} } }) + postResponse(undefined, { mimo: {} }) + + postResponse(requestId, { mimo: { fresh: {} } }) + await expect(promise).resolves.toEqual({ mimo: { fresh: {} } }) + }) + + it("resolves concurrent same-provider requests independently by request ID", async () => { + const first = startRequest(providerIdentifiers.mimo) + const second = startRequest(providerIdentifiers.mimo) + + 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 () => { + const controller = new AbortController() + controller.abort() + + await expect(fetchRouterModels(providerIdentifiers.mimo, controller.signal)).rejects.toMatchObject({ + name: "AbortError", + }) + expect(postMessageMock).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/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 64d3067092..02cad8e60c 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,12 +296,50 @@ describe("useSelectedModel", () => { expect(result.current.id).toBe(modelId) if (provider === providerIdentifiers.deepseek) { expect(result.current.info).toEqual(deepSeekModels[deepSeekDefaultModelId]) - } else { + } else if (provider === providerIdentifiers.moonshot) { expect(result.current.info).toEqual(moonshotModels[modelId as keyof typeof moonshotModels]) + } else { + expect(result.current.info).toEqual(mimoModels[modelId as keyof typeof mimoModels]) } }, ) + it("mimo: selects a router-only model not present in the static catalog", () => { + // Regression guard: the merged catalog `{ ...mimoModels, ...routerModels.mimo }` must admit + // model IDs that exist ONLY in the router response. A static-only regression would validate + // the configured ID against `mimoModels`, miss it, and silently reset the selection to the + // static default — which the existing MiMo tests (both keyed on `mimoDefaultModelId`) cannot + // catch. This is the only possible guard for that regression. + const routerOnlyModelId = "mimo-router-only-preview" + const routerModelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 262144, + supportsImages: false, + supportsPromptCache: true, + description: "Router-only MiMo model", + } + + // Guard the test premise: if this ID were ever added to the static catalog, the test would + // silently degrade into the "prefers router data" coverage and must fail loudly instead. + expect(mimoModels[routerOnlyModelId as keyof typeof mimoModels]).toBeUndefined() + + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ [providerIdentifiers.mimo]: { [routerOnlyModelId]: routerModelInfo } }), + ) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: routerOnlyModelId }), + { wrapper: createWrapper() }, + ) + + // The router-only ID survives validation instead of resetting to the static default. + expect(result.current.id).toBe(routerOnlyModelId) + expect(result.current.id).not.toBe(mimoDefaultModelId) + // The selection carries the ROUTER metadata, not a static fallback. + expect(result.current.info).toEqual(routerModelInfo) + }) + it("uses router data for Poe", () => { const modelInfo: ModelInfo = { contextWindow: 42_000, supportsPromptCache: false } mockUseRouterModels.mockReturnValue( @@ -641,6 +695,67 @@ describe("useSelectedModel", () => { }) }) + describe("static-catalog dynamic providers while router models are unavailable", () => { + beforeEach(() => { + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + }) + + // The extension host answers single-provider router requests with `{}` + // when the provider has no fetch candidate (e.g. no API key saved yet), + // so the response carries no entry for that provider and the hook stays + // not-ready. The selection must still resolve from the shipped static + // catalog: TaskHeader renders `contextWindow || 1` and showed "… / 1" + // while `info` was undefined. + it.each([ + [providerIdentifiers.deepseek, deepSeekDefaultModelId, deepSeekModels[deepSeekDefaultModelId]], + [providerIdentifiers.moonshot, moonshotDefaultModelId, moonshotModels[moonshotDefaultModelId]], + [providerIdentifiers.mimo, mimoDefaultModelId, mimoModels[mimoDefaultModelId]], + ])( + "falls back to the static %s catalog when the router response has no entry", + (provider, expectedId, expectedInfo) => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({})) + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel({ apiProvider: provider }), { wrapper }) + + expect(result.current.id).toBe(expectedId) + expect(result.current.info).toEqual(expectedInfo) + }, + ) + + it("keeps the configured MiMo V2.6 model specs while router models are still loading", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isLoading: true })) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.mimo, + apiModelId: "mimo-v2.6-flash", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe("mimo-v2.6-flash") + expect(result.current.info).toEqual(mimoModels["mimo-v2.6-flash"]) + expect(result.current.info?.contextWindow).toBe(1_048_576) + expect(result.current.info?.maxTokens).toBe(131_072) + }) + + it.each([ + [providerIdentifiers.deepseek, deepSeekDefaultModelId, deepSeekModels[deepSeekDefaultModelId]], + [providerIdentifiers.moonshot, moonshotDefaultModelId, moonshotModels[moonshotDefaultModelId]], + [providerIdentifiers.mimo, mimoDefaultModelId, mimoModels[mimoDefaultModelId]], + ])("keeps the static %s catalog when the router query has errored", (provider, expectedId, expectedInfo) => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isError: true })) + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel({ apiProvider: provider }), { wrapper }) + + expect(result.current.isError).toBe(true) + expect(result.current.id).toBe(expectedId) + expect(result.current.info).toEqual(expectedInfo) + }) + }) + describe("default behavior", () => { it("should return OpenRouter default when no configuration is provided", () => { mockUseRouterModels.mockReturnValue({ @@ -1578,4 +1693,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]) + }) + }) }) diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts index a7d6b36725..9073068c0f 100644 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ b/webview-ui/src/components/ui/hooks/useRouterModels.ts @@ -14,13 +14,14 @@ 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) - } - } + // 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() @@ -31,15 +32,13 @@ export const fetchRouterModels = async (provider?: string) => const message: ExtensionMessage = event.data if (message.type === RouterModelsMessageType.routerModels) { - const msgProvider = message?.values?.provider as string | undefined + const msgRequestId = message?.values?.requestId as string | undefined - // Verify response matches request - if (provider !== msgProvider) { - // Not our response; ignore and wait for the matching one + // Verify the response belongs to this exact request. + if (msgRequestId !== requestId) { return } - clearTimeout(timeout) cleanup() if (message.routerModels) { @@ -50,19 +49,43 @@ export const fetchRouterModels = async (provider?: string) => } } - window.addEventListener("message", handler) - if (provider) { - vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, values: { provider } }) - } else { - vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels }) + 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) + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + values: { requestId, ...(provider ? { provider } : {}) }, + }) }) 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, }) } diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 8d4b70ad4a..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, @@ -317,9 +379,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 = {