diff --git a/open-sse/executors/mimocode.ts b/open-sse/executors/mimocode.ts index 00bc5061b6..20df326af2 100644 --- a/open-sse/executors/mimocode.ts +++ b/open-sse/executors/mimocode.ts @@ -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 { 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 { 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) { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx index 4fd07cb763..b73cce3ca7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx @@ -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({
{connections.length > 0 && ( - + { await handleDistributeProxies(); }} + disabled={batchTesting || !!retestingId} + /> )} {connections.length > 1 && ( + /> {groupConns.length}
)} diff --git a/src/shared/components/DistributeProxiesButton.test.tsx b/src/shared/components/DistributeProxiesButton.test.tsx new file mode 100644 index 0000000000..fa59f49be5 --- /dev/null +++ b/src/shared/components/DistributeProxiesButton.test.tsx @@ -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> = {} + ) { + const { default: DistributeProxiesButton } = await import( + "./DistributeProxiesButton.tsx" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render( + + ); + }); + 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((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"); + }); +}); diff --git a/src/shared/components/DistributeProxiesButton.tsx b/src/shared/components/DistributeProxiesButton.tsx new file mode 100644 index 0000000000..f0d792a096 --- /dev/null +++ b/src/shared/components/DistributeProxiesButton.tsx @@ -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; + /** 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("idle"); + const timerRef = useRef | 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 ( + + ); +} diff --git a/src/shared/components/NoAuthAccountCard.tsx b/src/shared/components/NoAuthAccountCard.tsx index 9f03a5f80b..1f5d073e1d 100644 --- a/src/shared/components/NoAuthAccountCard.tsx +++ b/src/shared/components/NoAuthAccountCard.tsx @@ -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; + providerSpecificData?: Record; 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([]); const [loading, setLoading] = useState(true); const [adding, setAdding] = useState(false); + const [proxyAccountId, setProxyAccountId] = useState(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(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 (
@@ -133,9 +266,18 @@ export default function NoAuthAccountCard({ Accounts ({loading ? "..." : allAccountIds.length}) - +
+ {!loading && allAccountIds.length > 0 && ( + + )} + +
{!loading && allAccountIds.length === 0 && ( @@ -145,23 +287,111 @@ export default function NoAuthAccountCard({ )} {!loading && allAccountIds.length > 0 && ( -
- {allAccountIds.map((id, i) => ( -
- - Account {i + 1}: {id.slice(0, 12)}... - - -
- ))} +
+ {allAccountIds.map((id, i) => { + const proxy = getProxyForFingerprint(accountProxies, id); + return ( +
+
+
+ + {i + 1} + + + {id.slice(0, 12)}... + +
+
+ + +
+
+ + {proxyAccountId === id && ( +
+

+ Proxy for Account {i + 1} +

+
+
+ + 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" + /> + 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" + /> +
+ 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" + /> + 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" + /> +
+ + +
+
+
+ )} +
+ ); + })}
)}
diff --git a/src/shared/components/index.tsx b/src/shared/components/index.tsx index 9d47f02fdd..a6068632ad 100644 --- a/src/shared/components/index.tsx +++ b/src/shared/components/index.tsx @@ -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"; diff --git a/tests/integration/mimocode-proxy.integration.test.ts b/tests/integration/mimocode-proxy.integration.test.ts new file mode 100644 index 0000000000..85e5934e49 --- /dev/null +++ b/tests/integration/mimocode-proxy.integration.test.ts @@ -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"); + } + }); +}); diff --git a/tests/unit/mimocode-executor.test.ts b/tests/unit/mimocode-executor.test.ts index ee2f9848a9..b3e0641de6 100644 --- a/tests/unit/mimocode-executor.test.ts +++ b/tests/unit/mimocode-executor.test.ts @@ -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); + }); +});