diff --git a/.gitignore b/.gitignore index 6773d550..e17d69b7 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,14 @@ graphify-out/* # Kiro local workspace state .kiro/ 9router-* + +# Local sensitive / temp files +.engine-token.txt +.tmp-prov.json +.tmp-providers.json +.oauth-session.json +.dev-server.log +.dev-server-err.log +.start-dev.ps1 +start-dev-silent.cjs +debug.log diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 8dd03421..f48a8ecb 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -17,6 +17,7 @@ import { PerplexityWebExecutor } from "./perplexity-web.js"; import { OllamaLocalExecutor } from "./ollama-local.js"; import { CommandCodeExecutor } from "./commandcode.js"; import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; +import { XiaomiMimoExecutor } from "./xiaomi-mimo.js"; import { MimoFreeExecutor } from "./mimo-free.js"; import { CodeBuddyExecutor } from "./codebuddy-cn.js"; import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; @@ -50,6 +51,7 @@ const executors = { "ollama-local": new OllamaLocalExecutor(), commandcode: new CommandCodeExecutor(), "xiaomi-tokenplan": new XiaomiTokenplanExecutor(), + "xiaomi-mimo": new XiaomiMimoExecutor(), "mimo-free": new MimoFreeExecutor(), mmf: new MimoFreeExecutor(), // Alias for mimo-free "codebuddy-cn": new CodeBuddyExecutor(), @@ -93,6 +95,7 @@ export { PerplexityWebExecutor } from "./perplexity-web.js"; export { OllamaLocalExecutor } from "./ollama-local.js"; export { CommandCodeExecutor } from "./commandcode.js"; export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; +export { XiaomiMimoExecutor } from "./xiaomi-mimo.js"; export { MimoFreeExecutor } from "./mimo-free.js"; export { CodeBuddyExecutor } from "./codebuddy-cn.js"; export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; diff --git a/open-sse/executors/xiaomi-mimo.js b/open-sse/executors/xiaomi-mimo.js new file mode 100644 index 00000000..8b69412a --- /dev/null +++ b/open-sse/executors/xiaomi-mimo.js @@ -0,0 +1,99 @@ +import { DefaultExecutor } from "./default.js"; +import { getMimoAccountCookie, invalidateMimoAccountCookieCache, MIMO_API_BASE, MIMO_API_UA } from "../shared/mimoAccount.js"; + +// Desktop-exclusive Preview models. These are served by the account service's +// /api/route proxy, authorized by the Xiaomi account session (NOT the sk- key). +// See shared/mimoAccount.js for the session handshake. +const PREVIEW_MODELS = new Set(["mimo-x-pro-preview", "mimo-x-flash-preview"]); + +// Session cookie resolved in execute() (async) and read back by buildHeaders() +// (sync — BaseExecutor.execute does not await it). Carried on the per-request +// credentials object, same as runtimeTransport. +const COOKIE_KEY = "__mimoAccountCookie"; + +// Upstream calls may hand us either the bare id or a `provider/model` ref. +function bareModel(model) { + const s = String(model || ""); + const i = s.indexOf("/"); + return i >= 0 ? s.slice(i + 1) : s; +} + +export class XiaomiMimoExecutor extends DefaultExecutor { + constructor() { + super("xiaomi-mimo"); + } + + static isPreviewModel(model) { + return PREVIEW_MODELS.has(bareModel(model)); + } + + buildUrl(model, stream, urlIndex = 0, credentials = null) { + // Preview models live on the account-service route, which is not one of the + // declared transports — resolve it before the default runtimeTransport path. + if (XiaomiMimoExecutor.isPreviewModel(model)) { + return `${MIMO_API_BASE}/api/route/chat/completions`; + } + // Cloud API models keep default handling, so a Claude-format client reaches + // the /anthropic/v1/messages transport. + return super.buildUrl(model, stream, urlIndex, credentials); + } + + buildHeaders(credentials, stream = true, url, model) { + if (XiaomiMimoExecutor.isPreviewModel(model) && credentials?.[COOKIE_KEY]) { + // Preview models authenticate with the account-session cookie, not the key. + return { + "Content-Type": "application/json", + Accept: stream ? "text/event-stream" : "application/json", + "User-Agent": MIMO_API_UA, + Cookie: credentials[COOKIE_KEY], + }; + } + return super.buildHeaders(credentials, stream, url, model); + } + + transformRequest(model, body, stream, credentials) { + // super runs stripUnsupportedParams, which flattens Preview content-part + // arrays (see the xiaomi-mimo rule in translator/concerns/paramSupport.js). + const out = super.transformRequest(model, body, stream, credentials); + + // Preview models: thinking/params get defaults only — never override what the + // caller set explicitly. (body.model is already `xiaomi/` via upstreamModelId.) + if (XiaomiMimoExecutor.isPreviewModel(model)) { + if (out.thinking == null) out.thinking = { type: "enabled" }; + if (out.temperature == null) out.temperature = 1.0; + if (out.top_p == null) out.top_p = 0.95; + if (!out.max_tokens) out.max_tokens = 4096; + } + + return out; + } + + async execute(args) { + const { model, credentials, proxyOptions = null } = args; + if (!XiaomiMimoExecutor.isPreviewModel(model)) return super.execute(args); + + const cookie = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions); + if (!cookie) { + throw new Error( + "Xiaomi MiMo account session unavailable. Sign in to MiMo Desktop once so its passToken is present, then retry.", + ); + } + credentials[COOKIE_KEY] = cookie; + const result = await super.execute(args); + + // A cached session can expire early — drop it and retry once with a fresh one. + if (result.response.status === 401) { + invalidateMimoAccountCookieCache(); + const fresh = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions).catch(() => null); + if (fresh) { + credentials[COOKIE_KEY] = fresh; + return super.execute(args); + } + } + return result; + } +} + +export const __test__ = { PREVIEW_MODELS, bareModel, COOKIE_KEY }; + +export default XiaomiMimoExecutor; diff --git a/open-sse/providers/registry/xiaomi-mimo.js b/open-sse/providers/registry/xiaomi-mimo.js index 49465f43..cb0139b3 100644 --- a/open-sse/providers/registry/xiaomi-mimo.js +++ b/open-sse/providers/registry/xiaomi-mimo.js @@ -1,11 +1,19 @@ import { CLAUDE_API_HEADERS } from "../shared.js"; +// Dual auth (same pattern as kimi): +// - API key (sk-...) → cloud API on api.xiaomimimo.com +// - Desktop account/OAuth → same cloud host, plus the Desktop-exclusive Preview +// models served by the account-service route on mimo-server-cn.xiaomimimo.com +// (authorized by a Xiaomi account session cookie, not the key). +// Endpoint is picked per model in the executor, same as opencode-go's /responses split. export default { id: "xiaomi-mimo", priority: 290, alias: "xiaomi-mimo", aliases: [ "mimo", + "mimo-desktop", + "xmd", ], uiAlias: "mimo", display: { @@ -16,9 +24,12 @@ export default { website: "https://xiaomimimo.com", notice: { apiKeyUrl: "https://platform.xiaomimimo.com/console/api-keys", + signupUrl: "https://mimo.xiaomimimo.com/desktop/invite/", }, }, - category: "apikey", + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, serviceKinds: ["llm", "tts"], transport: { baseUrl: "https://api.xiaomimimo.com/v1/chat/completions", @@ -39,6 +50,11 @@ export default { }, ], models: [ + // Desktop-exclusive — served by the account-service route, which only accepts + // OpenAI format, so supportedFormats pins them to the openai transport. + { id: "mimo-x-pro-preview", name: "MiMo-X-Pro-Preview", upstreamModelId: "xiaomi/mimo-x-pro-preview", supportedFormats: ["openai"] }, + { id: "mimo-x-flash-preview", name: "MiMo-X-Flash-Preview", upstreamModelId: "xiaomi/mimo-x-flash-preview", supportedFormats: ["openai"] }, + // Cloud API models (api.xiaomimimo.com/v1) { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" }, { id: "mimo-v2.5", name: "MiMo V2.5" }, { id: "mimo-v2-omni", name: "MiMo V2 Omni" }, @@ -51,4 +67,18 @@ export default { authHeader: "bearer", format: "xiaomi-mimo-tts", }, + features: { + usage: true, + usageApikey: true, + }, + // Custom OAuth — non-standard ECDH encrypted-callback flow. + // Handled by the Xiaomi MiMo OAuth service, not the generic PKCE pipeline. + oauth: { + custom: true, + authorizeUrl: "https://platform.xiaomimimo.com/authorize", + // The callback carries ?u= instead of ?code=. + // Decryption yields { uid, sk, url }. + callbackParam: "u", + kn: "mimocode", + }, }; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index eeb46c3c..d7868760 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -17,6 +17,7 @@ import { getDeepseekUsage } from "./usage/deepseek.js"; import { getOpenCodeGoUsage } from "./usage/opencode-go.js"; import { getGroqUsage } from "./usage/groq.js"; import { getZedUsage } from "./usage/zed.js"; +import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.js"; import { resolveQoderCredentials } from "./qoderModels.js"; import { getGlmUsage } from "./usage/glm.js"; import { @@ -60,6 +61,7 @@ const USAGE_HANDLERS = { deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions), groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions), zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), + "xiaomi-mimo": (c) => getXiaomiMimoUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null, options = {}) { diff --git a/open-sse/services/usage/xiaomi-mimo.js b/open-sse/services/usage/xiaomi-mimo.js new file mode 100644 index 00000000..9d42fe37 --- /dev/null +++ b/open-sse/services/usage/xiaomi-mimo.js @@ -0,0 +1,125 @@ +/** + * Xiaomi MiMo usage — weekly quota from the Xiaomi account session. + * + * Primary path: GET {mimo-server}/api/user/usage authorized by the account-session + * cookie (see shared/mimoAccount.js). Response: { code: 0, data: { percent (remaining + * %), resetDate, resetAt } }. + * + * Fallback: the sk- API key cannot read the quota, so when no account session is + * available we surface a graceful message instead of failing. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { getMimoAccountUsage } from "../../shared/mimoAccount.js"; + +const USAGE_URL = "https://aistudio.xiaomimimo.com/open-apis/v1/user/usage"; + +/** + * @param {string|null|undefined} accessToken - sk- API key + * @param {object|null} providerSpecificData - may contain mimoPassToken, uid, etc. + * @param {object|null} proxyOptions + */ +export async function getXiaomiMimoUsage(accessToken = null, providerSpecificData = null, proxyOptions = null) { + // Preferred path: the weekly quota comes from the account service session + // (mimo-server /api/user/usage), which the sk- key cannot reach. The session is + // derived from MiMo Desktop's persisted passToken via the SSO/sts handshake. + const account = await getMimoAccountUsage(providerSpecificData, proxyOptions); + if (typeof account.percent === "number" && Number.isFinite(account.percent)) { + const remaining = Math.max(0, Math.min(100, Math.round(account.percent))); + const used = 100 - remaining; + let resetAt = null; + if (typeof account.resetAt === "number" && account.resetAt > 0) { + resetAt = new Date(account.resetAt * 1000).toISOString(); + } else if (typeof account.resetDate === "string") { + const parsed = new Date(`${account.resetDate}T00:00:00Z`); + if (!Number.isNaN(parsed.getTime())) resetAt = parsed.toISOString(); + } + return { + plan: "Xiaomi MiMo Desktop", + quotas: { + Weekly: { used, total: 100, remainingPercentage: remaining, resetAt, unlimited: false }, + }, + }; + } + + // Fallback: no account session available (Desktop never logged in, or its cookie + // store is locked). The sk- key cannot read the quota, so surface a clear message. + const key = accessToken || providerSpecificData?.apiKey; + if (!key || typeof key !== "string" || !key.trim()) { + return { message: "Xiaomi MiMo Desktop not connected. Add credentials to view usage." }; + } + + try { + const response = await proxyAwareFetch( + USAGE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${key.trim()}`, + "X-Mimo-Source": "mimocode-cli", + Accept: "application/json", + }, + signal: AbortSignal.timeout(10000), + }, + proxyOptions, + ); + + if (response.status === 401) { + return { + plan: "Xiaomi MiMo Desktop", + message: "Weekly quota requires Xiaomi account session. API key alone is insufficient.", + }; + } + + if (!response.ok) { + return { + plan: "Xiaomi MiMo Desktop", + message: `Usage API error (${response.status})`, + }; + } + + const data = await response.json().catch(() => null); + if (!data || data.code !== 0 || !data.data) { + return { + plan: "Xiaomi MiMo Desktop", + message: "Usage endpoint returned unexpected response.", + }; + } + + const { percent, resetDate } = data.data; + if (typeof percent !== "number" || !Number.isFinite(percent)) { + return { + plan: "Xiaomi MiMo Desktop", + message: "Usage data missing percent field.", + }; + } + + // percent = remaining percentage (e.g. 94 means 94% remaining) + const remaining = Math.max(0, Math.min(100, Math.round(percent))); + const used = 100 - remaining; + + // Parse resetDate — expected format "2026-09-16" + let resetAt = null; + if (resetDate && typeof resetDate === "string") { + const parsed = new Date(`${resetDate}T00:00:00Z`); + if (!Number.isNaN(parsed.getTime())) { + resetAt = parsed.toISOString(); + } + } + + return { + plan: "Xiaomi MiMo Desktop", + quotas: { + Weekly: { + used, + total: 100, + remainingPercentage: remaining, + resetAt, + unlimited: false, + }, + }, + }; + } catch (error) { + return { message: `Xiaomi MiMo Desktop usage error: ${error.message}` }; + } +} diff --git a/open-sse/shared/mimoAccount.js b/open-sse/shared/mimoAccount.js new file mode 100644 index 00000000..996f38df --- /dev/null +++ b/open-sse/shared/mimoAccount.js @@ -0,0 +1,264 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +/** + * Xiaomi MiMo account-session helpers (used for weekly quota). + * + * The weekly quota endpoint lives on the account service domain and is authorized + * by an account session cookie, NOT the sk- API key. Acquiring that cookie mirrors + * MiMo Desktop: a passToken (persisted in Desktop's cookie store) is exchanged via + * the passportapi SSO, then authorized for the `mimopc` service, and finally stamped + * by the mimo-server /api/sts callback into a `serviceToken` cookie. + * + * Flow (verified against MiMo Desktop traffic): + * 1. GET {api}/api/user/xiaomi/me -> 302 to account SSO (sid=mimopc) + * 2. GET account /pass/serviceLogin?sid=passportapi&_json=true -> nonce/ssecurity + * 3. GET {location}&clientSign=... -> account-level serviceToken + * 4. GET account /pass/serviceLogin?sid=mimopc&callback=&_json=true + * 5. GET {api}/api/sts?...&ticket... -> Set-Cookie: serviceToken (mimopc scope) + */ + +const API_BASE = "https://mimo-server-cn.xiaomimimo.com"; +const ACCOUNT_HOST = "account.xiaomi.com"; +const API_UA = + "miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0"; +const SSO_UA = "MiClaw/1.0"; +const COOKIE_TTL_MS = 30 * 60 * 1000; + +// Per-account session caches (keyed by passToken hash) so multiple Xiaomi +// accounts / connections can rotate without clobbering each other. +const _cache = new Map(); // key -> { cookie, at } +const _inflight = new Map(); // key -> Promise + +function desktopCookiePath() { + const home = os.homedir(); + if (process.platform === "win32") { + return path.join(home, "AppData", "Roaming", "Xiaomi MiMo", "Partitions", "xiaomi-account", "Network", "Cookies"); + } + if (process.platform === "darwin") { + return path.join(home, "Library", "Application Support", "Xiaomi MiMo", "Partitions", "xiaomi-account", "Network", "Cookies"); + } + return path.join(home, ".config", "Xiaomi MiMo", "Partitions", "xiaomi-account", "Network", "Cookies"); +} + +/** + * Read the persisted Xiaomi account cookies from MiMo Desktop's Electron profile. + * The Chromium cookie DB is held with an exclusive lock while Desktop runs, so we + * copy it first and bail (return null) if that fails. + * @returns {Promise|null>} + */ +async function readDesktopAccountCookies() { + const src = desktopCookiePath(); + if (!fs.existsSync(src)) return null; + const tmp = path.join(os.tmpdir(), `9r-mimo-cookies-${process.pid}-${crypto.randomBytes(4).toString("hex")}.db`); + try { + fs.copyFileSync(src, tmp); + } catch { + return null; // locked by a running Desktop + } + try { + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(tmp, { readOnly: true }); + const rows = db.prepare("SELECT name, value FROM cookies WHERE host_key = ?").all("." + ACCOUNT_HOST); + db.close(); + const jar = Object.fromEntries(rows.map((r) => [r.name, r.value])); + return jar.passToken ? jar : null; + } catch { + return null; + } finally { + try { + fs.unlinkSync(tmp); + } catch { + /* ignore */ + } + } +} + +/** + * Read just the passToken + identity cookies from Desktop's profile. + * Exported so the connect flow can persist a per-account passToken into the + * connection's providerSpecificData — this is what enables multi-account rotation. + * @returns {Promise<{passToken:string, userId:string|null, cUserId:string|null}|null>} + */ +export async function readDesktopPassToken() { + try { + const jar = await readDesktopAccountCookies(); + if (!jar?.passToken) return null; + return { passToken: jar.passToken, userId: jar.userId || null, cUserId: jar.cUserId || null }; + } catch { + return null; + } +} + +function signatureClientSign(nonce, ssecurity) { + const input = `nonce=${nonce}` + (ssecurity && ssecurity.trim() ? `&${ssecurity}` : ""); + return encodeURIComponent(crypto.createHash("sha1").update(input).digest("base64")); +} + +function absorbSetCookie(jar, res) { + for (const c of res.headers.getSetCookie?.() || []) { + const m = /^([^=]+)=([^;]*)/.exec(c.trim()); + if (m && m[2]) jar[m[1]] = m[2]; + } +} + +function cookieHeader(jar) { + return Object.entries(jar) + .filter(([, v]) => v) + .map(([k, v]) => `${k}=${v}`) + .join("; "); +} + +/** + * Exchange a passToken for a mimo-server service session cookie. + * @returns {Promise} Cookie header value, or null on failure. + */ +async function acquireServiceCookie(passJar, proxyOptions) { + const jar = { ...passJar }; + const ck = () => cookieHeader(jar); + + // 1. Unauthenticated API call -> 302 carrying the sts callback (sid=mimopc) + const r1 = await proxyAwareFetch( + `${API_BASE}/api/user/xiaomi/me`, + { redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } }, + proxyOptions, + ); + const redirect = r1.headers.get("location"); + if (!redirect) return null; + const stsCallback = new URL(redirect).searchParams.get("callback"); + if (!stsCallback) return null; + + // 2. passportapi SSO phase 1 -> nonce + ssecurity + const sso1 = await proxyAwareFetch( + `https://${ACCOUNT_HOST}/pass/serviceLogin?sid=passportapi&_json=true`, + { headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } }, + proxyOptions, + ); + const j1 = JSON.parse((await sso1.text()).replace(/^&&&START&&&/, "")); + const nonce = j1.nonce || (j1.location ? new URL(j1.location).searchParams.get("nonce") : null); + if (!nonce || !j1.location) return null; + + // 3. passportapi SSO phase 2 -> account-level serviceToken + const sso2 = await proxyAwareFetch( + `${j1.location}&clientSign=${signatureClientSign(nonce, j1.ssecurity)}`, + { redirect: "manual", headers: { Cookie: ck(), "User-Agent": SSO_UA } }, + proxyOptions, + ); + absorbSetCookie(jar, sso2); + + // 4. mimopc SSO -> sts callback carrying a ticket + const sso3 = await proxyAwareFetch( + `https://${ACCOUNT_HOST}/pass/serviceLogin?sid=mimopc&callback=${encodeURIComponent(stsCallback)}&_json=true`, + { headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } }, + proxyOptions, + ); + const j3 = JSON.parse((await sso3.text()).replace(/^&&&START&&&/, "")); + absorbSetCookie(jar, sso3); + if (!j3?.location || !/\/api\/sts/.test(j3.location)) return null; + + // 5. sts callback -> Set-Cookie: serviceToken (mimopc scope) + const sts = await proxyAwareFetch( + j3.location, + { redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } }, + proxyOptions, + ); + absorbSetCookie(jar, sts); + + const needed = ["serviceToken", "mimopc_ph", "mimopc_slh", "userId"]; + if (!jar.serviceToken) return null; + const out = {}; + for (const k of needed) if (jar[k]) out[k] = jar[k]; + return cookieHeader(out); +} + +/** + * Get (and cache) the mimo-server account cookie. + * @param {object|null} providerSpecificData - may carry `mimoPassToken` override + */ +async function getServiceCookie(providerSpecificData, proxyOptions) { + const passJar = providerSpecificData?.mimoPassToken + ? { passToken: providerSpecificData.mimoPassToken, userId: providerSpecificData.mimoUserId, cUserId: providerSpecificData.mimoCUserId } + : await readDesktopAccountCookies(); + if (!passJar) return { cookie: null, reason: "no-pass-token" }; + + // One cached session per passToken — accounts/connections rotate independently. + const key = crypto.createHash("sha256").update(passJar.passToken).digest("hex"); + + const cached = _cache.get(key); + if (cached && Date.now() - cached.at < COOKIE_TTL_MS) { + return { cookie: cached.cookie }; + } + + // De-dupe concurrent handshakes for the same account: a burst of requests must + // not each run the full 5-step SSO chain. + const inflight = _inflight.get(key); + if (inflight) { + const cookie = await inflight; + return cookie ? { cookie } : { cookie: null, reason: "sso-failed" }; + } + + const promise = (async () => { + try { + return await acquireServiceCookie(passJar, proxyOptions); + } catch { + return null; // network/parse failure — callers degrade, never throw + } finally { + _inflight.delete(key); + } + })(); + _inflight.set(key, promise); + + const cookie = await promise; + if (!cookie) return { cookie: null, reason: "sso-failed" }; + _cache.set(key, { cookie, at: Date.now() }); + return { cookie }; +} + +/** Drop cached sessions so the next call re-runs the handshake (e.g. after a 401). */ +export function invalidateMimoAccountCookieCache() { + _cache.clear(); +} + +/** mimo-server account API base + the User-Agent its backend expects. */ +export const MIMO_API_BASE = API_BASE; +export const MIMO_API_UA = API_UA; + +/** + * Resolve the mimo-server account-session cookie, for upstream /api/route/* calls. + * @returns {Promise} Cookie header value, or null when unavailable. + */ +export async function getMimoAccountCookie(providerSpecificData = null, proxyOptions = null) { + try { + const { cookie } = await getServiceCookie(providerSpecificData, proxyOptions); + return cookie; + } catch { + return null; + } +} + +/** + * Fetch the weekly quota from the account service. + * @returns {Promise<{percent?:number, resetDate?:string, resetAt?:number, error?:string}>} + */ +export async function getMimoAccountUsage(providerSpecificData = null, proxyOptions = null) { + const { cookie, reason } = await getServiceCookie(providerSpecificData, proxyOptions); + if (!cookie) { + return { error: reason === "no-pass-token" ? "no-session" : "session-failed" }; + } + try { + const res = await proxyAwareFetch( + `${API_BASE}/api/user/usage`, + { headers: { "User-Agent": API_UA, Cookie: cookie, Accept: "application/json" }, signal: AbortSignal.timeout(10000) }, + proxyOptions, + ); + if (!res.ok) return { error: `http-${res.status}` }; + const data = await res.json().catch(() => null); + if (!data || data.code !== 0 || !data.data) return { error: "bad-response" }; + return { percent: data.data.percent, resetDate: data.data.resetDate, resetAt: data.data.resetAt }; + } catch (e) { + return { error: e.message }; + } +} diff --git a/open-sse/translator/concerns/paramSupport.js b/open-sse/translator/concerns/paramSupport.js index e222b23f..863b627e 100644 --- a/open-sse/translator/concerns/paramSupport.js +++ b/open-sse/translator/concerns/paramSupport.js @@ -14,6 +14,9 @@ const STRIP_RULES = [ { provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] }, // Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926) { provider: "cloudflare-ai", flattenContent: true }, + // MiMo Desktop Preview models (account-service route): content must be plain string, + // rejects OpenAI content-part array. Cloud models keep their parts (mimo-v2-omni is multi-modal). + { provider: "xiaomi-mimo", match: /preview/i, flattenContent: true }, { provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true }, // VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's // advertised ceiling is far higher (Kimi-K2.7-Code resolves to maxOutput 262144), diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 9657b7ed..5ae217fb 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon"; -import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components"; +import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, XiaomiMimoAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers"; import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js"; @@ -45,6 +45,7 @@ export default function ProviderDetailPage() { const [providerNode, setProviderNode] = useState(null); const [proxyPools, setProxyPools] = useState([]); const [showOAuthModal, setShowOAuthModal] = useState(false); + const [showXiaomiMimoModal, setShowXiaomiMimoModal] = useState(false); const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false); const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); const [addConnectionError, setAddConnectionError] = useState(""); @@ -98,6 +99,11 @@ export default function ProviderDetailPage() { return; } } + // Xiaomi Desktop: auto-import local credentials first, OAuth as fallback + if (providerId === "xiaomi-mimo") { + setShowXiaomiMimoModal(true); + return; + } if (isOAuth) { openOAuthConnection(); return; @@ -1796,6 +1802,13 @@ export default function ProviderDetailPage() { onClose={() => setShowOAuthModal(false)} /> )} + + {/* Xiaomi Desktop: auto-import local credentials modal */} + setShowXiaomiMimoModal(false)} + /> {providerId === "iflow" && ( c.provider === "xiaomi-mimo" && ( + (uid && c.email === `${uid}@xiaomi`) || + c.accessToken === key + ), + ); + if (existing) { + const updated = await updateProviderConnection(existing.id, { + accessToken: key, + providerSpecificData: { + ...existing.providerSpecificData, + uid: uid || existing.providerSpecificData?.uid || null, + baseUrl: effectiveBaseUrl, + // Per-account session credential — enables multi-account rotation. + mimoPassToken: mimoPassToken || existing.providerSpecificData?.mimoPassToken || null, + mimoUserId: mimoUserId || existing.providerSpecificData?.mimoUserId || null, + mimoCUserId: mimoCUserId || existing.providerSpecificData?.mimoCUserId || null, + modelCount, + }, + testStatus: validated ? "active" : existing.testStatus, + }); + return NextResponse.json({ + success: true, + validated, + modelCount, + updated: true, + connection: { + id: existing.id, + provider: existing.provider, + email: existing.email, + displayName: existing.displayName, + }, + }); + } + + const connection = await createProviderConnection({ + provider: "xiaomi-mimo", + authType: "api_key", + accessToken: key, + refreshToken: null, + // API keys don't expire on a fixed schedule; use a long horizon + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + email: uid ? `${uid}@xiaomi` : null, + displayName: uid ? `Xiaomi ${uid}` : "Xiaomi MiMo", + providerSpecificData: { + uid: uid || null, + baseUrl: effectiveBaseUrl, + authMethod: "api_key", + provider: "API Key", + modelCount, + // Per-account session credential — enables multi-account rotation. + mimoPassToken: mimoPassToken || null, + mimoUserId: mimoUserId || null, + mimoCUserId: mimoCUserId || null, + }, + testStatus: validated ? "active" : "untested", + }); + + return NextResponse.json({ + success: true, + validated, + modelCount, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + }, + }); + } catch (error) { + console.log("Xiaomi MiMo API key import error:", error); + return NextResponse.json( + { error: "API key import failed" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/oauth/xiaomi-mimo/auto-import/route.js b/src/app/api/oauth/xiaomi-mimo/auto-import/route.js new file mode 100644 index 00000000..0508c222 --- /dev/null +++ b/src/app/api/oauth/xiaomi-mimo/auto-import/route.js @@ -0,0 +1,140 @@ +import { NextResponse } from "next/server"; +import { readFile, access, constants } from "fs/promises"; +import { homedir } from "os"; +import { join } from "path"; +import { readDesktopPassToken } from "open-sse/shared/mimoAccount.js"; + +/** + * GET /api/oauth/xiaomi-mimo/auto-import + * Auto-detect Xiaomi MiMo credentials from local auth.json. + * + * Sources (in priority order): + * 1. ~/.local/share/mimocode/auth.json → xiaomi field + * 2. %APPDATA%/Xiaomi MiMo/... → (future: Desktop keychain) + * + * auth.json shape: + * { + * "xiaomi": { + * "type": "api", + * "key": "sk-xxxx", + * "metadata": { "uid": "...", "base_url": "https://api.xiaomimimo.com/v1" } + * } + * } + */ + +function getCandidatePaths() { + const home = homedir(); + const paths = []; + + // MiMoCode / MiMo Desktop shared data dir (cross-platform XDG) + paths.push(join(home, ".local", "share", "mimocode", "auth.json")); + + // Windows: also check USERPROFILE-based XDG + if (process.platform === "win32") { + const appData = process.env.APPDATA || join(home, "AppData", "Roaming"); + // Desktop's own storage (may have separate credentials in the future) + paths.push(join(appData, "Xiaomi MiMo", "auth.json")); + } + + // macOS + if (process.platform === "darwin") { + paths.push( + join(home, "Library", "Application Support", "mimocode", "auth.json"), + ); + } + + return paths; +} + +/** + * GET /api/oauth/xiaomi-mimo/auto-import + */ +export async function GET() { + try { + const candidates = getCandidatePaths(); + + let authPath = null; + for (const candidate of candidates) { + try { + await access(candidate, constants.R_OK); + authPath = candidate; + break; + } catch { + // Try next candidate + } + } + + if (!authPath) { + return NextResponse.json({ + found: false, + error: `Xiaomi MiMo Desktop auth file not found. Checked:\n${candidates.join("\n")}\n\nMake sure Xiaomi MiMo Desktop is installed and you are signed in.`, + }); + } + + const raw = await readFile(authPath, "utf-8"); + let auth; + try { + auth = JSON.parse(raw); + } catch { + return NextResponse.json({ + found: false, + error: "auth.json is not valid JSON. Please sign in to Xiaomi MiMo Desktop again.", + }); + } + + const xiaomi = auth?.xiaomi; + if (!xiaomi || !xiaomi.key) { + return NextResponse.json({ + found: false, + error: "No Xiaomi credentials found in auth.json. Please sign in to Xiaomi MiMo Desktop.", + }); + } + + // Validate key format + const key = String(xiaomi.key).trim(); + if (!key.startsWith("sk-")) { + return NextResponse.json({ + found: false, + error: "Xiaomi key does not appear to be a valid API key (expected sk- prefix).", + }); + } + + const metadata = xiaomi.metadata || {}; + const uid = metadata.uid || null; + const baseUrl = metadata.base_url || "https://api.xiaomimimo.com/v1"; + + // Account-session passToken from Desktop's cookie store. Persisting it per + // connection is what lets multiple Xiaomi accounts rotate independently. + // (null while Desktop is running — its cookie DB is exclusively locked.) + let mimoPassToken = null; + let mimoUserId = null; + let mimoCUserId = null; + try { + const pt = await readDesktopPassToken(); + if (pt) { + mimoPassToken = pt.passToken; + mimoUserId = pt.userId; + mimoCUserId = pt.cUserId; + } + } catch (e) { + console.log("[xiaomi-mimo] passToken read failed (non-fatal):", e.message); + } + + return NextResponse.json({ + found: true, + apiKey: key, + uid, + baseUrl, + source: authPath, + mimoPassToken, + mimoUserId, + mimoCUserId, + }); + } catch (error) { + console.log("Xiaomi MiMo auto-import error:", error); + return NextResponse.json( + { found: false, error: error.message }, + { status: 500 }, + ); + } +} diff --git a/src/lib/db/driver.js b/src/lib/db/driver.js index 050514d9..17b6dd49 100644 --- a/src/lib/db/driver.js +++ b/src/lib/db/driver.js @@ -19,6 +19,10 @@ async function tryBunSqlite() { async function tryBetterSqlite() { // Skip on Bun — better-sqlite3 native bindings unsupported if (process.versions.bun) return null; + // Skip on Node >= 24: the native addon SIGSEGVs on load there, which is a + // process-level crash the try/catch below cannot recover from. node:sqlite covers it. + const [nodeMajor] = process.versions.node.split(".").map(Number); + if (nodeMajor >= 24) return null; try { const { createBetterSqliteAdapter } = await import("./adapters/betterSqliteAdapter.js"); return createBetterSqliteAdapter(DATA_FILE); diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index c9e3cae6..77cd3c13 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -130,6 +130,21 @@ export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] }; // 3) Redirect → ${cb}?refreshToken=...&loginHost=...&isRedirect=true // 4) POST ExchangeToken {ClientID, RefreshToken, ClientSecret:"-"} → {Result.AccessToken, ExpiresAt} // 5) POST GetUserInfo (x-cloudide-token) → email/name +// Xiaomi MiMo Desktop OAuth — custom ECDH encrypted-callback flow (NOT standard OAuth2). +// 1) Client generates X25519 keypair +// 2) Browser opens ${platformUrl}/authorize?pk=&redirect_uri=http://localhost:/&kn=mimocode&key_name=... +// 3) Redirect → http://localhost:/?u= +// 4) Decrypt: ECDH(shared) → SHA256 → AES-256-GCM +// Layout: [12-byte nonce][32-byte ephemeral pubkey][ciphertext][16-byte GCM tag] +// 5) Result JSON: { uid, sk, url } +export const XIAOMI_MIMO_CONFIG = { + platformUrl: process.env.MIMO_PLATFORM_URL || "https://platform.xiaomimimo.com", + defaultBaseUrl: "https://api.xiaomimimo.com/v1", + kn: "mimocode", + callbackPath: "/", + timeoutMs: 300000, // 5 minutes +}; + export const TRAE_CONFIG = { clientId: "ono9krqynydwx5", clientSecret: "-", diff --git a/src/lib/oauth/providers/xiaomi-mimo.js b/src/lib/oauth/providers/xiaomi-mimo.js new file mode 100644 index 00000000..ff85cf7b --- /dev/null +++ b/src/lib/oauth/providers/xiaomi-mimo.js @@ -0,0 +1,123 @@ +import crypto from "crypto"; +import { XIAOMI_MIMO_CONFIG } from "../constants/oauth.js"; + +// ─────────────────────────────────────────────────────────────────────────── +// Xiaomi MiMo OAuth helpers +// Custom ECDH + AES-256-GCM encrypted-callback flow (NOT standard OAuth2). +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Generate an X25519 keypair for the OAuth handshake. + * @returns {{ publicKey: string, privateKeyDer: Buffer }} + * publicKey — base64 SPKI (for the `pk` URL param) + * privateKeyDer — PKCS8 DER Buffer (for ECDH later) + */ +export function generateKeyPair() { + const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519"); + + const publicKeyDer = publicKey.export({ format: "der", type: "spki" }); + // SPKI for X25519 is 44 bytes; the raw 32-byte key is the last 32 bytes. + // But the platform expects the full base64 SPKI — pass as-is. + const publicKeyB64 = publicKeyDer.toString("base64"); + + const privateKeyDer = privateKey.export({ format: "der", type: "pkcs8" }); + + return { publicKey: publicKeyB64, privateKeyDer }; +} + +/** + * Decrypt the `u` query parameter from the Xiaomi OAuth callback. + * + * Wire format (base64-decoded): + * bytes 0..11 — 12-byte AES-GCM nonce + * bytes 12..43 — 32-byte ephemeral public key (raw X25519) + * bytes 44..n-16 — ciphertext + * last 16 bytes — GCM auth tag + * + * Key derivation: SHA256(ECDH(clientPrivateKey, ephemeralPublicKey)) + * + * @param {Buffer} privateKeyDer — PKCS8 DER private key from generateKeyPair() + * @param {string} encryptedB64 — the `u` query param value (base64) + * @returns {{ uid: string, sk: string, url?: string }} + */ +export function decryptCallback(privateKeyDer, encryptedB64) { + const raw = Buffer.from(encryptedB64, "base64"); + + if (raw.length < 12 + 32 + 16 + 1) { + throw new Error(`Encrypted payload too short: ${raw.length} bytes`); + } + + const nonce = raw.subarray(0, 12); + const ephemeralPubRaw = raw.subarray(12, 44); + const ciphertextAndTag = raw.subarray(44); + const tag = ciphertextAndTag.subarray(ciphertextAndTag.length - 16); + const ciphertext = ciphertextAndTag.subarray(0, ciphertextAndTag.length - 16); + + // Reconstruct the ephemeral public key as SPKI DER for Node crypto. + // X25519 SPKI prefix: 302a300506032b656e032100 + const ephemeralPub = crypto.createPublicKey({ + key: Buffer.concat([ + Buffer.from("302a300506032b656e032100", "hex"), + ephemeralPubRaw, + ]), + format: "der", + type: "spki", + }); + + const privateKey = crypto.createPrivateKey({ + key: privateKeyDer, + format: "der", + type: "pkcs8", + }); + + const sharedSecret = crypto.diffieHellman({ privateKey, publicKey: ephemeralPub }); + const derivedKey = crypto.createHash("sha256").update(sharedSecret).digest(); + + const decipher = crypto.createDecipheriv("aes-256-gcm", derivedKey, nonce); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + + const parsed = JSON.parse(decrypted.toString("utf-8")); + + if (!parsed || typeof parsed !== "object") { + throw new Error("Decrypted payload is not a valid object"); + } + + return { + uid: parsed.uid || null, + sk: parsed.sk || null, + url: parsed.url || XIAOMI_MIMO_CONFIG.defaultBaseUrl, + }; +} + +/** + * Build the browser authorization URL. + * @param {string} publicKey — base64 SPKI from generateKeyPair() + * @param {string} redirectUri — e.g. http://localhost:12345/ + * @param {string} [keyName] — optional stable key name + * @returns {string} + */ +export function buildAuthorizeUrl(publicKey, redirectUri, keyName) { + const params = new URLSearchParams({ + pk: publicKey, + redirect_uri: redirectUri, + kn: XIAOMI_MIMO_CONFIG.kn, + }); + if (keyName) params.set("key_name", keyName); + return `${XIAOMI_MIMO_CONFIG.platformUrl}/authorize?${params.toString()}`; +} + +/** + * Get or create a stable key name for this installation. + * Stored in the 9Router data dir so re-auth reuses the same name. + */ +export function getKeyName() { + // Use a deterministic name based on machine — avoids needing filesystem writes + // in the OAuth provider layer. The platform treats key_name as a label only. + const machineId = crypto + .createHash("sha256") + .update(`${process.platform}-${process.env.COMPUTERNAME || process.env.HOSTNAME || "unknown"}`) + .digest("hex") + .slice(0, 8); + return `9router-xmd-${machineId}`; +} diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js index 56eb67b1..80377752 100644 --- a/src/lib/oauth/utils/server.js +++ b/src/lib/oauth/utils/server.js @@ -755,3 +755,185 @@ export function stopZedProxy() { zedProxyPort = null; } +// ─────────────────────────────────────────────────────────────────────────── +// Xiaomi MiMo Desktop OAuth callback proxy +// Receives the ECDH-encrypted `u` param, decrypts it, stores the session. +// ─────────────────────────────────────────────────────────────────────────── + +let xiaomiMimoProxyServer = null; +let xiaomiMimoProxyPort = null; +let xiaomiMimoProxyTimeout = null; + +const xiaomiMimoSessions = new Map(); + +export function registerXiaomiMimoSession({ state, privateKeyDer }) { + if (!state || !privateKeyDer) return false; + xiaomiMimoSessions.set(state, { + privateKeyDer, + status: "pending", + createdAt: Date.now(), + }); + return true; +} + +export function getXiaomiMimoSessionStatus(state) { + const s = xiaomiMimoSessions.get(state); + if (!s) return null; + // Don't leak the private key to the client + return { status: s.status, result: s.result || null, error: s.error || null }; +} + +export function clearXiaomiMimoSession(state) { + xiaomiMimoSessions.delete(state); +} + +function renderXiaomiMimoResultPage(success, message) { + const color = success ? "#22c55e" : "#ef4444"; + const icon = success ? "✓" : "✗"; + const title = success ? "Authentication Successful" : "Authentication Failed"; + return ` + +${title} + + + +
+
${icon}
+

${title}

+

${message || (success ? "You can close this tab and return to 9Router." : "Please try again.")}

+ ${success ? "" : ""} +
+ +`; +} + +/** + * Start the Xiaomi Desktop OAuth callback proxy. + * @returns {Promise<{success: boolean, port?: number, callbackUrl?: string, reason?: string}>} + */ +export function startXiaomiMimoProxy() { + return new Promise((resolve) => { + if (xiaomiMimoProxyServer) { + resolve({ + success: true, + port: xiaomiMimoProxyPort, + callbackUrl: `http://127.0.0.1:${xiaomiMimoProxyPort}/`, + }); + return; + } + + const server = http.createServer(async (req, res) => { + // Origin guard + if (!isLoopbackOrigin(req.headers.origin)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + const url = new URL(req.url, "http://127.0.0.1"); + const u = url.searchParams.get("u"); + + if (!u) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXiaomiMimoResultPage(false, "Missing encrypted payload (u parameter).")); + return; + } + + // Try each pending session's private key — the callback URL carries no + // state param, so we attempt decryption with every pending key. + const pendingSessions = [...xiaomiMimoSessions.entries()] + .filter(([, s]) => s.status === "pending"); + + if (pendingSessions.length === 0) { + res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXiaomiMimoResultPage(false, "No active OAuth session. Please restart the login flow.")); + return; + } + + try { + const { decryptCallback } = await import("../providers/xiaomi-mimo.js"); + let result = null; + let matchedState = null; + + for (const [state, session] of pendingSessions) { + try { + result = decryptCallback(session.privateKeyDer, u); + matchedState = state; + break; + } catch { + // Wrong key for this session — try next + } + } + + if (!result || !matchedState) { + throw new Error("Could not decrypt with any pending session key"); + } + + if (!result.sk) { + throw new Error("Decrypted payload missing sk (API key)"); + } + + // Store result only in the matched session + const session = xiaomiMimoSessions.get(matchedState); + if (session) { + session.status = "done"; + session.result = { + uid: result.uid, + accessToken: result.sk, + baseUrl: result.url || "https://api.xiaomimimo.com/v1", + }; + } + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXiaomiMimoResultPage(true, "Xiaomi account linked. You can close this tab.")); + console.log("[xiaomi-mimo oauth] callback decrypted, uid:", result.uid); + } catch (err) { + console.error("[xiaomi-mimo oauth] decrypt failed:", err.message); + for (const [, session] of pendingSessions) { + session.status = "error"; + session.error = err.message; + } + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXiaomiMimoResultPage(false, `Decryption failed: ${err.message}`)); + } + }); + + server.on("error", (err) => { + console.log("[xiaomi-mimo oauth] listen error:", err.message); + resolve({ success: false, reason: err.message }); + }); + + server.listen(0, "127.0.0.1", () => { + xiaomiMimoProxyServer = server; + xiaomiMimoProxyPort = server.address().port; + xiaomiMimoProxyTimeout = setTimeout(() => { + console.log("[xiaomi-mimo oauth] timeout, stopping"); + stopXiaomiMimoProxy(); + }, 300000); + console.log(`[xiaomi-mimo oauth] listening on port ${xiaomiMimoProxyPort}`); + resolve({ + success: true, + port: xiaomiMimoProxyPort, + callbackUrl: `http://127.0.0.1:${xiaomiMimoProxyPort}/`, + }); + }); + }); +} + +export function stopXiaomiMimoProxy() { + console.log(`[xiaomi-mimo oauth] stopping (port ${xiaomiMimoProxyPort || "-"})`); + if (xiaomiMimoProxyTimeout) { clearTimeout(xiaomiMimoProxyTimeout); xiaomiMimoProxyTimeout = null; } + if (xiaomiMimoProxyServer) { xiaomiMimoProxyServer.close(); xiaomiMimoProxyServer = null; } + xiaomiMimoProxyPort = null; + // No callback can arrive once the listener is down, so drop every pending + // session — each holds an X25519 private key and they would otherwise + // accumulate for the process lifetime (one per /authorize call). + xiaomiMimoSessions.clear(); +} + diff --git a/src/shared/components/XiaomiMimoAuthModal.js b/src/shared/components/XiaomiMimoAuthModal.js new file mode 100644 index 00000000..e501698f --- /dev/null +++ b/src/shared/components/XiaomiMimoAuthModal.js @@ -0,0 +1,276 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Modal, Button } from "@/shared/components"; + +/** + * Xiaomi MiMo Auth Modal + * + * Auto-imports credentials from the local Xiaomi MiMo Desktop auth.json (~/.local/share/mimocode/auth.json). + * If auto-import fails, offers a one-click browser OAuth fallback. + * Reached only via the "Connect with OAuth" button — the API-key path uses the + * standard Add API Key modal, since Xiaomi MiMo supports both auth modes. + */ +export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) { + const [phase, setPhase] = useState("detecting"); // detecting | found | not-found | importing | error + const [detectResult, setDetectResult] = useState(null); + const [error, setError] = useState(null); + const [oauthUrl, setOauthUrl] = useState(null); + const [oauthState, setOauthState] = useState(null); + + // Auto-detect local credentials when modal opens + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + + (async () => { + setPhase("detecting"); + setError(null); + setDetectResult(null); + setOauthUrl(null); + + try { + const res = await fetch("/api/oauth/xiaomi-mimo/auto-import"); + const data = await res.json(); + if (cancelled) return; + + if (data.found && data.apiKey) { + setDetectResult(data); + setPhase("found"); + } else { + setPhase("not-found"); + setError(data.error || "Xiaomi MiMo Desktop credentials not found on this machine."); + } + } catch { + if (!cancelled) { + setPhase("not-found"); + setError("Failed to read local Xiaomi MiMo Desktop credentials."); + } + } + })(); + + return () => { cancelled = true; }; + }, [isOpen]); + + // Import the auto-detected key + const handleImport = async () => { + if (!detectResult?.apiKey) return; + setPhase("importing"); + setError(null); + + try { + const res = await fetch("/api/oauth/xiaomi-mimo/api-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: detectResult.apiKey, + uid: detectResult.uid, + baseUrl: detectResult.baseUrl, + mimoPassToken: detectResult.mimoPassToken || null, + mimoUserId: detectResult.mimoUserId || null, + mimoCUserId: detectResult.mimoCUserId || null, + }), + }); + const data = await res.json(); + + if (!res.ok || !data.success) { + throw new Error(data.error || "Import failed"); + } + + onSuccess?.(data.connection); + onClose(); + } catch (err) { + setPhase("found"); + setError(err.message); + } + }; + + // Start browser OAuth fallback + const handleStartOAuth = async () => { + setError(null); + try { + const state = crypto.randomUUID(); + const res = await fetch(`/api/oauth/xiaomi-mimo/authorize?state=${state}`); + const data = await res.json(); + if (data.authorizeUrl) { + setOauthUrl(data.authorizeUrl); + setOauthState(data.state); + window.open(data.authorizeUrl, "_blank", "width=600,height=700"); + } else { + throw new Error(data.error || "Failed to start OAuth"); + } + } catch (err) { + setError(err.message); + } + }; + + // Poll OAuth result + const handlePollOAuth = async () => { + if (!oauthState) return; + setError(null); + try { + const res = await fetch(`/api/oauth/xiaomi-mimo/poll-status?state=${oauthState}`); + const data = await res.json(); + + if (data.status === "done" && data.result) { + // Exchange to create the connection + const exRes = await fetch("/api/oauth/xiaomi-mimo/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: oauthState }), + }); + const exData = await exRes.json(); + if (exData.success) { + onSuccess?.(exData.connection); + onClose(); + } else { + throw new Error(exData.error || "Exchange failed"); + } + } else if (data.status === "error") { + throw new Error(data.error || "OAuth failed"); + } else { + setError("Authorization not completed yet. Finish in the browser, then click Check Again."); + } + } catch (err) { + setError(err.message); + } + }; + + return ( + +
+ {/* Detecting */} + {phase === "detecting" && ( +
+
+ + progress_activity + +
+

Reading local credentials...

+

+ Checking ~/.local/share/mimocode/auth.json +

+
+ )} + + {/* Found — one-click import */} + {phase === "found" && detectResult && ( + <> +
+
+ check_circle +
+

Xiaomi MiMo Desktop credentials found!

+

+ UID: {detectResult.uid || "—"} · Source: {detectResult.source?.split(/[\\/]/).pop()} +

+
+
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
+ + )} + + {/* Importing */} + {phase === "importing" && ( +
+
+ + progress_activity + +
+

Connecting...

+
+ )} + + {/* Not found — offer OAuth fallback */} + {phase === "not-found" && ( + <> +
+
+ info +
+

Local credentials not found

+

{error}

+

+ Make sure Xiaomi MiMo Desktop is installed and you are signed in, then retry. + Or sign in via browser below. +

+
+
+
+ + {!oauthUrl ? ( +
+ + +
+ ) : ( +
+
+

+ Browser opened. Complete the Xiaomi sign-in, then click{" "} + Check Again. +

+
+
+ + +
+
+ )} + + )} +
+
+ ); +} + +XiaomiMimoAuthModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onSuccess: PropTypes.func, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/index.js b/src/shared/components/index.js index c04453a9..3d508e91 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -28,6 +28,7 @@ export { default as KiroAuthModal } from "./KiroAuthModal"; export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper"; export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal"; export { default as CursorAuthModal } from "./CursorAuthModal"; +export { default as XiaomiMimoAuthModal } from "./XiaomiMimoAuthModal"; export { default as IFlowCookieModal } from "./IFlowCookieModal"; export { default as GitLabAuthModal } from "./GitLabAuthModal"; export { default as EditConnectionModal } from "./EditConnectionModal"; diff --git a/tests/unit/xiaomi-mimo-executor.test.js b/tests/unit/xiaomi-mimo-executor.test.js new file mode 100644 index 00000000..10c6a520 --- /dev/null +++ b/tests/unit/xiaomi-mimo-executor.test.js @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { XiaomiMimoExecutor, __test__ } from "../../open-sse/executors/xiaomi-mimo.js"; +import { getExecutor } from "../../open-sse/executors/index.js"; + +const { bareModel, COOKIE_KEY } = __test__; + +const OPENAI_T = { runtimeTransport: { format: "openai", baseUrl: "https://api.xiaomimimo.com/v1/chat/completions" } }; +const CLAUDE_T = { runtimeTransport: { format: "claude", baseUrl: "https://api.xiaomimimo.com/anthropic/v1/messages" } }; + +describe("xiaomi-mimo executor", () => { + let ex; + beforeEach(() => { + ex = new XiaomiMimoExecutor(); + }); + + it("is registered for xiaomi-mimo", () => { + expect(getExecutor("xiaomi-mimo")).toBeInstanceOf(XiaomiMimoExecutor); + }); + + it("routes Preview models to the account-service route regardless of transport", () => { + const expected = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions"; + expect(ex.buildUrl("mimo-x-pro-preview", true, 0, OPENAI_T)).toBe(expected); + expect(ex.buildUrl("mimo-x-pro-preview", true, 0, CLAUDE_T)).toBe(expected); + // body.model arrives as `xiaomi/` via upstreamModelId + expect(ex.buildUrl("xiaomi/mimo-x-flash-preview", true, 0, OPENAI_T)).toBe(expected); + }); + + it("keeps the sourceFormat-matched endpoint for cloud models", () => { + // Regression: a Claude client must reach /anthropic/v1/messages, not /v1/chat/completions. + expect(ex.buildUrl("mimo-v2.5-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl); + expect(ex.buildUrl("mimo-v2.5-pro", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl); + }); + + it("authenticates Preview calls with the account cookie", () => { + const headers = ex.buildHeaders({ [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" }, true, "u", "mimo-x-pro-preview"); + expect(headers.Cookie).toBe("serviceToken=abc"); + expect(headers.Authorization).toBeUndefined(); + }); + + it("authenticates cloud calls with the bearer key", () => { + const headers = ex.buildHeaders({ accessToken: "sk-x" }, true, "u", "mimo-v2.5-pro"); + expect(headers.Authorization).toBe("Bearer sk-x"); + expect(headers.Cookie).toBeUndefined(); + }); + + it("fails fast when a Preview call has no account session", async () => { + await expect( + ex.execute({ model: "mimo-x-pro-preview", body: {}, stream: true, credentials: {}, log: null }), + ).rejects.toThrow(/account session unavailable/); + }); + + it("flattens content-part arrays to plain strings", () => { + const out = ex.transformRequest( + "mimo-x-pro-preview", + { messages: [{ role: "user", content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }] }, + true, + {}, + ); + expect(out.messages[0].content).toBe("ab"); + }); + + it("applies Preview defaults without overriding explicit values", () => { + const body = { messages: [{ role: "user", content: "hi" }], temperature: 0.2 }; + const out = ex.transformRequest("mimo-x-pro-preview", body, true, {}); + expect(out.temperature).toBe(0.2); // caller's value kept + expect(out.top_p).toBe(0.95); // default filled in + expect(out.max_tokens).toBe(4096); + }); + + it("leaves cloud bodies free of Preview defaults", () => { + const out = ex.transformRequest("mimo-v2.5-pro", { messages: [{ role: "user", content: "hi" }] }, true, {}); + expect(out.thinking).toBeUndefined(); + expect(out.max_tokens).toBeUndefined(); + }); + + it("strips a provider/model prefix when testing preview ids", () => { + expect(bareModel("xiaomi/mimo-x-pro-preview")).toBe("mimo-x-pro-preview"); + expect(bareModel("mimo-x-pro-preview")).toBe("mimo-x-pro-preview"); + }); +}); diff --git a/tests/unit/xiaomi-mimo-oauth-proxy.test.js b/tests/unit/xiaomi-mimo-oauth-proxy.test.js new file mode 100644 index 00000000..c74573ff --- /dev/null +++ b/tests/unit/xiaomi-mimo-oauth-proxy.test.js @@ -0,0 +1,54 @@ +/** + * Regression: the xiaomi-mimo OAuth session store must not retain sessions + * once the callback listener is down. + * + * Each /authorize registers a session holding an X25519 private key, keyed by a + * fresh state. Unlike trae/windsurf/zed (singleton session) this is a Map, so + * without an explicit clear every login attempt would leak a private key for + * the whole process lifetime. + */ +import { describe, it, expect } from "vitest"; +import { + registerXiaomiMimoSession, + getXiaomiMimoSessionStatus, + clearXiaomiMimoSession, + stopXiaomiMimoProxy, +} from "../../src/lib/oauth/utils/server.js"; + +const KEY = Buffer.from("x25519-private-key-material"); + +describe("xiaomi-mimo OAuth session store", () => { + it("drops pending sessions when the proxy stops", () => { + registerXiaomiMimoSession({ state: "s1", privateKeyDer: KEY }); + expect(getXiaomiMimoSessionStatus("s1")).not.toBeNull(); + + stopXiaomiMimoProxy(); + + expect(getXiaomiMimoSessionStatus("s1")).toBeNull(); + }); + + it("drops every session, not just the last one", () => { + registerXiaomiMimoSession({ state: "a", privateKeyDer: KEY }); + registerXiaomiMimoSession({ state: "b", privateKeyDer: KEY }); + registerXiaomiMimoSession({ state: "c", privateKeyDer: KEY }); + + stopXiaomiMimoProxy(); + + for (const s of ["a", "b", "c"]) { + expect(getXiaomiMimoSessionStatus(s)).toBeNull(); + } + }); + + it("ignores registrations with a missing state or key", () => { + expect(registerXiaomiMimoSession({ state: "", privateKeyDer: KEY })).toBe(false); + expect(registerXiaomiMimoSession({ state: "s", privateKeyDer: null })).toBe(false); + }); + + it("never exposes the private key to callers", () => { + registerXiaomiMimoSession({ state: "s1", privateKeyDer: KEY }); + const view = getXiaomiMimoSessionStatus("s1"); + expect(view).toEqual({ status: "pending", result: null, error: null }); + expect(JSON.stringify(view)).not.toContain("privateKeyDer"); + clearXiaomiMimoSession("s1"); + }); +}); diff --git a/tests/unit/xiaomi-mimo-oauth-session.test.js b/tests/unit/xiaomi-mimo-oauth-session.test.js new file mode 100644 index 00000000..3aee1cc2 --- /dev/null +++ b/tests/unit/xiaomi-mimo-oauth-session.test.js @@ -0,0 +1,152 @@ +/** + * Regression: the poll-status/exchange session lifecycle for xiaomi-mimo. + * + * The original PR cleared the session inside poll-status, so the client's + * following POST /exchange always saw a missing session and returned 400 — + * the whole browser-OAuth fallback was dead. These tests pin the contract: + * - a finished session survives /poll-status until /exchange consumes it + * - a failed session is cleaned up by /poll-status itself + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("next/server", () => ({ + NextResponse: { + json: (body, init) => ({ + status: init?.status || 200, + body, + json: async () => body, + }), + }, +})); + +vi.mock("@/lib/oauth/providers", () => ({ + getProvider: vi.fn(), + generateAuthData: vi.fn(), + exchangeTokens: vi.fn(), + requestDeviceCode: vi.fn(), + pollForToken: vi.fn(), +})); + +vi.mock("@/models", () => ({ + createProviderConnection: vi.fn(async (d) => ({ id: "conn-1", ...d })), +})); + +vi.mock("open-sse/shared/mimoAccount.js", () => ({ + readDesktopPassToken: vi.fn(async () => ({ passToken: "pt-abc", userId: "u1", cUserId: "c1" })), +})); + +vi.mock("@/lib/oauth/utils/ideDetect", () => ({ detectIdeInstalled: vi.fn() })); + +// Session store backing the mocked OAuth server helpers, so the test can assert +// on real lifecycle transitions rather than on call counts alone. +const sessions = new Map(); +const stopped = { count: 0 }; + +vi.mock("@/lib/oauth/utils/server", () => { + const notUsed = () => { throw new Error("unexpected helper"); }; + const noop = () => {}; + return { + startCodexProxy: notUsed, stopCodexProxy: noop, registerCodexSession: noop, + getCodexSessionStatus: () => null, clearCodexSession: noop, + startXaiProxy: notUsed, stopXaiProxy: noop, registerXaiSession: noop, + getXaiSessionStatus: () => null, clearXaiSession: noop, + startTraeProxy: notUsed, stopTraeProxy: noop, registerTraeSession: noop, + getTraeSessionStatus: () => null, clearTraeSession: noop, + startWindsurfProxy: notUsed, stopWindsurfProxy: noop, registerWindsurfSession: noop, + getWindsurfSessionStatus: () => null, clearWindsurfSession: noop, + startZedProxy: notUsed, stopZedProxy: noop, registerZedSession: noop, + getZedSessionStatus: () => null, clearZedSession: noop, + startXiaomiMimoProxy: notUsed, + stopXiaomiMimoProxy: () => { stopped.count += 1; }, + registerXiaomiMimoSession: () => {}, + getXiaomiMimoSessionStatus: (state) => { + const s = sessions.get(state); + return s ? { status: s.status, result: s.result || null, error: s.error || null } : null; + }, + clearXiaomiMimoSession: (state) => { sessions.delete(state); }, + }; +}); + +const { GET, POST } = await import("../../src/app/api/oauth/[provider]/[action]/route.js"); + +const get = (action, state) => + GET(new Request(`http://localhost/api/oauth/xiaomi-mimo/${action}?state=${state}`), { + params: Promise.resolve({ provider: "xiaomi-mimo", action }), + }); + +const exchange = (state) => + POST( + new Request("http://localhost/api/oauth/xiaomi-mimo/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state }), + }), + { params: Promise.resolve({ provider: "xiaomi-mimo", action: "exchange" }) }, + ); + +describe("xiaomi-mimo OAuth session lifecycle", () => { + beforeEach(() => { + sessions.clear(); + stopped.count = 0; + }); + + it("keeps a finished session alive so /exchange can consume it", async () => { + sessions.set("st1", { status: "done", result: { uid: "u1", accessToken: "sk-x", baseUrl: "https://api.xiaomimimo.com/v1" } }); + + const poll = await get("poll-status", "st1"); + expect(poll.status).toBe(200); + expect(await poll.json()).toMatchObject({ status: "done" }); + + // The bug: this used to be gone, making /exchange always 400. + expect(sessions.has("st1")).toBe(true); + + const res = await exchange("st1"); + expect(res.status).toBe(200); + expect((await res.json()).success).toBe(true); + }); + + it("clears the session once /exchange consumed it", async () => { + sessions.set("st1", { status: "done", result: { uid: "u1", accessToken: "sk-x" } }); + await exchange("st1"); + expect(sessions.has("st1")).toBe(false); + }); + + it("cleans up a failed session in poll-status and stops the proxy", async () => { + sessions.set("st2", { status: "error", error: "Could not decrypt with any pending session key" }); + + const poll = await get("poll-status", "st2"); + expect(await poll.json()).toMatchObject({ status: "error" }); + + expect(sessions.has("st2")).toBe(false); + expect(stopped.count).toBe(1); + }); + + it("persists the Desktop passToken onto the connection (Preview models need it)", async () => { + const { createProviderConnection } = await import("@/models"); + sessions.set("st3", { status: "done", result: { uid: "u1", accessToken: "sk-x" } }); + + await exchange("st3"); + + const arg = createProviderConnection.mock.calls.at(-1)[0]; + expect(arg.provider).toBe("xiaomi-mimo"); + expect(arg.providerSpecificData.mimoPassToken).toBe("pt-abc"); + expect(arg.providerSpecificData.mimoUserId).toBe("u1"); + }); + + it("still reports unknown for an unregistered state", async () => { + const poll = await get("poll-status", "nope"); + expect(await poll.json()).toEqual({ status: "unknown" }); + }); + + it("rejects /exchange without a state", async () => { + const res = await POST( + new Request("http://localhost/api/oauth/xiaomi-mimo/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }), + { params: Promise.resolve({ provider: "xiaomi-mimo", action: "exchange" }) }, + ); + expect(res.status).toBe(400); + }); +});