diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index fb38a604db..36261fd46b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -738,6 +738,8 @@ interface ConnectionRowConnection { tokenExpiresAt?: string; maxConcurrent?: number | null; authType?: string; + proxyEnabled?: boolean; + perKeyProxyEnabled?: boolean; } interface ConnectionRowProps { @@ -770,6 +772,10 @@ interface ConnectionRowProps { hasProxy?: boolean; proxySource?: string; proxyHost?: string; + proxyEnabled?: boolean; + perKeyProxyEnabled?: boolean; + onToggleProxyEnabled?: (enabled: boolean) => void; + onTogglePerKeyProxyEnabled?: (enabled: boolean) => void; onRefreshToken?: () => void; isRefreshing?: boolean; onApplyCodexAuthLocal?: () => void; @@ -1393,6 +1399,7 @@ export default function ProviderDetailPage() { const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); const notify = useNotificationStore(); const [proxyTarget, setProxyTarget] = useState(null); + const [distributingProxies, setDistributingProxies] = useState(false); const [proxyConfig, setProxyConfig] = useState(null); const [connProxyMap, setConnProxyMap] = useState< Record @@ -2389,6 +2396,248 @@ export default function ProviderDetailPage() { } }; + const handleToggleProxyEnabled = async (connectionId, proxyEnabled) => { + try { + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxyEnabled }), + }); + if (res.ok) { + setConnections((prev) => + prev.map((c) => (c.id === connectionId ? { ...c, proxyEnabled } : c)) + ); + } + } catch (error) { + console.error("Error toggling proxy enabled:", error); + } + }; + + const handleTogglePerKeyProxyEnabled = async (connectionId, perKeyProxyEnabled) => { + try { + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ perKeyProxyEnabled }), + }); + if (res.ok) { + setConnections((prev) => + prev.map((c) => (c.id === connectionId ? { ...c, perKeyProxyEnabled } : c)) + ); + } + } catch (error) { + console.error("Error toggling per-key proxy enabled:", error); + } + }; + + const handleDistributeProxies = async (tagFilter?: string) => { + const targetConnections = tagFilter + ? connections.filter( + (c: any) => + (c.providerSpecificData?.tag as string | undefined)?.trim() === tagFilter + ) + : connections; + if (targetConnections.length === 0) return; + setDistributingProxies(true); + try { + const proxiesRes = await fetch("/api/settings/proxies"); + if (!proxiesRes.ok) throw new Error("Failed to fetch proxies"); + const proxiesData = await proxiesRes.json(); + const savedProxies = (proxiesData?.items || []).filter( + (p: any) => p.status === "active" + ); + if (savedProxies.length === 0) { + notify.error("No saved proxies found. Add proxies in Settings → Proxy first."); + return; + } + + let assigned = 0; + const sorted = [...targetConnections].sort( + (a: any, b: any) => (a.priority || 0) - (b.priority || 0) + ); + + for (let i = 0; i < sorted.length; i++) { + const conn = sorted[i] as any; + const proxy = savedProxies[i % savedProxies.length]; + + try { + await fetch("/api/settings/proxies/assignments", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: "account", + scopeId: conn.id, + proxyId: null, + }), + }); + } catch { + /* clear old assignment */ + } + + const patchRes = await fetch(`/api/providers/${conn.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxyEnabled: true, perKeyProxyEnabled: true }), + }); + + if (!patchRes.ok) { + console.error(`Failed to update connection ${conn.id}`); + continue; + } + + // Assign new proxy + const assignRes = await fetch("/api/settings/proxies/assignments", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: "account", + scopeId: conn.id, + proxyId: proxy.id, + }), + }); + + if (!assignRes.ok) { + console.error(`Failed to assign proxy to ${conn.id}`); + continue; + } + + assigned++; + } + + await fetchConnections(); + const tagLabel = tagFilter ? `"${tagFilter}" ` : ""; + notify.success( + `Distributed ${assigned} proxy assignment(s) across ${tagLabel}${sorted.length} connection(s).` + ); + } catch (err) { + console.error("Error distributing proxies:", err); + notify.error("Failed to distribute proxies."); + } finally { + setDistributingProxies(false); + } + }; + + const handleToggleProxyEnabled = async (connectionId, proxyEnabled) => { + try { + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxyEnabled }), + }); + if (res.ok) { + setConnections((prev) => + prev.map((c) => (c.id === connectionId ? { ...c, proxyEnabled } : c)) + ); + } + } catch (error) { + console.error("Error toggling proxy enabled:", error); + } + }; + + const handleTogglePerKeyProxyEnabled = async (connectionId, perKeyProxyEnabled) => { + try { + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ perKeyProxyEnabled }), + }); + if (res.ok) { + setConnections((prev) => + prev.map((c) => (c.id === connectionId ? { ...c, perKeyProxyEnabled } : c)) + ); + } + } catch (error) { + console.error("Error toggling per-key proxy enabled:", error); + } + }; + + const handleDistributeProxies = async (tagFilter?: string) => { + const targetConnections = tagFilter + ? connections.filter( + (c: any) => + (c.providerSpecificData?.tag as string | undefined)?.trim() === tagFilter + ) + : connections; + if (targetConnections.length === 0) return; + setDistributingProxies(true); + try { + const proxiesRes = await fetch("/api/settings/proxies"); + if (!proxiesRes.ok) throw new Error("Failed to fetch proxies"); + const proxiesData = await proxiesRes.json(); + const savedProxies = (proxiesData?.items || []).filter( + (p: any) => p.status === "active" + ); + if (savedProxies.length === 0) { + notify.error("No saved proxies found. Add proxies in Settings → Proxy first."); + return; + } + + let assigned = 0; + const sorted = [...targetConnections].sort( + (a: any, b: any) => (a.priority || 0) - (b.priority || 0) + ); + + for (let i = 0; i < sorted.length; i++) { + const conn = sorted[i] as any; + const proxy = savedProxies[i % savedProxies.length]; + + try { + await fetch("/api/settings/proxies/assignments", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: "account", + scopeId: conn.id, + proxyId: null, + }), + }); + } catch { + /* clear old assignment */ + } + + const patchRes = await fetch(`/api/providers/${conn.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxyEnabled: true, perKeyProxyEnabled: true }), + }); + + if (!patchRes.ok) { + console.error(`Failed to update connection ${conn.id}`); + continue; + } + + // Assign new proxy + const assignRes = await fetch("/api/settings/proxies/assignments", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: "account", + scopeId: conn.id, + proxyId: proxy.id, + }), + }); + + if (!assignRes.ok) { + console.error(`Failed to assign proxy to ${conn.id}`); + continue; + } + + assigned++; + } + + await fetchConnections(); + const tagLabel = tagFilter ? `"${tagFilter}" ` : ""; + notify.success( + `Distributed ${assigned} proxy assignment(s) across ${tagLabel}${sorted.length} connection(s).` + ); + } catch (err) { + console.error("Error distributing proxies:", err); + notify.error("Failed to distribute proxies."); + } finally { + setDistributingProxies(false); + } + }; + const handleToggleRateLimit = async (connectionId, enabled) => { try { const res = await fetch("/api/rate-limits", { @@ -3289,8 +3538,9 @@ export default function ProviderDetailPage() { const [clearingModels, setClearingModels] = useState(false); const providerAliasEntries = useMemo( () => - Object.entries(modelAliases).filter(([, model]) => - (model as string).startsWith(`${providerStorageAlias}/`) + Object.entries(modelAliases).filter( + ([, model]) => + typeof model === "string" && model.startsWith(`${providerStorageAlias}/`) ), [modelAliases, providerStorageAlias] ); @@ -4073,6 +4323,24 @@ export default function ProviderDetailPage() {
+ {connections.length > 0 && ( + + )} {connections.length > 1 && (
@@ -4451,17 +4723,30 @@ export default function ProviderDetailPage() { - {selectedIds.size > 0 && ( - - )} +
+ {selectedIds.size === 0 && connections.length > 0 && ( + + )} + {selectedIds.size > 0 && ( + + )} +
) : null}
@@ -4485,6 +4770,15 @@ export default function ProviderDetailPage() { {tag}
+ {groupConns.length} @@ -4597,6 +4891,10 @@ export default function ProviderDetailPage() { hasProxy={!!connProxyMap[conn.id]?.proxy} proxySource={connProxyMap[conn.id]?.level || null} proxyHost={connProxyMap[conn.id]?.proxy?.host || null} + proxyEnabled={conn.proxyEnabled !== false} + onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)} + perKeyProxyEnabled={conn.perKeyProxyEnabled === true} + onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)} /> ))}
@@ -5050,11 +5348,13 @@ export default function ProviderDetailPage() { {importProgress.logs.length > 0 && (
- {importProgress.logs.map((log, i) => ( + {importProgress.logs.map((log, i) => (

{log} @@ -6846,6 +7146,10 @@ function ConnectionRow({ isApplyingGeminiAuthLocal, onExportGeminiAuthFile, isExportingGeminiAuthFile, + perKeyProxyEnabled, + onTogglePerKeyProxyEnabled, + proxyEnabled, + onToggleProxyEnabled, }: ConnectionRowProps) { const t = useTranslations("providers"); const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); @@ -7167,6 +7471,74 @@ function ConnectionRow({ )} + {onToggleProxyEnabled && ( + <> + | + + + )} + {onTogglePerKeyProxyEnabled && ( + <> + | + + + )} + {onToggleProxyEnabled && ( + <> + | + + + )} + {onTogglePerKeyProxyEnabled && ( + <> + | + + + )} {hasProxy && (() => { const colorClass = diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx index 7b0b568df8..a588dce7cb 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx @@ -1,13 +1,33 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import { Card, Button, ProxyConfigModal } from "@/shared/components"; +import { useState, useEffect, useCallback, useRef } from "react"; +import { Card, Button, Toggle, ProxyConfigModal } from "@/shared/components"; import { useTranslations } from "next-intl"; type GlobalProxyConfig = { type: string; host: string; port: number } | null; +type HealthcheckResult = { + proxyUrl: string; + ok: boolean; + latencyMs: number | null; +}; + +type HealthcheckSummary = { + total: number; + working: number; + failed: number; +}; + export default function GlobalConfigTab() { const [proxyModalOpen, setProxyModalOpen] = useState(false); const [globalProxy, setGlobalProxy] = useState(null); + const [perKeyProxyEnabled, setPerKeyProxyEnabled] = useState(false); + const [perKeyLoading, setPerKeyLoading] = useState(true); + const [targetUrl, setTargetUrl] = useState("https://api.openai.com/v1/models"); + const [testing, setTesting] = useState(false); + const [results, setResults] = useState(null); + const [summary, setSummary] = useState(null); + const [error, setError] = useState(null); + const mountedRef = useRef(true); const t = useTranslations("settings"); const tc = useTranslations("common"); @@ -21,10 +41,73 @@ export default function GlobalConfigTab() { } catch {} }, []); + const loadPerKeyProxyEnabled = useCallback(async () => { + try { + const res = await fetch("/api/settings", { cache: "no-store" }); + if (res.ok) { + const data = await res.json(); + if (mountedRef.current) setPerKeyProxyEnabled(data.perKeyProxyEnabled === true); + } + } catch { + /* leave default */ + } finally { + if (mountedRef.current) setPerKeyLoading(false); + } + }, []); + useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- async data fetch on mount + mountedRef.current = true; loadGlobalProxy(); - }, [loadGlobalProxy]); + loadPerKeyProxyEnabled(); + return () => { + mountedRef.current = false; + }; + }, [loadGlobalProxy, loadPerKeyProxyEnabled]); + + const handleTogglePerKeyProxyEnabled = async () => { + const newValue = !perKeyProxyEnabled; + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ perKeyProxyEnabled: newValue }), + }); + if (res.ok) { + setPerKeyProxyEnabled(newValue); + } + } catch (err) { + console.error("Failed to update per-key proxy setting:", err); + } + }; + + const runHealthcheck = async () => { + setTesting(true); + setResults(null); + setSummary(null); + setError(null); + + try { + const res = await fetch("/api/proxy-fallback/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetUrl }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Request failed" })); + setError(err.error || `HTTP ${res.status}`); + return; + } + + const data = await res.json(); + setResults(data.results); + setSummary(data.summary); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : t("healthcheckFailed")); + } finally { + setTesting(false); + } + }; return ( <> @@ -59,6 +142,118 @@ export default function GlobalConfigTab() {

+ + +
+
+
+ +
+

{t("perKeyProxyEnabled")}

+

{t("perKeyProxyEnabledDesc")}

+
+
+ +
+
+
+ + +
+
+ +

Bulk Healthcheck

+
+

+ Test all configured proxies against a target URL to find which ones work. +

+
+ setTargetUrl(e.target.value)} + placeholder="https://api.openai.com/v1/models" + className="flex-1 px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50" + /> + +
+ + {error && ( +
+ {error} +
+ )} + + {testing && !results && ( +
+ refresh + Testing all proxies in parallel... +
+ )} + + {summary && ( +
+ + Total: {summary.total} + + + Working: {summary.working} + + + Failed: {summary.failed} + +
+ )} + + {results && results.length > 0 && ( +
+ + + + + + + + + + {results.map((r, i) => ( + + + + + + ))} + +
StatusProxy URLLatency
+ {r.ok ? ( + + ) : ( + + )} + {r.proxyUrl} + {r.latencyMs !== null ? `${r.latencyMs}ms` : "—"} +
+
+ )} +
+
+ setProxyModalOpen(false)} diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 980cf666a2..a48fea4100 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -132,6 +132,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: group, maxConcurrent, quotaWindowThresholds: incomingWindowThresholds, + proxyEnabled, + perKeyProxyEnabled, projectId, providerSpecificData: incomingPsd, } = body; @@ -184,6 +186,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: } } if (projectId !== undefined) updateData.projectId = projectId; + if (proxyEnabled !== undefined) updateData.proxyEnabled = proxyEnabled; + if (perKeyProxyEnabled !== undefined) updateData.perKeyProxyEnabled = perKeyProxyEnabled; // Merge providerSpecificData (partial update — preserve existing keys not sent by caller) if (incomingPsd !== undefined && incomingPsd !== null && typeof incomingPsd === "object") {