mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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();
|
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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user