Repository navigation
feat(mimo): fetch MiMo model catalog dynamically and add V2.6 series #1748
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8be27ac
a340b6f
4549770
a4f26b5
079f9d7
0ef9472
f719fbf
1f6a39b
d06f287
d7a6bc9
7d612f6
041cd82
5d0be3e
d0cda7a
bc077c2
91a2709
eb381e7
5cf7565
9fe5592
32ff432
0d81409
ba0d7bb
0b931f4
e0ef51b
2356a31
b32c98f
a073635
fbcd2fe
06cea82
aa032f1
c7d4447
6d462f0
6e131eb
c9cea12
6b75b81
b9c05eb
7bc5eb9
58ca62d
c822754
274952d
adfe116
653bf2e
079a7c3
b7eb128
e0a73f1
c189c0e
cb94c39
9fe6b71
d4679ff
54b8723
556cea2
3decc86
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { | ||
| SECRET_STATE_KEYS, | ||
| getMimoModelInfo, | ||
| isSecretStateKey, | ||
| mimoDefaultModelInfo, | ||
| mimoModels, | ||
| providerIdentifiers, | ||
| providerSettingsSchema, | ||
| } from "../index.js" | ||
|
|
||
| describe("MiMo provider secret classification", () => { | ||
| it("classifies mimoApiKey as a secret state key alongside its sibling provider keys", () => { | ||
| expect(SECRET_STATE_KEYS).toContain("mimoApiKey") | ||
| expect(isSecretStateKey("mimoApiKey")).toBe(true) | ||
| }) | ||
|
|
||
| it("keeps the non-secret MiMo settings out of the secret set", () => { | ||
| expect(isSecretStateKey("mimoBaseUrl")).toBe(false) | ||
| expect(SECRET_STATE_KEYS).not.toContain("mimoBaseUrl") | ||
| }) | ||
|
|
||
| it("still parses and exposes mimoApiKey through the provider settings schema", () => { | ||
| const parsed = providerSettingsSchema.parse({ | ||
| apiProvider: providerIdentifiers.mimo, | ||
| mimoApiKey: "mimo-key", | ||
| mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", | ||
| }) | ||
| expect(parsed.mimoApiKey).toBe("mimo-key") | ||
| expect(parsed.mimoBaseUrl).toBe("https://token-plan-sgp.xiaomimimo.com/v1") | ||
| }) | ||
| }) | ||
|
|
||
| describe("getMimoModelInfo", () => { | ||
| it("returns the curated spec for a known model ID", () => { | ||
| expect(getMimoModelInfo("mimo-v2.6-pro")).toBe(mimoModels["mimo-v2.6-pro"]) | ||
| }) | ||
|
|
||
| it("returns undefined for an unknown model ID", () => { | ||
| expect(getMimoModelInfo("mimo-v99-not-real")).toBeUndefined() | ||
| }) | ||
|
|
||
| // Regression: model IDs reach this lookup from the provider's /models response, | ||
| // so a plain `mimoModels[id]` read resolved Object.prototype members and handed | ||
| // callers a function (or `{}` after spreading) instead of the family default. | ||
| it.each(["constructor", "toString", "valueOf", "hasOwnProperty", "__proto__"])( | ||
| "returns undefined for the inherited Object.prototype key %s", | ||
| (key) => { | ||
| expect(getMimoModelInfo(key)).toBeUndefined() | ||
| }, | ||
| ) | ||
|
|
||
| it("keeps the default model resolvable so the fallback path has a spec", () => { | ||
| expect(getMimoModelInfo("mimo-v2.6-pro")).toBe(mimoDefaultModelInfo) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,9 +9,17 @@ import { | |
| createProviderDefinition, | ||
| } from "./common.js" | ||
|
|
||
| // https://developer.puter.com/ai/xiaomi/mimo-v2.5-pro/ | ||
| // https://developer.puter.com/ai/xiaomi/mimo-v2.5/ | ||
| // https://platform.xiaomimimo.com/docs/en-US/quick-start/model-hyperparameters | ||
| // https://mimo.mi.com/docs/en-US/quick-start/model-hyperparameters | ||
| // https://mimo.mi.com/docs/en-US/quick-start/usage-guide/text-generation/deep-thinking | ||
| // https://mimo.mi.com/docs/en-US/price/pay-as-you-go | ||
| // | ||
| // mimo-v2.5-pro and mimo-v2.5 were retired on 2026-10-21 10:00 (GMT+8) and their | ||
| // model names stopped working, so they are gone from this catalog: keeping them | ||
| // would leave dead IDs selectable. The V2.6 series below replaces them. A saved | ||
| // selection that still names a retired ID keeps working through the documented | ||
| // runtime fallback -- getMimoModelInfo(id) ?? mimoDefaultModelInfo in | ||
| // src/api/providers/mimo.ts -- which prices and limits the request as the | ||
| // default model while sending the stored ID through unchanged. | ||
| // | ||
| // NOTE: mimo-v2-flash is not included here. Its thinking mode defaults to | ||
| // disabled and it doesn't reliably handle reasoning_content passthrough | ||
|
|
@@ -21,53 +29,65 @@ import { | |
| // an agentic provider, and flash just can't do that yet. | ||
| export type MimoModelId = keyof typeof mimoModels | ||
|
|
||
| export const mimoDefaultModelId: MimoModelId = "mimo-v2.5-pro" | ||
| export const mimoDefaultModelId: MimoModelId = "mimo-v2.6-pro" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| export const mimoModels = { | ||
| "mimo-v2.5-pro": { | ||
| "mimo-v2.6-pro": { | ||
| maxTokens: 131_072, | ||
| contextWindow: 1_048_576, | ||
| supportsImages: false, // Pro series is text-only | ||
| supportsImages: true, // V2.6 series is full-modality (text, image, audio, video) | ||
| supportsPromptCache: false, | ||
| preserveReasoning: true, | ||
| inputPrice: 1.0, // $1.00/1M tokens (cache miss, ≤256K) | ||
| outputPrice: 3.0, // $3.00/1M tokens (≤256K) | ||
| cacheReadsPrice: 0.2, // $0.20/1M tokens (cache hit, ≤256K) | ||
| inputPrice: 0.435, // $0.435/1M tokens (cache miss) | ||
| outputPrice: 0.87, // $0.87/1M tokens | ||
| cacheReadsPrice: 0.0036, // $0.0036/1M tokens (cache hit) | ||
| cacheWritesPrice: 0, // Free for limited time | ||
| // MiMo charges 2x above 256K context | ||
| longContextPricing: { | ||
| thresholdTokens: 256_000, | ||
| inputPriceMultiplier: 2, | ||
| outputPriceMultiplier: 2, | ||
| cacheReadsPriceMultiplier: 2, | ||
| }, | ||
| description: | ||
| "MiMo V2.5 Pro - Xiaomi's flagship reasoning model with 1M context, deep thinking, tool calling, and structured output.", | ||
| "MiMo V2.6 Pro - Xiaomi's flagship omni-modal reasoning model with 1M context, deep thinking, tool calling, and structured output.", | ||
| }, | ||
| "mimo-v2.5": { | ||
| "mimo-v2.6-flash": { | ||
| maxTokens: 131_072, | ||
| contextWindow: 1_048_576, | ||
| supportsImages: true, // Full-modal: text, image, audio, video input | ||
| supportsImages: true, // Full-modality: text, image, audio, video input | ||
| supportsPromptCache: false, | ||
| preserveReasoning: true, | ||
| inputPrice: 0.4, // $0.40/1M tokens (cache miss, ≤256K) | ||
| outputPrice: 2.0, // $2.00/1M tokens (≤256K) | ||
| cacheReadsPrice: 0.08, // $0.08/1M tokens (cache hit, ≤256K) | ||
| inputPrice: 0.14, // $0.14/1M tokens (cache miss) | ||
| outputPrice: 0.28, // $0.28/1M tokens | ||
| cacheReadsPrice: 0.0028, // $0.0028/1M tokens (cache hit) | ||
| cacheWritesPrice: 0, // Free for limited time | ||
| // MiMo charges 2x above 256K context | ||
| longContextPricing: { | ||
| thresholdTokens: 256_000, | ||
| inputPriceMultiplier: 2, | ||
| outputPriceMultiplier: 2, | ||
| cacheReadsPriceMultiplier: 2, | ||
| }, | ||
| description: | ||
| "MiMo V2.5 - Full-modal understanding model (text, image, audio, video) with 1M context, deep thinking, tool calling, and structured output.", | ||
| "MiMo V2.6 Flash - Full-modality, low-cost reasoning model for high-frequency calls and large-scale tasks.", | ||
| }, | ||
| "mimo-v2.6-pro-ultraspeed": { | ||
| maxTokens: 131_072, | ||
| contextWindow: 1_048_576, | ||
| supportsImages: true, // Full-modality like the rest of the V2.6 series | ||
| supportsPromptCache: false, | ||
| preserveReasoning: true, | ||
| inputPrice: 4.35, // $4.35/1M tokens (cache miss) | ||
| outputPrice: 8.7, // $8.70/1M tokens | ||
| cacheReadsPrice: 0.036, // $0.036/1M tokens (cache hit) | ||
| cacheWritesPrice: 0, // Free for limited time | ||
| description: | ||
| "MiMo V2.6 Pro Ultraspeed - V2.6-Pro performance at up to 20x speed for real-time, latency-sensitive workloads.", | ||
| }, | ||
| } as const satisfies Record<string, ModelInfo> | ||
|
|
||
| export const mimoDefaultModelInfo: ModelInfo = mimoModels[mimoDefaultModelId] | ||
|
|
||
| /** | ||
| * Looks up a curated spec by model ID, or `undefined` when the ID is not in the | ||
| * catalog. A plain `mimoModels[id]` read is not safe for untrusted IDs: the | ||
| * dynamic catalog feeds IDs straight from the provider's `/models` response, so | ||
| * an ID like `constructor` or `toString` resolves to an inherited | ||
| * `Object.prototype` member instead of a spec, and spreading it yields `{}` -- | ||
| * a model with no metadata at all. Callers fall back to family defaults on | ||
| * `undefined`. | ||
| */ | ||
| export function getMimoModelInfo(modelId: string): ModelInfo | undefined { | ||
| return Object.hasOwn(mimoModels, modelId) ? mimoModels[modelId as MimoModelId] : undefined | ||
| } | ||
|
|
||
| export const MIMO_DEFAULT_TEMPERATURE = 1.0 | ||
|
|
||
| export const mimoProviderDefinition = createProviderDefinition({ | ||
|
|
@@ -77,6 +97,12 @@ export const mimoProviderDefinition = createProviderDefinition({ | |
| getDefaultModelId: () => mimoDefaultModelId, | ||
| schema: { | ||
| ...apiModelIdProviderModelShape, | ||
| // The four allowed Xiaomi MiMo endpoints. Runtime paths that read this | ||
| // value BEFORE schema validation can apply — unsaved webview values, the | ||
| // fail-open ContextProxy path, the chat-completion client — all enforce | ||
| // the same set via ALLOWED_BASE_URLS in | ||
| // src/api/providers/fetchers/mimo.ts; keep these literals in sync with | ||
| // that set (fetcher tests cross-check both directions of the mapping). | ||
| mimoBaseUrl: z | ||
| .union([ | ||
| z.literal("https://api.xiaomimimo.com/v1"), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| // npx vitest run api/providers/__tests__/mimo-redirect.spec.ts | ||
|
|
||
| // Deliberately does NOT mock "openai": this suite proves the redirect policy the | ||
| // real SDK client actually sends, which a mocked constructor cannot show. | ||
| import type { Anthropic } from "@anthropic-ai/sdk" | ||
|
|
||
| import type { ApiHandlerOptions } from "../../../shared/api" | ||
| import { MimoHandler } from "../mimo" | ||
| import { collectStream } from "../../../test-utils/stream" | ||
|
|
||
| const ALLOWED_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1" | ||
| const REDIRECT_TARGET = "https://attacker.example/collect" | ||
|
|
||
| const options = (): ApiHandlerOptions => ({ | ||
| mimoApiKey: "test-api-key", | ||
| apiModelId: "mimo-v2.6-pro", | ||
| mimoBaseUrl: ALLOWED_BASE_URL, | ||
| }) | ||
|
|
||
| const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] | ||
|
|
||
| /** | ||
| * Stands in for the transport, emulating the fetch redirect contract rather than | ||
| * just recording the init: a 3xx is refused when the init says redirect:"error", | ||
| * and otherwise replayed against the Location target with the same method, | ||
| * headers, and body -- exactly what undici's default redirect:"follow" does. That | ||
| * is what makes this a behavioral test: the assertion below fails on the old | ||
| * code because the request really does reach the attacker URL. | ||
| */ | ||
| const redirectingFetch = () => { | ||
| const calls: { url: string; init: RequestInit }[] = [] | ||
|
|
||
| const redirect = (status: number, location: string) => | ||
| new Response(null, { status, statusText: "Redirect", headers: { location } }) | ||
|
|
||
| const fetchDouble = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { | ||
| const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url | ||
| calls.push({ url, init: init ?? {} }) | ||
|
|
||
| const response = url.startsWith(ALLOWED_BASE_URL) | ||
| ? redirect(307, REDIRECT_TARGET) | ||
| : new Response('data: {"choices":[{"delta":{"content":"leaked"},"index":0}]}\n\ndata: [DONE]\n\n', { | ||
| status: 200, | ||
| headers: { "content-type": "text/event-stream" }, | ||
| }) | ||
|
|
||
| const location = response.headers.get("location") | ||
| if (!location) { | ||
| return response | ||
| } | ||
|
|
||
| if (init?.redirect === "error") { | ||
| throw new TypeError("fetch failed: unexpected redirect") | ||
| } | ||
|
|
||
| if (init?.redirect === "manual") { | ||
| return response | ||
| } | ||
|
|
||
| // Default policy: follow. Re-issue the same request against the target. | ||
| return fetchDouble(new URL(location, url).toString(), init) | ||
| } | ||
|
|
||
| return { fetchDouble, calls } | ||
| } | ||
|
|
||
| describe("MimoHandler inference client redirect policy", () => { | ||
| const originalFetch = globalThis.fetch | ||
|
|
||
| afterEach(() => { | ||
| globalThis.fetch = originalFetch | ||
| vi.restoreAllMocks() | ||
| }) | ||
|
|
||
| it("pins redirect:'error' on the SDK client it builds", () => { | ||
| const handler = new MimoHandler(options()) | ||
|
|
||
| // The SDK spreads client.fetchOptions into every request init, so this is | ||
| // the value the transport receives. Asserted directly as well as through | ||
| // the request below, so a change to the SDK's plumbing fails here first. | ||
| expect(handler["client"].fetchOptions).toEqual({ redirect: "error" }) | ||
| }) | ||
|
|
||
| // Regression: the SDK's default fetch follows 3xx, so an allowlisted Xiaomi | ||
| // origin answering with a redirect replayed the POST body (system prompt, | ||
| // conversation, tool output) to an origin the allowlist never vetted. Before | ||
| // the fix the 307 was followed and the request resolved against the attacker | ||
| // URL; with the policy pinned, the request must reject instead. | ||
| it("rejects a 307 from an allowlisted origin instead of replaying the request cross-origin", async () => { | ||
| const { fetchDouble, calls } = redirectingFetch() | ||
| globalThis.fetch = fetchDouble as unknown as typeof fetch | ||
|
|
||
| const handler = new MimoHandler(options()) | ||
|
|
||
| await expect(collectStream(handler.createMessage("System prompt", messages))).rejects.toThrow() | ||
|
|
||
| // The transport was told not to follow, and nothing was ever sent to the | ||
| // redirect target. | ||
| expect(calls.length).toBeGreaterThan(0) | ||
| for (const call of calls) { | ||
| expect(call.init.redirect).toBe("error") | ||
| expect(call.url.startsWith(ALLOWED_BASE_URL)).toBe(true) | ||
| } | ||
| expect(calls.some((call) => call.url.startsWith(REDIRECT_TARGET))).toBe(false) | ||
| }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.