feat: add round-robin routing strategy
Implements a round-robin (least recently used) account selection strategy alongside the existing fill-first priority system. Adds a toggle in the Profile dashboard to switch between strategies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9c3d6f4ad8
commit
9ebd7d3062
3 changed files with 91 additions and 5 deletions
|
|
@ -1,11 +1,42 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { Card, Button, Badge, Toggle } from "@/shared/components";
|
import { Card, Button, Badge, Toggle } from "@/shared/components";
|
||||||
import { useTheme } from "@/shared/hooks/useTheme";
|
import { useTheme } from "@/shared/hooks/useTheme";
|
||||||
import { APP_CONFIG } from "@/shared/constants/config";
|
import { APP_CONFIG } from "@/shared/constants/config";
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { theme, setTheme, isDark } = useTheme();
|
const { theme, setTheme, isDark } = useTheme();
|
||||||
|
const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/settings")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setSettings(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to fetch settings:", err);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateFallbackStrategy = async (strategy) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ fallbackStrategy: strategy }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSettings(prev => ({ ...prev, fallbackStrategy: strategy }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update settings:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
|
|
@ -28,6 +59,31 @@ export default function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Routing Preferences */}
|
||||||
|
<Card>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Routing Strategy</h3>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Round Robin</p>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
Cycle through accounts to distribute load
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={settings.fallbackStrategy === "round-robin"}
|
||||||
|
onChange={() => updateFallbackStrategy(settings.fallbackStrategy === "round-robin" ? "fill-first" : "round-robin")}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-muted italic pt-2 border-t border-border/50">
|
||||||
|
{settings.fallbackStrategy === "round-robin"
|
||||||
|
? "Currently distributing requests across all available accounts."
|
||||||
|
: "Currently using accounts in priority order (Fill First)."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Theme Preferences */}
|
{/* Theme Preferences */}
|
||||||
<Card>
|
<Card>
|
||||||
<h3 className="text-lg font-semibold mb-4">Appearance</h3>
|
<h3 className="text-lg font-semibold mb-4">Appearance</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -10,3 +10,14 @@ export async function GET() {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const settings = await updateSettings(body);
|
||||||
|
return NextResponse.json(settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error updating settings:", error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import { getProviderConnections, validateApiKey, updateProviderConnection } from "@/lib/localDb";
|
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings } from "@/lib/localDb";
|
||||||
import { isAccountUnavailable, getUnavailableUntil } from "open-sse/services/accountFallback.js";
|
import { isAccountUnavailable, getUnavailableUntil } from "open-sse/services/accountFallback.js";
|
||||||
import * as log from "../utils/logger.js";
|
import * as log from "../utils/logger.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get provider credentials from localDb
|
* Get provider credentials from localDb
|
||||||
* Filters out unavailable accounts and returns the highest priority available account
|
* Filters out unavailable accounts and returns the selected account based on strategy
|
||||||
* @param {string} provider - Provider name
|
* @param {string} provider - Provider name
|
||||||
* @param {string|null} excludeConnectionId - Connection ID to exclude (for retry with next account)
|
* @param {string|null} excludeConnectionId - Connection ID to exclude (for retry with next account)
|
||||||
*/
|
*/
|
||||||
export async function getProviderCredentials(provider, excludeConnectionId = null) {
|
export async function getProviderCredentials(provider, excludeConnectionId = null) {
|
||||||
const connections = await getProviderConnections({ provider, isActive: true });
|
const connections = await getProviderConnections({ provider, isActive: true });
|
||||||
|
|
||||||
if (connections.length === 0) {
|
if (connections.length === 0) {
|
||||||
log.warn("AUTH", `No credentials for ${provider}`);
|
log.warn("AUTH", `No credentials for ${provider}`);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -28,7 +28,26 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const connection = availableConnections[0];
|
const settings = await getSettings();
|
||||||
|
const strategy = settings.fallbackStrategy || "fill-first";
|
||||||
|
|
||||||
|
let connection;
|
||||||
|
if (strategy === "round-robin") {
|
||||||
|
// Sort by lastUsed (nulls first) to pick the least recently used
|
||||||
|
const sorted = [...availableConnections].sort((a, b) => {
|
||||||
|
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
|
||||||
|
if (!a.lastUsedAt) return -1;
|
||||||
|
if (!b.lastUsedAt) return 1;
|
||||||
|
return new Date(a.lastUsedAt) - new Date(b.lastUsedAt);
|
||||||
|
});
|
||||||
|
connection = sorted[0];
|
||||||
|
|
||||||
|
// Update lastUsedAt asynchronously
|
||||||
|
updateProviderConnection(connection.id, { lastUsedAt: new Date().toISOString() }).catch(() => {});
|
||||||
|
} else {
|
||||||
|
// Default: fill-first (already sorted by priority in getProviderConnections)
|
||||||
|
connection = availableConnections[0];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiKey: connection.apiKey,
|
apiKey: connection.apiKey,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue