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 15:59:52 +07:00
committed by decolua
parent f1f9d27061
commit e1f3399b73
3 changed files with 93 additions and 11 deletions

View File

@@ -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.
*/

View File

@@ -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 (
<Card>
<div className="flex items-center gap-3 mb-4">
@@ -67,16 +88,43 @@ export default function NoAuthProxyCard({ providerId }) {
</div>
{savedFlash && <Badge variant="success" size="sm">Saved</Badge>}
</div>
<Select
label="Proxy Pool"
value={proxyPoolId}
onChange={(e) => handleChange(e.target.value)}
disabled={saving}
onChange={(e) => handlePoolChange(e.target.value)}
disabled={saving || isRotation}
options={[
{ value: NONE_PROXY_POOL_VALUE, label: "None (direct)" },
...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>
);
}

View File

@@ -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",