feat: - Introduced per-provider strategy overrides in settings, allowing for more flexible connection management.
- Added new provider models: DeepSeek 3.1, DeepSeek 3.2, and Qwen3 Coder Next. - Implemented UI changes to support round-robin strategy with sticky limits in the provider detail page. - Improved logging to display connection names instead of IDs for better clarity.
This commit is contained in:
parent
2470ef84de
commit
fe49b61dfb
7 changed files with 114 additions and 34 deletions
|
|
@ -105,6 +105,9 @@ export const PROVIDER_MODELS = {
|
||||||
// { id: "claude-opus-4.5", name: "Claude Opus 4.5" },
|
// { id: "claude-opus-4.5", name: "Claude Opus 4.5" },
|
||||||
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
||||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
|
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
|
||||||
|
{ id: "deepseek-3.2", name: "DeepSeek 3.2" },
|
||||||
|
{ id: "deepseek-3.1", name: "DeepSeek 3.1" },
|
||||||
|
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
|
||||||
],
|
],
|
||||||
cu: [ // Cursor IDE
|
cu: [ // Cursor IDE
|
||||||
{ id: "default", name: "Auto (Server Picks)" },
|
{ id: "default", name: "Auto (Server Picks)" },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.3.41",
|
"version": "0.3.42",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
"ora": "^9.1.0",
|
"ora": "^9.1.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-is": "^16.13.1",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
"socks-proxy-agent": "^8.0.5",
|
"socks-proxy-agent": "^8.0.5",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ export default function ProviderDetailPage() {
|
||||||
const [selectedConnectionIds, setSelectedConnectionIds] = useState([]);
|
const [selectedConnectionIds, setSelectedConnectionIds] = useState([]);
|
||||||
const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__");
|
const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__");
|
||||||
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
||||||
|
const [providerStrategy, setProviderStrategy] = useState(null); // null = use global, "round-robin" = override
|
||||||
|
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
||||||
const { copied, copy } = useCopyToClipboard();
|
const { copied, copy } = useCopyToClipboard();
|
||||||
|
|
||||||
const providerInfo = providerNode
|
const providerInfo = providerNode
|
||||||
|
|
@ -75,14 +77,16 @@ export default function ProviderDetailPage() {
|
||||||
|
|
||||||
const fetchConnections = useCallback(async () => {
|
const fetchConnections = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [connectionsRes, nodesRes, proxyPoolsRes] = await Promise.all([
|
const [connectionsRes, nodesRes, proxyPoolsRes, settingsRes] = await Promise.all([
|
||||||
fetch("/api/providers", { cache: "no-store" }),
|
fetch("/api/providers", { cache: "no-store" }),
|
||||||
fetch("/api/provider-nodes", { cache: "no-store" }),
|
fetch("/api/provider-nodes", { cache: "no-store" }),
|
||||||
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }),
|
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }),
|
||||||
|
fetch("/api/settings", { cache: "no-store" }),
|
||||||
]);
|
]);
|
||||||
const connectionsData = await connectionsRes.json();
|
const connectionsData = await connectionsRes.json();
|
||||||
const nodesData = await nodesRes.json();
|
const nodesData = await nodesRes.json();
|
||||||
const proxyPoolsData = await proxyPoolsRes.json();
|
const proxyPoolsData = await proxyPoolsRes.json();
|
||||||
|
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||||
if (connectionsRes.ok) {
|
if (connectionsRes.ok) {
|
||||||
const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId);
|
const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId);
|
||||||
setConnections(filtered);
|
setConnections(filtered);
|
||||||
|
|
@ -90,6 +94,10 @@ export default function ProviderDetailPage() {
|
||||||
if (proxyPoolsRes.ok) {
|
if (proxyPoolsRes.ok) {
|
||||||
setProxyPools(proxyPoolsData.proxyPools || []);
|
setProxyPools(proxyPoolsData.proxyPools || []);
|
||||||
}
|
}
|
||||||
|
// Load per-provider strategy override
|
||||||
|
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
||||||
|
setProviderStrategy(override.fallbackStrategy || null);
|
||||||
|
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
||||||
if (nodesRes.ok) {
|
if (nodesRes.ok) {
|
||||||
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
||||||
|
|
||||||
|
|
@ -133,6 +141,49 @@ export default function ProviderDetailPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveProviderStrategy = async (strategy, stickyLimit) => {
|
||||||
|
try {
|
||||||
|
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||||
|
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||||
|
const current = settingsData.providerStrategies || {};
|
||||||
|
|
||||||
|
// Build override: null strategy means remove override, use global
|
||||||
|
const override = {};
|
||||||
|
if (strategy) override.fallbackStrategy = strategy;
|
||||||
|
if (strategy === "round-robin" && stickyLimit !== "") {
|
||||||
|
override.stickyRoundRobinLimit = Number(stickyLimit) || 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = { ...current };
|
||||||
|
if (Object.keys(override).length === 0) {
|
||||||
|
delete updated[providerId];
|
||||||
|
} else {
|
||||||
|
updated[providerId] = override;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ providerStrategies: updated }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error saving provider strategy:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoundRobinToggle = (enabled) => {
|
||||||
|
const strategy = enabled ? "round-robin" : null;
|
||||||
|
const sticky = enabled ? (providerStickyLimit || "1") : providerStickyLimit;
|
||||||
|
if (enabled && !providerStickyLimit) setProviderStickyLimit("1");
|
||||||
|
setProviderStrategy(strategy);
|
||||||
|
saveProviderStrategy(strategy, sticky);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStickyLimitChange = (value) => {
|
||||||
|
setProviderStickyLimit(value);
|
||||||
|
saveProviderStrategy("round-robin", value);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
fetchAliases();
|
fetchAliases();
|
||||||
|
|
@ -703,28 +754,27 @@ export default function ProviderDetailPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold">Connections</h2>
|
<h2 className="text-lg font-semibold">Connections</h2>
|
||||||
{!isCompatible && (
|
{/* Round Robin toggle */}
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{providerId === "iflow" && (
|
<span className="text-xs text-text-muted font-medium">Round Robin</span>
|
||||||
<Button
|
<Toggle
|
||||||
size="sm"
|
checked={providerStrategy === "round-robin"}
|
||||||
icon="cookie"
|
onChange={handleRoundRobinToggle}
|
||||||
variant="secondary"
|
/>
|
||||||
onClick={() => setShowIFlowCookieModal(true)}
|
{providerStrategy === "round-robin" && (
|
||||||
title="Add connection using browser cookie"
|
<div className="flex items-center gap-1.5">
|
||||||
>
|
<span className="text-xs text-text-muted">Sticky:</span>
|
||||||
Cookie
|
<input
|
||||||
</Button>
|
type="number"
|
||||||
)}
|
min={1}
|
||||||
<Button
|
value={providerStickyLimit}
|
||||||
size="sm"
|
onChange={(e) => handleStickyLimitChange(e.target.value)}
|
||||||
icon="add"
|
placeholder="1"
|
||||||
onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
|
className="w-14 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||||
>
|
/>
|
||||||
Add
|
</div>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{connections.length === 0 ? (
|
{connections.length === 0 ? (
|
||||||
|
|
@ -750,6 +800,28 @@ export default function ProviderDetailPage() {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{connectionsList}
|
{connectionsList}
|
||||||
|
{!isCompatible && (
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
{providerId === "iflow" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="cookie"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowIFlowCookieModal(true)}
|
||||||
|
title="Add connection using browser cookie"
|
||||||
|
>
|
||||||
|
Cookie
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="add"
|
||||||
|
onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ const defaultData = {
|
||||||
tunnelEnabled: false,
|
tunnelEnabled: false,
|
||||||
tunnelUrl: "",
|
tunnelUrl: "",
|
||||||
stickyRoundRobinLimit: 3,
|
stickyRoundRobinLimit: 3,
|
||||||
|
providerStrategies: {},
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
observabilityEnabled: true,
|
observabilityEnabled: true,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
|
|
@ -80,6 +81,7 @@ function cloneDefaultData() {
|
||||||
tunnelEnabled: false,
|
tunnelEnabled: false,
|
||||||
tunnelUrl: "",
|
tunnelUrl: "",
|
||||||
stickyRoundRobinLimit: 3,
|
stickyRoundRobinLimit: 3,
|
||||||
|
providerStrategies: {},
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
observabilityEnabled: true,
|
observabilityEnabled: true,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
|
|
|
||||||
|
|
@ -156,8 +156,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log account selection
|
// Log account selection
|
||||||
const accountId = credentials.connectionId.slice(0, 8);
|
log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
|
||||||
log.info("AUTH", `Using ${provider} account: ${accountId}...`);
|
|
||||||
|
|
||||||
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
||||||
|
|
||||||
|
|
@ -202,7 +201,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
||||||
|
|
||||||
if (shouldFallback) {
|
if (shouldFallback) {
|
||||||
log.warn("AUTH", `Account ${accountId}... unavailable (${result.status}), trying fallback`);
|
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
||||||
excludeConnectionId = credentials.connectionId;
|
excludeConnectionId = credentials.connectionId;
|
||||||
lastError = result.error;
|
lastError = result.error;
|
||||||
lastStatus = result.status;
|
lastStatus = result.status;
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,7 @@ export async function handleEmbeddings(request) {
|
||||||
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
|
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountId = credentials.connectionId.slice(0, 8);
|
log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
|
||||||
log.info("AUTH", `Using ${provider} account: ${accountId}...`);
|
|
||||||
|
|
||||||
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
||||||
|
|
||||||
|
|
@ -131,7 +130,7 @@ export async function handleEmbeddings(request) {
|
||||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
||||||
|
|
||||||
if (shouldFallback) {
|
if (shouldFallback) {
|
||||||
log.warn("AUTH", `Account ${accountId}... unavailable (${result.status}), trying fallback`);
|
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
||||||
excludeConnectionId = credentials.connectionId;
|
excludeConnectionId = credentials.connectionId;
|
||||||
lastError = result.error;
|
lastError = result.error;
|
||||||
lastStatus = result.status;
|
lastStatus = result.status;
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,13 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const strategy = settings.fallbackStrategy || "fill-first";
|
// Per-provider strategy overrides global setting
|
||||||
|
const providerOverride = (settings.providerStrategies || {})[providerId] || {};
|
||||||
|
const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
|
||||||
|
|
||||||
let connection;
|
let connection;
|
||||||
if (strategy === "round-robin") {
|
if (strategy === "round-robin") {
|
||||||
const stickyLimit = settings.stickyRoundRobinLimit || 3;
|
const stickyLimit = providerOverride.stickyRoundRobinLimit || settings.stickyRoundRobinLimit || 3;
|
||||||
|
|
||||||
// Sort by lastUsed (most recent first) to find current candidate
|
// Sort by lastUsed (most recent first) to find current candidate
|
||||||
const byRecency = [...availableConnections].sort((a, b) => {
|
const byRecency = [...availableConnections].sort((a, b) => {
|
||||||
|
|
@ -178,7 +180,8 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
||||||
});
|
});
|
||||||
|
|
||||||
const lockKey = Object.keys(lockUpdate)[0];
|
const lockKey = Object.keys(lockUpdate)[0];
|
||||||
log.warn("AUTH", `${connectionId.slice(0, 8)} locked ${lockKey} for ${Math.round(cooldownMs / 1000)}s [${status}]`);
|
const connName = conn?.displayName || conn?.name || conn?.email || connectionId.slice(0, 8);
|
||||||
|
log.warn("AUTH", `${connName} locked ${lockKey} for ${Math.round(cooldownMs / 1000)}s [${status}]`);
|
||||||
|
|
||||||
if (provider && status && reason) {
|
if (provider && status && reason) {
|
||||||
console.error(`❌ ${provider} [${status}]: ${reason}`);
|
console.error(`❌ ${provider} [${status}]: ${reason}`);
|
||||||
|
|
@ -228,7 +231,8 @@ export async function clearAccountError(connectionId, currentConnection, model =
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateProviderConnection(connectionId, clearObj);
|
await updateProviderConnection(connectionId, clearObj);
|
||||||
log.info("AUTH", `Account ${connectionId.slice(0, 8)} cleared lock for model=${model || "__all"}`);
|
const connName = conn?.displayName || conn?.name || conn?.email || connectionId.slice(0, 8);
|
||||||
|
log.info("AUTH", `Account ${connName} cleared lock for model=${model || "__all"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue