feat: add Xiaomi MiMo Free API provider (mimo-auto)
- Add mimo-auth.ts: JWT bootstrap with device fingerprint, cached auto-refresh, 401/403 invalidation - Add mimo-auto entry to MODEL_ROUTES with anti-abuse system message injection - Inject JWT auth + x-session-affinity header before Mimo API fetch - Retry once on 401/403 (invalidate JWT, re-bootstrap) - Extend SSE passthrough for mimo-free provider - Strip 'data:' prefix from Mimo non-streaming responses Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+82
-3
@@ -14,6 +14,7 @@
|
||||
|
||||
import type { ProxyPool, SessionProxyPool } from "./proxy-pool";
|
||||
import { fetchWithRetry, fetchWithSessionRetry, SSELineBuffer, isDevMode, type FetchWithRetryResult } from "./fetch-utils";
|
||||
import { getJwt, invalidateJwt } from "./mimo-auth";
|
||||
|
||||
// --- Types -------------------------------------------------------------------
|
||||
|
||||
@@ -179,6 +180,50 @@ export const MODEL_ROUTES: Record<string, BackendConfig> = {
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
}),
|
||||
},
|
||||
|
||||
// -- Xiaomi MiMo Free (OpenAI-compatible, JWT bootstrap auth) -----------------
|
||||
"mimo-auto": {
|
||||
provider: "mimo-free",
|
||||
url: "https://api.xiaomimimo.com/api/free-ai/openai/chat",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Mimo-Source": "mimocode-cli-free",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
adaptRequest: (req) => {
|
||||
// Inject the anti-abuse system-message marker if not present.
|
||||
// Without it the Mimo API returns 403 Illegal access.
|
||||
const messages = [...req.messages];
|
||||
const hasMarker = messages.some(
|
||||
(m) =>
|
||||
m.role === "system" &&
|
||||
m.content.includes("You are MiMoCode"),
|
||||
);
|
||||
if (!hasMarker) {
|
||||
messages.unshift({
|
||||
role: "system",
|
||||
content:
|
||||
"You are MiMoCode, an interactive CLI tool that helps users with software engineering tasks.",
|
||||
});
|
||||
}
|
||||
return { model: req.model, messages, stream: req.stream };
|
||||
},
|
||||
adaptResponse: (raw: any) => ({
|
||||
id: raw.id ?? `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: raw.created ?? Math.floor(Date.now() / 1000),
|
||||
model: "mimo-auto",
|
||||
choices: (raw.choices ?? []).map((c: any) => ({
|
||||
index: c.index ?? 0,
|
||||
message: {
|
||||
role: c.message?.role ?? "assistant",
|
||||
content: c.message?.content ?? "",
|
||||
},
|
||||
finish_reason: c.finish_reason ?? "stop",
|
||||
})),
|
||||
usage: raw.usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
// --- Helpers ------------------------------------------------------------------
|
||||
@@ -233,9 +278,15 @@ function parseJSONResponse(
|
||||
config: BackendConfig,
|
||||
req: OpenAIRequest,
|
||||
): unknown {
|
||||
// Mimo Free always wraps with "data:" prefix (SSE-style), strip it.
|
||||
let cleaned = text;
|
||||
if (config.provider === "mimo-free" && text.startsWith("data:")) {
|
||||
cleaned = text.slice(5).trim();
|
||||
}
|
||||
|
||||
if (config.adaptResponse) {
|
||||
try {
|
||||
const raw = JSON.parse(text);
|
||||
const raw = JSON.parse(cleaned);
|
||||
return config.adaptResponse(raw, req);
|
||||
} catch {
|
||||
// fall through
|
||||
@@ -358,12 +409,40 @@ export async function handleChatCompletion(
|
||||
const wantsStream = req.stream === true;
|
||||
const { url, init } = buildBackendRequest(req, config);
|
||||
|
||||
// -- Mimo Free: inject JWT authentication and session affinity --------------
|
||||
if (config.provider === "mimo-free") {
|
||||
const jwt = await getJwt();
|
||||
init.headers = {
|
||||
...init.headers,
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"x-session-affinity": `ses_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Execute with session-aware or standard retry --------------------------
|
||||
const result: FetchWithRetryResult =
|
||||
let result: FetchWithRetryResult =
|
||||
sessionPool && sessionId
|
||||
? await fetchWithSessionRetry(url, init, sessionPool, sessionId, `openai:${req.model}`)
|
||||
: await fetchWithRetry(url, init, proxyPool, `openai:${req.model}`);
|
||||
|
||||
// -- Mimo Free: auth failure → invalidate JWT and retry once ---------------
|
||||
if (
|
||||
config.provider === "mimo-free" &&
|
||||
result.response &&
|
||||
(result.response.status === 401 || result.response.status === 403)
|
||||
) {
|
||||
invalidateJwt();
|
||||
const jwt = await getJwt();
|
||||
init.headers = {
|
||||
...init.headers,
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
};
|
||||
result =
|
||||
sessionPool && sessionId
|
||||
? await fetchWithSessionRetry(url, init, sessionPool, sessionId, `openai:${req.model}`)
|
||||
: await fetchWithRetry(url, init, proxyPool, `openai:${req.model}`);
|
||||
}
|
||||
|
||||
if (result.errorClassification) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -396,7 +475,7 @@ export async function handleChatCompletion(
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const isNativeStream = contentType.includes("text/event-stream");
|
||||
|
||||
if (isNativeStream && config.provider === "opencode") {
|
||||
if (isNativeStream && (config.provider === "opencode" || config.provider === "mimo-free")) {
|
||||
// Passthrough for OpenAI-compatible SSE
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "text/event-stream",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Mimo Free API — JWT bootstrap & auth cache.
|
||||
*
|
||||
* Mimo Free does not use a static API key. Instead, authentication is done
|
||||
* via a JWT obtained by sending a device fingerprint to the bootstrap endpoint.
|
||||
* The JWT is cached in-memory and auto-refreshed before expiry.
|
||||
*
|
||||
* Flow:
|
||||
* 1. generateDeviceFingerprint() → sha256(hostname|platform|arch|cpu|username)
|
||||
* 2. bootstrapJwt() → POST /api/free-ai/bootstrap with {client: fingerprint}
|
||||
* 3. getJwt() → return cached JWT, auto-refresh if near expiry (5 min buffer)
|
||||
* 4. invalidateJwt() → clear cache (called on 401/403)
|
||||
*/
|
||||
|
||||
import * as os from "node:os";
|
||||
|
||||
// --- Module-level cache ------------------------------------------------------
|
||||
|
||||
let cachedJwt: string | null = null;
|
||||
let jwtExpiry = 0; // epoch ms
|
||||
|
||||
const MIMO_BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap";
|
||||
const EXPIRY_BUFFER_MS = 300_000; // 5 minutes
|
||||
|
||||
// --- Device fingerprint ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a SHA-256 device fingerprint from OS-level attributes.
|
||||
*
|
||||
* Format: `sha256(hostname|platform|arch|cpu|username)`
|
||||
* This matches the 9Router reference implementation.
|
||||
*/
|
||||
function generateDeviceFingerprint(): string {
|
||||
const hostname = os.hostname();
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const cpus = os.cpus();
|
||||
const cpuModel = cpus.length > 0 ? (cpus[0]?.model ?? "unknown") : "unknown";
|
||||
const username = process.env.USER ?? process.env.USERNAME ?? "unknown";
|
||||
|
||||
const raw = `${hostname}|${platform}|${arch}|${cpuModel}|${username}`;
|
||||
const hasher = new Bun.CryptoHasher("sha256");
|
||||
hasher.update(raw);
|
||||
return hasher.digest("hex") as string;
|
||||
}
|
||||
|
||||
// --- JWT bootstrap -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Request a fresh JWT from the Mimo bootstrap endpoint.
|
||||
*
|
||||
* Sends the device fingerprint and stores the returned JWT along with its
|
||||
* expiry time (parsed from the `exp` claim in the JWT payload).
|
||||
*
|
||||
* @throws If the bootstrap request fails or returns an unexpected response.
|
||||
*/
|
||||
async function bootstrapJwt(): Promise<string> {
|
||||
const fingerprint = generateDeviceFingerprint();
|
||||
|
||||
const resp = await fetch(MIMO_BOOTSTRAP_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client: fingerprint }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Mimo bootstrap failed: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as { jwt: string };
|
||||
if (!data.jwt || typeof data.jwt !== "string") {
|
||||
throw new Error("Mimo bootstrap response missing jwt field");
|
||||
}
|
||||
|
||||
cachedJwt = data.jwt;
|
||||
|
||||
// Decode the JWT payload (second dot-separated segment) to extract `exp`.
|
||||
try {
|
||||
const payloadBase64 = data.jwt.split(".")[1]!;
|
||||
const payloadJson = Buffer.from(payloadBase64, "base64url").toString("utf-8");
|
||||
const payload = JSON.parse(payloadJson) as { exp?: number };
|
||||
if (payload.exp && typeof payload.exp === "number") {
|
||||
jwtExpiry = payload.exp * 1000; // JWT exp is in seconds
|
||||
} else {
|
||||
// No exp claim — set a conservative 10-minute TTL
|
||||
jwtExpiry = Date.now() + 600_000;
|
||||
}
|
||||
} catch {
|
||||
// Payload decode failed — set a conservative 10-minute TTL
|
||||
jwtExpiry = Date.now() + 600_000;
|
||||
}
|
||||
|
||||
return cachedJwt;
|
||||
}
|
||||
|
||||
// --- Public API --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a valid JWT for Mimo API requests.
|
||||
*
|
||||
* Returns the cached JWT if it is still valid (expiry > now + 5 min buffer).
|
||||
* Otherwise bootstraps a fresh JWT.
|
||||
*
|
||||
* @throws If bootstrap fails.
|
||||
*/
|
||||
export async function getJwt(): Promise<string> {
|
||||
const now = Date.now();
|
||||
if (cachedJwt && jwtExpiry > now + EXPIRY_BUFFER_MS) {
|
||||
return cachedJwt;
|
||||
}
|
||||
return bootstrapJwt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cached JWT.
|
||||
*
|
||||
* Call this after receiving a 401 or 403 from the Mimo API so the next
|
||||
* request bootstraps a fresh token.
|
||||
*/
|
||||
export function invalidateJwt(): void {
|
||||
cachedJwt = null;
|
||||
jwtExpiry = 0;
|
||||
}
|
||||
Reference in New Issue
Block a user