mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
feat(providers): add MiMoCode free-tier provider with bootstrap JWT auth (#3659)
Integrated into release/v3.8.22 — page.tsx conflict resolved + NoAuthAccountCard re-applied to ProviderDetailPageClient in review. MiMoCode endpoint validated live.
This commit is contained in:
@@ -343,6 +343,9 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
|
||||
{ id: "mimo-v2-omni", name: "MiMo-V2-Omni", contextLength: 262144, maxOutputTokens: 131072 },
|
||||
{ id: "mimo-v2-flash", name: "MiMo-V2-Flash", contextLength: 262144, maxOutputTokens: 65536 },
|
||||
],
|
||||
mimocode: [
|
||||
{ id: "mimo-auto", name: "MiMo Auto", contextLength: 1000000, maxOutputTokens: 128000 },
|
||||
],
|
||||
gitlawb: [
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1048576, maxOutputTokens: 131072 },
|
||||
{ id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1048576, maxOutputTokens: 131072 },
|
||||
@@ -4067,6 +4070,17 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
models: CHAT_OPENAI_COMPAT_MODELS["xiaomi-mimo"],
|
||||
},
|
||||
|
||||
mimocode: {
|
||||
id: "mimocode",
|
||||
alias: "mcode",
|
||||
format: "openai",
|
||||
executor: "mimocode",
|
||||
baseUrl: "https://api.xiaomimimo.com",
|
||||
chatPath: "/api/free-ai/openai/chat",
|
||||
authType: "none",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS["mimocode"],
|
||||
},
|
||||
|
||||
gitlawb: {
|
||||
id: "gitlawb",
|
||||
alias: "glb",
|
||||
|
||||
@@ -51,6 +51,7 @@ import { KimiExecutor } from "./kimi.ts"
|
||||
import { TheOldLlmExecutor } from "./theoldllm.ts";
|
||||
import { ChipotleExecutor } from "./chipotle.ts";
|
||||
import { LMArenaExecutor } from "./lmarena.ts";
|
||||
import { MimocodeExecutor } from "./mimocode.ts";
|
||||
|
||||
const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
@@ -146,6 +147,8 @@ const executors = {
|
||||
pepper: new ChipotleExecutor(), // Alias
|
||||
lmarena: new LMArenaExecutor(),
|
||||
lma: new LMArenaExecutor(), // Alias
|
||||
mimocode: new MimocodeExecutor(),
|
||||
mcode: new MimocodeExecutor(), // Alias
|
||||
};
|
||||
|
||||
const defaultCache = new Map();
|
||||
@@ -205,3 +208,4 @@ export { QwenWebExecutor } from "./qwen-web.ts";
|
||||
export { TheOldLlmExecutor } from "./theoldllm.ts";
|
||||
export { ChipotleExecutor } from "./chipotle.ts";
|
||||
export { LMArenaExecutor } from "./lmarena.ts";
|
||||
export { MimocodeExecutor } from "./mimocode.ts";
|
||||
|
||||
328
open-sse/executors/mimocode.ts
Normal file
328
open-sse/executors/mimocode.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* MiMoCode Executor — Free-tier Xiaomi MiMo models via bootstrap JWT auth.
|
||||
*
|
||||
* Implements the auth flow from the official MiMo-Code repository:
|
||||
* https://github.com/XiaomiMiMo/MiMo-Code/blob/main/packages/opencode/src/plugin/mimo-free.ts
|
||||
*
|
||||
* 1. Generate device fingerprint from hostname + OS + arch + CPU + username
|
||||
* 2. POST /api/free-ai/bootstrap with fingerprint → JWT
|
||||
* 3. Use JWT as Bearer token for chat requests
|
||||
* 4. Custom endpoint: /api/free-ai/openai/chat (not /v1/chat/completions)
|
||||
* 5. Custom header: X-Mimo-Source: mimocode-cli-free
|
||||
*
|
||||
* Only the "mimo-auto" model is supported (1M context, 128K output).
|
||||
* Supports multiple accounts: N fingerprints → N JWTs → round-robin with cooldown.
|
||||
* On 429, account enters cooldown (exponential backoff). On 401/403, JWT is re-bootstrapped.
|
||||
*/
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import * as os from "node:os";
|
||||
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
|
||||
|
||||
const BOOTSTRAP_PATH = "/api/free-ai/bootstrap";
|
||||
const CHAT_PATH = "/api/free-ai/openai/chat";
|
||||
const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||
const BOOTSTRAP_TIMEOUT_MS = 15_000;
|
||||
const COOLDOWN_BASE_MS = 5_000;
|
||||
const COOLDOWN_MAX_MS = 60_000;
|
||||
|
||||
const MIMO_SOURCE = "mimocode-cli-free";
|
||||
|
||||
const USER_AGENTS = [
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
];
|
||||
|
||||
// ── Account State ──────────────────────────────────────────────────────────
|
||||
|
||||
interface AccountState {
|
||||
fingerprint: string;
|
||||
jwt: string;
|
||||
expiresAt: number;
|
||||
cooldownUntil: number;
|
||||
consecutiveFails: number;
|
||||
}
|
||||
|
||||
function parseJwtExp(jwt: string): number {
|
||||
try {
|
||||
const parts = jwt.split(".");
|
||||
if (parts.length < 2) return Date.now() + 50 * 60 * 1000;
|
||||
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
|
||||
return (payload.exp ?? Math.floor(Date.now() / 1000) + 3000) * 1000;
|
||||
} catch {
|
||||
return Date.now() + 50 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
function isAccountReady(account: AccountState): boolean {
|
||||
if (account.cooldownUntil > Date.now()) return false;
|
||||
if (account.jwt && account.expiresAt - Date.now() > JWT_REFRESH_BUFFER_MS) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Fingerprint Generation ─────────────────────────────────────────────────
|
||||
|
||||
function getCpuModel(): string {
|
||||
try {
|
||||
const cpus = os.cpus();
|
||||
if (cpus.length > 0 && cpus[0].model) return cpus[0].model.trim();
|
||||
} catch { /* ignore */ }
|
||||
return "unknown-cpu";
|
||||
}
|
||||
|
||||
export function generateFingerprint(seed?: string): string {
|
||||
if (seed) return crypto.createHash("sha256").update(seed).digest("hex");
|
||||
const hostname = os.hostname();
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
const cpu = getCpuModel();
|
||||
let username = "unknown-user";
|
||||
try {
|
||||
username = os.userInfo().username;
|
||||
} catch { /* ignore */ }
|
||||
return crypto.createHash("sha256").update(`${hostname}|${platform}|${arch}|${cpu}|${username}`).digest("hex");
|
||||
}
|
||||
|
||||
// ── Bootstrap ──────────────────────────────────────────────────────────────
|
||||
|
||||
const bootstrapInflight = new Map<string, Promise<{ jwt: string; expiresAt: number }>>();
|
||||
|
||||
async function bootstrapJwt(
|
||||
baseUrl: string,
|
||||
fingerprint: string,
|
||||
signal?: AbortSignal | null,
|
||||
): Promise<{ jwt: string; expiresAt: number }> {
|
||||
const existing = bootstrapInflight.get(fingerprint);
|
||||
if (existing) return existing;
|
||||
|
||||
const url = `${baseUrl}${BOOTSTRAP_PATH}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), BOOTSTRAP_TIMEOUT_MS);
|
||||
const onSignal = signal ? () => controller.abort(signal.reason) : null;
|
||||
if (signal && onSignal) signal.addEventListener("abort", onSignal, { once: true });
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client: fingerprint }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => "");
|
||||
throw new Error(`Bootstrap failed: ${resp.status} ${body.slice(0, 200)}`);
|
||||
}
|
||||
const data = (await resp.json()) as { jwt?: string };
|
||||
if (!data.jwt) throw new Error("Bootstrap response missing jwt field");
|
||||
return { jwt: data.jwt, expiresAt: parseJwtExp(data.jwt) };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (signal && onSignal) signal.removeEventListener("abort", onSignal);
|
||||
bootstrapInflight.delete(fingerprint);
|
||||
}
|
||||
})();
|
||||
|
||||
bootstrapInflight.set(fingerprint, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ── Model Rewriting ────────────────────────────────────────────────────────
|
||||
|
||||
function rewriteModelName(model: string): string {
|
||||
const idx = model.lastIndexOf("/");
|
||||
return idx >= 0 ? model.slice(idx + 1) : model;
|
||||
}
|
||||
|
||||
// ── Executor ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class MimocodeExecutor extends BaseExecutor {
|
||||
private accounts: AccountState[] = [];
|
||||
private nextAccountIdx = 0;
|
||||
private baseUrl: string;
|
||||
private static encoder = new TextEncoder();
|
||||
|
||||
constructor() {
|
||||
super("mimocode", { format: "openai" });
|
||||
this.baseUrl = this.getBaseUrls()[0] || "https://api.xiaomimimo.com";
|
||||
this.accounts.push({ fingerprint: generateFingerprint(), jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0 });
|
||||
}
|
||||
|
||||
private syncAccountsFromCredentials(credentials: ProviderCredentials): void {
|
||||
const fingerprints = credentials?.providerSpecificData?.fingerprints;
|
||||
if (!Array.isArray(fingerprints)) return;
|
||||
const existing = new Set(this.accounts.map((a) => a.fingerprint));
|
||||
for (const fp of fingerprints) {
|
||||
if (typeof fp === "string" && !existing.has(fp)) {
|
||||
this.accounts.push({ fingerprint: fp, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0 });
|
||||
existing.add(fp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getJwtForAccount(account: AccountState, signal?: AbortSignal | null): Promise<string> {
|
||||
if (isAccountReady(account)) return account.jwt;
|
||||
const result = await bootstrapJwt(this.baseUrl, account.fingerprint, signal);
|
||||
account.jwt = result.jwt;
|
||||
account.expiresAt = result.expiresAt;
|
||||
return account.jwt;
|
||||
}
|
||||
|
||||
private pickAccount(): AccountState {
|
||||
for (let i = 0; i < this.accounts.length; i++) {
|
||||
const idx = (this.nextAccountIdx + i) % this.accounts.length;
|
||||
const acct = this.accounts[idx];
|
||||
if (isAccountReady(acct)) {
|
||||
this.nextAccountIdx = (idx + 1) % this.accounts.length;
|
||||
return acct;
|
||||
}
|
||||
}
|
||||
const fallbackIdx = this.nextAccountIdx % this.accounts.length;
|
||||
this.nextAccountIdx = (this.nextAccountIdx + 1) % this.accounts.length;
|
||||
return this.accounts[fallbackIdx];
|
||||
}
|
||||
|
||||
private markCooldown(account: AccountState): void {
|
||||
account.consecutiveFails++;
|
||||
const backoff = Math.min(COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1), COOLDOWN_MAX_MS);
|
||||
account.cooldownUntil = Date.now() + backoff + Math.random() * 1000;
|
||||
}
|
||||
|
||||
private markSuccess(account: AccountState): void {
|
||||
account.consecutiveFails = 0;
|
||||
}
|
||||
|
||||
buildUrl(_model: string, _stream: boolean, _urlIndex = 0, _credentials?: ProviderCredentials | null): string {
|
||||
return `${this.baseUrl.replace(/\/$/, "")}${CHAT_PATH}`;
|
||||
}
|
||||
|
||||
buildHeaders(
|
||||
_credentials: ProviderCredentials,
|
||||
stream = true,
|
||||
_clientHeaders?: Record<string, string> | null,
|
||||
_model?: string,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Mimo-Source": MIMO_SOURCE,
|
||||
"User-Agent": USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)],
|
||||
};
|
||||
if (stream) headers["Accept"] = "text/event-stream, application/json";
|
||||
return headers;
|
||||
}
|
||||
|
||||
transformRequest(model: string, body: unknown, _stream: boolean, _credentials?: ProviderCredentials | null): unknown {
|
||||
if (typeof body === "object" && body !== null) {
|
||||
return { ...(body as Record<string, unknown>), model: rewriteModelName(model) };
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async testConnection(
|
||||
_credentials: ProviderCredentials,
|
||||
_signal?: AbortSignal | null,
|
||||
log?: ExecuteInput["log"],
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const account = this.accounts[0];
|
||||
const jwt = await this.getJwtForAccount(account, _signal);
|
||||
const resp = await fetch(this.buildUrl("mimo-auto", false), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}`, "X-Mimo-Source": MIMO_SOURCE },
|
||||
body: JSON.stringify({ model: "mimo-auto", messages: [{ role: "user", content: "ping" }], stream: false }),
|
||||
signal: _signal ?? undefined,
|
||||
});
|
||||
return resp.status === 200;
|
||||
} catch {
|
||||
log?.warn?.("MIMOCODE", "testConnection network error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput): Promise<{
|
||||
response: Response;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
transformedBody: unknown;
|
||||
}> {
|
||||
const { model, stream, body, signal, log } = input;
|
||||
const encoder = MimocodeExecutor.encoder;
|
||||
|
||||
if (signal?.aborted) {
|
||||
return {
|
||||
response: new Response(encoder.encode(JSON.stringify({
|
||||
error: { message: "Request aborted", type: "abort", code: "ABORTED" },
|
||||
})), { status: 499, headers: { "Content-Type": "application/json" } }),
|
||||
url: this.buildUrl(model, stream),
|
||||
headers: this.buildHeaders(input.credentials, stream),
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
const url = this.buildUrl(model, stream);
|
||||
const reqBody = this.transformRequest(model, body, stream, input.credentials);
|
||||
|
||||
this.syncAccountsFromCredentials(input.credentials);
|
||||
|
||||
// Try each account, skip cooldown ones
|
||||
for (let attempt = 0; attempt < this.accounts.length; attempt++) {
|
||||
const account = this.pickAccount();
|
||||
try {
|
||||
const jwt = await this.getJwtForAccount(account, signal);
|
||||
const headers = this.buildHeaders(input.credentials, stream);
|
||||
headers["Authorization"] = `Bearer ${jwt}`;
|
||||
|
||||
let resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
|
||||
// On auth failure, re-bootstrap this account and retry once
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
log?.warn?.("MIMOCODE", `Auth failed (${resp.status}) on account ${account.fingerprint.slice(0, 8)}…`);
|
||||
account.jwt = "";
|
||||
account.expiresAt = 0;
|
||||
account.consecutiveFails = 0;
|
||||
const freshJwt = await this.getJwtForAccount(account, signal);
|
||||
headers["Authorization"] = `Bearer ${freshJwt}`;
|
||||
resp = await fetch(url, { method: "POST", headers, body: JSON.stringify(reqBody), signal: signal ?? undefined });
|
||||
}
|
||||
|
||||
if (resp.status === 429) {
|
||||
this.markCooldown(account);
|
||||
log?.warn?.("MIMOCODE", `Rate limited on account ${account.fingerprint.slice(0, 8)}, trying next…`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.markSuccess(account);
|
||||
const respHeaders: Record<string, string> = {};
|
||||
resp.headers.forEach((v, k) => { respHeaders[k] = v; });
|
||||
return { response: resp as unknown as Response, url, headers: respHeaders, transformedBody: reqBody };
|
||||
} catch (err) {
|
||||
this.markCooldown(account);
|
||||
if (attempt === this.accounts.length - 1) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log?.error?.("MIMOCODE", `Executor error: ${msg}`);
|
||||
return {
|
||||
response: new Response(encoder.encode(JSON.stringify({
|
||||
error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" },
|
||||
})), { status: 502, headers: { "Content-Type": "application/json" } }),
|
||||
url, headers: this.buildHeaders(input.credentials, stream), transformedBody: body,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
response: new Response(encoder.encode(JSON.stringify({
|
||||
error: { message: "All accounts exhausted", type: "upstream_error", code: "NO_ACCOUNTS" },
|
||||
})), { status: 502, headers: { "Content-Type": "application/json" } }),
|
||||
url, headers: this.buildHeaders(input.credentials, stream), transformedBody: body,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default MimocodeExecutor;
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Select,
|
||||
ProxyConfigModal,
|
||||
NoAuthProviderCard,
|
||||
NoAuthAccountCard,
|
||||
} from "@/shared/components";
|
||||
import {
|
||||
LOCAL_PROVIDERS,
|
||||
@@ -4133,7 +4134,24 @@ export default function ProviderDetailPageClient() {
|
||||
)}
|
||||
|
||||
{/* Connections */}
|
||||
{!isUpstreamProxyProvider && isFreeNoAuth && <NoAuthProviderCard />}
|
||||
{!isUpstreamProxyProvider && isFreeNoAuth && providerId === "mimocode" && (
|
||||
<NoAuthAccountCard
|
||||
providerId={providerId}
|
||||
providerName="MiMoCode"
|
||||
generateAccountId={() => crypto.randomUUID().replace(/-/g, "")}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider && isFreeNoAuth && providerId === "opencode" && (
|
||||
<NoAuthAccountCard
|
||||
providerId={providerId}
|
||||
providerName="OpenCode"
|
||||
generateAccountId={() => crypto.randomUUID().replace(/-/g, "")}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider &&
|
||||
isFreeNoAuth &&
|
||||
providerId !== "mimocode" &&
|
||||
providerId !== "opencode" && <NoAuthProviderCard />}
|
||||
{!isUpstreamProxyProvider && !isFreeNoAuth && (
|
||||
<Card>
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
170
src/shared/components/NoAuthAccountCard.tsx
Normal file
170
src/shared/components/NoAuthAccountCard.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Card from "./Card";
|
||||
import Button from "./Button";
|
||||
|
||||
interface NoAuthAccountCardProps {
|
||||
providerId: string;
|
||||
/** Display name for the provider (e.g. "MiMoCode", "OpenCode") */
|
||||
providerName: string;
|
||||
/** Generates a unique account identifier (fingerprint, session token, etc.) */
|
||||
generateAccountId: () => string;
|
||||
/** Key in providerSpecificData where account IDs are stored (default: "fingerprints") */
|
||||
dataKey?: string;
|
||||
/** Custom description text */
|
||||
description?: string;
|
||||
/** Custom "add" button label */
|
||||
addLabel?: string;
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
id: string;
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
providerSpecificData?: Record<string, string[]>;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export default function NoAuthAccountCard({
|
||||
providerId,
|
||||
providerName,
|
||||
generateAccountId,
|
||||
dataKey = "fingerprints",
|
||||
description = "Ready to use — no signup needed. Add accounts for rate-limit rotation.",
|
||||
addLabel = "Add Account",
|
||||
}: NoAuthAccountCardProps) {
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const fetchConnections = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const filtered = (data.connections || []).filter(
|
||||
(c: Connection) => c.provider === providerId
|
||||
);
|
||||
setConnections(filtered);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch connections:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchConnections();
|
||||
}, [fetchConnections]);
|
||||
|
||||
const allAccountIds = connections.flatMap(
|
||||
(c) => c.providerSpecificData?.[dataKey] || []
|
||||
);
|
||||
|
||||
const handleAddAccount = async () => {
|
||||
setAdding(true);
|
||||
try {
|
||||
const accountId = generateAccountId();
|
||||
if (connections.length === 0) {
|
||||
const res = await fetch("/api/providers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: providerId,
|
||||
name: `${providerName} Account 1`,
|
||||
providerSpecificData: { [dataKey]: [accountId] },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create connection");
|
||||
} else {
|
||||
const conn = connections[0];
|
||||
const updated = [...allAccountIds, accountId];
|
||||
const res = await fetch(`/api/providers/${conn.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerSpecificData: { [dataKey]: updated },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update connection");
|
||||
}
|
||||
await fetchConnections();
|
||||
} catch (err) {
|
||||
console.error("Failed to add account:", err);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAccount = async (accountId: string) => {
|
||||
if (connections.length === 0) return;
|
||||
const conn = connections[0];
|
||||
const updated = allAccountIds.filter((id) => id !== accountId);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${conn.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerSpecificData: { [dataKey]: updated },
|
||||
}),
|
||||
});
|
||||
if (res.ok) await fetchConnections();
|
||||
} catch (err) {
|
||||
console.error("Failed to remove account:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="inline-flex shrink-0 items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
|
||||
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">No authentication required</p>
|
||||
<p className="text-xs text-text-muted">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
Accounts ({loading ? "..." : allAccountIds.length})
|
||||
</span>
|
||||
<Button size="sm" icon="add" onClick={handleAddAccount} disabled={adding}>
|
||||
{adding ? "Adding..." : addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!loading && allAccountIds.length === 0 && (
|
||||
<p className="text-xs text-text-muted py-2">
|
||||
Using auto-generated account. Click "{addLabel}" for rate-limit rotation.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && allAccountIds.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{allAccountIds.map((id, i) => (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-center justify-between rounded-md bg-bg-secondary px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span className="font-mono text-text-muted">
|
||||
Account {i + 1}: {id.slice(0, 12)}...
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAccount(id)}
|
||||
className="text-red-500 hover:text-red-400 text-xs"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export { default as FilterBar } from "./FilterBar";
|
||||
export { default as ColumnToggle } from "./ColumnToggle";
|
||||
export { default as DataTable } from "./DataTable";
|
||||
export { default as NoAuthProviderCard } from "./NoAuthProviderCard";
|
||||
export { default as NoAuthAccountCard } from "./NoAuthAccountCard";
|
||||
export { default as CollapsibleSection } from "./CollapsibleSection";
|
||||
export { default as InfoTooltip } from "./InfoTooltip";
|
||||
export { default as PresetSlider } from "./PresetSlider";
|
||||
|
||||
@@ -106,9 +106,28 @@ export const NOAUTH_PROVIDERS = {
|
||||
freeNote: "Free video generation — VEO 3.1, Seedance. 6 requests/hour.",
|
||||
authHint: "No auth required. Rate limited to 6 requests/hour per IP.",
|
||||
},
|
||||
mimocode: {
|
||||
id: "mimocode",
|
||||
alias: "mcode",
|
||||
name: "MiMoCode (Free)",
|
||||
icon: "devices",
|
||||
color: "#FF6B35",
|
||||
textIcon: "MC",
|
||||
website: "https://mimo.mi.com",
|
||||
noAuth: true,
|
||||
hasFree: true,
|
||||
serviceKinds: ["llm"],
|
||||
freeNote:
|
||||
"Free — Xiaomi MiMo models via bootstrap JWT auth. No API key required. Supports streaming.",
|
||||
authHint:
|
||||
"No API key required. The executor auto-generates JWT tokens via device fingerprint bootstrap.",
|
||||
notice: {
|
||||
text: "MiMoCode uses Xiaomi's public free AI endpoint with bootstrap-based JWT authentication. No signup needed. Rate limits apply.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FREE_APIKEY_PROVIDER_IDS = new Set(["qoder"]);
|
||||
export const FREE_APIKEY_PROVIDER_IDS = new Set(["qoder", "mimocode", "opencode"]);
|
||||
|
||||
export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean {
|
||||
return typeof providerId === "string" && FREE_APIKEY_PROVIDER_IDS.has(providerId);
|
||||
@@ -2853,6 +2872,8 @@ export function providerAllowsOptionalApiKey(providerId: unknown): boolean {
|
||||
providerId === "huggingchat" ||
|
||||
providerId === "gitlawb" ||
|
||||
providerId === "gitlawb-gmi" ||
|
||||
providerId === "mimocode" ||
|
||||
providerId === "opencode" ||
|
||||
isLocalProvider(providerId) ||
|
||||
isSelfHostedChatProvider(providerId) ||
|
||||
isOpenAICompatibleProvider(providerId) ||
|
||||
|
||||
169
tests/unit/mimocode-executor.test.ts
Normal file
169
tests/unit/mimocode-executor.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { MimocodeExecutor, generateFingerprint } from "../../open-sse/executors/mimocode.ts";
|
||||
|
||||
const executor = new MimocodeExecutor();
|
||||
|
||||
describe("MimocodeExecutor", () => {
|
||||
it("generateFingerprint returns a 64-char hex string", () => {
|
||||
const fp = generateFingerprint();
|
||||
assert.match(fp, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("generateFingerprint is deterministic", () => {
|
||||
assert.strictEqual(generateFingerprint(), generateFingerprint());
|
||||
});
|
||||
|
||||
it("generateFingerprint with seed is deterministic", () => {
|
||||
assert.strictEqual(generateFingerprint("seed-a"), generateFingerprint("seed-a"));
|
||||
});
|
||||
|
||||
it("generateFingerprint with different seeds differs", () => {
|
||||
assert.notStrictEqual(generateFingerprint("seed-a"), generateFingerprint("seed-b"));
|
||||
});
|
||||
|
||||
it("buildUrl returns the free-ai chat endpoint", () => {
|
||||
const url = executor.buildUrl("mimo-auto", false);
|
||||
assert.ok(url.includes("/api/free-ai/openai/chat"));
|
||||
assert.ok(url.startsWith("https://"));
|
||||
});
|
||||
|
||||
it("buildHeaders includes X-Mimo-Source and Content-Type", () => {
|
||||
const headers = (executor as any).buildHeaders({}, true);
|
||||
assert.strictEqual(headers["Content-Type"], "application/json");
|
||||
assert.strictEqual(headers["X-Mimo-Source"], "mimocode-cli-free");
|
||||
});
|
||||
|
||||
it("buildHeaders includes Accept for streaming", () => {
|
||||
const headers = (executor as any).buildHeaders({}, true);
|
||||
assert.ok(headers["Accept"]?.includes("text/event-stream"));
|
||||
});
|
||||
|
||||
it("buildHeaders omits Accept for non-streaming", () => {
|
||||
const headers = (executor as any).buildHeaders({}, false);
|
||||
assert.ok(!headers["Accept"]?.includes("text/event-stream"));
|
||||
});
|
||||
|
||||
it("transformRequest strips model prefix", () => {
|
||||
const result = (executor as any).transformRequest(
|
||||
"mcode/mimo-auto",
|
||||
{ model: "mcode/mimo-auto", messages: [{ role: "user", content: "hi" }] },
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(result.model, "mimo-auto");
|
||||
});
|
||||
|
||||
it("transformRequest passes model through when no prefix", () => {
|
||||
const result = (executor as any).transformRequest(
|
||||
"mimo-auto",
|
||||
{ model: "mimo-auto", messages: [{ role: "user", content: "hi" }] },
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(result.model, "mimo-auto");
|
||||
});
|
||||
|
||||
it("returns 499 on pre-aborted signal", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("cancelled"));
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "mimo-auto",
|
||||
body: { messages: [{ role: "user", content: "hi" }], stream: false },
|
||||
stream: false,
|
||||
signal: controller.signal,
|
||||
credentials: {},
|
||||
log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
|
||||
assert.strictEqual((result as any).response.status, 499);
|
||||
});
|
||||
|
||||
it("is registered in executor index", async () => {
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
const exec = getExecutor("mimocode");
|
||||
assert.ok(exec instanceof MimocodeExecutor);
|
||||
});
|
||||
|
||||
it("mcode alias works", async () => {
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
const exec = getExecutor("mcode");
|
||||
assert.ok(exec instanceof MimocodeExecutor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mimocode multi-account", () => {
|
||||
it("executor has at least one account", () => {
|
||||
const accounts = (executor as any).accounts;
|
||||
assert.ok(Array.isArray(accounts));
|
||||
assert.ok(accounts.length >= 1);
|
||||
});
|
||||
|
||||
it("each account has required fields", () => {
|
||||
const accounts = (executor as any).accounts;
|
||||
for (const acct of accounts) {
|
||||
assert.ok(typeof acct.fingerprint === "string");
|
||||
assert.ok(typeof acct.jwt === "string");
|
||||
assert.ok(typeof acct.expiresAt === "number");
|
||||
assert.ok(typeof acct.cooldownUntil === "number");
|
||||
assert.ok(typeof acct.consecutiveFails === "number");
|
||||
}
|
||||
});
|
||||
|
||||
it("pickAccount returns an account", () => {
|
||||
const acct = (executor as any).pickAccount();
|
||||
assert.ok(acct);
|
||||
assert.ok(typeof acct.fingerprint === "string");
|
||||
});
|
||||
|
||||
it("markCooldown increases consecutiveFails and sets cooldownUntil", () => {
|
||||
const acct = (executor as any).accounts[0];
|
||||
const before = acct.consecutiveFails;
|
||||
(executor as any).markCooldown(acct);
|
||||
assert.strictEqual(acct.consecutiveFails, before + 1);
|
||||
assert.ok(acct.cooldownUntil > Date.now());
|
||||
});
|
||||
|
||||
it("markSuccess resets consecutiveFails", () => {
|
||||
const acct = (executor as any).accounts[0];
|
||||
acct.consecutiveFails = 5;
|
||||
(executor as any).markSuccess(acct);
|
||||
assert.strictEqual(acct.consecutiveFails, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mimocode provider registration", () => {
|
||||
it("provider is registered in NOAUTH_PROVIDERS", async () => {
|
||||
const { NOAUTH_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
|
||||
const provider = (NOAUTH_PROVIDERS as Record<string, any>)["mimocode"];
|
||||
assert.ok(provider);
|
||||
assert.strictEqual(provider.id, "mimocode");
|
||||
assert.strictEqual(provider.alias, "mcode");
|
||||
assert.strictEqual(provider.noAuth, true);
|
||||
assert.strictEqual(provider.hasFree, true);
|
||||
});
|
||||
|
||||
it("provider has correct service kinds", async () => {
|
||||
const { NOAUTH_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
|
||||
const provider = (NOAUTH_PROVIDERS as Record<string, any>)["mimocode"];
|
||||
assert.ok(provider.serviceKinds?.includes("llm"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("mimocode providerRegistry entry", () => {
|
||||
it("registry entry exists with correct executor", async () => {
|
||||
const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts");
|
||||
const entry = getRegistryEntry("mimocode");
|
||||
assert.ok(entry);
|
||||
assert.strictEqual(entry.executor, "mimocode");
|
||||
assert.strictEqual(entry.format, "openai");
|
||||
assert.strictEqual(entry.authType, "none");
|
||||
});
|
||||
|
||||
it("registry entry has mimo-auto model", async () => {
|
||||
const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts");
|
||||
const entry = getRegistryEntry("mimocode");
|
||||
const models = entry.models as Array<{ id: string }>;
|
||||
const mimoAuto = models.find((m) => m.id === "mimo-auto");
|
||||
assert.ok(mimoAuto);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user