diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 314f81ff9c..b63bc38164 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -369,12 +369,19 @@ export default function ProviderDetailPage() { const settingsRes = await fetch("/api/settings", { cache: "no-store" }); const settingsData = settingsRes.ok ? await settingsRes.json() : {}; const current = settingsData.providerStrategies || {}; + const existingOverride = current[providerId] || {}; - // Build override: null strategy means remove override, use global - const override = {}; - if (strategy) override.fallbackStrategy = strategy; + // Build override: merge with existing override to preserve proxy settings + const override = { ...existingOverride }; + if (strategy) { + override.fallbackStrategy = strategy; + } else { + delete override.fallbackStrategy; + } if (strategy === "round-robin" && stickyLimit !== "") { override.stickyRoundRobinLimit = Number(stickyLimit) || 3; + } else { + delete override.stickyRoundRobinLimit; } const updated = { ...current }; @@ -1415,9 +1422,11 @@ export default function ProviderDetailPage() { {/* Connections */} {isFreeNoAuth ? ( - + ) : ( - +
+ +

Connections

@@ -1630,6 +1639,7 @@ export default function ProviderDetailPage() { )} +
)} {/* Models */} diff --git a/src/lib/network/connectionProxy.js b/src/lib/network/connectionProxy.js index f4a0ae6bfb..f5e1ce8de9 100644 --- a/src/lib/network/connectionProxy.js +++ b/src/lib/network/connectionProxy.js @@ -186,3 +186,57 @@ export function getProxyHash(providerSpecificData = {}) { if (poolId) return `pool-${djb2(poolId)}`; return "direct"; } + +/** + * --------------------------------------------------------------------------- + * Proxy Rotation Strategy + * --------------------------------------------------------------------------- + * + * Module-level state tracking for round-robin rotation. + * Maps providerId -> { currentIndex, poolHash } + * When the pool list changes (pools added/removed), the hash changes and the + * cursor resets automatically. + */ + +const _roundRobinState = new Map(); + +function computePoolHash(poolIds) { + return [...poolIds].sort().join(","); +} + +/** + * Pick a proxy pool ID from the available pool list using the configured + * rotation strategy. + * + * @param {string[]} poolIds - Active proxy pool IDs to choose from. + * @param {string} strategy - "random" or "round-robin". + * @param {string} providerId - Provider identifier (state key, each + * provider rotates independently). + * @returns {string|null} Selected proxy pool ID, or null if poolIds empty. + */ +export function pickProxyPoolId(poolIds, strategy, providerId) { + if (!Array.isArray(poolIds) || poolIds.length === 0) return null; + + if (strategy === "random") { + const idx = Math.floor(Math.random() * poolIds.length); + return poolIds[idx]; + } + + if (strategy === "round-robin") { + const currentHash = computePoolHash(poolIds); + const prev = _roundRobinState.get(providerId); + + // Reset cursor when pool composition changes (pools added/removed) + if (!prev || prev.poolHash !== currentHash) { + _roundRobinState.set(providerId, { currentIndex: 0, poolHash: currentHash }); + return poolIds[0]; + } + + const nextIndex = (prev.currentIndex + 1) % poolIds.length; + _roundRobinState.set(providerId, { currentIndex: nextIndex, poolHash: currentHash }); + return poolIds[nextIndex]; + } + + // Unknown strategy — fall back to first pool + return poolIds[0]; +} diff --git a/src/shared/components/NoAuthProxyCard.js b/src/shared/components/NoAuthProxyCard.js index 34fd4f4393..920cd68eab 100644 --- a/src/shared/components/NoAuthProxyCard.js +++ b/src/shared/components/NoAuthProxyCard.js @@ -7,9 +7,10 @@ import Badge from "./Badge"; const NONE_PROXY_POOL_VALUE = "__none__"; -export default function NoAuthProxyCard({ providerId }) { +export default function NoAuthProxyCard({ providerId, isFreeNoAuth = true }) { const [proxyPools, setProxyPools] = useState([]); const [proxyPoolId, setProxyPoolId] = useState(NONE_PROXY_POOL_VALUE); + const [rotateStrategy, setRotateStrategy] = useState("none"); const [saving, setSaving] = useState(false); const [savedFlash, setSavedFlash] = useState(false); @@ -23,23 +24,41 @@ export default function NoAuthProxyCard({ providerId }) { setProxyPools(poolData.proxyPools || []); const override = (settingsData.providerStrategies || {})[providerId] || {}; setProxyPoolId(override.proxyPoolId || NONE_PROXY_POOL_VALUE); + setRotateStrategy(override.rotateStrategy || "none"); }).catch(() => {}); return () => controller.abort(); }, [providerId]); - const handleChange = async (newValue) => { - setProxyPoolId(newValue); + const handleSave = async (updatedFields) => { setSaving(true); try { const res = await fetch("/api/settings", { cache: "no-store" }); const data = res.ok ? await res.json() : {}; const current = data.providerStrategies || {}; const override = { ...(current[providerId] || {}) }; - if (newValue === NONE_PROXY_POOL_VALUE) delete override.proxyPoolId; - else override.proxyPoolId = newValue; + + if ("rotateStrategy" in updatedFields) { + if (updatedFields.rotateStrategy === "none") { + delete override.rotateStrategy; + } else { + override.rotateStrategy = updatedFields.rotateStrategy; + } + } + if ("proxyPoolId" in updatedFields) { + if (updatedFields.proxyPoolId === NONE_PROXY_POOL_VALUE) { + delete override.proxyPoolId; + } else { + override.proxyPoolId = updatedFields.proxyPoolId; + } + } + const updated = { ...current }; - if (Object.keys(override).length === 0) delete updated[providerId]; - else updated[providerId] = override; + if (Object.keys(override).length === 0) { + delete updated[providerId]; + } else { + updated[providerId] = override; + } + await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -48,35 +67,77 @@ export default function NoAuthProxyCard({ providerId }) { setSavedFlash(true); setTimeout(() => setSavedFlash(false), 1500); } catch (e) { - console.log("Save proxyPoolId error:", e); + console.log("Save proxy override error:", e); } finally { setSaving(false); } }; + const handleStrategyChange = (newVal) => { + setRotateStrategy(newVal); + handleSave({ rotateStrategy: newVal }); + }; + + const handlePoolChange = (newVal) => { + setProxyPoolId(newVal); + handleSave({ proxyPoolId: newVal }); + }; + return (
- lock_open + lan
-

No authentication required

-

This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.

+

Proxy Routing & Strategy

+

+ Configure how traffic for this provider is routed through proxy pools. +

{savedFlash && Saved}
- handleStrategyChange(e.target.value)} + disabled={saving} + options={[ + { value: "none", label: "None (Direct / Static)" }, + { value: "round-robin", label: "Round-Robin (Rotate active pools)" }, + { value: "random", label: "Random (Rotate active pools)" }, + ]} + /> + + {rotateStrategy === "none" ? ( + isFreeNoAuth ? ( +