mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
219
src/shared/components/DistributeProxiesButton.test.tsx
Normal file
219
src/shared/components/DistributeProxiesButton.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
74
src/shared/components/DistributeProxiesButton.tsx
Normal file
74
src/shared/components/DistributeProxiesButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
150
tests/integration/mimocode-proxy.integration.test.ts
Normal file
150
tests/integration/mimocode-proxy.integration.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user