+ No active proxy pools available. Create one in Proxy Pools page first.
+
+ )}
+
+
+ Runtime prefers proxy pool settings. Legacy proxy fields are still supported as fallback.
+
+
+ {connection.providerSpecificData?.connectionProxyEnabled === true && !connection.providerSpecificData?.proxyPoolId && (
+
+ This connection is still using legacy manual proxy settings until you bind a proxy pool.
+
+ )}
+
+ {connection.providerSpecificData?.proxyPoolId && formData.proxyPoolId === NONE_PROXY_POOL_VALUE && (
+
+ Saving with None will unbind this connection from proxy pool and fallback to legacy/global proxy behavior.
+
+ )}
+
+ {connection.providerSpecificData?.proxyPoolId && formData.proxyPoolId !== NONE_PROXY_POOL_VALUE && connection.providerSpecificData?.proxyPoolId !== formData.proxyPoolId && (
+
+ You changed proxy pool binding. Use Test Connection to verify connectivity.
+
+ )}
+
+ {connection.providerSpecificData?.proxyPoolId && !(proxyPools || []).some((pool) => pool.id === connection.providerSpecificData.proxyPoolId) && (
+
+ Current bound proxy pool is inactive or missing. Runtime will fallback to legacy proxy if available.
+
+ )}
+
+ {!connection.providerSpecificData?.proxyPoolId && (connection.providerSpecificData?.connectionProxyUrl || connection.providerSpecificData?.connectionNoProxy) && (
+
+ Legacy proxy: {connection.providerSpecificData?.connectionProxyUrl || "(empty)"}
+ {connection.providerSpecificData?.connectionNoProxy ? ` · no_proxy: ${connection.providerSpecificData.connectionNoProxy}` : ""}
+
+ )}
+
{!isOAuth && (
<>
@@ -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,
};
diff --git a/src/app/(dashboard)/dashboard/proxy-pools/page.js b/src/app/(dashboard)/dashboard/proxy-pools/page.js
new file mode 100644
index 00000000..36db1eca
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/proxy-pools/page.js
@@ -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 (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Proxy Pools
+
+ Manage reusable per-connection proxies and bind them to provider connections.
+
+
+
+
+
+
+
+
+
+
+
+
+ Total: {proxyPools.length}
+ Active: {activeCount}
+
+
+
+ {proxyPools.length === 0 ? (
+
+
No proxy pool entries yet
+
+ Create a proxy pool entry, then assign it to connections.
+
+
+
+ ) : (
+
+ {proxyPools.map((pool) => (
+
+
+
+
{pool.name}
+
+ {pool.testStatus || "unknown"}
+
+
+ {pool.isActive ? "active" : "inactive"}
+
+
+ {pool.boundConnectionCount || 0} bound
+
+
+
{pool.proxyUrl}
+ {pool.noProxy ? (
+
No proxy: {pool.noProxy}
+ ) : null}
+
+ Last tested: {formatDateTime(pool.lastTestedAt)}
+ {pool.lastError ? ` · ${pool.lastError}` : ""}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setFormData((prev) => ({ ...prev, name: e.target.value }))}
+ placeholder="Office Proxy"
+ />
+
setFormData((prev) => ({ ...prev, proxyUrl: e.target.value }))}
+ placeholder="http://127.0.0.1:7897"
+ />
+
setFormData((prev) => ({ ...prev, noProxy: e.target.value }))}
+ placeholder="localhost,127.0.0.1,.internal"
+ hint="Comma-separated hosts/domains to bypass proxy"
+ />
+
+
+
+
Active
+
Inactive pools are ignored by runtime resolution.
+
+
setFormData((prev) => ({ ...prev, isActive: !prev.isActive }))}
+ disabled={saving}
+ />
+
+
+
+
+
Strict Proxy
+
Fail request if proxy is unreachable instead of falling back to direct.
+
+
setFormData((prev) => ({ ...prev, strictProxy: !prev.strictProxy }))}
+ disabled={saving}
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js
index 1b58aecc..6ab51797 100644
--- a/src/app/api/providers/[id]/route.js
+++ b/src/app/api/providers/[id]/route.js
@@ -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);
diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js
index 45c673ee..0f08fca6 100644
--- a/src/app/api/providers/[id]/test/testUtils.js
+++ b/src/app/api/providers/[id]/test/testUtils.js
@@ -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;
diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js
index 01eba1d7..67e49e68 100644
--- a/src/app/api/providers/route.js
+++ b/src/app/api/providers/route.js
@@ -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",
});
diff --git a/src/app/api/proxy-pools/[id]/route.js b/src/app/api/proxy-pools/[id]/route.js
new file mode 100644
index 00000000..c5ec9b1f
--- /dev/null
+++ b/src/app/api/proxy-pools/[id]/route.js
@@ -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 });
+ }
+}
diff --git a/src/app/api/proxy-pools/[id]/test/route.js b/src/app/api/proxy-pools/[id]/test/route.js
new file mode 100644
index 00000000..31269e7d
--- /dev/null
+++ b/src/app/api/proxy-pools/[id]/test/route.js
@@ -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 });
+ }
+}
diff --git a/src/app/api/proxy-pools/migrate/route.js b/src/app/api/proxy-pools/migrate/route.js
new file mode 100644
index 00000000..020d7fbf
--- /dev/null
+++ b/src/app/api/proxy-pools/migrate/route.js
@@ -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 });
+ }
+}
diff --git a/src/app/api/proxy-pools/route.js b/src/app/api/proxy-pools/route.js
new file mode 100644
index 00000000..c5b8d9f0
--- /dev/null
+++ b/src/app/api/proxy-pools/route.js
@@ -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 });
+ }
+}
diff --git a/src/lib/localDb.js b/src/lib/localDb.js
index 19ccf8e3..cad6c9eb 100644
--- a/src/lib/localDb.js
+++ b/src/lib/localDb.js
@@ -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
*/
diff --git a/src/lib/network/connectionProxy.js b/src/lib/network/connectionProxy.js
new file mode 100644
index 00000000..cb51f11c
--- /dev/null
+++ b/src/lib/network/connectionProxy.js
@@ -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,
+ };
+}
diff --git a/src/models/index.js b/src/models/index.js
index 637b6f3b..e61129fe 100644
--- a/src/models/index.js
+++ b/src/models/index.js
@@ -10,7 +10,18 @@ export {
createProviderNode,
updateProviderNode,
deleteProviderNode,
+ getProxyPools,
+ getProxyPoolById,
+ createProxyPool,
+ updateProxyPool,
+ deleteProxyPool,
deleteProviderConnectionsByProvider,
+ getCombos,
+ getComboById,
+ getComboByName,
+ createCombo,
+ updateCombo,
+ deleteCombo,
getModelAliases,
setModelAlias,
deleteModelAlias,
diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js
index 8a35fe61..df225b4d 100644
--- a/src/shared/components/Sidebar.js
+++ b/src/shared/components/Sidebar.js
@@ -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" },
diff --git a/src/shared/components/layouts/DashboardLayout.js b/src/shared/components/layouts/DashboardLayout.js
index 078deb5d..6bc23640 100644
--- a/src/shared/components/layouts/DashboardLayout.js
+++ b/src/shared/components/layouts/DashboardLayout.js
@@ -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 (
+
+ {notifications.map((n) => {
+ const style = getToastStyle(n.type);
+ return (
+
+
+
{style.icon}
+
+ {n.title ?
{n.title}
: null}
+
{n.message}
+
+ {n.dismissible ? (
+
+ ) : null}
+
+
+ );
+ })}
+
{/* Mobile sidebar overlay */}
{sidebarOpen && (