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:
decolua
2026-03-11 18:04:38 +07:00
parent 2470ef84de
commit fe49b61dfb
7 changed files with 114 additions and 34 deletions

View File

@@ -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)" },

View File

@@ -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",

View File

@@ -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>

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"}`);
} }
/** /**