diff --git a/src/lib/network/connectionProxy.js b/src/lib/network/connectionProxy.js index 71c5e008..9ecd2535 100644 --- a/src/lib/network/connectionProxy.js +++ b/src/lib/network/connectionProxy.js @@ -6,6 +6,33 @@ function normalizeString(value) { return String(value).trim(); } +// ─── Proxy pool rotation state (in-memory) ───────────────────────── +const rotateState = new Map(); // providerId → { index } + +/** + * Pick one proxy pool ID from a list based on strategy. + * round-robin: cycle sequentially (in-memory, resets on restart) + * random: uniform random pick + * none/single: return first entry + */ +export function pickProxyPoolId(poolIds, strategy, providerId) { + if (!poolIds || poolIds.length === 0) return null; + if (poolIds.length === 1) return poolIds[0]; + + if (strategy === "round-robin") { + const state = rotateState.get(providerId) || { index: -1 }; + state.index = (state.index + 1) % poolIds.length; + rotateState.set(providerId, state); + return poolIds[state.index]; + } + + if (strategy === "random") { + return poolIds[Math.floor(Math.random() * poolIds.length)]; + } + + return poolIds[0]; // "none" or unknown +} + /** * Normalize legacy proxy configuration. */ diff --git a/src/shared/components/NoAuthProxyCard.js b/src/shared/components/NoAuthProxyCard.js index df9db696..6229e60c 100644 --- a/src/shared/components/NoAuthProxyCard.js +++ b/src/shared/components/NoAuthProxyCard.js @@ -1,16 +1,22 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import PropTypes from "prop-types"; import Card from "./Card"; import Select from "./Select"; import Badge from "./Badge"; const NONE_PROXY_POOL_VALUE = "__none__"; +const STRATEGIES = [ + { value: "none", label: "None (single pool)" }, + { value: "round-robin", label: "Round-robin" }, + { value: "random", label: "Random" }, +]; export default function NoAuthProxyCard({ providerId }) { 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); @@ -24,20 +30,22 @@ 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 () => { cancelled = true; }; }, [providerId]); - const handleChange = async (newValue) => { - setProxyPoolId(newValue); + const save = useCallback(async (poolId, strategy) => { 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 (poolId === NONE_PROXY_POOL_VALUE) delete override.proxyPoolId; + else override.proxyPoolId = poolId; + if (strategy === "none") delete override.rotateStrategy; + else override.rotateStrategy = strategy; const updated = { ...current }; if (Object.keys(override).length === 0) delete updated[providerId]; else updated[providerId] = override; @@ -49,12 +57,25 @@ export default function NoAuthProxyCard({ providerId }) { setSavedFlash(true); setTimeout(() => setSavedFlash(false), 1500); } catch (e) { - console.log("Save proxyPoolId error:", e); + console.log("Save proxy config error:", e); } finally { setSaving(false); } + }, [providerId]); + + const handlePoolChange = (newPoolId) => { + setProxyPoolId(newPoolId); + save(newPoolId, rotateStrategy); }; + const handleStrategyChange = (newStrategy) => { + setRotateStrategy(newStrategy); + save(proxyPoolId, newStrategy); + }; + + const canRotate = proxyPools.length >= 2; + const isRotation = rotateStrategy !== "none"; + return (
@@ -67,16 +88,43 @@ export default function NoAuthProxyCard({ providerId }) {
{savedFlash && Saved} + handleStrategyChange(e.target.value)} + disabled={saving} + className="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 disabled:opacity-50" + > + {STRATEGIES.map((s) => ( + + ))} + +

+ {!canRotate + ? `Need at least 2 active proxy pools for rotation.` + : isRotation + ? rotateStrategy === "round-robin" + ? `Rotating through all ${proxyPools.length} active pools in order. State is in-memory (resets on restart).` + : `Picking a random pool from ${proxyPools.length} active pools each request.` + : `Uses the selected pool above. Set to Round-robin or Random to rotate across all active pools.`} +

+
); } diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 241082d4..f931209b 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -1,5 +1,5 @@ -import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings } from "@/lib/localDb"; -import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; +import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb"; +import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy"; import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js"; import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js"; import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js"; @@ -36,7 +36,14 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu if (FREE_PROVIDERS[providerId]?.noAuth) { const settings = await getSettings(); const override = (settings.providerStrategies || {})[providerId] || {}; - const resolvedProxy = await resolveConnectionProxyConfig({ proxyPoolId: override.proxyPoolId || "" }); + const strategy = override.rotateStrategy || "none"; + let pickedId = override.proxyPoolId || null; + if (strategy !== "none") { + const allPools = await getProxyPools({ isActive: true }); + const poolIds = allPools.filter(p => p.proxyUrl).map(p => p.id); + pickedId = pickProxyPoolId(poolIds, strategy, providerId); + } + const resolvedProxy = await resolveConnectionProxyConfig({ proxyPoolId: pickedId || "" }); return { id: "noauth", connectionName: "Public",