feat(proxy): add proxy pool and per-connection binding + strictProxy support

- Centralize proxy management with reusable proxy pools
- Per-connection proxy binding with legacy fallback
- Add strictProxy option: fail hard instead of silently falling back to direct
- Resolve alicode-intl conflict: keep alicode-intl support + proxy support

Made-with: Cursor
This commit is contained in:
decolua
2026-03-09 15:46:06 +07:00
parent 4c469291a1
commit 880f4eca91
22 changed files with 1811 additions and 146 deletions

View File

@@ -2,6 +2,7 @@ import crypto from "crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER } from "../config/constants.js";
import { deriveSessionId } from "../utils/sessionManager.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
const MAX_RETRY_AFTER_MS = 10000;
@@ -161,7 +162,7 @@ export class AntigravityExecutor extends BaseExecutor {
return totalMs > 0 ? totalMs : null;
}
async execute({ model, body, stream, credentials, signal, log }) {
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const fallbackCount = this.getFallbackCount();
let lastError = null;
let lastStatus = 0;
@@ -180,12 +181,12 @@ export class AntigravityExecutor extends BaseExecutor {
}
try {
const response = await fetch(url, {
const response = await proxyAwareFetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
});
}, proxyOptions);
if (response.status === HTTP_STATUS.RATE_LIMITED || response.status === HTTP_STATUS.SERVICE_UNAVAILABLE) {
// Try to get retry time from headers first

View File

@@ -1,4 +1,5 @@
import { HTTP_STATUS } from "../config/constants.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
/**
* BaseExecutor - Base class for provider executors
@@ -75,7 +76,7 @@ export class BaseExecutor {
return { status: response.status, message: bodyText || `HTTP ${response.status}` };
}
async execute({ model, body, stream, credentials, signal, log }) {
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const fallbackCount = this.getFallbackCount();
let lastError = null;
let lastStatus = 0;
@@ -86,12 +87,12 @@ export class BaseExecutor {
const transformedBody = this.transformRequest(model, body, stream, credentials);
try {
const response = await fetch(url, {
const response = await proxyAwareFetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
});
}, proxyOptions);
if (this.shouldRetry(response.status, urlIndex)) {
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);

View File

@@ -8,6 +8,7 @@ import {
import { estimateUsage } from "../utils/usageTracking.js";
import { FORMATS } from "../translator/formats.js";
import { buildCursorRequest } from "../translator/request/openai-to-cursor.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import crypto from "crypto";
import { v5 as uuidv5 } from "uuid";
import zlib from "zlib";
@@ -163,13 +164,13 @@ export class CursorExecutor extends BaseExecutor {
return generateCursorBody(messages, model, tools, reasoningEffort);
}
async makeFetchRequest(url, headers, body, signal) {
const response = await fetch(url, {
async makeFetchRequest(url, headers, body, signal, proxyOptions = null) {
const response = await proxyAwareFetch(url, {
method: "POST",
headers,
body,
signal
});
}, proxyOptions);
return {
status: response.status,
@@ -227,15 +228,16 @@ export class CursorExecutor extends BaseExecutor {
});
}
async execute({ model, body, stream, credentials, signal, log }) {
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
const transformedBody = this.transformRequest(model, body, stream, credentials);
try {
const response = http2
const shouldForceFetch = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true;
const response = (http2 && !shouldForceFetch)
? await this.makeHttp2Request(url, headers, transformedBody, signal)
: await this.makeFetchRequest(url, headers, transformedBody, signal);
: await this.makeFetchRequest(url, headers, transformedBody, signal, proxyOptions);
if (response.status !== 200) {
const errorText = response.body?.toString() || "Unknown error";

View File

@@ -4,6 +4,7 @@ import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-res
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
import { initState } from "../translator/index.js";
import { parseSSELine, formatSSE } from "../utils/streamHelpers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import crypto from "crypto";
export class GithubExecutor extends BaseExecutor {
@@ -86,7 +87,7 @@ export class GithubExecutor extends BaseExecutor {
body: this.sanitizeMessagesForChatCompletions(options.body)
};
const result = await super.execute(sanitizedOptions);
const result = await super.execute({ ...sanitizedOptions, proxyOptions: options.proxyOptions || null });
if (result.response.status === HTTP_STATUS.BAD_REQUEST) {
const errorBody = await result.response.clone().text();
@@ -101,7 +102,7 @@ export class GithubExecutor extends BaseExecutor {
return result;
}
async executeWithResponsesEndpoint({ model, body, stream, credentials, signal, log }) {
async executeWithResponsesEndpoint({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const url = this.config.responsesUrl;
const headers = this.buildHeaders(credentials, stream);
@@ -109,12 +110,12 @@ export class GithubExecutor extends BaseExecutor {
log?.debug("GITHUB", "Sending translated request to /responses");
const response = await fetch(url, {
const response = await proxyAwareFetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
});
}, proxyOptions);
if (!response.ok) {
return { response, url, headers, transformedBody };

View File

@@ -2,6 +2,7 @@ import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/constants.js";
import { v4 as uuidv4 } from "uuid";
import { refreshKiroToken } from "../services/tokenRefresh.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
/**
* KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer)
@@ -33,17 +34,17 @@ export class KiroExecutor extends BaseExecutor {
/**
* Custom execute for Kiro - handles AWS EventStream binary response
*/
async execute({ model, body, stream, credentials, signal, log }) {
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const url = this.buildUrl(model, stream, 0);
const headers = this.buildHeaders(credentials, stream);
const transformedBody = this.transformRequest(model, body, stream, credentials);
const response = await fetch(url, {
const response = await proxyAwareFetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
});
}, proxyOptions);
if (!response.ok) {
return { response, url, headers, transformedBody };

View File

@@ -67,10 +67,38 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
log, provider, model
});
const proxyOptions = {
connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true,
connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "",
connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "",
};
if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) {
let maskedProxyUrl = proxyOptions.connectionProxyUrl;
try {
const parsed = new URL(proxyOptions.connectionProxyUrl);
const host = parsed.hostname || "";
const port = parsed.port ? `:${parsed.port}` : "";
const protocol = parsed.protocol || "http:";
maskedProxyUrl = `${protocol}//${host}${port}`;
} catch {
// Keep raw if URL parsing fails
}
const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none";
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | url=${maskedProxyUrl}`);
}
if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionNoProxy) {
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
log?.debug?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | no_proxy=${proxyOptions.connectionNoProxy}`);
}
// Execute request
let providerResponse, providerUrl, providerHeaders, finalBody;
try {
const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log });
const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
providerResponse = result.response;
providerUrl = result.url;
providerHeaders = result.headers;
@@ -106,7 +134,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
Object.assign(credentials, newCredentials);
if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials);
try {
const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log });
const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; }
} catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); }
} else {

View File

@@ -1,8 +1,7 @@
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
const originalFetch = globalThis.fetch;
let proxyDispatcher = null;
let proxyDispatcherUrl = null;
const proxyDispatchers = new Map();
// Constants
const DNS_CACHE = {};
@@ -14,12 +13,17 @@ const HTTPS_PORT = 443;
const HTTP_SUCCESS_MIN = 200;
const HTTP_SUCCESS_MAX = 300;
function normalizeString(value) {
if (value === undefined || value === null) return "";
return String(value).trim();
}
/**
* Resolve real IP using Google DNS (bypass system DNS)
*/
async function resolveRealIP(hostname) {
if (DNS_CACHE[hostname]) return DNS_CACHE[hostname];
try {
const dns = await import("dns");
const { promisify } = await import("util");
@@ -40,11 +44,11 @@ async function resolveRealIP(hostname) {
*/
function shouldBypassMitmDns(url, options) {
if (!options?.headers) return false;
const headers = options.headers;
const hasLocalMarker = headers[MITM_BYPASS_HEADER] === MITM_BYPASS_VALUE ||
const hasLocalMarker = headers[MITM_BYPASS_HEADER] === MITM_BYPASS_VALUE ||
headers[MITM_BYPASS_HEADER.charAt(0).toUpperCase() + MITM_BYPASS_HEADER.slice(1)] === MITM_BYPASS_VALUE;
if (!hasLocalMarker) {
// Debug: log when bypass is not triggered
const hostname = new URL(url).hostname;
@@ -53,37 +57,39 @@ function shouldBypassMitmDns(url, options) {
}
return false;
}
const hostname = new URL(url).hostname;
return MITM_BYPASS_HOSTS.some(host => hostname.includes(host));
}
function shouldBypassByNoProxy(targetUrl, noProxyValue) {
const noProxy = normalizeString(noProxyValue);
if (!noProxy) return false;
const hostname = new URL(targetUrl).hostname.toLowerCase();
const patterns = noProxy.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
return patterns.some((pattern) => {
if (pattern === "*") return true;
if (pattern.startsWith(".")) return hostname.endsWith(pattern) || hostname === pattern.slice(1);
return hostname === pattern || hostname.endsWith(`.${pattern}`);
});
}
/**
* Get proxy URL from environment
*/
function getProxyUrl(targetUrl) {
function getEnvProxyUrl(targetUrl) {
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
if (noProxy) {
const hostname = new URL(targetUrl).hostname.toLowerCase();
const patterns = noProxy.split(",").map(p => p.trim().toLowerCase());
const shouldBypass = patterns.some(pattern => {
if (pattern === "*") return true;
if (pattern.startsWith(".")) return hostname.endsWith(pattern) || hostname === pattern.slice(1);
return hostname === pattern || hostname.endsWith(`.${pattern}`);
});
if (shouldBypass) return null;
}
if (shouldBypassByNoProxy(targetUrl, noProxy)) return null;
const protocol = new URL(targetUrl).protocol;
if (protocol === "https:") {
return process.env.HTTPS_PROXY || process.env.https_proxy ||
return process.env.HTTPS_PROXY || process.env.https_proxy ||
process.env.ALL_PROXY || process.env.all_proxy;
}
return process.env.HTTP_PROXY || process.env.http_proxy ||
process.env.ALL_PROXY || process.env.all_proxy;
}
@@ -92,33 +98,45 @@ function getProxyUrl(targetUrl) {
* Normalize proxy URL (allow host:port)
*/
function normalizeProxyUrl(proxyUrl) {
if (!proxyUrl) return null;
const normalizedInput = normalizeString(proxyUrl);
if (!normalizedInput) return null;
try {
// eslint-disable-next-line no-new
new URL(proxyUrl);
return proxyUrl;
new URL(normalizedInput);
return normalizedInput;
} catch {
// Allow "127.0.0.1:7890" style values
return `http://${proxyUrl}`;
return `http://${normalizedInput}`;
}
}
function resolveConnectionProxyUrl(targetUrl, proxyOptions) {
const enabled = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true;
if (!enabled) return null;
const proxyUrlRaw = normalizeString(proxyOptions?.url ?? proxyOptions?.connectionProxyUrl);
if (!proxyUrlRaw) return null;
const noProxy = normalizeString(proxyOptions?.noProxy ?? proxyOptions?.connectionNoProxy);
if (noProxy && shouldBypassByNoProxy(targetUrl, noProxy)) return null;
return normalizeProxyUrl(proxyUrlRaw);
}
/**
* Create proxy dispatcher lazily (undici-compatible)
* Closes old dispatcher when proxy URL changes to prevent connection pool leak
*/
async function getDispatcher(proxyUrl) {
const normalized = normalizeProxyUrl(proxyUrl);
if (!normalized) return null;
if (!proxyDispatcher || proxyDispatcherUrl !== normalized) {
try { proxyDispatcher?.close?.(); } catch { /* ignore */ }
if (!proxyDispatchers.has(normalized)) {
const { ProxyAgent } = await import("undici");
proxyDispatcher = new ProxyAgent({ uri: normalized });
proxyDispatcherUrl = normalized;
proxyDispatchers.set(normalized, new ProxyAgent({ uri: normalized }));
}
return proxyDispatcher;
return proxyDispatchers.get(normalized);
}
/**
@@ -128,10 +146,10 @@ async function createBypassRequest(parsedUrl, realIP, options) {
const https = await import("https");
const net = await import("net");
const { Readable } = require("stream");
return new Promise((resolve, reject) => {
const socket = new net.Socket();
socket.connect(HTTPS_PORT, realIP, () => {
const reqOptions = {
socket,
@@ -144,7 +162,7 @@ async function createBypassRequest(parsedUrl, realIP, options) {
Host: parsedUrl.hostname,
},
};
const req = https.request(reqOptions, (res) => {
const response = {
ok: res.statusCode >= HTTP_SUCCESS_MIN && res.statusCode < HTTP_SUCCESS_MAX,
@@ -161,24 +179,21 @@ async function createBypassRequest(parsedUrl, realIP, options) {
};
resolve(response);
});
req.on("error", reject);
if (options.body) {
req.write(typeof options.body === "string" ? options.body : JSON.stringify(options.body));
}
req.end();
});
socket.on("error", reject);
});
}
/**
* Patched fetch with proxy support and MITM DNS bypass
*/
async function patchedFetch(url, options = {}) {
export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
const targetUrl = typeof url === "string" ? url : url.toString();
// MITM DNS bypass: resolve real IP for googleapis.com when x-request-source: local
if (shouldBypassMitmDns(targetUrl, options)) {
try {
@@ -189,22 +204,35 @@ async function patchedFetch(url, options = {}) {
console.warn(`[ProxyFetch] MITM bypass failed: ${error.message}`);
}
}
// Normal proxy handling
const proxyUrl = normalizeProxyUrl(getProxyUrl(targetUrl));
const connectionProxyUrl = resolveConnectionProxyUrl(targetUrl, proxyOptions);
const envProxyUrl = connectionProxyUrl ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl));
const proxyUrl = connectionProxyUrl || envProxyUrl;
if (proxyUrl) {
try {
const dispatcher = await getDispatcher(proxyUrl);
return await originalFetch(url, { ...options, dispatcher });
} catch (proxyError) {
// If strictProxy is enabled, fail hard instead of falling back to direct
if (proxyOptions?.strictProxy === true) {
throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${proxyError.message}`);
}
console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${proxyError.message}`);
return originalFetch(url, options);
}
}
return originalFetch(url, options);
}
/**
* Patched global fetch with env-proxy support and MITM DNS bypass
*/
async function patchedFetch(url, options = {}) {
return proxyAwareFetch(url, options, null);
}
// Idempotency guard — only patch once to avoid wrapping multiple times
if (!isCloud && globalThis.fetch !== patchedFetch) {
globalThis.fetch = patchedFetch;

View File

@@ -17,11 +17,13 @@ export default function ProviderDetailPage() {
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [providerNode, setProviderNode] = useState(null);
const [proxyPools, setProxyPools] = useState([]);
const [showOAuthModal, setShowOAuthModal] = useState(false);
const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false);
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
const [selectedConnection, setSelectedConnection] = useState(null);
const [modelAliases, setModelAliases] = useState({});
const [headerImgError, setHeaderImgError] = useState(false);
@@ -29,6 +31,9 @@ export default function ProviderDetailPage() {
const [modelsTestError, setModelsTestError] = useState("");
const [testingModelId, setTestingModelId] = useState(null);
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
const [selectedConnectionIds, setSelectedConnectionIds] = useState([]);
const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__");
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
const { copied, copy } = useCopyToClipboard();
const providerInfo = providerNode
@@ -70,16 +75,21 @@ export default function ProviderDetailPage() {
const fetchConnections = useCallback(async () => {
try {
const [connectionsRes, nodesRes] = await Promise.all([
const [connectionsRes, nodesRes, proxyPoolsRes] = await Promise.all([
fetch("/api/providers", { cache: "no-store" }),
fetch("/api/provider-nodes", { cache: "no-store" }),
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }),
]);
const connectionsData = await connectionsRes.json();
const nodesData = await nodesRes.json();
const proxyPoolsData = await proxyPoolsRes.json();
if (connectionsRes.ok) {
const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId);
setConnections(filtered);
}
if (proxyPoolsRes.ok) {
setProxyPools(proxyPoolsData.proxyPools || []);
}
if (nodesRes.ok) {
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
@@ -266,6 +276,201 @@ export default function ProviderDetailPage() {
}
};
const selectedConnections = connections.filter((conn) => selectedConnectionIds.includes(conn.id));
const allSelected = connections.length > 0 && selectedConnectionIds.length === connections.length;
const toggleSelectConnection = (connectionId) => {
setSelectedConnectionIds((prev) => (
prev.includes(connectionId)
? prev.filter((id) => id !== connectionId)
: [...prev, connectionId]
));
};
const toggleSelectAllConnections = () => {
if (allSelected) {
setSelectedConnectionIds([]);
return;
}
setSelectedConnectionIds(connections.map((conn) => conn.id));
};
const clearSelection = () => {
setSelectedConnectionIds([]);
setBulkProxyPoolId("__none__");
};
useEffect(() => {
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
}, [connections]);
const selectedProxySummary = (() => {
if (selectedConnections.length === 0) return "";
const poolIds = new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__"));
if (poolIds.size === 1) {
const onlyId = [...poolIds][0];
if (onlyId === "__none__") return "All selected currently unbound";
const pool = proxyPools.find((p) => p.id === onlyId);
return `All selected currently bound to ${pool?.name || onlyId}`;
}
return "Selected connections have mixed proxy bindings";
})();
const openBulkProxyModal = () => {
if (selectedConnections.length === 0) return;
const uniquePoolIds = [...new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__"))];
setBulkProxyPoolId(uniquePoolIds.length === 1 ? uniquePoolIds[0] : "__none__");
setShowBulkProxyModal(true);
};
const closeBulkProxyModal = () => {
if (bulkUpdatingProxy) return;
setShowBulkProxyModal(false);
};
const handleBulkApplyProxyPool = async () => {
if (selectedConnectionIds.length === 0) return;
const proxyPoolId = bulkProxyPoolId === "__none__" ? null : bulkProxyPoolId;
setBulkUpdatingProxy(true);
try {
const results = await Promise.all(
selectedConnectionIds.map(async (connectionId) => {
const res = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ proxyPoolId }),
});
return res.ok;
})
);
const failedCount = results.filter((ok) => !ok).length;
if (failedCount > 0) {
alert(`Updated with ${failedCount} failed request(s).`);
}
await fetchConnections();
clearSelection();
setShowBulkProxyModal(false);
} catch (error) {
console.log("Error applying bulk proxy pool:", error);
} finally {
setBulkUpdatingProxy(false);
}
};
const selectionToolbar = connections.length > 0 ? (
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-3 mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleSelectAllConnections}
className="text-xs px-2 py-1 rounded bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-text-muted"
>
{allSelected ? "Unselect all" : "Select all"}
</button>
{selectedConnectionIds.length > 0 && (
<button
type="button"
onClick={clearSelection}
className="text-xs px-2 py-1 rounded bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-text-muted"
>
Clear
</button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">{selectedConnectionIds.length} selected</span>
<Button size="sm" variant="secondary" onClick={openBulkProxyModal} disabled={selectedConnectionIds.length === 0}>
Proxy Action
</Button>
</div>
</div>
) : null;
const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId);
const connectionsList = (
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{connections
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
.map((conn, index) => (
<div key={conn.id} className="flex items-stretch">
<div className="pr-2 flex items-center">
<input
type="checkbox"
checked={isSelected(conn.id)}
onChange={() => toggleSelectConnection(conn.id)}
className="size-4 rounded border-border bg-transparent"
title="Select connection"
aria-label={`Select ${conn.name || conn.email || conn.id}`}
/>
</div>
<div className="flex-1 min-w-0">
<ConnectionRow
connection={conn}
proxyPools={proxyPools}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === connections.length - 1}
onMoveUp={() => handleSwapPriority(conn, connections[index - 1])}
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onEdit={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
/>
</div>
</div>
))}
</div>
);
const bulkProxyOptions = [
{ value: "__none__", label: "None" },
...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })),
];
const bulkHint = selectedConnectionIds.length === 0
? "Select one or more connections, then click Proxy Action."
: selectedProxySummary;
const canApplyBulkProxy = selectedConnectionIds.length > 0 && !bulkUpdatingProxy;
const bulkActionModal = (
<Modal
isOpen={showBulkProxyModal}
onClose={closeBulkProxyModal}
title={`Proxy Action (${selectedConnectionIds.length} selected)`}
>
<div className="flex flex-col gap-4">
<Select
label="Proxy Pool"
value={bulkProxyPoolId}
onChange={(e) => setBulkProxyPoolId(e.target.value)}
options={bulkProxyOptions}
placeholder="None"
/>
<p className="text-xs text-text-muted">{bulkHint}</p>
<p className="text-xs text-text-muted">Selecting None will unbind selected connections from proxy pool.</p>
<div className="flex gap-2">
<Button onClick={handleBulkApplyProxyPool} fullWidth disabled={!canApplyBulkProxy}>
{bulkUpdatingProxy ? "Applying..." : "Apply"}
</Button>
<Button onClick={closeBulkProxyModal} variant="ghost" fullWidth disabled={bulkUpdatingProxy}>
Cancel
</Button>
</div>
</div>
</Modal>
);
const handleTestModel = async (modelId) => {
if (testingModelId) return;
setTestingModelId(modelId);
@@ -560,27 +765,10 @@ export default function ProviderDetailPage() {
)}
</div>
) : (
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{connections
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === connections.length - 1}
onMoveUp={() => handleSwapPriority(conn, connections[index - 1])}
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onEdit={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
/>
))}
</div>
<>
{selectionToolbar}
{connectionsList}
</>
)}
</Card>
@@ -597,6 +785,8 @@ export default function ProviderDetailPage() {
{renderModelsSection()}
</Card>
{bulkActionModal}
{/* Modals */}
{providerId === "kiro" ? (
<KiroOAuthWrapper
@@ -633,12 +823,14 @@ export default function ProviderDetailPage() {
providerName={providerInfo.name}
isCompatible={isCompatible}
isAnthropic={isAnthropicCompatible}
proxyPools={proxyPools}
onSave={handleSaveApiKey}
onClose={() => setShowAddApiKeyModal(false)}
/>
<EditConnectionModal
isOpen={showEditModal}
connection={selectedConnection}
proxyPools={proxyPools}
onSave={handleUpdateConnection}
onClose={() => setShowEditModal(false)}
/>
@@ -1126,7 +1318,40 @@ CooldownTimer.propTypes = {
until: PropTypes.string.isRequired,
};
function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) {
function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) {
const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null;
const hasLegacyProxy = connection.providerSpecificData?.connectionProxyEnabled === true && !!connection.providerSpecificData?.connectionProxyUrl;
const hasAnyProxy = !!boundProxyPoolId || hasLegacyProxy;
const proxyDisplayText = boundProxyPool
? `Pool: ${boundProxyPool.name}`
: boundProxyPoolId
? `Pool: ${boundProxyPoolId} (inactive/missing)`
: hasLegacyProxy
? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}`
: "";
let proxyBadgeVariant = "default";
if (boundProxyPool?.isActive === true) {
proxyBadgeVariant = "success";
} else if (boundProxyPoolId || hasLegacyProxy) {
proxyBadgeVariant = "error";
}
let maskedProxyUrl = "";
if (boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl) {
const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl;
try {
const parsed = new URL(rawProxyUrl);
maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
} catch {
maskedProxyUrl = rawProxyUrl;
}
}
const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || "";
const displayName = isOAuth
? connection.name || connection.email || connection.displayName || "OAuth Account"
: connection.name;
@@ -1199,6 +1424,11 @@ function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveD
<Badge variant={getStatusVariant()} size="sm" dot>
{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")}
</Badge>
{hasAnyProxy && (
<Badge variant={proxyBadgeVariant} size="sm">
Proxy
</Badge>
)}
{isCooldown && connection.isActive !== false && <CooldownTimer until={modelLockUntil} />}
{connection.lastError && connection.isActive !== false && (
<span className="text-xs text-red-500 truncate max-w-[300px]" title={connection.lastError}>
@@ -1210,6 +1440,23 @@ function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveD
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
)}
</div>
{hasAnyProxy && (
<div className="mt-1 flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-text-muted truncate max-w-[420px]" title={proxyDisplayText}>
{proxyDisplayText}
</span>
{maskedProxyUrl && (
<code className="text-[10px] font-mono bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded text-text-muted">
{maskedProxyUrl}
</code>
)}
{noProxyText && (
<span className="text-[11px] text-text-muted truncate max-w-[320px]" title={noProxyText}>
no_proxy: {noProxyText}
</span>
)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
@@ -1245,6 +1492,13 @@ ConnectionRow.propTypes = {
priority: PropTypes.number,
globalPriority: PropTypes.number,
}).isRequired,
proxyPools: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
proxyUrl: PropTypes.string,
noProxy: PropTypes.string,
isActive: PropTypes.bool,
})),
isOAuth: PropTypes.bool.isRequired,
isFirst: PropTypes.bool.isRequired,
isLast: PropTypes.bool.isRequired,
@@ -1255,11 +1509,14 @@ ConnectionRow.propTypes = {
onDelete: PropTypes.func.isRequired,
};
function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, onSave, onClose }) {
function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, proxyPools, onSave, onClose }) {
const NONE_PROXY_POOL_VALUE = "__none__";
const [formData, setFormData] = useState({
name: "",
apiKey: "",
priority: 1,
proxyPoolId: NONE_PROXY_POOL_VALUE,
});
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
@@ -1309,6 +1566,7 @@ function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthro
name: formData.name,
apiKey: formData.apiKey,
priority: formData.priority,
proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId,
testStatus: isValid ? "active" : "unknown",
});
} finally {
@@ -1360,6 +1618,28 @@ function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthro
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
/>
<Select
label="Proxy Pool"
value={formData.proxyPoolId}
onChange={(e) => setFormData({ ...formData, proxyPoolId: e.target.value })}
options={[
{ value: NONE_PROXY_POOL_VALUE, label: "None" },
...(proxyPools || []).map((pool) => ({ value: pool.id, label: pool.name })),
]}
placeholder="None"
/>
{(proxyPools || []).length === 0 && (
<p className="text-xs text-text-muted">
No active proxy pools available. Create one in Proxy Pools page first.
</p>
)}
<p className="text-xs text-text-muted">
Legacy manual proxy fields are still accepted by API for backward compatibility.
</p>
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey || saving}>
{saving ? "Saving..." : "Save"}
@@ -1379,15 +1659,22 @@ AddApiKeyModal.propTypes = {
providerName: PropTypes.string,
isCompatible: PropTypes.bool,
isAnthropic: PropTypes.bool,
proxyPools: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
})),
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
function EditConnectionModal({ isOpen, connection, proxyPools, onSave, onClose }) {
const NONE_PROXY_POOL_VALUE = "__none__";
const [formData, setFormData] = useState({
name: "",
priority: 1,
apiKey: "",
proxyPoolId: NONE_PROXY_POOL_VALUE,
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
@@ -1401,6 +1688,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
name: connection.name || "",
priority: connection.priority || 1,
apiKey: "",
proxyPoolId: connection.providerSpecificData?.proxyPoolId || NONE_PROXY_POOL_VALUE,
});
setTestResult(null);
setValidationResult(null);
@@ -1444,7 +1732,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
const handleSubmit = async () => {
setSaving(true);
try {
const updates = { name: formData.name, priority: formData.priority };
const updates = {
name: formData.name,
priority: formData.priority,
proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId,
};
if (!isOAuth && formData.apiKey) {
updates.apiKey = formData.apiKey;
let isValid = validationResult === "success";
@@ -1504,6 +1796,59 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
/>
<Select
label="Proxy Pool"
value={formData.proxyPoolId}
onChange={(e) => setFormData({ ...formData, proxyPoolId: e.target.value })}
options={[
{ value: NONE_PROXY_POOL_VALUE, label: "None" },
...(proxyPools || []).map((pool) => ({ value: pool.id, label: pool.name })),
]}
placeholder="None"
/>
{(proxyPools || []).length === 0 && (
<p className="text-xs text-text-muted">
No active proxy pools available. Create one in Proxy Pools page first.
</p>
)}
<p className="text-xs text-text-muted">
Runtime prefers proxy pool settings. Legacy proxy fields are still supported as fallback.
</p>
{connection.providerSpecificData?.connectionProxyEnabled === true && !connection.providerSpecificData?.proxyPoolId && (
<p className="text-xs text-amber-500">
This connection is still using legacy manual proxy settings until you bind a proxy pool.
</p>
)}
{connection.providerSpecificData?.proxyPoolId && formData.proxyPoolId === NONE_PROXY_POOL_VALUE && (
<p className="text-xs text-amber-500">
Saving with None will unbind this connection from proxy pool and fallback to legacy/global proxy behavior.
</p>
)}
{connection.providerSpecificData?.proxyPoolId && formData.proxyPoolId !== NONE_PROXY_POOL_VALUE && connection.providerSpecificData?.proxyPoolId !== formData.proxyPoolId && (
<p className="text-xs text-amber-500">
You changed proxy pool binding. Use Test Connection to verify connectivity.
</p>
)}
{connection.providerSpecificData?.proxyPoolId && !(proxyPools || []).some((pool) => pool.id === connection.providerSpecificData.proxyPoolId) && (
<p className="text-xs text-red-500">
Current bound proxy pool is inactive or missing. Runtime will fallback to legacy proxy if available.
</p>
)}
{!connection.providerSpecificData?.proxyPoolId && (connection.providerSpecificData?.connectionProxyUrl || connection.providerSpecificData?.connectionNoProxy) && (
<p className="text-xs text-text-muted">
Legacy proxy: {connection.providerSpecificData?.connectionProxyUrl || "(empty)"}
{connection.providerSpecificData?.connectionNoProxy ? ` · no_proxy: ${connection.providerSpecificData.connectionNoProxy}` : ""}
</p>
)}
{!isOAuth && (
<>
<div className="flex gap-2">
@@ -1562,7 +1907,12 @@ EditConnectionModal.propTypes = {
priority: PropTypes.number,
authType: PropTypes.string,
provider: PropTypes.string,
providerSpecificData: PropTypes.object,
}),
proxyPools: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
})),
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,489 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge, Button, Card, CardSkeleton, Input, Modal, Toggle } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
function getStatusVariant(status) {
if (status === "active") return "success";
if (status === "error") return "error";
return "default";
}
function formatDateTime(value) {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Never";
return date.toLocaleString();
}
function normalizeFormData(data = {}) {
return {
name: data.name || "",
proxyUrl: data.proxyUrl || "",
noProxy: data.noProxy || "",
isActive: data.isActive !== false,
strictProxy: data.strictProxy === true,
};
}
export default function ProxyPoolsPage() {
const [proxyPools, setProxyPools] = useState([]);
const [loading, setLoading] = useState(true);
const [showFormModal, setShowFormModal] = useState(false);
const [showBatchImportModal, setShowBatchImportModal] = useState(false);
const [editingProxyPool, setEditingProxyPool] = useState(null);
const [formData, setFormData] = useState(normalizeFormData());
const [batchImportText, setBatchImportText] = useState("");
const [saving, setSaving] = useState(false);
const [importing, setImporting] = useState(false);
const [testingId, setTestingId] = useState(null);
const notify = useNotificationStore();
const fetchProxyPools = useCallback(async () => {
try {
const res = await fetch("/api/proxy-pools?includeUsage=true", { cache: "no-store" });
const data = await res.json();
if (res.ok) {
setProxyPools(data.proxyPools || []);
}
} catch (error) {
console.log("Error fetching proxy pools:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchProxyPools();
}, [fetchProxyPools]);
const resetForm = () => {
setEditingProxyPool(null);
setFormData(normalizeFormData());
};
const openCreateModal = () => {
resetForm();
setShowFormModal(true);
};
const openEditModal = (proxyPool) => {
setEditingProxyPool(proxyPool);
setFormData(normalizeFormData(proxyPool));
setShowFormModal(true);
};
const closeFormModal = () => {
setShowFormModal(false);
resetForm();
};
const handleSave = async () => {
const payload = {
name: formData.name.trim(),
proxyUrl: formData.proxyUrl.trim(),
noProxy: formData.noProxy.trim(),
isActive: formData.isActive === true,
strictProxy: formData.strictProxy === true,
};
if (!payload.name || !payload.proxyUrl) return;
setSaving(true);
try {
const isEdit = !!editingProxyPool;
const res = await fetch(isEdit ? `/api/proxy-pools/${editingProxyPool.id}` : "/api/proxy-pools", {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
await fetchProxyPools();
closeFormModal();
notify.success(editingProxyPool ? "Proxy pool updated" : "Proxy pool created");
} else {
const data = await res.json();
notify.error(data.error || "Failed to save proxy pool");
}
} catch (error) {
console.log("Error saving proxy pool:", error);
} finally {
setSaving(false);
}
};
const handleDelete = async (proxyPool) => {
const deleting = confirm(`Delete proxy pool \"${proxyPool.name}\"?`);
if (!deleting) return;
try {
const res = await fetch(`/api/proxy-pools/${proxyPool.id}`, { method: "DELETE" });
if (res.ok) {
setProxyPools((prev) => prev.filter((item) => item.id !== proxyPool.id));
notify.success("Proxy pool deleted");
return;
}
const data = await res.json();
if (res.status === 409) {
notify.warning(`Cannot delete: ${data.boundConnectionCount || 0} connection(s) are still using this pool.`);
} else {
notify.error(data.error || "Failed to delete proxy pool");
}
} catch (error) {
console.log("Error deleting proxy pool:", error);
notify.error("Failed to delete proxy pool");
}
};
const handleTest = async (proxyPoolId) => {
setTestingId(proxyPoolId);
try {
const res = await fetch(`/api/proxy-pools/${proxyPoolId}/test`, { method: "POST" });
const data = await res.json();
if (!res.ok) {
notify.error(data.error || "Failed to test proxy");
return;
}
await fetchProxyPools();
notify.success(data.ok ? "Proxy test passed" : "Proxy test failed");
} catch (error) {
console.log("Error testing proxy pool:", error);
notify.error("Failed to test proxy");
} finally {
setTestingId(null);
}
};
const openBatchImportModal = () => {
setBatchImportText("");
setShowBatchImportModal(true);
};
const closeBatchImportModal = () => {
if (importing) return;
setShowBatchImportModal(false);
};
const parseProxyLine = (line) => {
const trimmed = line.trim();
if (!trimmed) return null;
if (trimmed.includes("://")) {
const parsed = new URL(trimmed);
const hostLabel = parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname;
return {
proxyUrl: parsed.toString(),
name: `Imported ${hostLabel}`,
};
}
const parts = trimmed.split(":");
if (parts.length === 4) {
const [host, port, username, password] = parts;
if (!host || !port || !username || !password) {
throw new Error("Invalid host:port:user:pass format");
}
const proxyUrl = `http://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
const parsed = new URL(proxyUrl);
return {
proxyUrl: parsed.toString(),
name: `Imported ${host}:${port}`,
};
}
throw new Error("Unsupported format");
};
const handleBatchImport = async () => {
const lines = batchImportText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
notify.warning("Please paste at least one proxy line.");
return;
}
const parsedEntries = [];
const invalidLines = [];
lines.forEach((line, index) => {
try {
const parsed = parseProxyLine(line);
if (parsed) {
parsedEntries.push({
...parsed,
lineNumber: index + 1,
});
}
} catch (error) {
invalidLines.push(`Line ${index + 1}: ${error.message}`);
}
});
if (invalidLines.length > 0) {
notify.error(`Invalid proxy format:\n${invalidLines.join("\n")}`);
return;
}
setImporting(true);
try {
const existingKeys = new Set(
proxyPools.map((pool) => `${(pool.proxyUrl || "").trim()}|||${(pool.noProxy || "").trim()}`)
);
let created = 0;
let skipped = 0;
let failed = 0;
for (const entry of parsedEntries) {
const dedupeKey = `${entry.proxyUrl}|||`;
if (existingKeys.has(dedupeKey)) {
skipped += 1;
continue;
}
const res = await fetch("/api/proxy-pools", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: entry.name,
proxyUrl: entry.proxyUrl,
noProxy: "",
isActive: true,
}),
});
if (res.ok) {
created += 1;
existingKeys.add(dedupeKey);
} else {
failed += 1;
}
}
await fetchProxyPools();
setShowBatchImportModal(false);
notify.success(`Batch import completed: Created ${created}, Skipped ${skipped}, Failed ${failed}`);
} catch (error) {
console.log("Error batch importing proxies:", error);
notify.error("Batch import failed");
} finally {
setImporting(false);
}
};
const activeCount = useMemo(
() => proxyPools.filter((pool) => pool.isActive === true).length,
[proxyPools]
);
if (loading) {
return (
<div className="flex flex-col gap-6">
<CardSkeleton />
<CardSkeleton />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold">Proxy Pools</h1>
<p className="text-sm text-text-muted mt-1">
Manage reusable per-connection proxies and bind them to provider connections.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" icon="upload" onClick={openBatchImportModal}>
Batch Import Proxies
</Button>
<Button icon="add" onClick={openCreateModal}>Add Proxy Pool</Button>
</div>
</div>
<Card>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Badge variant="default">Total: {proxyPools.length}</Badge>
<Badge variant="success">Active: {activeCount}</Badge>
</div>
</div>
{proxyPools.length === 0 ? (
<div className="text-center py-10">
<p className="text-text-main font-medium mb-1">No proxy pool entries yet</p>
<p className="text-sm text-text-muted mb-4">
Create a proxy pool entry, then assign it to connections.
</p>
<Button icon="add" onClick={openCreateModal}>Add Proxy Pool</Button>
</div>
) : (
<div className="flex flex-col divide-y divide-black/[0.04] dark:divide-white/[0.05]">
{proxyPools.map((pool) => (
<div key={pool.id} className="py-3 flex items-center justify-between gap-3 group">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">{pool.name}</p>
<Badge variant={getStatusVariant(pool.testStatus)} size="sm" dot>
{pool.testStatus || "unknown"}
</Badge>
<Badge variant={pool.isActive ? "success" : "default"} size="sm">
{pool.isActive ? "active" : "inactive"}
</Badge>
<Badge variant="default" size="sm">
{pool.boundConnectionCount || 0} bound
</Badge>
</div>
<p className="text-xs text-text-muted truncate mt-1">{pool.proxyUrl}</p>
{pool.noProxy ? (
<p className="text-xs text-text-muted truncate">No proxy: {pool.noProxy}</p>
) : null}
<p className="text-[11px] text-text-muted mt-1">
Last tested: {formatDateTime(pool.lastTestedAt)}
{pool.lastError ? ` · ${pool.lastError}` : ""}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleTest(pool.id)}
className="p-2 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
title="Test proxy"
disabled={testingId === pool.id}
>
<span
className="material-symbols-outlined text-[18px]"
style={testingId === pool.id ? { animation: "spin 1s linear infinite" } : undefined}
>
{testingId === pool.id ? "progress_activity" : "science"}
</span>
</button>
<button
onClick={() => openEditModal(pool)}
className="p-2 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
title="Edit"
>
<span className="material-symbols-outlined text-[18px]">edit</span>
</button>
<button
onClick={() => handleDelete(pool)}
className="p-2 rounded hover:bg-red-500/10 text-red-500"
title="Delete"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</div>
))}
</div>
)}
</Card>
<Modal
isOpen={showBatchImportModal}
title="Batch Import Proxies"
onClose={closeBatchImportModal}
>
<div className="flex flex-col gap-4">
<div>
<label className="text-sm font-medium text-text-main mb-1 block">Paste Proxy List (One per line)</label>
<textarea
value={batchImportText}
onChange={(e) => setBatchImportText(e.target.value)}
placeholder={"http://user:pass@127.0.0.1:7897\n127.0.0.1:7897:user:pass"}
className="w-full min-h-[180px] py-2 px-3 text-sm text-text-main bg-white dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-md focus:ring-1 focus:ring-primary/30 focus:border-primary/50 focus:outline-none transition-all"
/>
<p className="text-xs text-text-muted mt-1">
Supported formats: protocol://user:pass@host:port, host:port:user:pass
</p>
</div>
<div className="flex gap-2">
<Button fullWidth onClick={handleBatchImport} disabled={!batchImportText.trim() || importing}>
{importing ? "Importing..." : "Import"}
</Button>
<Button fullWidth variant="ghost" onClick={closeBatchImportModal} disabled={importing}>
Cancel
</Button>
</div>
</div>
</Modal>
<Modal
isOpen={showFormModal}
title={editingProxyPool ? "Edit Proxy Pool" : "Add Proxy Pool"}
onClose={closeFormModal}
>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Office Proxy"
/>
<Input
label="Proxy URL"
value={formData.proxyUrl}
onChange={(e) => setFormData((prev) => ({ ...prev, proxyUrl: e.target.value }))}
placeholder="http://127.0.0.1:7897"
/>
<Input
label="No Proxy"
value={formData.noProxy}
onChange={(e) => setFormData((prev) => ({ ...prev, noProxy: e.target.value }))}
placeholder="localhost,127.0.0.1,.internal"
hint="Comma-separated hosts/domains to bypass proxy"
/>
<div className="rounded-lg border border-border/50 p-3 flex items-center justify-between">
<div>
<p className="font-medium text-sm">Active</p>
<p className="text-xs text-text-muted">Inactive pools are ignored by runtime resolution.</p>
</div>
<Toggle
checked={formData.isActive === true}
onChange={() => setFormData((prev) => ({ ...prev, isActive: !prev.isActive }))}
disabled={saving}
/>
</div>
<div className="rounded-lg border border-border/50 p-3 flex items-center justify-between">
<div>
<p className="font-medium text-sm">Strict Proxy</p>
<p className="text-xs text-text-muted">Fail request if proxy is unreachable instead of falling back to direct.</p>
</div>
<Toggle
checked={formData.strictProxy === true}
onChange={() => setFormData((prev) => ({ ...prev, strictProxy: !prev.strictProxy }))}
disabled={saving}
/>
</div>
<div className="flex gap-2">
<Button
fullWidth
onClick={handleSave}
disabled={!formData.name.trim() || !formData.proxyUrl.trim() || saving}
>
{saving ? "Saving..." : "Save"}
</Button>
<Button fullWidth variant="ghost" onClick={closeFormModal} disabled={saving}>
Cancel
</Button>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,5 +1,63 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById, updateProviderConnection, deleteProviderConnection } from "@/models";
import {
getProviderConnectionById,
getProxyPoolById,
updateProviderConnection,
deleteProviderConnection,
} from "@/models";
function normalizeProxyConfig(body = {}) {
const hasAnyProxyField =
Object.prototype.hasOwnProperty.call(body, "connectionProxyEnabled") ||
Object.prototype.hasOwnProperty.call(body, "connectionProxyUrl") ||
Object.prototype.hasOwnProperty.call(body, "connectionNoProxy");
if (!hasAnyProxyField) return { hasAnyProxyField: false };
const enabled = body?.connectionProxyEnabled === true;
const url = typeof body?.connectionProxyUrl === "string" ? body.connectionProxyUrl.trim() : "";
const noProxy = typeof body?.connectionNoProxy === "string" ? body.connectionNoProxy.trim() : "";
if (enabled && !url) {
return {
hasAnyProxyField: true,
error: "Connection proxy URL is required when connection proxy is enabled",
};
}
return {
hasAnyProxyField: true,
connectionProxyEnabled: enabled,
connectionProxyUrl: url,
connectionNoProxy: noProxy,
};
}
async function normalizeProxyPoolUpdate(proxyPoolIdInput) {
if (proxyPoolIdInput === undefined) {
return { hasProxyPoolField: false, proxyPoolId: null };
}
if (proxyPoolIdInput === null || proxyPoolIdInput === "" || proxyPoolIdInput === "__none__") {
return { hasProxyPoolField: true, proxyPoolId: null };
}
const proxyPoolId = String(proxyPoolIdInput).trim();
if (!proxyPoolId) {
return { hasProxyPoolField: true, proxyPoolId: null };
}
const proxyPool = await getProxyPoolById(proxyPoolId);
if (!proxyPool) {
return { hasProxyPoolField: true, error: "Proxy pool not found" };
}
return { hasProxyPoolField: true, proxyPoolId };
}
function shouldMergeProviderSpecificData(existing, incoming, hasLegacyProxy, hasProxyPoolField) {
return existing !== undefined || incoming !== undefined || hasLegacyProxy || hasProxyPoolField;
}
// GET /api/providers/[id] - Get single connection
export async function GET(request, { params }) {
@@ -48,6 +106,16 @@ export async function PUT(request, { params }) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
const proxyConfig = normalizeProxyConfig(body);
if (proxyConfig.error) {
return NextResponse.json({ error: proxyConfig.error }, { status: 400 });
}
const proxyPoolResult = await normalizeProxyPoolUpdate(body.proxyPoolId);
if (proxyPoolResult.error) {
return NextResponse.json({ error: proxyPoolResult.error }, { status: 400 });
}
const updateData = {};
if (name !== undefined) updateData.name = name;
if (priority !== undefined) updateData.priority = priority;
@@ -58,11 +126,33 @@ export async function PUT(request, { params }) {
if (testStatus !== undefined) updateData.testStatus = testStatus;
if (lastError !== undefined) updateData.lastError = lastError;
if (lastErrorAt !== undefined) updateData.lastErrorAt = lastErrorAt;
if (providerSpecificData !== undefined) {
if (
shouldMergeProviderSpecificData(
existing.providerSpecificData,
providerSpecificData,
proxyConfig.hasAnyProxyField,
proxyPoolResult.hasProxyPoolField
)
) {
updateData.providerSpecificData = {
...(existing.providerSpecificData || {}),
...providerSpecificData,
...(providerSpecificData || {}),
};
if (proxyConfig.hasAnyProxyField) {
updateData.providerSpecificData.connectionProxyEnabled = proxyConfig.connectionProxyEnabled;
updateData.providerSpecificData.connectionProxyUrl = proxyConfig.connectionProxyUrl;
updateData.providerSpecificData.connectionNoProxy = proxyConfig.connectionNoProxy;
}
if (proxyPoolResult.hasProxyPoolField) {
if (proxyPoolResult.proxyPoolId === null) {
delete updateData.providerSpecificData.proxyPoolId;
} else {
updateData.providerSpecificData.proxyPoolId = proxyPoolResult.proxyPoolId;
}
}
}
const updated = await updateProviderConnection(id, updateData);

View File

@@ -1,4 +1,6 @@
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { testProxyUrl } from "@/lib/network/proxyTest";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { getDefaultModel } from "open-sse/config/providerModels.js";
import {
@@ -206,7 +208,7 @@ function isTokenExpired(connection) {
return expiresAt <= Date.now() + buffer;
}
async function testOAuthConnection(connection) {
async function testOAuthConnection(connection, effectiveProxy = null) {
const config = OAUTH_TEST_CONFIG[connection.provider];
if (!config) return { valid: false, error: "Provider test not supported", refreshed: false };
if (!connection.accessToken) return { valid: false, error: "No access token", refreshed: false };
@@ -268,7 +270,7 @@ async function testOAuthConnection(connection) {
const headers = config.noAuth
? { ...config.extraHeaders }
: { [config.authHeader]: `${config.authPrefix}${accessToken}`, ...config.extraHeaders };
const res = await fetch(testUrl, { method: config.method, headers });
const res = await fetchWithConnectionProxy(testUrl, { method: config.method, headers }, effectiveProxy);
if (res.ok) return { valid: true, error: null, refreshed, newTokens };
@@ -279,10 +281,10 @@ async function testOAuthConnection(connection) {
const retryHeaders = config.noAuth
? { ...config.extraHeaders }
: { [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, ...config.extraHeaders };
const retryRes = await fetch(retryUrl, {
const retryRes = await fetchWithConnectionProxy(retryUrl, {
method: config.method,
headers: retryHeaders,
});
}, effectiveProxy);
if (retryRes.ok) return { valid: true, error: null, refreshed: true, newTokens: tokens };
}
return { valid: false, error: "Token invalid or revoked", refreshed: false };
@@ -296,14 +298,27 @@ async function testOAuthConnection(connection) {
}
}
async function testApiKeyConnection(connection) {
async function fetchWithConnectionProxy(url, options = {}, effectiveProxy = null) {
if (!effectiveProxy?.connectionProxyEnabled || !effectiveProxy?.connectionProxyUrl) {
return fetch(url, options);
}
const { proxyAwareFetch } = await import("open-sse/utils/proxyFetch.js");
return proxyAwareFetch(url, options, {
connectionProxyEnabled: true,
connectionProxyUrl: effectiveProxy.connectionProxyUrl,
connectionNoProxy: effectiveProxy.connectionNoProxy || "",
});
}
async function testApiKeyConnection(connection, effectiveProxy = null) {
if (isOpenAICompatibleProvider(connection.provider)) {
const modelsBase = connection.providerSpecificData?.baseUrl;
if (!modelsBase) return { valid: false, error: "Missing base URL" };
try {
const res = await fetch(`${modelsBase.replace(/\/$/, "")}/models`, {
const res = await fetchWithConnectionProxy(`${modelsBase.replace(/\/$/, "")}/models`, {
headers: { "Authorization": `Bearer ${connection.apiKey}` },
});
}, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
} catch (err) {
return { valid: false, error: err.message };
@@ -316,9 +331,9 @@ async function testApiKeyConnection(connection) {
try {
modelsBase = modelsBase.replace(/\/$/, "");
if (modelsBase.endsWith("/messages")) modelsBase = modelsBase.slice(0, -9);
const res = await fetch(`${modelsBase}/models`, {
const res = await fetchWithConnectionProxy(`${modelsBase}/models`, {
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "Authorization": `Bearer ${connection.apiKey}` },
});
}, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
} catch (err) {
return { valid: false, error: err.message };
@@ -328,61 +343,61 @@ async function testApiKeyConnection(connection) {
try {
switch (connection.provider) {
case "openai": {
const res = await fetch("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "anthropic": {
const res = await fetch("https://api.anthropic.com/v1/messages", {
const res = await fetchWithConnectionProxy("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
body: JSON.stringify({ model: "claude-3-haiku-20240307", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
}, effectiveProxy);
const valid = res.status !== 401;
return { valid, error: valid ? null : "Invalid API key" };
}
case "gemini": {
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`);
const res = await fetchWithConnectionProxy(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`, {}, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "openrouter": {
const res = await fetch("https://openrouter.ai/api/v1/auth/key", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://openrouter.ai/api/v1/auth/key", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "glm": {
const res = await fetch("https://api.z.ai/api/anthropic/v1/messages", {
const res = await fetchWithConnectionProxy("https://api.z.ai/api/anthropic/v1/messages", {
method: "POST",
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
body: JSON.stringify({ model: "glm-4.7", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
}, effectiveProxy);
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "glm-cn": {
const res = await fetch("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", {
const res = await fetchWithConnectionProxy("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", {
method: "POST",
headers: { "Authorization": `Bearer ${connection.apiKey}`, "content-type": "application/json" },
body: JSON.stringify({ model: "glm-4.7", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
}, effectiveProxy);
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "minimax":
case "minimax-cn": {
const endpoints = { minimax: "https://api.minimax.io/anthropic/v1/messages", "minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages" };
const res = await fetch(endpoints[connection.provider], {
const res = await fetchWithConnectionProxy(endpoints[connection.provider], {
method: "POST",
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
body: JSON.stringify({ model: "minimax-m2", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
}, effectiveProxy);
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "kimi": {
const res = await fetch("https://api.kimi.com/coding/v1/messages", {
const res = await fetchWithConnectionProxy("https://api.kimi.com/coding/v1/messages", {
method: "POST",
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
body: JSON.stringify({ model: "kimi-latest", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
}, effectiveProxy);
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
@@ -392,80 +407,80 @@ async function testApiKeyConnection(connection) {
const aliBaseUrl = connection.provider === "alicode-intl"
? "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions"
: "https://coding.dashscope.aliyuncs.com/v1/chat/completions";
const res = await fetch(aliBaseUrl, {
const res = await fetchWithConnectionProxy(aliBaseUrl, {
method: "POST",
headers: { "Authorization": `Bearer ${connection.apiKey}`, "content-type": "application/json" },
body: JSON.stringify({ model: getDefaultModel(connection.provider), max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
}, effectiveProxy);
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "deepseek": {
const res = await fetch("https://api.deepseek.com/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.deepseek.com/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "groq": {
const res = await fetch("https://api.groq.com/openai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.groq.com/openai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "mistral": {
const res = await fetch("https://api.mistral.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.mistral.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "xai": {
const res = await fetch("https://api.x.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.x.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "nvidia": {
const res = await fetch("https://integrate.api.nvidia.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://integrate.api.nvidia.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "perplexity": {
const res = await fetch("https://api.perplexity.ai/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.perplexity.ai/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "together": {
const res = await fetch("https://api.together.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.together.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "fireworks": {
const res = await fetch("https://api.fireworks.ai/inference/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.fireworks.ai/inference/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "cerebras": {
const res = await fetch("https://api.cerebras.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.cerebras.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "cohere": {
const res = await fetch("https://api.cohere.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.cohere.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "nebius": {
const res = await fetch("https://api.studio.nebius.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.studio.nebius.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "siliconflow": {
const res = await fetch("https://api.siliconflow.cn/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.siliconflow.cn/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "hyperbolic": {
const res = await fetch("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "deepgram": {
const res = await fetch("https://api.deepgram.com/v1/projects", { headers: { Authorization: `Token ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.deepgram.com/v1/projects", { headers: { Authorization: `Token ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "assemblyai": {
const res = await fetch("https://api.assemblyai.com/v1/account", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.assemblyai.com/v1/account", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "nanobanana": {
const res = await fetch("https://api.nanobananaapi.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://api.nanobananaapi.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "chutes": {
const res = await fetch("https://llm.chutes.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
const res = await fetchWithConnectionProxy("https://llm.chutes.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
default:
@@ -483,13 +498,28 @@ export async function testSingleConnection(id) {
const connection = await getProviderConnectionById(id);
if (!connection) return { valid: false, error: "Connection not found", latencyMs: 0, testedAt: new Date().toISOString() };
const effectiveProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
if (effectiveProxy.connectionProxyEnabled && effectiveProxy.connectionProxyUrl) {
const proxyResult = await testProxyUrl({ proxyUrl: effectiveProxy.connectionProxyUrl });
if (!proxyResult.ok) {
const proxyError = proxyResult.error || `Proxy test failed with status ${proxyResult.status}`;
await updateProviderConnection(id, {
testStatus: "error",
lastError: proxyError,
lastErrorAt: new Date().toISOString(),
});
return { valid: false, error: proxyError, latencyMs: 0, testedAt: new Date().toISOString() };
}
}
const start = Date.now();
let result;
if (connection.authType === "apikey") {
result = await testApiKeyConnection(connection);
result = await testApiKeyConnection(connection, effectiveProxy);
} else {
result = await testOAuthConnection(connection);
result = await testOAuthConnection(connection, effectiveProxy);
}
const latencyMs = Date.now() - start;

View File

@@ -1,10 +1,50 @@
import { NextResponse } from "next/server";
import { getProviderConnections, createProviderConnection, getProviderNodeById, getProviderNodes } from "@/models";
import {
getProviderConnections,
createProviderConnection,
getProviderNodeById,
getProviderNodes,
getProxyPoolById,
} from "@/models";
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
export const dynamic = "force-dynamic";
function normalizeProxyConfig(body = {}) {
const enabled = body?.connectionProxyEnabled === true;
const url = typeof body?.connectionProxyUrl === "string" ? body.connectionProxyUrl.trim() : "";
const noProxy = typeof body?.connectionNoProxy === "string" ? body.connectionNoProxy.trim() : "";
if (enabled && !url) {
return { error: "Connection proxy URL is required when connection proxy is enabled" };
}
return {
connectionProxyEnabled: enabled,
connectionProxyUrl: url,
connectionNoProxy: noProxy,
};
}
async function normalizeProxyPoolId(proxyPoolId) {
if (proxyPoolId === undefined || proxyPoolId === null || proxyPoolId === "" || proxyPoolId === "__none__") {
return { proxyPoolId: null };
}
const normalizedId = String(proxyPoolId).trim();
if (!normalizedId) {
return { proxyPoolId: null };
}
const proxyPool = await getProxyPoolById(normalizedId);
if (!proxyPool) {
return { error: "Proxy pool not found" };
}
return { proxyPoolId: normalizedId };
}
// GET /api/providers - List all connections
export async function GET() {
try {
@@ -47,6 +87,16 @@ export async function POST(request) {
try {
const body = await request.json();
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body;
const proxyConfig = normalizeProxyConfig(body);
if (proxyConfig.error) {
return NextResponse.json({ error: proxyConfig.error }, { status: 400 });
}
const proxyPoolResult = await normalizeProxyPoolId(body.proxyPoolId);
if (proxyPoolResult.error) {
return NextResponse.json({ error: proxyPoolResult.error }, { status: 400 });
}
const proxyPoolId = proxyPoolResult.proxyPoolId;
// Validation
const isValidProvider = APIKEY_PROVIDERS[provider] ||
@@ -100,6 +150,17 @@ export async function POST(request) {
};
}
const mergedProviderSpecificData = {
...(providerSpecificData || {}),
connectionProxyEnabled: proxyConfig.connectionProxyEnabled,
connectionProxyUrl: proxyConfig.connectionProxyUrl,
connectionNoProxy: proxyConfig.connectionNoProxy,
};
if (proxyPoolId !== null) {
mergedProviderSpecificData.proxyPoolId = proxyPoolId;
}
const newConnection = await createProviderConnection({
provider,
authType: "apikey",
@@ -108,7 +169,7 @@ export async function POST(request) {
priority: priority || 1,
globalPriority: globalPriority || null,
defaultModel: defaultModel || null,
providerSpecificData,
providerSpecificData: mergedProviderSpecificData,
isActive: true,
testStatus: testStatus || "unknown",
});

View File

@@ -0,0 +1,118 @@
import { NextResponse } from "next/server";
import {
deleteProxyPool,
getProviderConnections,
getProxyPoolById,
updateProxyPool,
} from "@/models";
function normalizeProxyPoolUpdate(body = {}) {
const updates = {};
if (Object.prototype.hasOwnProperty.call(body, "name")) {
const name = typeof body?.name === "string" ? body.name.trim() : "";
if (!name) {
return { error: "Name is required" };
}
updates.name = name;
}
if (Object.prototype.hasOwnProperty.call(body, "proxyUrl")) {
const proxyUrl = typeof body?.proxyUrl === "string" ? body.proxyUrl.trim() : "";
if (!proxyUrl) {
return { error: "Proxy URL is required" };
}
updates.proxyUrl = proxyUrl;
}
if (Object.prototype.hasOwnProperty.call(body, "noProxy")) {
updates.noProxy = typeof body?.noProxy === "string" ? body.noProxy.trim() : "";
}
if (Object.prototype.hasOwnProperty.call(body, "isActive")) {
updates.isActive = body?.isActive === true;
}
if (Object.prototype.hasOwnProperty.call(body, "strictProxy")) {
updates.strictProxy = body?.strictProxy === true;
}
return { updates };
}
function countBoundConnections(connections = [], proxyPoolId) {
return connections.filter((connection) => connection?.providerSpecificData?.proxyPoolId === proxyPoolId).length;
}
// GET /api/proxy-pools/[id] - Get proxy pool
export async function GET(request, { params }) {
try {
const { id } = await params;
const proxyPool = await getProxyPoolById(id);
if (!proxyPool) {
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
}
return NextResponse.json({ proxyPool });
} catch (error) {
console.log("Error fetching proxy pool:", error);
return NextResponse.json({ error: "Failed to fetch proxy pool" }, { status: 500 });
}
}
// PUT /api/proxy-pools/[id] - Update proxy pool
export async function PUT(request, { params }) {
try {
const { id } = await params;
const existing = await getProxyPoolById(id);
if (!existing) {
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
}
const body = await request.json();
const normalized = normalizeProxyPoolUpdate(body);
if (normalized.error) {
return NextResponse.json({ error: normalized.error }, { status: 400 });
}
const updated = await updateProxyPool(id, normalized.updates);
return NextResponse.json({ proxyPool: updated });
} catch (error) {
console.log("Error updating proxy pool:", error);
return NextResponse.json({ error: "Failed to update proxy pool" }, { status: 500 });
}
}
// DELETE /api/proxy-pools/[id] - Delete proxy pool
export async function DELETE(request, { params }) {
try {
const { id } = await params;
const existing = await getProxyPoolById(id);
if (!existing) {
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
}
const connections = await getProviderConnections();
const boundConnectionCount = countBoundConnections(connections, id);
if (boundConnectionCount > 0) {
return NextResponse.json(
{
error: "Proxy pool is currently in use",
boundConnectionCount,
},
{ status: 409 }
);
}
await deleteProxyPool(id);
return NextResponse.json({ success: true });
} catch (error) {
console.log("Error deleting proxy pool:", error);
return NextResponse.json({ error: "Failed to delete proxy pool" }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { getProxyPoolById, updateProxyPool } from "@/models";
import { testProxyUrl } from "@/lib/network/proxyTest";
// POST /api/proxy-pools/[id]/test - Test proxy pool entry
export async function POST(request, { params }) {
try {
const { id } = await params;
const proxyPool = await getProxyPoolById(id);
if (!proxyPool) {
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
}
const result = await testProxyUrl({ proxyUrl: proxyPool.proxyUrl });
const now = new Date().toISOString();
await updateProxyPool(id, {
testStatus: result.ok ? "active" : "error",
lastTestedAt: now,
lastError: result.ok ? null : (result.error || `Proxy test failed with status ${result.status}`),
isActive: result.ok,
});
return NextResponse.json({
ok: result.ok,
status: result.status,
statusText: result.statusText || null,
error: result.error || null,
elapsedMs: result.elapsedMs || 0,
testedAt: now,
});
} catch (error) {
console.log("Error testing proxy pool:", error);
return NextResponse.json({ error: "Failed to test proxy pool" }, { status: 500 });
}
}

View File

@@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import {
createProxyPool,
getProviderConnections,
getProxyPools,
updateProviderConnection,
} from "@/models";
function normalizeString(value) {
if (value === undefined || value === null) return "";
return String(value).trim();
}
function buildProxyKey(proxyUrl, noProxy) {
return `${normalizeString(proxyUrl)}|||${normalizeString(noProxy)}`;
}
function extractLegacyProxy(connection) {
const providerSpecificData = connection?.providerSpecificData || {};
const connectionProxyEnabled = providerSpecificData.connectionProxyEnabled === true;
const connectionProxyUrl = normalizeString(providerSpecificData.connectionProxyUrl);
const connectionNoProxy = normalizeString(providerSpecificData.connectionNoProxy);
if (!connectionProxyEnabled || !connectionProxyUrl) {
return null;
}
return {
connectionProxyUrl,
connectionNoProxy,
};
}
function buildMigratedName(index) {
return `Migrated Proxy ${index}`;
}
// POST /api/proxy-pools/migrate - Migrate legacy connection proxy config into proxy pools
export async function POST() {
try {
const connections = await getProviderConnections();
const existingPools = await getProxyPools();
const poolByKey = new Map();
for (const pool of existingPools) {
const key = buildProxyKey(pool.proxyUrl, pool.noProxy);
if (!poolByKey.has(key)) {
poolByKey.set(key, pool);
}
}
let migratedConnectionCount = 0;
let legacyConnectionCount = 0;
const createdPools = [];
for (const connection of connections) {
const legacyProxy = extractLegacyProxy(connection);
if (!legacyProxy) continue;
legacyConnectionCount += 1;
const key = buildProxyKey(legacyProxy.connectionProxyUrl, legacyProxy.connectionNoProxy);
let pool = poolByKey.get(key);
if (!pool) {
pool = await createProxyPool({
name: buildMigratedName(existingPools.length + createdPools.length + 1),
proxyUrl: legacyProxy.connectionProxyUrl,
noProxy: legacyProxy.connectionNoProxy,
isActive: true,
testStatus: "unknown",
});
createdPools.push(pool);
poolByKey.set(key, pool);
}
if (connection?.providerSpecificData?.proxyPoolId !== pool.id) {
await updateProviderConnection(connection.id, {
providerSpecificData: {
...(connection.providerSpecificData || {}),
proxyPoolId: pool.id,
},
});
migratedConnectionCount += 1;
}
}
return NextResponse.json({
success: true,
summary: {
totalConnections: connections.length,
legacyConnections: legacyConnectionCount,
poolsCreated: createdPools.length,
connectionsBound: migratedConnectionCount,
},
createdPools,
});
} catch (error) {
console.log("Error migrating proxy pools:", error);
return NextResponse.json({ error: "Failed to migrate proxy pools" }, { status: 500 });
}
}

View File

@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { createProxyPool, getProviderConnections, getProxyPools } from "@/models";
function toBoolean(value) {
if (value === "true") return true;
if (value === "false") return false;
return undefined;
}
function normalizeProxyPoolInput(body = {}) {
const name = typeof body?.name === "string" ? body.name.trim() : "";
const proxyUrl = typeof body?.proxyUrl === "string" ? body.proxyUrl.trim() : "";
const noProxy = typeof body?.noProxy === "string" ? body.noProxy.trim() : "";
const isActive = body?.isActive === undefined ? true : body.isActive === true;
const strictProxy = body?.strictProxy === true;
if (!name) {
return { error: "Name is required" };
}
if (!proxyUrl) {
return { error: "Proxy URL is required" };
}
return { name, proxyUrl, noProxy, isActive, strictProxy };
}
function buildUsageMap(connections = []) {
const usageMap = new Map();
for (const connection of connections) {
const proxyPoolId = connection?.providerSpecificData?.proxyPoolId;
if (!proxyPoolId) continue;
usageMap.set(proxyPoolId, (usageMap.get(proxyPoolId) || 0) + 1);
}
return usageMap;
}
// GET /api/proxy-pools - List proxy pools
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const isActive = toBoolean(searchParams.get("isActive"));
const includeUsage = searchParams.get("includeUsage") === "true";
const filter = {};
if (isActive !== undefined) {
filter.isActive = isActive;
}
const proxyPools = await getProxyPools(filter);
if (!includeUsage) {
return NextResponse.json({ proxyPools });
}
const connections = await getProviderConnections();
const usageMap = buildUsageMap(connections);
const enrichedProxyPools = proxyPools.map((pool) => ({
...pool,
boundConnectionCount: usageMap.get(pool.id) || 0,
}));
return NextResponse.json({ proxyPools: enrichedProxyPools });
} catch (error) {
console.log("Error fetching proxy pools:", error);
return NextResponse.json({ error: "Failed to fetch proxy pools" }, { status: 500 });
}
}
// POST /api/proxy-pools - Create proxy pool
export async function POST(request) {
try {
const body = await request.json();
const normalized = normalizeProxyPoolInput(body);
if (normalized.error) {
return NextResponse.json({ error: normalized.error }, { status: 400 });
}
const proxyPool = await createProxyPool(normalized);
return NextResponse.json({ proxyPool }, { status: 201 });
} catch (error) {
console.log("Error creating proxy pool:", error);
return NextResponse.json({ error: "Failed to create proxy pool" }, { status: 500 });
}
}

View File

@@ -43,6 +43,7 @@ if (!isCloud && !fs.existsSync(DATA_DIR)) {
const defaultData = {
providerConnections: [],
providerNodes: [],
proxyPools: [],
modelAliases: {},
mitmAlias: {},
combos: [],
@@ -69,6 +70,7 @@ function cloneDefaultData() {
return {
providerConnections: [],
providerNodes: [],
proxyPools: [],
modelAliases: {},
mitmAlias: {},
combos: [],
@@ -308,7 +310,7 @@ export async function deleteProviderNode(id) {
if (!db.data.providerNodes) {
db.data.providerNodes = [];
}
const index = db.data.providerNodes.findIndex((node) => node.id === id);
if (index === -1) return null;
@@ -319,6 +321,104 @@ export async function deleteProviderNode(id) {
return removed;
}
// ============ Proxy Pools ============
/**
* Get proxy pools
*/
export async function getProxyPools(filter = {}) {
const db = await getDb();
let pools = db.data.proxyPools || [];
if (filter.isActive !== undefined) {
pools = pools.filter((pool) => pool.isActive === filter.isActive);
}
if (filter.testStatus) {
pools = pools.filter((pool) => pool.testStatus === filter.testStatus);
}
return pools.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
}
/**
* Get proxy pool by ID
*/
export async function getProxyPoolById(id) {
const db = await getDb();
return (db.data.proxyPools || []).find((pool) => pool.id === id) || null;
}
/**
* Create proxy pool
*/
export async function createProxyPool(data) {
const db = await getDb();
if (!db.data.proxyPools) {
db.data.proxyPools = [];
}
const now = new Date().toISOString();
const pool = {
id: data.id || uuidv4(),
name: data.name,
proxyUrl: data.proxyUrl,
noProxy: data.noProxy || "",
isActive: data.isActive !== undefined ? data.isActive : true,
strictProxy: data.strictProxy === true,
testStatus: data.testStatus || "unknown",
lastTestedAt: data.lastTestedAt || null,
lastError: data.lastError || null,
createdAt: now,
updatedAt: now,
};
db.data.proxyPools.push(pool);
await db.write();
return pool;
}
/**
* Update proxy pool
*/
export async function updateProxyPool(id, data) {
const db = await getDb();
if (!db.data.proxyPools) {
db.data.proxyPools = [];
}
const index = db.data.proxyPools.findIndex((pool) => pool.id === id);
if (index === -1) return null;
db.data.proxyPools[index] = {
...db.data.proxyPools[index],
...data,
updatedAt: new Date().toISOString(),
};
await db.write();
return db.data.proxyPools[index];
}
/**
* Delete proxy pool
*/
export async function deleteProxyPool(id) {
const db = await getDb();
if (!db.data.proxyPools) {
db.data.proxyPools = [];
}
const index = db.data.proxyPools.findIndex((pool) => pool.id === id);
if (index === -1) return null;
const [removed] = db.data.proxyPools.splice(index, 1);
await db.write();
return removed;
}
/**
* Delete all provider connections by provider ID
*/

View File

@@ -0,0 +1,58 @@
import { getProxyPoolById } from "@/models";
function normalizeString(value) {
if (value === undefined || value === null) return "";
return String(value).trim();
}
function normalizeLegacyProxy(providerSpecificData = {}) {
const connectionProxyEnabled = providerSpecificData?.connectionProxyEnabled === true;
const connectionProxyUrl = normalizeString(providerSpecificData?.connectionProxyUrl);
const connectionNoProxy = normalizeString(providerSpecificData?.connectionNoProxy);
return {
connectionProxyEnabled,
connectionProxyUrl,
connectionNoProxy,
};
}
export async function resolveConnectionProxyConfig(providerSpecificData = {}) {
const proxyPoolIdRaw = normalizeString(providerSpecificData?.proxyPoolId);
const proxyPoolId = proxyPoolIdRaw === "__none__" ? "" : proxyPoolIdRaw;
const legacy = normalizeLegacyProxy(providerSpecificData);
if (proxyPoolId) {
const proxyPool = await getProxyPoolById(proxyPoolId);
const proxyUrl = normalizeString(proxyPool?.proxyUrl);
const noProxy = normalizeString(proxyPool?.noProxy);
if (proxyPool && proxyPool.isActive === true && proxyUrl) {
return {
source: "pool",
proxyPoolId,
proxyPool,
connectionProxyEnabled: true,
connectionProxyUrl: proxyUrl,
connectionNoProxy: noProxy,
strictProxy: proxyPool.strictProxy === true,
};
}
}
if (legacy.connectionProxyEnabled && legacy.connectionProxyUrl) {
return {
source: "legacy",
proxyPoolId: proxyPoolId || null,
proxyPool: null,
...legacy,
};
}
return {
source: "none",
proxyPoolId: proxyPoolId || null,
proxyPool: null,
...legacy,
};
}

View File

@@ -10,7 +10,18 @@ export {
createProviderNode,
updateProviderNode,
deleteProviderNode,
getProxyPools,
getProxyPoolById,
createProxyPool,
updateProxyPool,
deleteProxyPool,
deleteProviderConnectionsByProvider,
getCombos,
getComboById,
getComboByName,
createCombo,
updateCombo,
deleteCombo,
getModelAliases,
setModelAlias,
deleteModelAlias,

View File

@@ -12,6 +12,7 @@ import { ConfirmModal } from "./Modal";
const navItems = [
{ href: "/dashboard/endpoint", label: "Endpoint", icon: "api" },
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
{ href: "/dashboard/proxy-pools", label: "Proxy Pools", icon: "lan" },
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },

View File

@@ -2,15 +2,72 @@
import { useState } from "react";
import { usePathname } from "next/navigation";
import { useNotificationStore } from "@/store/notificationStore";
import Sidebar from "../Sidebar";
import Header from "../Header";
function getToastStyle(type) {
if (type === "success") {
return {
wrapper: "border-green-500/30 bg-green-500/10 text-green-600 dark:text-green-400",
icon: "check_circle",
};
}
if (type === "error") {
return {
wrapper: "border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400",
icon: "error",
};
}
if (type === "warning") {
return {
wrapper: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400",
icon: "warning",
};
}
return {
wrapper: "border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400",
icon: "info",
};
}
export default function DashboardLayout({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const pathname = usePathname();
const notifications = useNotificationStore((state) => state.notifications);
const removeNotification = useNotificationStore((state) => state.removeNotification);
return (
<div className="flex h-screen w-full overflow-hidden bg-bg">
<div className="fixed top-4 right-4 z-[80] flex w-[min(92vw,380px)] flex-col gap-2">
{notifications.map((n) => {
const style = getToastStyle(n.type);
return (
<div
key={n.id}
className={`rounded-lg border px-3 py-2 shadow-lg backdrop-blur-sm ${style.wrapper}`}
>
<div className="flex items-start gap-2">
<span className="material-symbols-outlined text-[18px] leading-5">{style.icon}</span>
<div className="min-w-0 flex-1">
{n.title ? <p className="text-xs font-semibold mb-0.5">{n.title}</p> : null}
<p className="text-xs whitespace-pre-wrap break-words">{n.message}</p>
</div>
{n.dismissible ? (
<button
type="button"
onClick={() => removeNotification(n.id)}
className="text-current/70 hover:text-current"
aria-label="Dismiss notification"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
) : null}
</div>
</div>
);
})}
</div>
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div

View File

@@ -1,5 +1,6 @@
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings } from "@/lib/localDb";
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, buildClearModelLocksUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
import { resolveProviderId } from "@/shared/constants/providers.js";
import * as log from "../utils/logger.js";
@@ -118,13 +119,22 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
connection = availableConnections[0];
}
const resolvedProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
return {
apiKey: connection.apiKey,
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
projectId: connection.projectId,
connectionName: connection.displayName || connection.name || connection.email || connection.id,
copilotToken: connection.providerSpecificData?.copilotToken,
providerSpecificData: connection.providerSpecificData,
providerSpecificData: {
...(connection.providerSpecificData || {}),
connectionProxyEnabled: resolvedProxy.connectionProxyEnabled,
connectionProxyUrl: resolvedProxy.connectionProxyUrl,
connectionNoProxy: resolvedProxy.connectionNoProxy,
connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
},
connectionId: connection.id,
// Include current status for optimization check
testStatus: connection.testStatus,