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 { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER } from "../config/constants.js";
|
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER } from "../config/constants.js";
|
||||||
import { deriveSessionId } from "../utils/sessionManager.js";
|
import { deriveSessionId } from "../utils/sessionManager.js";
|
||||||
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
|
|
||||||
const MAX_RETRY_AFTER_MS = 10000;
|
const MAX_RETRY_AFTER_MS = 10000;
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
return totalMs > 0 ? totalMs : null;
|
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();
|
const fallbackCount = this.getFallbackCount();
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
let lastStatus = 0;
|
let lastStatus = 0;
|
||||||
@@ -180,12 +181,12 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await proxyAwareFetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(transformedBody),
|
body: JSON.stringify(transformedBody),
|
||||||
signal
|
signal
|
||||||
});
|
}, proxyOptions);
|
||||||
|
|
||||||
if (response.status === HTTP_STATUS.RATE_LIMITED || response.status === HTTP_STATUS.SERVICE_UNAVAILABLE) {
|
if (response.status === HTTP_STATUS.RATE_LIMITED || response.status === HTTP_STATUS.SERVICE_UNAVAILABLE) {
|
||||||
// Try to get retry time from headers first
|
// Try to get retry time from headers first
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { HTTP_STATUS } from "../config/constants.js";
|
import { HTTP_STATUS } from "../config/constants.js";
|
||||||
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BaseExecutor - Base class for provider executors
|
* BaseExecutor - Base class for provider executors
|
||||||
@@ -75,7 +76,7 @@ export class BaseExecutor {
|
|||||||
return { status: response.status, message: bodyText || `HTTP ${response.status}` };
|
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();
|
const fallbackCount = this.getFallbackCount();
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
let lastStatus = 0;
|
let lastStatus = 0;
|
||||||
@@ -86,12 +87,12 @@ export class BaseExecutor {
|
|||||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await proxyAwareFetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(transformedBody),
|
body: JSON.stringify(transformedBody),
|
||||||
signal
|
signal
|
||||||
});
|
}, proxyOptions);
|
||||||
|
|
||||||
if (this.shouldRetry(response.status, urlIndex)) {
|
if (this.shouldRetry(response.status, urlIndex)) {
|
||||||
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);
|
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { estimateUsage } from "../utils/usageTracking.js";
|
import { estimateUsage } from "../utils/usageTracking.js";
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
import { buildCursorRequest } from "../translator/request/openai-to-cursor.js";
|
import { buildCursorRequest } from "../translator/request/openai-to-cursor.js";
|
||||||
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
import zlib from "zlib";
|
import zlib from "zlib";
|
||||||
@@ -163,13 +164,13 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
return generateCursorBody(messages, model, tools, reasoningEffort);
|
return generateCursorBody(messages, model, tools, reasoningEffort);
|
||||||
}
|
}
|
||||||
|
|
||||||
async makeFetchRequest(url, headers, body, signal) {
|
async makeFetchRequest(url, headers, body, signal, proxyOptions = null) {
|
||||||
const response = await fetch(url, {
|
const response = await proxyAwareFetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
signal
|
signal
|
||||||
});
|
}, proxyOptions);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: response.status,
|
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 url = this.buildUrl();
|
||||||
const headers = this.buildHeaders(credentials);
|
const headers = this.buildHeaders(credentials);
|
||||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||||
|
|
||||||
try {
|
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.makeHttp2Request(url, headers, transformedBody, signal)
|
||||||
: await this.makeFetchRequest(url, headers, transformedBody, signal);
|
: await this.makeFetchRequest(url, headers, transformedBody, signal, proxyOptions);
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
const errorText = response.body?.toString() || "Unknown error";
|
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 { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
|
||||||
import { initState } from "../translator/index.js";
|
import { initState } from "../translator/index.js";
|
||||||
import { parseSSELine, formatSSE } from "../utils/streamHelpers.js";
|
import { parseSSELine, formatSSE } from "../utils/streamHelpers.js";
|
||||||
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
|
||||||
export class GithubExecutor extends BaseExecutor {
|
export class GithubExecutor extends BaseExecutor {
|
||||||
@@ -86,7 +87,7 @@ export class GithubExecutor extends BaseExecutor {
|
|||||||
body: this.sanitizeMessagesForChatCompletions(options.body)
|
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) {
|
if (result.response.status === HTTP_STATUS.BAD_REQUEST) {
|
||||||
const errorBody = await result.response.clone().text();
|
const errorBody = await result.response.clone().text();
|
||||||
@@ -101,7 +102,7 @@ export class GithubExecutor extends BaseExecutor {
|
|||||||
return result;
|
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 url = this.config.responsesUrl;
|
||||||
const headers = this.buildHeaders(credentials, stream);
|
const headers = this.buildHeaders(credentials, stream);
|
||||||
|
|
||||||
@@ -109,12 +110,12 @@ export class GithubExecutor extends BaseExecutor {
|
|||||||
|
|
||||||
log?.debug("GITHUB", "Sending translated request to /responses");
|
log?.debug("GITHUB", "Sending translated request to /responses");
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await proxyAwareFetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(transformedBody),
|
body: JSON.stringify(transformedBody),
|
||||||
signal
|
signal
|
||||||
});
|
}, proxyOptions);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return { response, url, headers, transformedBody };
|
return { response, url, headers, transformedBody };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BaseExecutor } from "./base.js";
|
|||||||
import { PROVIDERS } from "../config/constants.js";
|
import { PROVIDERS } from "../config/constants.js";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { refreshKiroToken } from "../services/tokenRefresh.js";
|
import { refreshKiroToken } from "../services/tokenRefresh.js";
|
||||||
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer)
|
* 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
|
* 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 url = this.buildUrl(model, stream, 0);
|
||||||
const headers = this.buildHeaders(credentials, stream);
|
const headers = this.buildHeaders(credentials, stream);
|
||||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await proxyAwareFetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(transformedBody),
|
body: JSON.stringify(transformedBody),
|
||||||
signal
|
signal
|
||||||
});
|
}, proxyOptions);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return { response, url, headers, transformedBody };
|
return { response, url, headers, transformedBody };
|
||||||
|
|||||||
@@ -67,10 +67,38 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
log, provider, model
|
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
|
// Execute request
|
||||||
let providerResponse, providerUrl, providerHeaders, finalBody;
|
let providerResponse, providerUrl, providerHeaders, finalBody;
|
||||||
try {
|
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;
|
providerResponse = result.response;
|
||||||
providerUrl = result.url;
|
providerUrl = result.url;
|
||||||
providerHeaders = result.headers;
|
providerHeaders = result.headers;
|
||||||
@@ -106,7 +134,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
Object.assign(credentials, newCredentials);
|
Object.assign(credentials, newCredentials);
|
||||||
if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials);
|
if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials);
|
||||||
try {
|
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; }
|
if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; }
|
||||||
} catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); }
|
} catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); }
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let proxyDispatcher = null;
|
const proxyDispatchers = new Map();
|
||||||
let proxyDispatcherUrl = null;
|
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const DNS_CACHE = {};
|
const DNS_CACHE = {};
|
||||||
@@ -14,6 +13,11 @@ const HTTPS_PORT = 443;
|
|||||||
const HTTP_SUCCESS_MIN = 200;
|
const HTTP_SUCCESS_MIN = 200;
|
||||||
const HTTP_SUCCESS_MAX = 300;
|
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)
|
* Resolve real IP using Google DNS (bypass system DNS)
|
||||||
*/
|
*/
|
||||||
@@ -58,24 +62,26 @@ function shouldBypassMitmDns(url, options) {
|
|||||||
return MITM_BYPASS_HOSTS.some(host => hostname.includes(host));
|
return MITM_BYPASS_HOSTS.some(host => hostname.includes(host));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function shouldBypassByNoProxy(targetUrl, noProxyValue) {
|
||||||
* Get proxy URL from environment
|
const noProxy = normalizeString(noProxyValue);
|
||||||
*/
|
if (!noProxy) return false;
|
||||||
function getProxyUrl(targetUrl) {
|
|
||||||
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
|
|
||||||
|
|
||||||
if (noProxy) {
|
|
||||||
const hostname = new URL(targetUrl).hostname.toLowerCase();
|
const hostname = new URL(targetUrl).hostname.toLowerCase();
|
||||||
const patterns = noProxy.split(",").map(p => p.trim().toLowerCase());
|
const patterns = noProxy.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
|
||||||
|
|
||||||
const shouldBypass = patterns.some(pattern => {
|
return patterns.some((pattern) => {
|
||||||
if (pattern === "*") return true;
|
if (pattern === "*") return true;
|
||||||
if (pattern.startsWith(".")) return hostname.endsWith(pattern) || hostname === pattern.slice(1);
|
if (pattern.startsWith(".")) return hostname.endsWith(pattern) || hostname === pattern.slice(1);
|
||||||
return hostname === pattern || hostname.endsWith(`.${pattern}`);
|
return hostname === pattern || hostname.endsWith(`.${pattern}`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldBypass) return null;
|
/**
|
||||||
}
|
* Get proxy URL from environment
|
||||||
|
*/
|
||||||
|
function getEnvProxyUrl(targetUrl) {
|
||||||
|
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
|
||||||
|
if (shouldBypassByNoProxy(targetUrl, noProxy)) return null;
|
||||||
|
|
||||||
const protocol = new URL(targetUrl).protocol;
|
const protocol = new URL(targetUrl).protocol;
|
||||||
|
|
||||||
@@ -92,33 +98,45 @@ function getProxyUrl(targetUrl) {
|
|||||||
* Normalize proxy URL (allow host:port)
|
* Normalize proxy URL (allow host:port)
|
||||||
*/
|
*/
|
||||||
function normalizeProxyUrl(proxyUrl) {
|
function normalizeProxyUrl(proxyUrl) {
|
||||||
if (!proxyUrl) return null;
|
const normalizedInput = normalizeString(proxyUrl);
|
||||||
|
if (!normalizedInput) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-new
|
// eslint-disable-next-line no-new
|
||||||
new URL(proxyUrl);
|
new URL(normalizedInput);
|
||||||
return proxyUrl;
|
return normalizedInput;
|
||||||
} catch {
|
} catch {
|
||||||
// Allow "127.0.0.1:7890" style values
|
// 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)
|
* Create proxy dispatcher lazily (undici-compatible)
|
||||||
* Closes old dispatcher when proxy URL changes to prevent connection pool leak
|
|
||||||
*/
|
*/
|
||||||
async function getDispatcher(proxyUrl) {
|
async function getDispatcher(proxyUrl) {
|
||||||
const normalized = normalizeProxyUrl(proxyUrl);
|
const normalized = normalizeProxyUrl(proxyUrl);
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
|
|
||||||
if (!proxyDispatcher || proxyDispatcherUrl !== normalized) {
|
if (!proxyDispatchers.has(normalized)) {
|
||||||
try { proxyDispatcher?.close?.(); } catch { /* ignore */ }
|
|
||||||
const { ProxyAgent } = await import("undici");
|
const { ProxyAgent } = await import("undici");
|
||||||
proxyDispatcher = new ProxyAgent({ uri: normalized });
|
proxyDispatchers.set(normalized, new ProxyAgent({ uri: normalized }));
|
||||||
proxyDispatcherUrl = normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return proxyDispatcher;
|
return proxyDispatchers.get(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,10 +191,7 @@ async function createBypassRequest(parsedUrl, realIP, options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
|
||||||
* Patched fetch with proxy support and MITM DNS bypass
|
|
||||||
*/
|
|
||||||
async function patchedFetch(url, options = {}) {
|
|
||||||
const targetUrl = typeof url === "string" ? url : url.toString();
|
const targetUrl = typeof url === "string" ? url : url.toString();
|
||||||
|
|
||||||
// MITM DNS bypass: resolve real IP for googleapis.com when x-request-source: local
|
// MITM DNS bypass: resolve real IP for googleapis.com when x-request-source: local
|
||||||
@@ -190,13 +205,19 @@ async function patchedFetch(url, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal proxy handling
|
const connectionProxyUrl = resolveConnectionProxyUrl(targetUrl, proxyOptions);
|
||||||
const proxyUrl = normalizeProxyUrl(getProxyUrl(targetUrl));
|
const envProxyUrl = connectionProxyUrl ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl));
|
||||||
|
const proxyUrl = connectionProxyUrl || envProxyUrl;
|
||||||
|
|
||||||
if (proxyUrl) {
|
if (proxyUrl) {
|
||||||
try {
|
try {
|
||||||
const dispatcher = await getDispatcher(proxyUrl);
|
const dispatcher = await getDispatcher(proxyUrl);
|
||||||
return await originalFetch(url, { ...options, dispatcher });
|
return await originalFetch(url, { ...options, dispatcher });
|
||||||
} catch (proxyError) {
|
} 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}`);
|
console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${proxyError.message}`);
|
||||||
return originalFetch(url, options);
|
return originalFetch(url, options);
|
||||||
}
|
}
|
||||||
@@ -205,6 +226,13 @@ async function patchedFetch(url, options = {}) {
|
|||||||
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
|
// Idempotency guard — only patch once to avoid wrapping multiple times
|
||||||
if (!isCloud && globalThis.fetch !== patchedFetch) {
|
if (!isCloud && globalThis.fetch !== patchedFetch) {
|
||||||
globalThis.fetch = patchedFetch;
|
globalThis.fetch = patchedFetch;
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ export default function ProviderDetailPage() {
|
|||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [providerNode, setProviderNode] = useState(null);
|
const [providerNode, setProviderNode] = useState(null);
|
||||||
|
const [proxyPools, setProxyPools] = useState([]);
|
||||||
const [showOAuthModal, setShowOAuthModal] = useState(false);
|
const [showOAuthModal, setShowOAuthModal] = useState(false);
|
||||||
const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false);
|
const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false);
|
||||||
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
|
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
|
||||||
|
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
|
||||||
const [selectedConnection, setSelectedConnection] = useState(null);
|
const [selectedConnection, setSelectedConnection] = useState(null);
|
||||||
const [modelAliases, setModelAliases] = useState({});
|
const [modelAliases, setModelAliases] = useState({});
|
||||||
const [headerImgError, setHeaderImgError] = useState(false);
|
const [headerImgError, setHeaderImgError] = useState(false);
|
||||||
@@ -29,6 +31,9 @@ export default function ProviderDetailPage() {
|
|||||||
const [modelsTestError, setModelsTestError] = useState("");
|
const [modelsTestError, setModelsTestError] = useState("");
|
||||||
const [testingModelId, setTestingModelId] = useState(null);
|
const [testingModelId, setTestingModelId] = useState(null);
|
||||||
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
|
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
|
||||||
|
const [selectedConnectionIds, setSelectedConnectionIds] = useState([]);
|
||||||
|
const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__");
|
||||||
|
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
||||||
const { copied, copy } = useCopyToClipboard();
|
const { copied, copy } = useCopyToClipboard();
|
||||||
|
|
||||||
const providerInfo = providerNode
|
const providerInfo = providerNode
|
||||||
@@ -70,16 +75,21 @@ export default function ProviderDetailPage() {
|
|||||||
|
|
||||||
const fetchConnections = useCallback(async () => {
|
const fetchConnections = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [connectionsRes, nodesRes] = await Promise.all([
|
const [connectionsRes, nodesRes, proxyPoolsRes] = await Promise.all([
|
||||||
fetch("/api/providers", { cache: "no-store" }),
|
fetch("/api/providers", { cache: "no-store" }),
|
||||||
fetch("/api/provider-nodes", { 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 connectionsData = await connectionsRes.json();
|
||||||
const nodesData = await nodesRes.json();
|
const nodesData = await nodesRes.json();
|
||||||
|
const proxyPoolsData = await proxyPoolsRes.json();
|
||||||
if (connectionsRes.ok) {
|
if (connectionsRes.ok) {
|
||||||
const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId);
|
const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId);
|
||||||
setConnections(filtered);
|
setConnections(filtered);
|
||||||
}
|
}
|
||||||
|
if (proxyPoolsRes.ok) {
|
||||||
|
setProxyPools(proxyPoolsData.proxyPools || []);
|
||||||
|
}
|
||||||
if (nodesRes.ok) {
|
if (nodesRes.ok) {
|
||||||
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
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) => {
|
const handleTestModel = async (modelId) => {
|
||||||
if (testingModelId) return;
|
if (testingModelId) return;
|
||||||
setTestingModelId(modelId);
|
setTestingModelId(modelId);
|
||||||
@@ -560,27 +765,10 @@ export default function ProviderDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
<>
|
||||||
{connections
|
{selectionToolbar}
|
||||||
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
|
{connectionsList}
|
||||||
.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>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -597,6 +785,8 @@ export default function ProviderDetailPage() {
|
|||||||
{renderModelsSection()}
|
{renderModelsSection()}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{bulkActionModal}
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
{providerId === "kiro" ? (
|
{providerId === "kiro" ? (
|
||||||
<KiroOAuthWrapper
|
<KiroOAuthWrapper
|
||||||
@@ -633,12 +823,14 @@ export default function ProviderDetailPage() {
|
|||||||
providerName={providerInfo.name}
|
providerName={providerInfo.name}
|
||||||
isCompatible={isCompatible}
|
isCompatible={isCompatible}
|
||||||
isAnthropic={isAnthropicCompatible}
|
isAnthropic={isAnthropicCompatible}
|
||||||
|
proxyPools={proxyPools}
|
||||||
onSave={handleSaveApiKey}
|
onSave={handleSaveApiKey}
|
||||||
onClose={() => setShowAddApiKeyModal(false)}
|
onClose={() => setShowAddApiKeyModal(false)}
|
||||||
/>
|
/>
|
||||||
<EditConnectionModal
|
<EditConnectionModal
|
||||||
isOpen={showEditModal}
|
isOpen={showEditModal}
|
||||||
connection={selectedConnection}
|
connection={selectedConnection}
|
||||||
|
proxyPools={proxyPools}
|
||||||
onSave={handleUpdateConnection}
|
onSave={handleUpdateConnection}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
/>
|
/>
|
||||||
@@ -1126,7 +1318,40 @@ CooldownTimer.propTypes = {
|
|||||||
until: PropTypes.string.isRequired,
|
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
|
const displayName = isOAuth
|
||||||
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
||||||
: connection.name;
|
: connection.name;
|
||||||
@@ -1199,6 +1424,11 @@ function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveD
|
|||||||
<Badge variant={getStatusVariant()} size="sm" dot>
|
<Badge variant={getStatusVariant()} size="sm" dot>
|
||||||
{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")}
|
{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{hasAnyProxy && (
|
||||||
|
<Badge variant={proxyBadgeVariant} size="sm">
|
||||||
|
Proxy
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{isCooldown && connection.isActive !== false && <CooldownTimer until={modelLockUntil} />}
|
{isCooldown && connection.isActive !== false && <CooldownTimer until={modelLockUntil} />}
|
||||||
{connection.lastError && connection.isActive !== false && (
|
{connection.lastError && connection.isActive !== false && (
|
||||||
<span className="text-xs text-red-500 truncate max-w-[300px]" title={connection.lastError}>
|
<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>
|
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1245,6 +1492,13 @@ ConnectionRow.propTypes = {
|
|||||||
priority: PropTypes.number,
|
priority: PropTypes.number,
|
||||||
globalPriority: PropTypes.number,
|
globalPriority: PropTypes.number,
|
||||||
}).isRequired,
|
}).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,
|
isOAuth: PropTypes.bool.isRequired,
|
||||||
isFirst: PropTypes.bool.isRequired,
|
isFirst: PropTypes.bool.isRequired,
|
||||||
isLast: PropTypes.bool.isRequired,
|
isLast: PropTypes.bool.isRequired,
|
||||||
@@ -1255,11 +1509,14 @@ ConnectionRow.propTypes = {
|
|||||||
onDelete: PropTypes.func.isRequired,
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
|
proxyPoolId: NONE_PROXY_POOL_VALUE,
|
||||||
});
|
});
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
const [validationResult, setValidationResult] = useState(null);
|
const [validationResult, setValidationResult] = useState(null);
|
||||||
@@ -1309,6 +1566,7 @@ function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthro
|
|||||||
name: formData.name,
|
name: formData.name,
|
||||||
apiKey: formData.apiKey,
|
apiKey: formData.apiKey,
|
||||||
priority: formData.priority,
|
priority: formData.priority,
|
||||||
|
proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId,
|
||||||
testStatus: isValid ? "active" : "unknown",
|
testStatus: isValid ? "active" : "unknown",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1360,6 +1618,28 @@ function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthro
|
|||||||
value={formData.priority}
|
value={formData.priority}
|
||||||
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
|
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">
|
<div className="flex gap-2">
|
||||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey || saving}>
|
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey || saving}>
|
||||||
{saving ? "Saving..." : "Save"}
|
{saving ? "Saving..." : "Save"}
|
||||||
@@ -1379,15 +1659,22 @@ AddApiKeyModal.propTypes = {
|
|||||||
providerName: PropTypes.string,
|
providerName: PropTypes.string,
|
||||||
isCompatible: PropTypes.bool,
|
isCompatible: PropTypes.bool,
|
||||||
isAnthropic: PropTypes.bool,
|
isAnthropic: PropTypes.bool,
|
||||||
|
proxyPools: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
id: PropTypes.string,
|
||||||
|
name: PropTypes.string,
|
||||||
|
})),
|
||||||
onSave: PropTypes.func.isRequired,
|
onSave: PropTypes.func.isRequired,
|
||||||
onClose: 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({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
|
proxyPoolId: NONE_PROXY_POOL_VALUE,
|
||||||
});
|
});
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState(null);
|
const [testResult, setTestResult] = useState(null);
|
||||||
@@ -1401,6 +1688,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
|||||||
name: connection.name || "",
|
name: connection.name || "",
|
||||||
priority: connection.priority || 1,
|
priority: connection.priority || 1,
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
|
proxyPoolId: connection.providerSpecificData?.proxyPoolId || NONE_PROXY_POOL_VALUE,
|
||||||
});
|
});
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
setValidationResult(null);
|
setValidationResult(null);
|
||||||
@@ -1444,7 +1732,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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) {
|
if (!isOAuth && formData.apiKey) {
|
||||||
updates.apiKey = formData.apiKey;
|
updates.apiKey = formData.apiKey;
|
||||||
let isValid = validationResult === "success";
|
let isValid = validationResult === "success";
|
||||||
@@ -1504,6 +1796,59 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
|||||||
value={formData.priority}
|
value={formData.priority}
|
||||||
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
|
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 && (
|
{!isOAuth && (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -1562,7 +1907,12 @@ EditConnectionModal.propTypes = {
|
|||||||
priority: PropTypes.number,
|
priority: PropTypes.number,
|
||||||
authType: PropTypes.string,
|
authType: PropTypes.string,
|
||||||
provider: PropTypes.string,
|
provider: PropTypes.string,
|
||||||
|
providerSpecificData: PropTypes.object,
|
||||||
}),
|
}),
|
||||||
|
proxyPools: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
id: PropTypes.string,
|
||||||
|
name: PropTypes.string,
|
||||||
|
})),
|
||||||
onSave: PropTypes.func.isRequired,
|
onSave: PropTypes.func.isRequired,
|
||||||
onClose: 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 { 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
|
// GET /api/providers/[id] - Get single connection
|
||||||
export async function GET(request, { params }) {
|
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 });
|
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 = {};
|
const updateData = {};
|
||||||
if (name !== undefined) updateData.name = name;
|
if (name !== undefined) updateData.name = name;
|
||||||
if (priority !== undefined) updateData.priority = priority;
|
if (priority !== undefined) updateData.priority = priority;
|
||||||
@@ -58,11 +126,33 @@ export async function PUT(request, { params }) {
|
|||||||
if (testStatus !== undefined) updateData.testStatus = testStatus;
|
if (testStatus !== undefined) updateData.testStatus = testStatus;
|
||||||
if (lastError !== undefined) updateData.lastError = lastError;
|
if (lastError !== undefined) updateData.lastError = lastError;
|
||||||
if (lastErrorAt !== undefined) updateData.lastErrorAt = lastErrorAt;
|
if (lastErrorAt !== undefined) updateData.lastErrorAt = lastErrorAt;
|
||||||
if (providerSpecificData !== undefined) {
|
|
||||||
|
if (
|
||||||
|
shouldMergeProviderSpecificData(
|
||||||
|
existing.providerSpecificData,
|
||||||
|
providerSpecificData,
|
||||||
|
proxyConfig.hasAnyProxyField,
|
||||||
|
proxyPoolResult.hasProxyPoolField
|
||||||
|
)
|
||||||
|
) {
|
||||||
updateData.providerSpecificData = {
|
updateData.providerSpecificData = {
|
||||||
...(existing.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);
|
const updated = await updateProviderConnection(id, updateData);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
|
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 { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||||
import { getDefaultModel } from "open-sse/config/providerModels.js";
|
import { getDefaultModel } from "open-sse/config/providerModels.js";
|
||||||
import {
|
import {
|
||||||
@@ -206,7 +208,7 @@ function isTokenExpired(connection) {
|
|||||||
return expiresAt <= Date.now() + buffer;
|
return expiresAt <= Date.now() + buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testOAuthConnection(connection) {
|
async function testOAuthConnection(connection, effectiveProxy = null) {
|
||||||
const config = OAUTH_TEST_CONFIG[connection.provider];
|
const config = OAUTH_TEST_CONFIG[connection.provider];
|
||||||
if (!config) return { valid: false, error: "Provider test not supported", refreshed: false };
|
if (!config) return { valid: false, error: "Provider test not supported", refreshed: false };
|
||||||
if (!connection.accessToken) return { valid: false, error: "No access token", 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
|
const headers = config.noAuth
|
||||||
? { ...config.extraHeaders }
|
? { ...config.extraHeaders }
|
||||||
: { [config.authHeader]: `${config.authPrefix}${accessToken}`, ...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 };
|
if (res.ok) return { valid: true, error: null, refreshed, newTokens };
|
||||||
|
|
||||||
@@ -279,10 +281,10 @@ async function testOAuthConnection(connection) {
|
|||||||
const retryHeaders = config.noAuth
|
const retryHeaders = config.noAuth
|
||||||
? { ...config.extraHeaders }
|
? { ...config.extraHeaders }
|
||||||
: { [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, ...config.extraHeaders };
|
: { [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, ...config.extraHeaders };
|
||||||
const retryRes = await fetch(retryUrl, {
|
const retryRes = await fetchWithConnectionProxy(retryUrl, {
|
||||||
method: config.method,
|
method: config.method,
|
||||||
headers: retryHeaders,
|
headers: retryHeaders,
|
||||||
});
|
}, effectiveProxy);
|
||||||
if (retryRes.ok) return { valid: true, error: null, refreshed: true, newTokens: tokens };
|
if (retryRes.ok) return { valid: true, error: null, refreshed: true, newTokens: tokens };
|
||||||
}
|
}
|
||||||
return { valid: false, error: "Token invalid or revoked", refreshed: false };
|
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)) {
|
if (isOpenAICompatibleProvider(connection.provider)) {
|
||||||
const modelsBase = connection.providerSpecificData?.baseUrl;
|
const modelsBase = connection.providerSpecificData?.baseUrl;
|
||||||
if (!modelsBase) return { valid: false, error: "Missing base URL" };
|
if (!modelsBase) return { valid: false, error: "Missing base URL" };
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${modelsBase.replace(/\/$/, "")}/models`, {
|
const res = await fetchWithConnectionProxy(`${modelsBase.replace(/\/$/, "")}/models`, {
|
||||||
headers: { "Authorization": `Bearer ${connection.apiKey}` },
|
headers: { "Authorization": `Bearer ${connection.apiKey}` },
|
||||||
});
|
}, effectiveProxy);
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { valid: false, error: err.message };
|
return { valid: false, error: err.message };
|
||||||
@@ -316,9 +331,9 @@ async function testApiKeyConnection(connection) {
|
|||||||
try {
|
try {
|
||||||
modelsBase = modelsBase.replace(/\/$/, "");
|
modelsBase = modelsBase.replace(/\/$/, "");
|
||||||
if (modelsBase.endsWith("/messages")) modelsBase = modelsBase.slice(0, -9);
|
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}` },
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { valid: false, error: err.message };
|
return { valid: false, error: err.message };
|
||||||
@@ -328,61 +343,61 @@ async function testApiKeyConnection(connection) {
|
|||||||
try {
|
try {
|
||||||
switch (connection.provider) {
|
switch (connection.provider) {
|
||||||
case "openai": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "anthropic": {
|
case "anthropic": {
|
||||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
const res = await fetchWithConnectionProxy("https://api.anthropic.com/v1/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
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" }] }),
|
body: JSON.stringify({ model: "claude-3-haiku-20240307", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||||
});
|
}, effectiveProxy);
|
||||||
const valid = res.status !== 401;
|
const valid = res.status !== 401;
|
||||||
return { valid, error: valid ? null : "Invalid API key" };
|
return { valid, error: valid ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "gemini": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "openrouter": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "glm": {
|
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",
|
method: "POST",
|
||||||
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
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" }] }),
|
body: JSON.stringify({ model: "glm-4.7", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||||
});
|
}, effectiveProxy);
|
||||||
const valid = res.status !== 401 && res.status !== 403;
|
const valid = res.status !== 401 && res.status !== 403;
|
||||||
return { valid, error: valid ? null : "Invalid API key" };
|
return { valid, error: valid ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "glm-cn": {
|
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",
|
method: "POST",
|
||||||
headers: { "Authorization": `Bearer ${connection.apiKey}`, "content-type": "application/json" },
|
headers: { "Authorization": `Bearer ${connection.apiKey}`, "content-type": "application/json" },
|
||||||
body: JSON.stringify({ model: "glm-4.7", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
body: JSON.stringify({ model: "glm-4.7", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||||
});
|
}, effectiveProxy);
|
||||||
const valid = res.status !== 401 && res.status !== 403;
|
const valid = res.status !== 401 && res.status !== 403;
|
||||||
return { valid, error: valid ? null : "Invalid API key" };
|
return { valid, error: valid ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "minimax":
|
case "minimax":
|
||||||
case "minimax-cn": {
|
case "minimax-cn": {
|
||||||
const endpoints = { minimax: "https://api.minimax.io/anthropic/v1/messages", "minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages" };
|
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",
|
method: "POST",
|
||||||
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
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" }] }),
|
body: JSON.stringify({ model: "minimax-m2", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||||
});
|
}, effectiveProxy);
|
||||||
const valid = res.status !== 401 && res.status !== 403;
|
const valid = res.status !== 401 && res.status !== 403;
|
||||||
return { valid, error: valid ? null : "Invalid API key" };
|
return { valid, error: valid ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "kimi": {
|
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",
|
method: "POST",
|
||||||
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
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" }] }),
|
body: JSON.stringify({ model: "kimi-latest", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||||
});
|
}, effectiveProxy);
|
||||||
const valid = res.status !== 401 && res.status !== 403;
|
const valid = res.status !== 401 && res.status !== 403;
|
||||||
return { valid, error: valid ? null : "Invalid API key" };
|
return { valid, error: valid ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
@@ -392,80 +407,80 @@ async function testApiKeyConnection(connection) {
|
|||||||
const aliBaseUrl = connection.provider === "alicode-intl"
|
const aliBaseUrl = connection.provider === "alicode-intl"
|
||||||
? "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions"
|
? "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions"
|
||||||
: "https://coding.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",
|
method: "POST",
|
||||||
headers: { "Authorization": `Bearer ${connection.apiKey}`, "content-type": "application/json" },
|
headers: { "Authorization": `Bearer ${connection.apiKey}`, "content-type": "application/json" },
|
||||||
body: JSON.stringify({ model: getDefaultModel(connection.provider), max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
body: JSON.stringify({ model: getDefaultModel(connection.provider), max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||||
});
|
}, effectiveProxy);
|
||||||
const valid = res.status !== 401 && res.status !== 403;
|
const valid = res.status !== 401 && res.status !== 403;
|
||||||
return { valid, error: valid ? null : "Invalid API key" };
|
return { valid, error: valid ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "deepseek": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "groq": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "mistral": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "xai": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "nvidia": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "perplexity": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "together": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "fireworks": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "cerebras": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "cohere": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "nebius": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "siliconflow": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "hyperbolic": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "deepgram": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "assemblyai": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "nanobanana": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
case "chutes": {
|
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" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -483,13 +498,28 @@ export async function testSingleConnection(id) {
|
|||||||
const connection = await getProviderConnectionById(id);
|
const connection = await getProviderConnectionById(id);
|
||||||
if (!connection) return { valid: false, error: "Connection not found", latencyMs: 0, testedAt: new Date().toISOString() };
|
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();
|
const start = Date.now();
|
||||||
let result;
|
let result;
|
||||||
|
|
||||||
if (connection.authType === "apikey") {
|
if (connection.authType === "apikey") {
|
||||||
result = await testApiKeyConnection(connection);
|
result = await testApiKeyConnection(connection, effectiveProxy);
|
||||||
} else {
|
} else {
|
||||||
result = await testOAuthConnection(connection);
|
result = await testOAuthConnection(connection, effectiveProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
const latencyMs = Date.now() - start;
|
const latencyMs = Date.now() - start;
|
||||||
|
|||||||
@@ -1,10 +1,50 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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
|
// GET /api/providers - List all connections
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -47,6 +87,16 @@ export async function POST(request) {
|
|||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body;
|
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
|
// Validation
|
||||||
const isValidProvider = APIKEY_PROVIDERS[provider] ||
|
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({
|
const newConnection = await createProviderConnection({
|
||||||
provider,
|
provider,
|
||||||
authType: "apikey",
|
authType: "apikey",
|
||||||
@@ -108,7 +169,7 @@ export async function POST(request) {
|
|||||||
priority: priority || 1,
|
priority: priority || 1,
|
||||||
globalPriority: globalPriority || null,
|
globalPriority: globalPriority || null,
|
||||||
defaultModel: defaultModel || null,
|
defaultModel: defaultModel || null,
|
||||||
providerSpecificData,
|
providerSpecificData: mergedProviderSpecificData,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
testStatus: testStatus || "unknown",
|
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 = {
|
const defaultData = {
|
||||||
providerConnections: [],
|
providerConnections: [],
|
||||||
providerNodes: [],
|
providerNodes: [],
|
||||||
|
proxyPools: [],
|
||||||
modelAliases: {},
|
modelAliases: {},
|
||||||
mitmAlias: {},
|
mitmAlias: {},
|
||||||
combos: [],
|
combos: [],
|
||||||
@@ -69,6 +70,7 @@ function cloneDefaultData() {
|
|||||||
return {
|
return {
|
||||||
providerConnections: [],
|
providerConnections: [],
|
||||||
providerNodes: [],
|
providerNodes: [],
|
||||||
|
proxyPools: [],
|
||||||
modelAliases: {},
|
modelAliases: {},
|
||||||
mitmAlias: {},
|
mitmAlias: {},
|
||||||
combos: [],
|
combos: [],
|
||||||
@@ -319,6 +321,104 @@ export async function deleteProviderNode(id) {
|
|||||||
return removed;
|
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
|
* 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,
|
createProviderNode,
|
||||||
updateProviderNode,
|
updateProviderNode,
|
||||||
deleteProviderNode,
|
deleteProviderNode,
|
||||||
|
getProxyPools,
|
||||||
|
getProxyPoolById,
|
||||||
|
createProxyPool,
|
||||||
|
updateProxyPool,
|
||||||
|
deleteProxyPool,
|
||||||
deleteProviderConnectionsByProvider,
|
deleteProviderConnectionsByProvider,
|
||||||
|
getCombos,
|
||||||
|
getComboById,
|
||||||
|
getComboByName,
|
||||||
|
createCombo,
|
||||||
|
updateCombo,
|
||||||
|
deleteCombo,
|
||||||
getModelAliases,
|
getModelAliases,
|
||||||
setModelAlias,
|
setModelAlias,
|
||||||
deleteModelAlias,
|
deleteModelAlias,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ConfirmModal } from "./Modal";
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard/endpoint", label: "Endpoint", icon: "api" },
|
{ href: "/dashboard/endpoint", label: "Endpoint", icon: "api" },
|
||||||
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
|
{ 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/combos", label: "Combos", icon: "layers" },
|
||||||
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
|
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
|
||||||
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
|
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
|
||||||
|
|||||||
@@ -2,15 +2,72 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useNotificationStore } from "@/store/notificationStore";
|
||||||
import Sidebar from "../Sidebar";
|
import Sidebar from "../Sidebar";
|
||||||
import Header from "../Header";
|
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 }) {
|
export default function DashboardLayout({ children }) {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const notifications = useNotificationStore((state) => state.notifications);
|
||||||
|
const removeNotification = useNotificationStore((state) => state.removeNotification);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full overflow-hidden bg-bg">
|
<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 */}
|
{/* Mobile sidebar overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings } from "@/lib/localDb";
|
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 { resolveProviderId } from "@/shared/constants/providers.js";
|
||||||
import * as log from "../utils/logger.js";
|
import * as log from "../utils/logger.js";
|
||||||
|
|
||||||
@@ -118,13 +119,22 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
|
|||||||
connection = availableConnections[0];
|
connection = availableConnections[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiKey: connection.apiKey,
|
apiKey: connection.apiKey,
|
||||||
accessToken: connection.accessToken,
|
accessToken: connection.accessToken,
|
||||||
refreshToken: connection.refreshToken,
|
refreshToken: connection.refreshToken,
|
||||||
projectId: connection.projectId,
|
projectId: connection.projectId,
|
||||||
|
connectionName: connection.displayName || connection.name || connection.email || connection.id,
|
||||||
copilotToken: connection.providerSpecificData?.copilotToken,
|
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,
|
connectionId: connection.id,
|
||||||
// Include current status for optimization check
|
// Include current status for optimization check
|
||||||
testStatus: connection.testStatus,
|
testStatus: connection.testStatus,
|
||||||
|
|||||||
Reference in New Issue
Block a user