Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/app/(dashboard)/dashboard/providers/[id]/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,12 +369,19 @@ export default function ProviderDetailPage() {
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
const current = settingsData.providerStrategies || {};
const existingOverride = current[providerId] || {};

// Build override: null strategy means remove override, use global
const override = {};
if (strategy) override.fallbackStrategy = strategy;
// Build override: merge with existing override to preserve proxy settings
const override = { ...existingOverride };
if (strategy) {
override.fallbackStrategy = strategy;
} else {
delete override.fallbackStrategy;
}
if (strategy === "round-robin" && stickyLimit !== "") {
override.stickyRoundRobinLimit = Number(stickyLimit) || 3;
} else {
delete override.stickyRoundRobinLimit;
}

const updated = { ...current };
Expand Down Expand Up @@ -1415,9 +1422,11 @@ export default function ProviderDetailPage() {

{/* Connections */}
{isFreeNoAuth ? (
<NoAuthProxyCard providerId={providerId} />
<NoAuthProxyCard providerId={providerId} isFreeNoAuth={true} />
) : (
<Card>
<div className="flex flex-col gap-6">
<NoAuthProxyCard providerId={providerId} isFreeNoAuth={false} />
<Card>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Connections</h2>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
Expand Down Expand Up @@ -1630,6 +1639,7 @@ export default function ProviderDetailPage() {
</>
)}
</Card>
</div>
)}

{/* Models */}
Expand Down
54 changes: 54 additions & 0 deletions src/lib/network/connectionProxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,57 @@ export function getProxyHash(providerSpecificData = {}) {
if (poolId) return `pool-${djb2(poolId)}`;
return "direct";
}

/**
* ---------------------------------------------------------------------------
* Proxy Rotation Strategy
* ---------------------------------------------------------------------------
*
* Module-level state tracking for round-robin rotation.
* Maps providerId -> { currentIndex, poolHash }
* When the pool list changes (pools added/removed), the hash changes and the
* cursor resets automatically.
*/

const _roundRobinState = new Map();

function computePoolHash(poolIds) {
return [...poolIds].sort().join(",");
}

/**
* Pick a proxy pool ID from the available pool list using the configured
* rotation strategy.
*
* @param {string[]} poolIds - Active proxy pool IDs to choose from.
* @param {string} strategy - "random" or "round-robin".
* @param {string} providerId - Provider identifier (state key, each
* provider rotates independently).
* @returns {string|null} Selected proxy pool ID, or null if poolIds empty.
*/
export function pickProxyPoolId(poolIds, strategy, providerId) {
if (!Array.isArray(poolIds) || poolIds.length === 0) return null;

if (strategy === "random") {
const idx = Math.floor(Math.random() * poolIds.length);
return poolIds[idx];
}

if (strategy === "round-robin") {
const currentHash = computePoolHash(poolIds);
const prev = _roundRobinState.get(providerId);

// Reset cursor when pool composition changes (pools added/removed)
if (!prev || prev.poolHash !== currentHash) {
_roundRobinState.set(providerId, { currentIndex: 0, poolHash: currentHash });
return poolIds[0];
}

const nextIndex = (prev.currentIndex + 1) % poolIds.length;
_roundRobinState.set(providerId, { currentIndex: nextIndex, poolHash: currentHash });
return poolIds[nextIndex];
}

// Unknown strategy — fall back to first pool
return poolIds[0];
}
105 changes: 83 additions & 22 deletions src/shared/components/NoAuthProxyCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import Badge from "./Badge";

const NONE_PROXY_POOL_VALUE = "__none__";

export default function NoAuthProxyCard({ providerId }) {
export default function NoAuthProxyCard({ providerId, isFreeNoAuth = true }) {
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);

Expand All @@ -23,23 +24,41 @@ 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 () => controller.abort();
}, [providerId]);

