feat(proxy-pools): auto-rotate strategy for no-auth providers (#2409)

Add round-robin/random proxy pool rotation for no-auth free providers
(e.g. OpenCode Free) to distribute load across all active pools and
avoid per-IP rate limits. Rotation strategy is selectable per provider
in NoAuthProxyCard and persisted to settings.providerStrategies.
This commit is contained in:
Fadjrir Herlambang
2026-07-10 16:05:07 +07:00
committed by decolua
parent f1f9d27061
commit e1f3399b73
3 changed files with 93 additions and 11 deletions
+27
View File
@@ -6,6 +6,33 @@ function normalizeString(value) {
return String(value).trim(); 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. * Normalize legacy proxy configuration.
*/ */
+56 -8
View File
@@ -1,16 +1,22 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Card from "./Card"; import Card from "./Card";
import Select from "./Select"; import Select from "./Select";
import Badge from "./Badge"; import Badge from "./Badge";
const NONE_PROXY_POOL_VALUE = "__none__"; 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 }) { export default function NoAuthProxyCard({ providerId }) {
const [proxyPools, setProxyPools] = useState([]); const [proxyPools, setProxyPools] = useState([]);
const [proxyPoolId, setProxyPoolId] = useState(NONE_PROXY_POOL_VALUE); const [proxyPoolId, setProxyPoolId] = useState(NONE_PROXY_POOL_VALUE);
const [rotateStrategy, setRotateStrategy] = useState("none");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [savedFlash, setSavedFlash] = useState(false); const [savedFlash, setSavedFlash] = useState(false);
@@ -24,20 +30,22 @@ export default function NoAuthProxyCard({ providerId }) {
setProxyPools(poolData.proxyPools || []); setProxyPools(poolData.proxyPools || []);
const override = (settingsData.providerStrategies || {})[providerId] || {}; const override = (settingsData.providerStrategies || {})[providerId] || {};
setProxyPoolId(override.proxyPoolId || NONE_PROXY_POOL_VALUE); setProxyPoolId(override.proxyPoolId || NONE_PROXY_POOL_VALUE);
setRotateStrategy(override.rotateStrategy || "none");
}).catch(() => {}); }).catch(() => {});
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [providerId]); }, [providerId]);
const handleChange = async (newValue) => { const save = useCallback(async (poolId, strategy) => {
setProxyPoolId(newValue);
setSaving(true); setSaving(true);
try { try {
const res = await fetch("/api/settings", { cache: "no-store" }); const res = await fetch("/api/settings", { cache: "no-store" });
const data = res.ok ? await res.json() : {}; const data = res.ok ? await res.json() : {};
const current = data.providerStrategies || {}; const current = data.providerStrategies || {};
const override = { ...(current[providerId] || {}) }; const override = { ...(current[providerId] || {}) };
if (newValue === NONE_PROXY_POOL_VALUE) delete override.proxyPoolId; if (poolId === NONE_PROXY_POOL_VALUE) delete override.proxyPoolId;
else override.proxyPoolId = newValue; else override.proxyPoolId = poolId;
if (strategy === "none") delete override.rotateStrategy;
else override.rotateStrategy = strategy;
const updated = { ...current }; const updated = { ...current };
if (Object.keys(override).length === 0) delete updated[providerId]; if (Object.keys(override).length === 0) delete updated[providerId];
else updated[providerId] = override; else updated[providerId] = override;
@@ -49,12 +57,25 @@ export default function NoAuthProxyCard({ providerId }) {
setSavedFlash(true); setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 1500); setTimeout(() => setSavedFlash(false), 1500);
} catch (e) { } catch (e) {
console.log("Save proxyPoolId error:", e); console.log("Save proxy config error:", e);
} finally { } finally {
setSaving(false); 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 ( return (
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
@@ -67,16 +88,43 @@ export default function NoAuthProxyCard({ providerId }) {
</div> </div>
{savedFlash && <Badge variant="success" size="sm">Saved</Badge>} {savedFlash && <Badge variant="success" size="sm">Saved</Badge>}
</div> </div>
<Select <Select
label="Proxy Pool" label="Proxy Pool"
value={proxyPoolId} value={proxyPoolId}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handlePoolChange(e.target.value)}
disabled={saving} disabled={saving || isRotation}
options={[ options={[
{ value: NONE_PROXY_POOL_VALUE, label: "None (direct)" }, { value: NONE_PROXY_POOL_VALUE, label: "None (direct)" },
...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })), ...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })),
]} ]}
hint={isRotation ? "Pool selector is ignored when rotation is active — all active pools are used." : undefined}
/> />
<div className="flex flex-col gap-2 mt-4">
<label className="text-sm font-medium text-text-main">Rotation Strategy</label>
<select
value={rotateStrategy}
onChange={(e) => 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) => (
<option key={s.value} value={s.value} disabled={s.value !== "none" && !canRotate}>
{s.label}
</option>
))}
</select>
<p className="text-xs text-text-muted">
{!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.`}
</p>
</div>
</Card> </Card>
); );
} }
+10 -3
View File
@@ -1,5 +1,5 @@
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings } from "@/lib/localDb"; import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy";
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js"; 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 { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";
import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.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) { if (FREE_PROVIDERS[providerId]?.noAuth) {
const settings = await getSettings(); const settings = await getSettings();
const override = (settings.providerStrategies || {})[providerId] || {}; 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 { return {
id: "noauth", id: "noauth",
connectionName: "Public", connectionName: "Public",