mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
489
src/app/(dashboard)/dashboard/proxy-pools/page.js
Normal file
489
src/app/(dashboard)/dashboard/proxy-pools/page.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
118
src/app/api/proxy-pools/[id]/route.js
Normal file
118
src/app/api/proxy-pools/[id]/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
37
src/app/api/proxy-pools/[id]/test/route.js
Normal file
37
src/app/api/proxy-pools/[id]/test/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
101
src/app/api/proxy-pools/migrate/route.js
Normal file
101
src/app/api/proxy-pools/migrate/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
90
src/app/api/proxy-pools/route.js
Normal file
90
src/app/api/proxy-pools/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
58
src/lib/network/connectionProxy.js
Normal file
58
src/lib/network/connectionProxy.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,18 @@ export {
|
||||
createProviderNode,
|
||||
updateProviderNode,
|
||||
deleteProviderNode,
|
||||
getProxyPools,
|
||||
getProxyPoolById,
|
||||
createProxyPool,
|
||||
updateProxyPool,
|
||||
deleteProxyPool,
|
||||
deleteProviderConnectionsByProvider,
|
||||
getCombos,
|
||||
getComboById,
|
||||
getComboByName,
|
||||
createCombo,
|
||||
updateCombo,
|
||||
deleteCombo,
|
||||
getModelAliases,
|
||||
setModelAlias,
|
||||
deleteModelAlias,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user