const handleChange = async (newValue) => {
setProxyPoolId(newValue);
const handleSave = async (updatedFields) => {
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 ("rotateStrategy" in updatedFields) {
if (updatedFields.rotateStrategy === "none") {
delete override.rotateStrategy;
} else {
override.rotateStrategy = updatedFields.rotateStrategy;
}
}
if ("proxyPoolId" in updatedFields) {
if (updatedFields.proxyPoolId === NONE_PROXY_POOL_VALUE) {
delete override.proxyPoolId;
} else {
override.proxyPoolId = updatedFields.proxyPoolId;
}
}

const updated = { ...current };
if (Object.keys(override).length === 0) delete updated[providerId];
else updated[providerId] = override;
if (Object.keys(override).length === 0) {
delete updated[providerId];
} else {
updated[providerId] = override;
}

await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
Expand All @@ -48,35 +67,77 @@ export default function NoAuthProxyCard({ providerId }) {
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 1500);
} catch (e) {
console.log("Save proxyPoolId error:", e);
console.log("Save proxy override error:", e);
} finally {
setSaving(false);
}
};

const handleStrategyChange = (newVal) => {
setRotateStrategy(newVal);
handleSave({ rotateStrategy: newVal });
};

const handlePoolChange = (newVal) => {
setProxyPoolId(newVal);
handleSave({ proxyPoolId: newVal });
};

return (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[20px]">lock_open</span>
<span className="material-symbols-outlined text-[20px]">lan</span>
</div>
<div className="flex-1">
<p className="text-sm font-medium">No authentication required</p>
<p className="text-xs text-text-muted">This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.</p>
<p className="text-sm font-medium">Proxy Routing & Strategy</p>
<p className="text-xs text-text-muted">
Configure how traffic for this provider is routed through proxy pools.
</p>
</div>
{savedFlash && <Badge variant="success" size="sm">Saved</Badge>}
</div>
<Select
label="Proxy Pool"
value={proxyPoolId}
onChange={(e) => handleChange(e.target.value)}
disabled={saving}
options={[
{ value: NONE_PROXY_POOL_VALUE, label: "None (direct)" },
...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })),
]}
/>

<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Proxy Strategy"
value={rotateStrategy}
onChange={(e) => handleStrategyChange(e.target.value)}
disabled={saving}
options={[
{ value: "none", label: "None (Direct / Static)" },
{ value: "round-robin", label: "Round-Robin (Rotate active pools)" },
{ value: "random", label: "Random (Rotate active pools)" },
]}
/>

{rotateStrategy === "none" ? (
isFreeNoAuth ? (
<Select
label="Static Proxy Pool"
value={proxyPoolId}
onChange={(e) => handlePoolChange(e.target.value)}
disabled={saving}
options={[
{ value: NONE_PROXY_POOL_VALUE, label: "None (direct)" },
...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })),
]}
/>
) : (
<div className="flex flex-col justify-end pb-1.5">
<span className="text-xs text-text-muted italic">
Using connection-level proxy settings. Select specific proxies on connections below or click "Apply Proxy".
</span>
</div>
)
) : (
<div className="flex flex-col justify-end pb-1.5">
<span className="text-xs text-brand-500 font-medium">
🔄 Dynamic Rotation active. Individual connection proxy settings will be bypassed.
</span>
</div>
)}
</div>
</Card>
);
}

17 changes: 16 additions & 1 deletion src/sse/services/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,22 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
connection = availableConnections[0];
}

const resolvedProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
// Proxy rotation — override per-connection proxy pool with dynamically selected
// pool when the provider has a rotation strategy configured (round-robin/random).
const proxyOverride = (settings.providerStrategies || {})[providerId] || {};
const proxyStrategy = proxyOverride.rotateStrategy || "none";
let rotProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
if (proxyStrategy !== "none") {
const allPools = await getProxyPools({ isActive: true });
const poolIds = allPools.filter(p => p.proxyUrl).map(p => p.id);
if (poolIds.length > 0) {
rotProxyPoolId = pickProxyPoolId(poolIds, proxyStrategy, providerId);
}
}
const resolvedProxy = await resolveConnectionProxyConfig({
...(connection.providerSpecificData || {}),
proxyPoolId: rotProxyPoolId || "",
});

return {
authType: connection.authType,
Expand Down
Loading