feat(mimocode): per-account proxy support for multi-account round-robin (#3837)

Integrated into release/v3.8.25 — feat(mimocode): per-account proxy for multi-account round-robin (runWithProxyContext per account, keyed by fingerprint). Orphan test relocated to a collected vitest path (14/14 green).
This commit is contained in:
PizzaV
2026-06-15 02:30:02 +02:00
committed by GitHub
parent 337cd18932
commit f42e8fa751
9 changed files with 935 additions and 100 deletions

View File

@@ -18,6 +18,7 @@
import * as crypto from "node:crypto";
import * as os from "node:os";
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
import { runWithProxyContext } from "../utils/proxyFetch.ts";
const BOOTSTRAP_PATH = "/api/free-ai/bootstrap";
const CHAT_PATH = "/api/free-ai/openai/chat";
@@ -72,12 +73,26 @@ const USER_AGENTS = [
// ── Account State ──────────────────────────────────────────────────────────
/** Per-account proxy configuration, passed through providerSpecificData.accountProxies. */
export interface AccountProxyConfig {
fingerprint: string;
proxy: {
type: string;
host: string;
port: number;
username?: string;
password?: string;
} | null;
}
interface AccountState {
fingerprint: string;
jwt: string;
expiresAt: number;
cooldownUntil: number;
consecutiveFails: number;
/** Resolved proxy config for this account (null = direct). */
proxy: AccountProxyConfig["proxy"];
}
function parseJwtExp(jwt: string): number {
@@ -195,23 +210,41 @@ export class MimocodeExecutor extends BaseExecutor {
expiresAt: 0,
cooldownUntil: 0,
consecutiveFails: 0,
proxy: null,
});
}
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);
if (Array.isArray(fingerprints)) {
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,
proxy: null,
});
existing.add(fp);
}
}
}
const accountProxies = credentials?.providerSpecificData
?.accountProxies as AccountProxyConfig[] | undefined;
const proxyMap = Array.isArray(accountProxies)
? new Map(accountProxies.map((ap) => [ap.fingerprint, ap.proxy] as const))
: null;
for (const acct of this.accounts) {
if (proxyMap) {
const entry = proxyMap.get(acct.fingerprint);
acct.proxy = entry !== undefined ? (entry ?? null) : null;
} else {
acct.proxy = null;
}
}
}
@@ -221,7 +254,10 @@ export class MimocodeExecutor extends BaseExecutor {
signal?: AbortSignal | null
): Promise<string> {
if (isAccountReady(account)) return account.jwt;
const result = await bootstrapJwt(this.baseUrl, account.fingerprint, signal);
const proxy = account.proxy;
const result = await runWithProxyContext(proxy, () =>
bootstrapJwt(this.baseUrl, account.fingerprint, signal)
);
account.jwt = result.jwt;
account.expiresAt = result.expiresAt;
return account.jwt;
@@ -297,24 +333,28 @@ export class MimocodeExecutor extends BaseExecutor {
log?: ExecuteInput["log"]
): Promise<boolean> {
try {
this.syncAccountsFromCredentials(_credentials);
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(
injectSystemMarker({
model: "mimo-auto",
messages: [{ role: "user", content: "ping" }],
stream: false,
})
),
signal: _signal ?? undefined,
});
const proxy = account.proxy;
const resp = await runWithProxyContext(proxy, () =>
fetch(this.buildUrl("mimo-auto", false), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwt}`,
"X-Mimo-Source": MIMO_SOURCE,
},
body: JSON.stringify(
injectSystemMarker({
model: "mimo-auto",
messages: [{ role: "user", content: "ping" }],
stream: false,
})
),
signal: _signal ?? undefined,
})
);
return resp.status === 200;
} catch {
log?.warn?.("MIMOCODE", "testConnection network error");
@@ -359,13 +399,16 @@ export class MimocodeExecutor extends BaseExecutor {
const jwt = await this.getJwtForAccount(account, signal);
const headers = this.buildHeaders(input.credentials, stream);
headers["Authorization"] = `Bearer ${jwt}`;
const proxy = account.proxy;
let resp = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
});
let resp = await runWithProxyContext(proxy, () =>
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) {
@@ -378,12 +421,14 @@ export class MimocodeExecutor extends BaseExecutor {
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,
});
resp = await runWithProxyContext(proxy, () =>
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
})
);
}
if (resp.status === 429) {

View File

@@ -1,6 +1,6 @@
"use client";
import { Button, Toggle } from "@/shared/components";
import { Button, DistributeProxiesButton, Toggle } from "@/shared/components";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
import type { CodexGlobalServiceMode } from "@/lib/providers/codexFastTier";
@@ -15,7 +15,6 @@ type ConnectionsHeaderToolbarProps = {
batchTesting: boolean;
batchRetesting: boolean;
retestingId: string | null;
distributingProxies: boolean;
proxyConfig: any;
// from useProviderSettings
preferClaudeCodeForUnprefixedClaudeModels: boolean;
@@ -60,7 +59,6 @@ export default function ConnectionsHeaderToolbar({
batchTesting,
batchRetesting,
retestingId,
distributingProxies,
proxyConfig,
preferClaudeCodeForUnprefixedClaudeModels,
claudeRoutingSettingsLoaded,
@@ -224,22 +222,10 @@ export default function ConnectionsHeaderToolbar({
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{connections.length > 0 && (
<button
onClick={() => handleDistributeProxies()}
disabled={distributingProxies || batchTesting || !!retestingId}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
distributingProxies
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title={t("distributeProxies")}
aria-label={t("distributeProxies")}
>
<span className="material-symbols-outlined text-[14px]">
{distributingProxies ? "sync" : "swap_horiz"}
</span>
{distributingProxies ? t("distributing") : t("distributeProxies")}
</button>
<DistributeProxiesButton
onDistribute={async () => { await handleDistributeProxies(); }}
disabled={batchTesting || !!retestingId}
/>
)}
{connections.length > 1 && (
<button

View File

@@ -2,7 +2,7 @@
import React from "react";
import { type ConnectionRowConnection } from "./ConnectionRow";
import ConnectionRow from "./ConnectionRow";
import { Button } from "@/shared/components";
import { Button, DistributeProxiesButton } from "@/shared/components";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import { readBooleanToggle, providerCountText } from "../providerPageHelpers";
import { compareTr } from "@/shared/utils/turkishText";
@@ -499,15 +499,11 @@ export default function ConnectionsListPanel({
{tag}
</span>
<div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
<Button
variant="ghost"
<DistributeProxiesButton
onDistribute={async () => { await handleDistributeProxies(tag); }}
disabled={batchTesting || !!retestingId}
size="sm"
icon="shield"
loading={distributingProxies}
onClick={() => handleDistributeProxies(tag)}
>
Distribute Proxies
</Button>
/>
<span className="text-[10px] text-text-muted/40">{groupConns.length}</span>
</div>
)}

View File

@@ -0,0 +1,219 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => {
container.remove();
});
return container;
}
describe("DistributeProxiesButton", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
vi.useRealTimers();
vi.clearAllMocks();
});
async function renderButton(
props: Partial<React.ComponentProps<typeof import("./DistributeProxiesButton").default>> = {}
) {
const { default: DistributeProxiesButton } = await import(
"./DistributeProxiesButton.tsx"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(
<DistributeProxiesButton
onDistribute={props.onDistribute ?? vi.fn().mockResolvedValue(undefined)}
{...props}
/>
);
});
return { container, root };
}
it("renders with default label", async () => {
const { container } = await renderButton();
const button = container.querySelector("button");
expect(button).not.toBeNull();
expect(button?.textContent).toContain("Distribute Proxies");
});
it("renders with custom label", async () => {
const { container } = await renderButton({ label: "Custom Label" });
const button = container.querySelector("button");
expect(button?.textContent).toContain("Custom Label");
});
it("shows swap_horiz icon in idle state", async () => {
const { container } = await renderButton();
const icon = container.querySelector(".material-symbols-outlined");
expect(icon?.textContent).toBe("swap_horiz");
});
it("disables button when disabled prop is true", async () => {
const { container } = await renderButton({ disabled: true });
const button = container.querySelector("button") as HTMLButtonElement;
expect(button.disabled).toBe(true);
});
it("calls onDistribute when clicked", async () => {
const onDistribute = vi.fn().mockResolvedValue(undefined);
const { container } = await renderButton({ onDistribute });
const button = container.querySelector("button") as HTMLButtonElement;
await act(async () => {
button.click();
});
expect(onDistribute).toHaveBeenCalledTimes(1);
});
it("enters distributing state on click", async () => {
let resolveDistribute: () => void;
const onDistribute = vi.fn().mockImplementation(
() => new Promise<void>((resolve) => { resolveDistribute = resolve; })
);
const { container } = await renderButton({ onDistribute });
const button = container.querySelector("button") as HTMLButtonElement;
await act(async () => {
button.click();
});
// Should show distributing state
expect(button.textContent).toContain("Distributing...");
expect(button.disabled).toBe(true);
const icon = container.querySelector(".material-symbols-outlined");
expect(icon?.textContent).toBe("sync");
// Resolve the promise
await act(async () => {
resolveDistribute!();
});
});
it("enters complete state after successful distribution", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const onDistribute = vi.fn().mockResolvedValue(undefined);
const { container } = await renderButton({ onDistribute });
const button = container.querySelector("button") as HTMLButtonElement;
await act(async () => {
button.click();
});
// Wait for distributing to complete
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(button.textContent).toContain("Complete");
const icon = container.querySelector(".material-symbols-outlined");
expect(icon?.textContent).toBe("check");
});
it("returns to idle state after complete timeout", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const onDistribute = vi.fn().mockResolvedValue(undefined);
const { container } = await renderButton({ onDistribute });
const button = container.querySelector("button") as HTMLButtonElement;
await act(async () => {
button.click();
});
// Wait for distributing to complete
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(button.textContent).toContain("Complete");
// Wait for the 1.5s timeout
await act(async () => {
await vi.advanceTimersByTimeAsync(1600);
});
expect(button.textContent).toContain("Distribute Proxies");
const icon = container.querySelector(".material-symbols-outlined");
expect(icon?.textContent).toBe("swap_horiz");
});
it("returns to idle state on error", async () => {
const onDistribute = vi.fn().mockRejectedValue(new Error("fail"));
const { container } = await renderButton({ onDistribute });
const button = container.querySelector("button") as HTMLButtonElement;
await act(async () => {
button.click();
});
// Wait for error handling
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(button.textContent).toContain("Distribute Proxies");
expect(button.disabled).toBe(false);
});
it("does not click when disabled", async () => {
const onDistribute = vi.fn().mockResolvedValue(undefined);
const { container } = await renderButton({ onDistribute, disabled: true });
const button = container.querySelector("button") as HTMLButtonElement;
await act(async () => {
button.click();
});
expect(onDistribute).not.toHaveBeenCalled();
});
it("applies sm size classes", async () => {
const { container } = await renderButton({ size: "sm" });
const button = container.querySelector("button") as HTMLButtonElement;
expect(button.className).toContain("px-2");
expect(button.className).toContain("py-1");
expect(button.className).toContain("text-[11px]");
});
it("applies md size classes by default", async () => {
const { container } = await renderButton();
const button = container.querySelector("button") as HTMLButtonElement;
expect(button.className).toContain("px-3");
expect(button.className).toContain("py-1.5");
expect(button.className).toContain("text-xs");
});
it("sets aria-label to the current label", async () => {
const { container } = await renderButton();
const button = container.querySelector("button") as HTMLButtonElement;
expect(button.getAttribute("aria-label")).toBe("Distribute Proxies");
});
it("sets title to the current label", async () => {
const { container } = await renderButton();
const button = container.querySelector("button") as HTMLButtonElement;
expect(button.getAttribute("title")).toBe("Distribute Proxies");
});
});

View File

@@ -0,0 +1,74 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
type ButtonState = "idle" | "distributing" | "complete";
interface DistributeProxiesButtonProps {
/** Async callback that performs the actual proxy distribution */
onDistribute: () => Promise<void>;
/** Whether the button should be disabled (e.g., during batch testing) */
disabled?: boolean;
/** Button label override */
label?: string;
/** Size variant */
size?: "sm" | "md";
}
export default function DistributeProxiesButton({
onDistribute,
disabled = false,
label = "Distribute Proxies",
size = "md",
}: DistributeProxiesButtonProps) {
const [state, setState] = useState<ButtonState>("idle");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleClick = useCallback(async () => {
if (disabled || state === "distributing") return;
setState("distributing");
try {
await onDistribute();
setState("complete");
timerRef.current = setTimeout(() => setState("idle"), 1500);
} catch {
setState("idle");
}
}, [onDistribute, disabled, state]);
const isDisabled = disabled || state === "distributing";
const sizeClasses =
size === "sm" ? "px-2 py-1 text-[11px]" : "px-3 py-1.5 text-xs";
const stateClasses =
state === "distributing"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: state === "complete"
? "bg-green-500/15 border-green-500/40 text-green-500"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40";
const icon = state === "distributing" ? "sync" : state === "complete" ? "check" : "swap_horiz";
const displayLabel = state === "distributing" ? "Distributing..." : state === "complete" ? "Complete" : label;
return (
<button
onClick={handleClick}
disabled={isDisabled}
className={`flex items-center gap-1.5 rounded-lg font-medium border transition-colors ${sizeClasses} ${stateClasses}`}
title={displayLabel}
aria-label={displayLabel}
>
<span className={`material-symbols-outlined text-[14px] ${state === "distributing" ? "animate-spin" : ""}`}>
{icon}
</span>
{displayLabel}
</button>
);
}

View File

@@ -1,20 +1,16 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import Card from "./Card";
import Button from "./Button";
import DistributeProxiesButton from "./DistributeProxiesButton";
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;
}
@@ -22,10 +18,29 @@ interface Connection {
id: string;
provider: string;
apiKey?: string;
providerSpecificData?: Record<string, string[]>;
providerSpecificData?: Record<string, any>;
isActive?: boolean;
}
interface AccountProxyConfig {
fingerprint: string;
proxy: { type: string; host: string; port: number; username?: string; password?: string } | null;
}
const PROXY_TYPES = [
{ value: "http", label: "HTTP" },
{ value: "https", label: "HTTPS" },
{ value: "socks5", label: "SOCKS5" },
];
function getAccountProxies(conn: Connection | undefined): AccountProxyConfig[] {
return (conn?.providerSpecificData?.accountProxies as AccountProxyConfig[]) || [];
}
function getProxyForFingerprint(proxies: AccountProxyConfig[], fp: string) {
return proxies.find((p) => p.fingerprint === fp)?.proxy ?? null;
}
export default function NoAuthAccountCard({
providerId,
providerName,
@@ -37,6 +52,14 @@ export default function NoAuthAccountCard({
const [connections, setConnections] = useState<Connection[]>([]);
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const [proxyAccountId, setProxyAccountId] = useState<string | null>(null);
const [proxyType, setProxyType] = useState("socks5");
const [proxyHost, setProxyHost] = useState("");
const [proxyPort, setProxyPort] = useState("1080");
const [proxyUsername, setProxyUsername] = useState("");
const [proxyPassword, setProxyPassword] = useState("");
const [savingProxy, setSavingProxy] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const fetchConnections = useCallback(async () => {
try {
@@ -59,10 +82,25 @@ export default function NoAuthAccountCard({
void fetchConnections();
}, [fetchConnections]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setProxyAccountId(null);
}
};
if (proxyAccountId) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [proxyAccountId]);
const allAccountIds = connections.flatMap(
(c) => c.providerSpecificData?.[dataKey] || []
);
const conn = connections[0];
const accountProxies = getAccountProxies(conn);
const handleAddAccount = async () => {
setAdding(true);
try {
@@ -79,7 +117,6 @@ export default function NoAuthAccountCard({
});
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",
@@ -99,15 +136,18 @@ export default function NoAuthAccountCard({
};
const handleRemoveAccount = async (accountId: string) => {
if (connections.length === 0) return;
const conn = connections[0];
if (!conn) return;
const updated = allAccountIds.filter((id) => id !== accountId);
const updatedProxies = accountProxies.filter((p) => p.fingerprint !== accountId);
try {
const res = await fetch(`/api/providers/${conn.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerSpecificData: { [dataKey]: updated },
providerSpecificData: {
[dataKey]: updated,
accountProxies: updatedProxies,
},
}),
});
if (res.ok) await fetchConnections();
@@ -116,6 +156,99 @@ export default function NoAuthAccountCard({
}
};
const openProxyConfig = (accountId: string) => {
const existing = getProxyForFingerprint(accountProxies, accountId);
if (existing) {
setProxyType(existing.type);
setProxyHost(existing.host);
setProxyPort(String(existing.port));
setProxyUsername(existing.username || "");
setProxyPassword(existing.password || "");
} else {
setProxyType("socks5");
setProxyHost("");
setProxyPort("1080");
setProxyUsername("");
setProxyPassword("");
}
setProxyAccountId(accountId);
};
const handleSaveProxy = async () => {
if (!conn || !proxyAccountId) return;
setSavingProxy(true);
try {
const trimmedHost = proxyHost.trim();
const newProxy: AccountProxyConfig["proxy"] = trimmedHost
? {
type: proxyType,
host: trimmedHost,
port: Number(proxyPort) || 1080,
...(proxyUsername.trim() ? { username: proxyUsername.trim() } : {}),
...(proxyPassword.trim() ? { password: proxyPassword.trim() } : {}),
}
: null;
const existing = accountProxies.filter((p) => p.fingerprint !== proxyAccountId);
const updatedProxies = newProxy
? [...existing, { fingerprint: proxyAccountId, proxy: newProxy }]
: existing;
const res = await fetch(`/api/providers/${conn.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerSpecificData: { accountProxies: updatedProxies },
}),
});
if (res.ok) {
await fetchConnections();
setProxyAccountId(null);
}
} catch (err) {
console.error("Failed to save proxy:", err);
} finally {
setSavingProxy(false);
}
};
const handleDistributeProxies = async () => {
if (!conn || allAccountIds.length === 0) return;
const proxiesRes = await fetch("/api/settings/proxies");
if (!proxiesRes.ok) throw new Error("Failed to fetch proxies");
const proxiesData = await proxiesRes.json();
const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active");
if (savedProxies.length === 0) {
throw new Error("No saved proxies found. Add proxies in Settings → Proxy first.");
}
const updatedProxies: AccountProxyConfig[] = allAccountIds.map((fp, i) => {
const proxy = savedProxies[i % savedProxies.length];
return {
fingerprint: fp,
proxy: {
type: proxy.type || "socks5",
host: proxy.host,
port: proxy.port,
...(proxy.username ? { username: proxy.username } : {}),
...(proxy.password ? { password: proxy.password } : {}),
},
};
});
const res = await fetch(`/api/providers/${conn.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerSpecificData: { accountProxies: updatedProxies },
}),
});
if (!res.ok) throw new Error("Failed to update connection");
await fetchConnections();
};
return (
<Card>
<div className="flex items-center gap-3 mb-3">
@@ -133,9 +266,18 @@ export default function NoAuthAccountCard({
<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 className="flex items-center gap-2">
{!loading && allAccountIds.length > 0 && (
<DistributeProxiesButton
onDistribute={handleDistributeProxies}
disabled={adding}
size="sm"
/>
)}
<Button size="sm" icon="add" onClick={handleAddAccount} disabled={adding}>
{adding ? "Adding..." : addLabel}
</Button>
</div>
</div>
{!loading && allAccountIds.length === 0 && (
@@ -145,23 +287,111 @@ export default function NoAuthAccountCard({
)}
{!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 className="space-y-1 relative">
{allAccountIds.map((id, i) => {
const proxy = getProxyForFingerprint(accountProxies, id);
return (
<div key={id} className="relative">
<div className="group flex items-center justify-between p-3 rounded-lg hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-bg text-text-muted text-xs font-medium">
{i + 1}
</span>
<span className="font-mono text-xs text-text-muted truncate">
{id.slice(0, 12)}...
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openProxyConfig(id)}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-colors"
title={proxy ? `${proxy.type}://${proxy.host}:${proxy.port}` : "Configure proxy"}
>
<span className={`material-symbols-outlined text-[14px] ${proxy ? "text-blue-400" : "text-text-muted"}`}>
{proxy ? "shield" : "shield"}
</span>
<span className={proxy ? "text-blue-400" : "text-text-muted"}>
{proxy ? `${proxy.type}://${proxy.host}` : "Proxy"}
</span>
</button>
<button
onClick={() => handleRemoveAccount(id)}
className="p-1 hover:bg-red-500/10 rounded text-text-muted hover:text-red-500 transition-colors opacity-0 group-hover:opacity-100"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
</div>
</div>
{proxyAccountId === id && (
<div
ref={popoverRef}
className="absolute right-0 top-full z-50 mt-1 w-80 rounded-lg border border-black/10 dark:border-white/10 bg-surface shadow-lg p-4"
>
<p className="text-sm font-medium mb-3">
Proxy for Account {i + 1}
</p>
<div className="space-y-3">
<div className="flex gap-2">
<select
value={proxyType}
onChange={(e) => setProxyType(e.target.value)}
className="rounded-md border border-black/10 dark:border-white/10 bg-bg px-2.5 py-1.5 text-xs flex-shrink-0"
>
{PROXY_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<input
type="text"
value={proxyHost}
onChange={(e) => setProxyHost(e.target.value)}
placeholder="Host"
className="flex-1 rounded-md border border-black/10 dark:border-white/10 bg-bg px-2.5 py-1.5 text-xs"
/>
<input
type="text"
value={proxyPort}
onChange={(e) => setProxyPort(e.target.value)}
placeholder="Port"
className="w-16 rounded-md border border-black/10 dark:border-white/10 bg-bg px-2.5 py-1.5 text-xs"
/>
</div>
<input
type="text"
value={proxyUsername}
onChange={(e) => setProxyUsername(e.target.value)}
placeholder="Username (optional)"
className="w-full rounded-md border border-black/10 dark:border-white/10 bg-bg px-2.5 py-1.5 text-xs"
/>
<input
type="password"
value={proxyPassword}
onChange={(e) => setProxyPassword(e.target.value)}
placeholder="Password (optional)"
className="w-full rounded-md border border-black/10 dark:border-white/10 bg-bg px-2.5 py-1.5 text-xs"
/>
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setProxyAccountId(null)}
className="rounded-md px-3 py-1.5 text-xs text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveProxy}
disabled={savingProxy}
className="rounded-md bg-primary/10 px-3 py-1.5 text-xs text-primary hover:bg-primary/20 disabled:opacity-50 transition-colors"
>
{savingProxy ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>

View File

@@ -39,6 +39,7 @@ export { default as NoAuthAccountCard } from "./NoAuthAccountCard";
export { default as CollapsibleSection } from "./CollapsibleSection";
export { default as InfoTooltip } from "./InfoTooltip";
export { default as PresetSlider } from "./PresetSlider";
export { default as DistributeProxiesButton } from "./DistributeProxiesButton";
export { SkillsConceptCard } from "./SkillsConceptCard";

View File

@@ -0,0 +1,150 @@
import { describe, it, before } from "node:test";
import assert from "node:assert";
import { MimocodeExecutor, generateFingerprint } from "../../open-sse/executors/mimocode.ts";
const PROXY_URL = process.env.MIMOCODE_SOCKS5_PROXY;
function parseProxyUrl(url: string): { type: string; host: string; port: number } | null {
try {
const parsed = new URL(url);
return { type: parsed.protocol.replace(":", ""), host: parsed.hostname, port: parsed.port ? Number(parsed.port) : 1080 };
} catch {
return null;
}
}
function requireProxy() {
if (!PROXY_URL) {
return false;
}
const parsed = parseProxyUrl(PROXY_URL);
return parsed !== null;
}
const proxyConfig = PROXY_URL ? parseProxyUrl(PROXY_URL) : null;
describe("mimocode per-account proxy — SOCKS5 integration", { timeout: 30_000 }, () => {
before(() => {
if (!PROXY_URL) {
console.log("# MIMOCODE_SOCKS5_PROXY not set, skipping live proxy tests");
}
});
it("bootstrap returns JWT through configured proxy", { skip: !requireProxy() ? "MIMOCODE_SOCKS5_PROXY not set" : false }, async () => {
process.env.ENABLE_SOCKS5_PROXY = "true";
const { Socks5ProxyAgent } = await import("undici");
const agent = new Socks5ProxyAgent(PROXY_URL!);
const fp = generateFingerprint("integration-bootstrap-" + Date.now());
const resp = await fetch("https://api.xiaomimimo.com/api/free-ai/bootstrap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client: fp }),
// @ts-expect-error — undici dispatcher
dispatcher: agent,
signal: AbortSignal.timeout(15_000),
});
assert.strictEqual(resp.status, 200, `Bootstrap through proxy: expected 200, got ${resp.status}`);
const data = await resp.json();
assert.ok(data.jwt, "Response should contain JWT");
assert.ok(typeof data.jwt === "string" && data.jwt.length > 10, "JWT should be a non-trivial string");
});
it("chat request succeeds through configured proxy", { skip: !requireProxy() ? "MIMOCODE_SOCKS5_PROXY not set" : false }, async () => {
process.env.ENABLE_SOCKS5_PROXY = "true";
const { Socks5ProxyAgent } = await import("undici");
const agent = new Socks5ProxyAgent(PROXY_URL!);
const fp = generateFingerprint("integration-chat-" + Date.now());
const bootstrapResp = await fetch("https://api.xiaomimimo.com/api/free-ai/bootstrap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client: fp }),
// @ts-expect-error — undici dispatcher
dispatcher: agent,
signal: AbortSignal.timeout(15_000),
});
assert.strictEqual(bootstrapResp.status, 200);
const { jwt } = await bootstrapResp.json();
const chatResp = await fetch("https://api.xiaomimimo.com/api/free-ai/openai/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${jwt}`,
"X-Mimo-Source": "mimocode-cli-free",
},
body: JSON.stringify({
model: "mimo-auto",
messages: [
{ role: "system", content: "You are MiMoCode, an interactive CLI tool that helps users with software engineering tasks." },
{ role: "user", content: "Say exactly: proxy-integration-ok" },
],
stream: false,
}),
// @ts-expect-error — undici dispatcher
dispatcher: agent,
signal: AbortSignal.timeout(20_000),
});
assert.ok(chatResp.status === 200 || chatResp.status === 429,
`Chat through proxy: expected 200/429, got ${chatResp.status}`);
});
it("accounts carry proxy config after sync", () => {
const exec = new MimocodeExecutor();
const fp = "integration-fp-1";
const cfg = proxyConfig || { type: "socks5", host: "127.0.0.1", port: 1080 };
(exec as any).accounts = [
{ fingerprint: fp, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: null },
];
(exec as any).nextAccountIdx = 0;
(exec as any).syncAccountsFromCredentials({
providerSpecificData: {
accountProxies: [{ fingerprint: fp, proxy: cfg }],
},
});
const acct = (exec as any).accounts.find((a: any) => a.fingerprint === fp);
assert.ok(acct, "Account should exist");
assert.deepStrictEqual(acct.proxy, cfg);
});
it("two accounts with different proxies tracked independently", () => {
const exec = new MimocodeExecutor();
const fp1 = "integration-fp-a";
const fp2 = "integration-fp-b";
const proxy1 = { type: "http" as const, host: "proxy-a.example.com", port: 8080 };
const proxy2 = { type: "socks5" as const, host: "proxy-b.example.com", port: 1080 };
(exec as any).accounts = [
{ fingerprint: fp1, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: null },
{ fingerprint: fp2, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: null },
];
(exec as any).nextAccountIdx = 0;
(exec as any).syncAccountsFromCredentials({
providerSpecificData: {
accountProxies: [
{ fingerprint: fp1, proxy: proxy1 },
{ fingerprint: fp2, proxy: proxy2 },
],
},
});
const a1 = (exec as any).accounts.find((a: any) => a.fingerprint === fp1);
const a2 = (exec as any).accounts.find((a: any) => a.fingerprint === fp2);
assert.deepStrictEqual(a1.proxy, proxy1, "Account 1 should have proxy1");
assert.deepStrictEqual(a2.proxy, proxy2, "Account 2 should have proxy2");
assert.notDeepStrictEqual(a1.proxy, a2.proxy, "Proxies should differ");
});
it("no accountProxies keeps all proxies null (backward compat)", () => {
const exec = new MimocodeExecutor();
const accounts = (exec as any).accounts;
assert.ok(accounts.length >= 1);
for (const acct of accounts) {
assert.strictEqual(acct.proxy, null, "Default account proxy should be null");
}
});
});

View File

@@ -4,6 +4,7 @@ import {
MimocodeExecutor,
generateFingerprint,
MIMO_SYSTEM_MARKER,
type AccountProxyConfig,
} from "../../open-sse/executors/mimocode.ts";
const executor = new MimocodeExecutor();
@@ -251,3 +252,136 @@ describe("mimocode providerRegistry entry", () => {
assert.ok(mimoAuto);
});
});
describe("mimocode per-account proxy", () => {
const exec = new MimocodeExecutor();
it("AccountProxyConfig type has required fields", () => {
const config: AccountProxyConfig = {
fingerprint: "abc123",
proxy: { type: "http", host: "proxy.example.com", port: 8080 },
};
assert.strictEqual(config.fingerprint, "abc123");
assert.strictEqual(config.proxy?.host, "proxy.example.com");
});
it("default account has null proxy", () => {
const accounts = (exec as any).accounts;
assert.ok(Array.isArray(accounts));
assert.ok(accounts.length >= 1);
assert.strictEqual(accounts[0].proxy, null);
});
it("syncAccountsFromCredentials reads accountProxies", () => {
const testExec = new MimocodeExecutor();
const fp1 = "fingerprint-1";
const fp2 = "fingerprint-2";
(testExec as any).accounts = [
{ fingerprint: fp1, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: null },
{ fingerprint: fp2, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: null },
];
(testExec as any).nextAccountIdx = 0;
const credentials = {
providerSpecificData: {
accountProxies: [
{ fingerprint: fp1, proxy: { type: "http", host: "p1.example.com", port: 1080 } },
{ fingerprint: fp2, proxy: null },
],
},
};
(testExec as any).syncAccountsFromCredentials(credentials);
const accounts = (testExec as any).accounts;
const acct1 = accounts.find((a: any) => a.fingerprint === fp1);
const acct2 = accounts.find((a: any) => a.fingerprint === fp2);
assert.deepStrictEqual(acct1.proxy, { type: "http", host: "p1.example.com", port: 1080 });
assert.strictEqual(acct2.proxy, null);
});
it("syncAccountsFromCredentials skips when accountProxies absent", () => {
const testExec = new MimocodeExecutor();
const before = JSON.parse(JSON.stringify((testExec as any).accounts));
(testExec as any).syncAccountsFromCredentials({ providerSpecificData: {} });
const after = (testExec as any).accounts;
assert.strictEqual(after.length, before.length);
assert.strictEqual(after[0].proxy, null);
});
it("syncAccountsFromCredentials skips unknown fingerprints", () => {
const testExec = new MimocodeExecutor();
const existingFp = (testExec as any).accounts[0].fingerprint;
(testExec as any).syncAccountsFromCredentials({
providerSpecificData: {
accountProxies: [
{ fingerprint: "nonexistent-fingerprint", proxy: { type: "socks5", host: "s5.example.com", port: 1080 } },
],
},
});
assert.strictEqual((testExec as any).accounts[0].proxy, null);
});
it("accounts with different proxies are tracked independently", () => {
const testExec = new MimocodeExecutor();
const fp1 = "fp-a";
const fp2 = "fp-b";
(testExec as any).accounts = [
{ fingerprint: fp1, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: null },
{ fingerprint: fp2, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: null },
];
(testExec as any).syncAccountsFromCredentials({
providerSpecificData: {
accountProxies: [
{ fingerprint: fp1, proxy: { type: "http", host: "a.com", port: 8080 } },
{ fingerprint: fp2, proxy: { type: "socks5", host: "b.com", port: 1080 } },
],
},
});
const accounts = (testExec as any).accounts;
const a1 = accounts.find((a: any) => a.fingerprint === fp1);
const a2 = accounts.find((a: any) => a.fingerprint === fp2);
assert.notDeepStrictEqual(a1.proxy, a2.proxy);
assert.strictEqual(a1.proxy?.host, "a.com");
assert.strictEqual(a2.proxy?.host, "b.com");
});
it("getJwtForAccount reads proxy from account", async () => {
const testExec = new MimocodeExecutor();
const fp = "test-fp-proxy";
const proxyConfig = { type: "http", host: "proxy.test", port: 3128 };
(testExec as any).accounts = [
{ fingerprint: fp, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: proxyConfig },
];
(testExec as any).nextAccountIdx = 0;
const acct = (testExec as any).accounts[0];
assert.deepStrictEqual(acct.proxy, proxyConfig);
assert.strictEqual(acct.jwt, "");
});
it("proxy field persists on account after sync", () => {
const testExec = new MimocodeExecutor();
const fp = "test-fp-persist";
(testExec as any).accounts = [
{ fingerprint: fp, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: null },
];
(testExec as any).nextAccountIdx = 0;
const proxy1 = { type: "http", host: "first.proxy", port: 8080 };
(testExec as any).syncAccountsFromCredentials({
providerSpecificData: {
accountProxies: [{ fingerprint: fp, proxy: proxy1 }],
},
});
assert.deepStrictEqual((testExec as any).accounts[0].proxy, proxy1);
const proxy2 = { type: "socks5", host: "second.proxy", port: 1080 };
(testExec as any).syncAccountsFromCredentials({
providerSpecificData: {
accountProxies: [{ fingerprint: fp, proxy: proxy2 }],
},
});
assert.deepStrictEqual((testExec as any).accounts[0].proxy, proxy2);
});
});