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:
叶炜朋
2026-09-10 23:41:40 +07:00
parent 83af3f1853
commit 73cb89143c
21 changed files with 1828 additions and 4 deletions

View File

@@ -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 `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>${title}</title>
<style>
body { font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
.container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.icon { color: ${color}; font-size: 3rem; }
h1 { margin: 1rem 0; font-size: 1.25rem; }
p { color: #666; font-size: 0.875rem; }
</style>
</head>
<body>
<div class="container">
<div class="icon">${icon}</div>
<h1>${title}</h1>
<p>${message || (success ? "You can close this tab and return to 9Router." : "Please try again.")}</p>
${success ? "<script>setTimeout(() => window.close(), 3000);</script>" : ""}
</div>
</body>
</html>`;
}
/**
* 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();
}