feat(xiaomi-mimo): merge MiMo Desktop support into xiaomi-mimo as dual auth
Adds the Desktop-exclusive Preview models and the Xiaomi account-session
route to the existing xiaomi-mimo provider instead of a separate
xiaomi-desktop provider, so the dashboard shows one MiMo entry rather than
three overlapping ones.
Dual auth, same pattern as kimi — API key (sk-) covers the cloud API,
Desktop/OAuth adds the account session used by the Preview models:
- registry: category oauth, authModes [oauth, apikey], oauth block, the two
mimo-x-*-preview models, and the invite signupUrl
- executor: routes Preview models to the account-service route with a Cookie
session, everything else keeps the sourceFormat-matched transport
- oauth: custom ECDH encrypted-callback flow (X25519 -> SHA256 -> AES-256-GCM)
with a loopback callback proxy, plus one-click import of the local Desktop
auth.json
- usage: weekly quota from the account session
Fixes found while merging:
- the OAuth browser flow was dead: poll-status cleared the session before the
client could POST /exchange, so every exchange returned 400
- a Claude-format client was sent to /v1/chat/completions instead of the
declared /anthropic/v1/messages transport, because buildUrl ignored
runtimeTransport
- stopXiaomiMimoProxy leaked every pending session (each holding an X25519
private key) for the process lifetime
- the OAuth exchange did not persist the Desktop passToken, so the Preview
models could never work after a browser sign-in
Removes dead code: the local engine token minting (mimoEngine, never called
on the request path), the model-catalog and usage routes, engineToken/
engineUrl plumbing, and an unread top-level usage block.
Adds tests/unit/xiaomi-mimo-{executor,oauth-session,oauth-proxy}.test.js —
the provider previously had none.
This commit is contained in:
@@ -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";
|
||||
|
||||
99
open-sse/executors/xiaomi-mimo.js
Normal file
99
open-sse/executors/xiaomi-mimo.js
Normal file
@@ -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/<id>` 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;
|
||||
@@ -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=<ECDH-encrypted payload> instead of ?code=.
|
||||
// Decryption yields { uid, sk, url }.
|
||||
callbackParam: "u",
|
||||
kn: "mimocode",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
125
open-sse/services/usage/xiaomi-mimo.js
Normal file
125
open-sse/services/usage/xiaomi-mimo.js
Normal file
@@ -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}` };
|
||||
}
|
||||
}
|
||||
264
open-sse/shared/mimoAccount.js
Normal file
264
open-sse/shared/mimoAccount.js
Normal file
@@ -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=<sts>&_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<cookie|null>
|
||||
|
||||
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<Record<string,string>|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<string|null>} 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<string|null>} 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 };
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user