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:
committed by
decolua
parent
f1f9d27061
commit
e1f3399b73
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user