diff --git a/src/lib/ai-proxy.ts b/src/lib/ai-proxy.ts index 5cab053..b05e925 100644 --- a/src/lib/ai-proxy.ts +++ b/src/lib/ai-proxy.ts @@ -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 = { 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 = { "Content-Type": "text/event-stream", diff --git a/src/lib/mimo-auth.ts b/src/lib/mimo-auth.ts new file mode 100644 index 0000000..0290911 --- /dev/null +++ b/src/lib/mimo-auth.ts @@ -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 { + 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 { + 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; +}