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:
@@ -1,5 +1,63 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById, updateProviderConnection, deleteProviderConnection } from "@/models";
|
||||
import {
|
||||
getProviderConnectionById,
|
||||
getProxyPoolById,
|
||||
updateProviderConnection,
|
||||
deleteProviderConnection,
|
||||
} from "@/models";
|
||||
|
||||
function normalizeProxyConfig(body = {}) {
|
||||
const hasAnyProxyField =
|
||||
Object.prototype.hasOwnProperty.call(body, "connectionProxyEnabled") ||
|
||||
Object.prototype.hasOwnProperty.call(body, "connectionProxyUrl") ||
|
||||
Object.prototype.hasOwnProperty.call(body, "connectionNoProxy");
|
||||
|
||||
if (!hasAnyProxyField) return { hasAnyProxyField: false };
|
||||
|
||||
const enabled = body?.connectionProxyEnabled === true;
|
||||
const url = typeof body?.connectionProxyUrl === "string" ? body.connectionProxyUrl.trim() : "";
|
||||
const noProxy = typeof body?.connectionNoProxy === "string" ? body.connectionNoProxy.trim() : "";
|
||||
|
||||
if (enabled && !url) {
|
||||
return {
|
||||
hasAnyProxyField: true,
|
||||
error: "Connection proxy URL is required when connection proxy is enabled",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasAnyProxyField: true,
|
||||
connectionProxyEnabled: enabled,
|
||||
connectionProxyUrl: url,
|
||||
connectionNoProxy: noProxy,
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeProxyPoolUpdate(proxyPoolIdInput) {
|
||||
if (proxyPoolIdInput === undefined) {
|
||||
return { hasProxyPoolField: false, proxyPoolId: null };
|
||||
}
|
||||
|
||||
if (proxyPoolIdInput === null || proxyPoolIdInput === "" || proxyPoolIdInput === "__none__") {
|
||||
return { hasProxyPoolField: true, proxyPoolId: null };
|
||||
}
|
||||
|
||||
const proxyPoolId = String(proxyPoolIdInput).trim();
|
||||
if (!proxyPoolId) {
|
||||
return { hasProxyPoolField: true, proxyPoolId: null };
|
||||
}
|
||||
|
||||
const proxyPool = await getProxyPoolById(proxyPoolId);
|
||||
if (!proxyPool) {
|
||||
return { hasProxyPoolField: true, error: "Proxy pool not found" };
|
||||
}
|
||||
|
||||
return { hasProxyPoolField: true, proxyPoolId };
|
||||
}
|
||||
|
||||
function shouldMergeProviderSpecificData(existing, incoming, hasLegacyProxy, hasProxyPoolField) {
|
||||
return existing !== undefined || incoming !== undefined || hasLegacyProxy || hasProxyPoolField;
|
||||
}
|
||||
|
||||
// GET /api/providers/[id] - Get single connection
|
||||
export async function GET(request, { params }) {
|
||||
@@ -48,6 +106,16 @@ export async function PUT(request, { params }) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const proxyConfig = normalizeProxyConfig(body);
|
||||
if (proxyConfig.error) {
|
||||
return NextResponse.json({ error: proxyConfig.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const proxyPoolResult = await normalizeProxyPoolUpdate(body.proxyPoolId);
|
||||
if (proxyPoolResult.error) {
|
||||
return NextResponse.json({ error: proxyPoolResult.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (priority !== undefined) updateData.priority = priority;
|
||||
@@ -58,11 +126,33 @@ export async function PUT(request, { params }) {
|
||||
if (testStatus !== undefined) updateData.testStatus = testStatus;
|
||||
if (lastError !== undefined) updateData.lastError = lastError;
|
||||
if (lastErrorAt !== undefined) updateData.lastErrorAt = lastErrorAt;
|
||||
if (providerSpecificData !== undefined) {
|
||||
|
||||
if (
|
||||
shouldMergeProviderSpecificData(
|
||||
existing.providerSpecificData,
|
||||
providerSpecificData,
|
||||
proxyConfig.hasAnyProxyField,
|
||||
proxyPoolResult.hasProxyPoolField
|
||||
)
|
||||
) {
|
||||
updateData.providerSpecificData = {
|
||||
...(existing.providerSpecificData || {}),
|
||||
...providerSpecificData,
|
||||
...(providerSpecificData || {}),
|
||||
};
|
||||
|
||||
if (proxyConfig.hasAnyProxyField) {
|
||||
updateData.providerSpecificData.connectionProxyEnabled = proxyConfig.connectionProxyEnabled;
|
||||
updateData.providerSpecificData.connectionProxyUrl = proxyConfig.connectionProxyUrl;
|
||||
updateData.providerSpecificData.connectionNoProxy = proxyConfig.connectionNoProxy;
|
||||
}
|
||||
|
||||
if (proxyPoolResult.hasProxyPoolField) {
|
||||
if (proxyPoolResult.proxyPoolId === null) {
|
||||
delete updateData.providerSpecificData.proxyPoolId;
|
||||
} else {
|
||||
updateData.providerSpecificData.proxyPoolId = proxyPoolResult.proxyPoolId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateProviderConnection(id, updateData);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { testProxyUrl } from "@/lib/network/proxyTest";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { getDefaultModel } from "open-sse/config/providerModels.js";
|
||||
import {
|
||||
@@ -206,7 +208,7 @@ function isTokenExpired(connection) {
|
||||
return expiresAt <= Date.now() + buffer;
|
||||
}
|
||||
|
||||
async function testOAuthConnection(connection) {
|
||||
async function testOAuthConnection(connection, effectiveProxy = null) {
|
||||
const config = OAUTH_TEST_CONFIG[connection.provider];
|
||||
if (!config) return { valid: false, error: "Provider test not supported", refreshed: false };
|
||||
if (!connection.accessToken) return { valid: false, error: "No access token", refreshed: false };
|
||||
@@ -268,7 +270,7 @@ async function testOAuthConnection(connection) {
|
||||
const headers = config.noAuth
|
||||
? { ...config.extraHeaders }
|
||||
: { [config.authHeader]: `${config.authPrefix}${accessToken}`, ...config.extraHeaders };
|
||||
const res = await fetch(testUrl, { method: config.method, headers });
|
||||
const res = await fetchWithConnectionProxy(testUrl, { method: config.method, headers }, effectiveProxy);
|
||||
|
||||
if (res.ok) return { valid: true, error: null, refreshed, newTokens };
|
||||
|
||||
@@ -279,10 +281,10 @@ async function testOAuthConnection(connection) {
|
||||
const retryHeaders = config.noAuth
|
||||
? { ...config.extraHeaders }
|
||||
: { [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, ...config.extraHeaders };
|
||||
const retryRes = await fetch(retryUrl, {
|
||||
const retryRes = await fetchWithConnectionProxy(retryUrl, {
|
||||
method: config.method,
|
||||
headers: retryHeaders,
|
||||
});
|
||||
}, effectiveProxy);
|
||||
if (retryRes.ok) return { valid: true, error: null, refreshed: true, newTokens: tokens };
|
||||
}
|
||||
return { valid: false, error: "Token invalid or revoked", refreshed: false };
|
||||
@@ -296,14 +298,27 @@ async function testOAuthConnection(connection) {
|
||||
}
|
||||
}
|
||||
|
||||
async function testApiKeyConnection(connection) {
|
||||
async function fetchWithConnectionProxy(url, options = {}, effectiveProxy = null) {
|
||||
if (!effectiveProxy?.connectionProxyEnabled || !effectiveProxy?.connectionProxyUrl) {
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
const { proxyAwareFetch } = await import("open-sse/utils/proxyFetch.js");
|
||||
return proxyAwareFetch(url, options, {
|
||||
connectionProxyEnabled: true,
|
||||
connectionProxyUrl: effectiveProxy.connectionProxyUrl,
|
||||
connectionNoProxy: effectiveProxy.connectionNoProxy || "",
|
||||
});
|
||||
}
|
||||
|
||||
async function testApiKeyConnection(connection, effectiveProxy = null) {
|
||||
if (isOpenAICompatibleProvider(connection.provider)) {
|
||||
const modelsBase = connection.providerSpecificData?.baseUrl;
|
||||
if (!modelsBase) return { valid: false, error: "Missing base URL" };
|
||||
try {
|
||||
const res = await fetch(`${modelsBase.replace(/\/$/, "")}/models`, {
|
||||
const res = await fetchWithConnectionProxy(`${modelsBase.replace(/\/$/, "")}/models`, {
|
||||
headers: { "Authorization": `Bearer ${connection.apiKey}` },
|
||||
});
|
||||
}, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
|
||||
} catch (err) {
|
||||
return { valid: false, error: err.message };
|
||||
@@ -316,9 +331,9 @@ async function testApiKeyConnection(connection) {
|
||||
try {
|
||||
modelsBase = modelsBase.replace(/\/$/, "");
|
||||
if (modelsBase.endsWith("/messages")) modelsBase = modelsBase.slice(0, -9);
|
||||
const res = await fetch(`${modelsBase}/models`, {
|
||||
const res = await fetchWithConnectionProxy(`${modelsBase}/models`, {
|
||||
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "Authorization": `Bearer ${connection.apiKey}` },
|
||||
});
|
||||
}, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
|
||||
} catch (err) {
|
||||
return { valid: false, error: err.message };
|
||||
@@ -328,61 +343,61 @@ async function testApiKeyConnection(connection) {
|
||||
try {
|
||||
switch (connection.provider) {
|
||||
case "openai": {
|
||||
const res = await fetch("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "anthropic": {
|
||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
const res = await fetchWithConnectionProxy("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "claude-3-haiku-20240307", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||
});
|
||||
}, effectiveProxy);
|
||||
const valid = res.status !== 401;
|
||||
return { valid, error: valid ? null : "Invalid API key" };
|
||||
}
|
||||
case "gemini": {
|
||||
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`);
|
||||
const res = await fetchWithConnectionProxy(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`, {}, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "openrouter": {
|
||||
const res = await fetch("https://openrouter.ai/api/v1/auth/key", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://openrouter.ai/api/v1/auth/key", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "glm": {
|
||||
const res = await fetch("https://api.z.ai/api/anthropic/v1/messages", {
|
||||
const res = await fetchWithConnectionProxy("https://api.z.ai/api/anthropic/v1/messages", {
|
||||
method: "POST",
|
||||
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "glm-4.7", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||
});
|
||||
}, effectiveProxy);
|
||||
const valid = res.status !== 401 && res.status !== 403;
|
||||
return { valid, error: valid ? null : "Invalid API key" };
|
||||
}
|
||||
case "glm-cn": {
|
||||
const res = await fetch("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", {
|
||||
const res = await fetchWithConnectionProxy("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${connection.apiKey}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "glm-4.7", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||
});
|
||||
}, effectiveProxy);
|
||||
const valid = res.status !== 401 && res.status !== 403;
|
||||
return { valid, error: valid ? null : "Invalid API key" };
|
||||
}
|
||||
case "minimax":
|
||||
case "minimax-cn": {
|
||||
const endpoints = { minimax: "https://api.minimax.io/anthropic/v1/messages", "minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages" };
|
||||
const res = await fetch(endpoints[connection.provider], {
|
||||
const res = await fetchWithConnectionProxy(endpoints[connection.provider], {
|
||||
method: "POST",
|
||||
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "minimax-m2", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||
});
|
||||
}, effectiveProxy);
|
||||
const valid = res.status !== 401 && res.status !== 403;
|
||||
return { valid, error: valid ? null : "Invalid API key" };
|
||||
}
|
||||
case "kimi": {
|
||||
const res = await fetch("https://api.kimi.com/coding/v1/messages", {
|
||||
const res = await fetchWithConnectionProxy("https://api.kimi.com/coding/v1/messages", {
|
||||
method: "POST",
|
||||
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "kimi-latest", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||
});
|
||||
}, effectiveProxy);
|
||||
const valid = res.status !== 401 && res.status !== 403;
|
||||
return { valid, error: valid ? null : "Invalid API key" };
|
||||
}
|
||||
@@ -392,80 +407,80 @@ async function testApiKeyConnection(connection) {
|
||||
const aliBaseUrl = connection.provider === "alicode-intl"
|
||||
? "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions"
|
||||
: "https://coding.dashscope.aliyuncs.com/v1/chat/completions";
|
||||
const res = await fetch(aliBaseUrl, {
|
||||
const res = await fetchWithConnectionProxy(aliBaseUrl, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${connection.apiKey}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: getDefaultModel(connection.provider), max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||
});
|
||||
}, effectiveProxy);
|
||||
const valid = res.status !== 401 && res.status !== 403;
|
||||
return { valid, error: valid ? null : "Invalid API key" };
|
||||
}
|
||||
case "deepseek": {
|
||||
const res = await fetch("https://api.deepseek.com/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.deepseek.com/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "groq": {
|
||||
const res = await fetch("https://api.groq.com/openai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.groq.com/openai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "mistral": {
|
||||
const res = await fetch("https://api.mistral.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.mistral.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "xai": {
|
||||
const res = await fetch("https://api.x.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.x.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "nvidia": {
|
||||
const res = await fetch("https://integrate.api.nvidia.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://integrate.api.nvidia.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "perplexity": {
|
||||
const res = await fetch("https://api.perplexity.ai/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.perplexity.ai/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "together": {
|
||||
const res = await fetch("https://api.together.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.together.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "fireworks": {
|
||||
const res = await fetch("https://api.fireworks.ai/inference/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.fireworks.ai/inference/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "cerebras": {
|
||||
const res = await fetch("https://api.cerebras.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.cerebras.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "cohere": {
|
||||
const res = await fetch("https://api.cohere.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.cohere.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "nebius": {
|
||||
const res = await fetch("https://api.studio.nebius.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.studio.nebius.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "siliconflow": {
|
||||
const res = await fetch("https://api.siliconflow.cn/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.siliconflow.cn/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "hyperbolic": {
|
||||
const res = await fetch("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "deepgram": {
|
||||
const res = await fetch("https://api.deepgram.com/v1/projects", { headers: { Authorization: `Token ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.deepgram.com/v1/projects", { headers: { Authorization: `Token ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "assemblyai": {
|
||||
const res = await fetch("https://api.assemblyai.com/v1/account", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.assemblyai.com/v1/account", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "nanobanana": {
|
||||
const res = await fetch("https://api.nanobananaapi.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://api.nanobananaapi.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "chutes": {
|
||||
const res = await fetch("https://llm.chutes.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
const res = await fetchWithConnectionProxy("https://llm.chutes.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
default:
|
||||
@@ -483,13 +498,28 @@ export async function testSingleConnection(id) {
|
||||
const connection = await getProviderConnectionById(id);
|
||||
if (!connection) return { valid: false, error: "Connection not found", latencyMs: 0, testedAt: new Date().toISOString() };
|
||||
|
||||
const effectiveProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
|
||||
|
||||
if (effectiveProxy.connectionProxyEnabled && effectiveProxy.connectionProxyUrl) {
|
||||
const proxyResult = await testProxyUrl({ proxyUrl: effectiveProxy.connectionProxyUrl });
|
||||
if (!proxyResult.ok) {
|
||||
const proxyError = proxyResult.error || `Proxy test failed with status ${proxyResult.status}`;
|
||||
await updateProviderConnection(id, {
|
||||
testStatus: "error",
|
||||
lastError: proxyError,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
});
|
||||
return { valid: false, error: proxyError, latencyMs: 0, testedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let result;
|
||||
|
||||
if (connection.authType === "apikey") {
|
||||
result = await testApiKeyConnection(connection);
|
||||
result = await testApiKeyConnection(connection, effectiveProxy);
|
||||
} else {
|
||||
result = await testOAuthConnection(connection);
|
||||
result = await testOAuthConnection(connection, effectiveProxy);
|
||||
}
|
||||
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
@@ -1,10 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections, createProviderConnection, getProviderNodeById, getProviderNodes } from "@/models";
|
||||
import {
|
||||
getProviderConnections,
|
||||
createProviderConnection,
|
||||
getProviderNodeById,
|
||||
getProviderNodes,
|
||||
getProxyPoolById,
|
||||
} from "@/models";
|
||||
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function normalizeProxyConfig(body = {}) {
|
||||
const enabled = body?.connectionProxyEnabled === true;
|
||||
const url = typeof body?.connectionProxyUrl === "string" ? body.connectionProxyUrl.trim() : "";
|
||||
const noProxy = typeof body?.connectionNoProxy === "string" ? body.connectionNoProxy.trim() : "";
|
||||
|
||||
if (enabled && !url) {
|
||||
return { error: "Connection proxy URL is required when connection proxy is enabled" };
|
||||
}
|
||||
|
||||
return {
|
||||
connectionProxyEnabled: enabled,
|
||||
connectionProxyUrl: url,
|
||||
connectionNoProxy: noProxy,
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeProxyPoolId(proxyPoolId) {
|
||||
if (proxyPoolId === undefined || proxyPoolId === null || proxyPoolId === "" || proxyPoolId === "__none__") {
|
||||
return { proxyPoolId: null };
|
||||
}
|
||||
|
||||
const normalizedId = String(proxyPoolId).trim();
|
||||
if (!normalizedId) {
|
||||
return { proxyPoolId: null };
|
||||
}
|
||||
|
||||
const proxyPool = await getProxyPoolById(normalizedId);
|
||||
if (!proxyPool) {
|
||||
return { error: "Proxy pool not found" };
|
||||
}
|
||||
|
||||
return { proxyPoolId: normalizedId };
|
||||
}
|
||||
|
||||
// GET /api/providers - List all connections
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -47,6 +87,16 @@ export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body;
|
||||
const proxyConfig = normalizeProxyConfig(body);
|
||||
if (proxyConfig.error) {
|
||||
return NextResponse.json({ error: proxyConfig.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const proxyPoolResult = await normalizeProxyPoolId(body.proxyPoolId);
|
||||
if (proxyPoolResult.error) {
|
||||
return NextResponse.json({ error: proxyPoolResult.error }, { status: 400 });
|
||||
}
|
||||
const proxyPoolId = proxyPoolResult.proxyPoolId;
|
||||
|
||||
// Validation
|
||||
const isValidProvider = APIKEY_PROVIDERS[provider] ||
|
||||
@@ -100,6 +150,17 @@ export async function POST(request) {
|
||||
};
|
||||
}
|
||||
|
||||
const mergedProviderSpecificData = {
|
||||
...(providerSpecificData || {}),
|
||||
connectionProxyEnabled: proxyConfig.connectionProxyEnabled,
|
||||
connectionProxyUrl: proxyConfig.connectionProxyUrl,
|
||||
connectionNoProxy: proxyConfig.connectionNoProxy,
|
||||
};
|
||||
|
||||
if (proxyPoolId !== null) {
|
||||
mergedProviderSpecificData.proxyPoolId = proxyPoolId;
|
||||
}
|
||||
|
||||
const newConnection = await createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
@@ -108,7 +169,7 @@ export async function POST(request) {
|
||||
priority: priority || 1,
|
||||
globalPriority: globalPriority || null,
|
||||
defaultModel: defaultModel || null,
|
||||
providerSpecificData,
|
||||
providerSpecificData: mergedProviderSpecificData,
|
||||
isActive: true,
|
||||
testStatus: testStatus || "unknown",
|
||||
});
|
||||
|
||||
118
src/app/api/proxy-pools/[id]/route.js
Normal file
118
src/app/api/proxy-pools/[id]/route.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
deleteProxyPool,
|
||||
getProviderConnections,
|
||||
getProxyPoolById,
|
||||
updateProxyPool,
|
||||
} from "@/models";
|
||||
|
||||
function normalizeProxyPoolUpdate(body = {}) {
|
||||
const updates = {};
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "name")) {
|
||||
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
||||
if (!name) {
|
||||
return { error: "Name is required" };
|
||||
}
|
||||
updates.name = name;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "proxyUrl")) {
|
||||
const proxyUrl = typeof body?.proxyUrl === "string" ? body.proxyUrl.trim() : "";
|
||||
if (!proxyUrl) {
|
||||
return { error: "Proxy URL is required" };
|
||||
}
|
||||
updates.proxyUrl = proxyUrl;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "noProxy")) {
|
||||
updates.noProxy = typeof body?.noProxy === "string" ? body.noProxy.trim() : "";
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "isActive")) {
|
||||
updates.isActive = body?.isActive === true;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "strictProxy")) {
|
||||
updates.strictProxy = body?.strictProxy === true;
|
||||
}
|
||||
|
||||
return { updates };
|
||||
}
|
||||
|
||||
function countBoundConnections(connections = [], proxyPoolId) {
|
||||
return connections.filter((connection) => connection?.providerSpecificData?.proxyPoolId === proxyPoolId).length;
|
||||
}
|
||||
|
||||
// GET /api/proxy-pools/[id] - Get proxy pool
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const proxyPool = await getProxyPoolById(id);
|
||||
|
||||
if (!proxyPool) {
|
||||
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ proxyPool });
|
||||
} catch (error) {
|
||||
console.log("Error fetching proxy pool:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch proxy pool" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/proxy-pools/[id] - Update proxy pool
|
||||
export async function PUT(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const existing = await getProxyPoolById(id);
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const normalized = normalizeProxyPoolUpdate(body);
|
||||
|
||||
if (normalized.error) {
|
||||
return NextResponse.json({ error: normalized.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await updateProxyPool(id, normalized.updates);
|
||||
return NextResponse.json({ proxyPool: updated });
|
||||
} catch (error) {
|
||||
console.log("Error updating proxy pool:", error);
|
||||
return NextResponse.json({ error: "Failed to update proxy pool" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/proxy-pools/[id] - Delete proxy pool
|
||||
export async function DELETE(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const existing = await getProxyPoolById(id);
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const connections = await getProviderConnections();
|
||||
const boundConnectionCount = countBoundConnections(connections, id);
|
||||
|
||||
if (boundConnectionCount > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Proxy pool is currently in use",
|
||||
boundConnectionCount,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await deleteProxyPool(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.log("Error deleting proxy pool:", error);
|
||||
return NextResponse.json({ error: "Failed to delete proxy pool" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
37
src/app/api/proxy-pools/[id]/test/route.js
Normal file
37
src/app/api/proxy-pools/[id]/test/route.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProxyPoolById, updateProxyPool } from "@/models";
|
||||
import { testProxyUrl } from "@/lib/network/proxyTest";
|
||||
|
||||
// POST /api/proxy-pools/[id]/test - Test proxy pool entry
|
||||
export async function POST(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const proxyPool = await getProxyPoolById(id);
|
||||
|
||||
if (!proxyPool) {
|
||||
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const result = await testProxyUrl({ proxyUrl: proxyPool.proxyUrl });
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await updateProxyPool(id, {
|
||||
testStatus: result.ok ? "active" : "error",
|
||||
lastTestedAt: now,
|
||||
lastError: result.ok ? null : (result.error || `Proxy test failed with status ${result.status}`),
|
||||
isActive: result.ok,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: result.ok,
|
||||
status: result.status,
|
||||
statusText: result.statusText || null,
|
||||
error: result.error || null,
|
||||
elapsedMs: result.elapsedMs || 0,
|
||||
testedAt: now,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error testing proxy pool:", error);
|
||||
return NextResponse.json({ error: "Failed to test proxy pool" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
101
src/app/api/proxy-pools/migrate/route.js
Normal file
101
src/app/api/proxy-pools/migrate/route.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
createProxyPool,
|
||||
getProviderConnections,
|
||||
getProxyPools,
|
||||
updateProviderConnection,
|
||||
} from "@/models";
|
||||
|
||||
function normalizeString(value) {
|
||||
if (value === undefined || value === null) return "";
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function buildProxyKey(proxyUrl, noProxy) {
|
||||
return `${normalizeString(proxyUrl)}|||${normalizeString(noProxy)}`;
|
||||
}
|
||||
|
||||
function extractLegacyProxy(connection) {
|
||||
const providerSpecificData = connection?.providerSpecificData || {};
|
||||
const connectionProxyEnabled = providerSpecificData.connectionProxyEnabled === true;
|
||||
const connectionProxyUrl = normalizeString(providerSpecificData.connectionProxyUrl);
|
||||
const connectionNoProxy = normalizeString(providerSpecificData.connectionNoProxy);
|
||||
|
||||
if (!connectionProxyEnabled || !connectionProxyUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
connectionProxyUrl,
|
||||
connectionNoProxy,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMigratedName(index) {
|
||||
return `Migrated Proxy ${index}`;
|
||||
}
|
||||
|
||||
// POST /api/proxy-pools/migrate - Migrate legacy connection proxy config into proxy pools
|
||||
export async function POST() {
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
const existingPools = await getProxyPools();
|
||||
|
||||
const poolByKey = new Map();
|
||||
for (const pool of existingPools) {
|
||||
const key = buildProxyKey(pool.proxyUrl, pool.noProxy);
|
||||
if (!poolByKey.has(key)) {
|
||||
poolByKey.set(key, pool);
|
||||
}
|
||||
}
|
||||
|
||||
let migratedConnectionCount = 0;
|
||||
let legacyConnectionCount = 0;
|
||||
const createdPools = [];
|
||||
|
||||
for (const connection of connections) {
|
||||
const legacyProxy = extractLegacyProxy(connection);
|
||||
if (!legacyProxy) continue;
|
||||
|
||||
legacyConnectionCount += 1;
|
||||
const key = buildProxyKey(legacyProxy.connectionProxyUrl, legacyProxy.connectionNoProxy);
|
||||
|
||||
let pool = poolByKey.get(key);
|
||||
if (!pool) {
|
||||
pool = await createProxyPool({
|
||||
name: buildMigratedName(existingPools.length + createdPools.length + 1),
|
||||
proxyUrl: legacyProxy.connectionProxyUrl,
|
||||
noProxy: legacyProxy.connectionNoProxy,
|
||||
isActive: true,
|
||||
testStatus: "unknown",
|
||||
});
|
||||
createdPools.push(pool);
|
||||
poolByKey.set(key, pool);
|
||||
}
|
||||
|
||||
if (connection?.providerSpecificData?.proxyPoolId !== pool.id) {
|
||||
await updateProviderConnection(connection.id, {
|
||||
providerSpecificData: {
|
||||
...(connection.providerSpecificData || {}),
|
||||
proxyPoolId: pool.id,
|
||||
},
|
||||
});
|
||||
migratedConnectionCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
summary: {
|
||||
totalConnections: connections.length,
|
||||
legacyConnections: legacyConnectionCount,
|
||||
poolsCreated: createdPools.length,
|
||||
connectionsBound: migratedConnectionCount,
|
||||
},
|
||||
createdPools,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error migrating proxy pools:", error);
|
||||
return NextResponse.json({ error: "Failed to migrate proxy pools" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
90
src/app/api/proxy-pools/route.js
Normal file
90
src/app/api/proxy-pools/route.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createProxyPool, getProviderConnections, getProxyPools } from "@/models";
|
||||
|
||||
function toBoolean(value) {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeProxyPoolInput(body = {}) {
|
||||
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
||||
const proxyUrl = typeof body?.proxyUrl === "string" ? body.proxyUrl.trim() : "";
|
||||
const noProxy = typeof body?.noProxy === "string" ? body.noProxy.trim() : "";
|
||||
const isActive = body?.isActive === undefined ? true : body.isActive === true;
|
||||
const strictProxy = body?.strictProxy === true;
|
||||
|
||||
if (!name) {
|
||||
return { error: "Name is required" };
|
||||
}
|
||||
|
||||
if (!proxyUrl) {
|
||||
return { error: "Proxy URL is required" };
|
||||
}
|
||||
|
||||
return { name, proxyUrl, noProxy, isActive, strictProxy };
|
||||
}
|
||||
|
||||
function buildUsageMap(connections = []) {
|
||||
const usageMap = new Map();
|
||||
|
||||
for (const connection of connections) {
|
||||
const proxyPoolId = connection?.providerSpecificData?.proxyPoolId;
|
||||
if (!proxyPoolId) continue;
|
||||
|
||||
usageMap.set(proxyPoolId, (usageMap.get(proxyPoolId) || 0) + 1);
|
||||
}
|
||||
|
||||
return usageMap;
|
||||
}
|
||||
|
||||
// GET /api/proxy-pools - List proxy pools
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const isActive = toBoolean(searchParams.get("isActive"));
|
||||
const includeUsage = searchParams.get("includeUsage") === "true";
|
||||
|
||||
const filter = {};
|
||||
if (isActive !== undefined) {
|
||||
filter.isActive = isActive;
|
||||
}
|
||||
|
||||
const proxyPools = await getProxyPools(filter);
|
||||
|
||||
if (!includeUsage) {
|
||||
return NextResponse.json({ proxyPools });
|
||||
}
|
||||
|
||||
const connections = await getProviderConnections();
|
||||
const usageMap = buildUsageMap(connections);
|
||||
|
||||
const enrichedProxyPools = proxyPools.map((pool) => ({
|
||||
...pool,
|
||||
boundConnectionCount: usageMap.get(pool.id) || 0,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ proxyPools: enrichedProxyPools });
|
||||
} catch (error) {
|
||||
console.log("Error fetching proxy pools:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch proxy pools" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/proxy-pools - Create proxy pool
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const normalized = normalizeProxyPoolInput(body);
|
||||
|
||||
if (normalized.error) {
|
||||
return NextResponse.json({ error: normalized.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const proxyPool = await createProxyPool(normalized);
|
||||
return NextResponse.json({ proxyPool }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.log("Error creating proxy pool:", error);
|
||||
return NextResponse.json({ error: "Failed to create proxy pool" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user