Skip to content
31 changes: 21 additions & 10 deletions gui/src/components/AddProviderModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { IconX, IconLock, IconKey, IconExternal } from "../icons";
import { buildProviderPayload, type ProviderPayload } from "../provider-payload";

Expand All @@ -17,6 +17,8 @@ interface Preset {
/** Where to create/copy the API key (for auth === "key" catalog providers). */
dashboardUrl?: string;
note?: string;
/** API key is optional — provider works without one (free public tier). */
keyOptional?: boolean;
}

const FALLBACK_PRESETS: Preset[] = [
Expand Down Expand Up @@ -185,13 +187,18 @@ export default function AddProviderModal({
<div className="title">{p.label}</div>
<div className="sub"><code className="chip">{p.adapter}</code>{p.note ? ` · ${p.note}` : ""}</div>
</div>
{p.auth === "oauth"
? <span className="badge badge-accent">OAuth</span>
: p.auth === "forward"
? <span className="badge badge-green">Codex login</span>
: p.auth === "local"
? <span className="badge badge-amber">Local</span>
: <span className="badge badge-muted">API key</span>}
<div style={{ display: "flex", gap: 4, alignItems: "center", flexShrink: 0 }}>
{p.keyOptional && <span className="badge badge-green">Free</span>}
{p.auth === "oauth"
? <span className="badge badge-accent">OAuth</span>
: p.auth === "forward"
? <span className="badge badge-green">Codex login</span>
: p.auth === "local"
? <span className="badge badge-amber">Local</span>
: !p.keyOptional
? <span className="badge badge-muted">API key</span>
: null}
</div>
</button>
))}
{filtered.length === 0 && <div className="muted" style={{ fontSize: 13, padding: 8 }}>No match.</div>}
Expand Down Expand Up @@ -220,9 +227,9 @@ export default function AddProviderModal({
</div>
</div>
) : (
// API key / Codex-forward form
// API key / Codex-forward / free-tier form
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{!isCustom && !isLocal && preset.note && (
{!isCustom && !isLocal && !preset.keyOptional && preset.note && (
<details className="setup-guide">
<summary>Setup guide</summary>
<ol style={{ margin: "8px 0 0", paddingLeft: 18, fontSize: 12, color: "var(--muted)", lineHeight: 1.7 }}>
Expand Down Expand Up @@ -253,6 +260,10 @@ export default function AddProviderModal({
<div style={{ fontSize: 12, color: "var(--amber)", background: "var(--amber-soft)", border: "1px solid var(--amber)", borderRadius: "var(--radius-sm)", padding: "8px 10px", lineHeight: 1.55 }}>
No API key is stored. This adds Cursor's static public model catalog for Codex, but live Cursor transport and native file/shell execution remain disabled until audited.
</div>
) : preset.keyOptional ? (
<div style={{ fontSize: 12, color: "var(--green)", background: "var(--green-soft)", border: "1px solid var(--green)", borderRadius: "var(--radius-sm)", padding: "10px 12px", lineHeight: 1.6 }}>
<strong>Free tier</strong> — {preset.note ?? "No API key required. Works out of the box."}
</div>
) : (
<>
{preset.dashboardUrl && (
Expand Down
7 changes: 5 additions & 2 deletions gui/src/pages/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { providerIconSrc } from "../provider-icons";
interface Config {
port: number;
defaultProvider: string;
providers: Record<string, { adapter: string; baseUrl: string; hasApiKey?: boolean; hasHeaders?: boolean; defaultModel?: string; authMode?: string; disabled?: boolean }>;
providers: Record<string, { adapter: string; baseUrl: string; hasApiKey?: boolean; hasHeaders?: boolean; defaultModel?: string; authMode?: string; keyOptional?: boolean; disabled?: boolean }>;
}

interface OAuthStatus { loggedIn: boolean; email?: string; error?: string; done?: boolean }
Expand Down Expand Up @@ -392,6 +392,8 @@ export default function Providers({ apiBase }: { apiBase: string }) {
})}
{keyProviders.map(name => {
const icon = providerIconSrc(name);
const provider = config?.providers[name];
const keylessFree = provider?.keyOptional === true && !provider?.hasApiKey;
return (
<div key={name} className="oauth-row">
<span className="oauth-name" title={name}>
Expand All @@ -400,7 +402,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
</span>
<span className="oauth-status">
<span className="dot dot-green" />
<span className="oauth-email muted">{t("prov.hasApiKey")}</span>
<span className="oauth-email muted">{keylessFree ? "free tier" : t("prov.hasApiKey")}</span>
</span>
<span className="oauth-actions" aria-hidden="true" />
</div>
Expand Down Expand Up @@ -444,6 +446,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
{isDisabled ? <span className="badge badge-muted">{t("prov.disabledBadge")}</span> : <span className="badge badge-green">{t("prov.activeBadge")}</span>}
{prov.authMode === "oauth" && <span className="badge badge-accent">oauth</span>}
{prov.authMode === "forward" && <span className="badge badge-amber">passthrough</span>}
{prov.keyOptional && <span className="badge badge-green">Free</span>}
</div>
<div className="muted prov-meta" style={{ fontSize: 13 }}>
<code className="chip">{prov.adapter}</code>
Expand Down
2 changes: 2 additions & 0 deletions gui/src/provider-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const PROVIDER_ICON_ALIASES: Record<string, string> = {
"ollama-cloud": "ollama-color.svg",
openai: "openai.svg",
"openai-apikey": "openai.svg",
"opencode-free": "opencode.svg",
"opencode-go": "opencode.svg",
"opencode-zen": "opencode.svg",
openrouter: "openrouter-color.svg",
Expand All @@ -35,6 +36,7 @@ const PROVIDER_ICON_ALIASES: Record<string, string> = {
"vercel-ai-gateway": "vercel-ai-gateway-color.svg",
vllm: "vllm-color.svg",
xai: "grok-color.svg",
"mimo-free": "xiaomi-color.svg",
xiaomi: "xiaomi-color.svg",
};

Expand Down
176 changes: 176 additions & 0 deletions src/adapters/mimo-free.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { createHash } from "node:crypto";
import os from "node:os";
import type { OcxProviderConfig, OcxParsedRequest } from "../types";
import { createOpenAIChatAdapter } from "./openai-chat";
import type { ProviderAdapter, AdapterRequest } from "./base";

const BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap";
export const MIMO_CHAT_URL = "https://api.xiaomimimo.com/api/free-ai/openai/chat";

/**
* Anti-abuse gate: the free chat endpoint returns 403 "Illegal access" unless
* a system message contains this exact string as a substring.
*/
export const MIMO_SYSTEM_MARKER =
"You are MiMoCode, an interactive CLI tool that helps users with software engineering tasks.";

// Chrome-like User-Agent required by the upstream anti-abuse gate.
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",
];

const JWT_FALLBACK_TTL_MS = 3_000_000; // 50 min
const JWT_EXPIRY_BUFFER_MS = 300_000; // 5 min early refresh

// In-process JWT cache -- survives across requests, reset on restart.
let cachedJwt: string | null = null;
let jwtExpiresAt = 0;

function randomUserAgent(): string {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]!;
}

/** SHA-256 fingerprint of stable machine attributes; stable per machine, not a secret. */
export function generateMimoFingerprint(): string {
let username = "unknown-user";
try { username = os.userInfo().username; } catch { /* ignore */ }
const cpu = (os.cpus()[0]?.model ?? "unknown-cpu").trim();
const seed = `${os.hostname()}|${os.platform()}|${os.arch()}|${cpu}|${username}`;
return createHash("sha256").update(seed).digest("hex");
}

function parseJwtExp(jwt: string): number {
try {
const parts = jwt.split(".");
if (parts.length < 2) return 0;
const payload = JSON.parse(Buffer.from(parts[1]!, "base64").toString()) as { exp?: number };
if (payload.exp) return payload.exp * 1000;
} catch { /* ignore */ }
return Date.now() + JWT_FALLBACK_TTL_MS;
}

export function resetMimoJwtCache(): void {
cachedJwt = null;
jwtExpiresAt = 0;
}

async function fetchJwt(): Promise<string> {
const response = await fetch(BOOTSTRAP_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": randomUserAgent(),
},
body: JSON.stringify({ client: generateMimoFingerprint() }),
});
if (!response.ok) {
throw new Error(`MiMo bootstrap failed: ${response.status}`);
}
const data = await response.json() as { jwt?: string };
if (!data.jwt) throw new Error("MiMo bootstrap returned no JWT");
return data.jwt;
}

export async function getMimoJwt(): Promise<string> {
if (cachedJwt && Date.now() < jwtExpiresAt - JWT_EXPIRY_BUFFER_MS) {
return cachedJwt;
}
const jwt = await fetchJwt();
cachedJwt = jwt;
jwtExpiresAt = parseJwtExp(jwt);
return jwt;
}

/**
* Idempotently prepend the MiMo anti-abuse system marker if it is not already present.
* The marker must appear in a system message; we prepend one if the request has none with it.
*/
export function injectMimoSystemMarker(body: unknown): unknown {
if (!body || typeof body !== "object") return body;
const parsed = body as Record<string, unknown>;
const messages = parsed["messages"];
if (!Array.isArray(messages)) return body;
const hasMarker = messages.some(
(m): m is { role: string; content: string } =>
m !== null &&
typeof m === "object" &&
(m as Record<string, unknown>)["role"] === "system" &&
typeof (m as Record<string, unknown>)["content"] === "string" &&
((m as Record<string, unknown>)["content"] as string).includes(MIMO_SYSTEM_MARKER),
);
if (hasMarker) return body;
return { ...parsed, messages: [{ role: "system", content: MIMO_SYSTEM_MARKER }, ...messages] };
}

/**
* Creates the MiMo Free adapter. Wraps openai-chat's request builder to inject:
* 1. JWT from the bootstrap endpoint (cached, auto-refreshed).
* 2. Anti-abuse system marker in the request body.
* 3. Required headers (User-Agent, X-Mimo-Source, x-session-affinity).
* On 401/403, flushes the JWT cache and retries once via fetchResponse.
*/
export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdapter {
const base = createOpenAIChatAdapter(provider);
// Per-adapter session-affinity id (random, per process instance).
const sessionId = `ses_${Math.random().toString(36).slice(2, 26)}`;

return {
...base,
name: "mimo-free",

async buildRequest(parsed: OcxParsedRequest): Promise<AdapterRequest> {
const jwt = await getMimoJwt();

// Let the base adapter build the wire body (handles reasoning, tools, etc.)
// but override the URL and headers after.
const baseReq = base.buildRequest(parsed) as AdapterRequest;
const baseBody = JSON.parse(baseReq.body as string) as unknown;
const markedBody = injectMimoSystemMarker(baseBody);

const headers: Record<string, string> = {
"Content-Type": "application/json",
"Authorization": `Bearer ${jwt}`,
"X-Mimo-Source": "mimocode-cli-free",
"User-Agent": randomUserAgent(),
"x-session-affinity": sessionId,
"Accept": parsed.stream ? "text/event-stream" : "application/json",
};

return {
url: MIMO_CHAT_URL,
method: "POST",
headers,
body: JSON.stringify(markedBody),
};
},

async fetchResponse(request: AdapterRequest, ctx): Promise<Response> {
const response = await fetch(request.url, {
method: request.method,
headers: request.headers as Record<string, string>,
body: request.body,
signal: ctx?.abortSignal,
});

// On auth failure, flush JWT cache and retry once with a fresh token.
if (response.status === 401 || response.status === 403) {
resetMimoJwtCache();
const freshJwt = await getMimoJwt();
const retryHeaders = {
...(request.headers as Record<string, string>),
"Authorization": `Bearer ${freshJwt}`,
};
return fetch(request.url, {
method: request.method,
headers: retryHeaders,
body: request.body,
signal: ctx?.abortSignal,
});
}

return response;
},
};
}
2 changes: 1 addition & 1 deletion src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd

const url = `${provider.baseUrl}/chat/completions`;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;
if (provider.headers) Object.assign(headers, provider.headers);
if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;

return { url, method: "POST", headers, body: JSON.stringify(body) };
},
Expand Down
2 changes: 2 additions & 0 deletions src/providers/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface DerivedProviderPreset {
oauthProvider?: string;
dashboardUrl?: string;
note?: string;
keyOptional?: boolean;
}

export function listRegistryEntries(): readonly ProviderRegistryEntry[] {
Expand Down Expand Up @@ -227,6 +228,7 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset {
...(entry.authKind === "oauth" ? { oauthProvider: entry.oauthId ?? entry.id } : {}),
...(entry.dashboardUrl ? { dashboardUrl: entry.dashboardUrl } : {}),
...(entry.note ? { note: entry.note } : {}),
...(entry.keyOptional ? { keyOptional: true } : {}),
};
}

Expand Down
14 changes: 14 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
{ id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" },
{ id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" },
{ id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" },
{
id: "mimo-free",
label: "MiMo Free",
adapter: "mimo-free",
baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat",
authKind: "key",
keyOptional: true,
featured: true,
liveModels: true,
dashboardUrl: "https://xiaomimimo.com",
defaultModel: "mimo-auto",
models: ["mimo-auto"],
note: "No key needed — uses Xiaomi MiMo's free public tier. A JWT is bootstrapped automatically per machine fingerprint.",
},
{ id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
// FREEZE 2026-07-10: /models is auth-gated, so ids remain unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
{ id: "github-copilot", label: "GitHub Copilot", baseUrl: "https://api.githubcopilot.com", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://github.com/settings/copilot" },
Expand Down
3 changes: 3 additions & 0 deletions src/server/adapter-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createAzureAdapter } from "../adapters/azure";
import { createCursorAdapter } from "../adapters/cursor";
import { createGoogleAdapter } from "../adapters/google";
import { createKiroAdapter } from "../adapters/kiro";
import { createMimoFreeAdapter } from "../adapters/mimo-free";
import { createOpenAIChatAdapter } from "../adapters/openai-chat";
import { createResponsesPassthroughAdapter } from "../adapters/openai-responses";
import type { OcxProviderConfig } from "../types";
Expand Down Expand Up @@ -40,6 +41,8 @@ export function resolveAdapter(providerConfig: OcxProviderConfig, cacheRetention
return createAzureAdapter(providerConfig);
case "cursor":
return createCursorAdapter(providerConfig);
case "mimo-free":
return createMimoFreeAdapter(providerConfig);
default:
throw new Error(`Unknown adapter: ${providerConfig.adapter}`);
}
Expand Down
1 change: 1 addition & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
"disabled",
"allowPrivateNetwork",
"authMode",
"keyOptional",
"liveModels",
"models",
"contextWindow",
Expand Down
7 changes: 6 additions & 1 deletion tests/claude-agents-inject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,12 @@ describe("syncClaudeAgentDefs ownership contract (audit 071 #2/#3)", () => {
mkdirSync(agentsDir, { recursive: true });
const victim = join(dir, "victim.md");
writeFileSync(victim, "precious");
symlinkSync(victim, join(agentsDir, "ocx-linked.md"));
try {
symlinkSync(victim, join(agentsDir, "ocx-linked.md"));
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === "EPERM") return; // skip on Windows without elevated symlink rights
throw e;
}
syncClaudeAgentDefs([], dir); // prune pass
expect(readFileSync(victim, "utf8")).toBe("precious");
expect(readdirSync(agentsDir)).toContain("ocx-linked.md");
Expand Down
Loading
Loading