diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 2360bd6b..c1726fbb 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -105,6 +105,9 @@ export const PROVIDER_MODELS = { // { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 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 { id: "default", name: "Auto (Server Picks)" }, diff --git a/package.json b/package.json index d52b5f7b..a83d22c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.41", + "version": "0.3.42", "description": "9Router web dashboard", "private": true, "scripts": { @@ -29,6 +29,7 @@ "ora": "^9.1.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-is": "^16.13.1", "recharts": "^3.7.0", "selfsigned": "^5.5.0", "socks-proxy-agent": "^8.0.5", diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index e034df7e..737ed1bf 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -34,6 +34,8 @@ export default function ProviderDetailPage() { const [selectedConnectionIds, setSelectedConnectionIds] = useState([]); const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__"); 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 providerInfo = providerNode @@ -75,14 +77,16 @@ export default function ProviderDetailPage() { const fetchConnections = useCallback(async () => { try { - const [connectionsRes, nodesRes, proxyPoolsRes] = await Promise.all([ + const [connectionsRes, nodesRes, proxyPoolsRes, settingsRes] = await Promise.all([ fetch("/api/providers", { cache: "no-store" }), fetch("/api/provider-nodes", { cache: "no-store" }), fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }), + fetch("/api/settings", { cache: "no-store" }), ]); const connectionsData = await connectionsRes.json(); const nodesData = await nodesRes.json(); const proxyPoolsData = await proxyPoolsRes.json(); + const settingsData = settingsRes.ok ? await settingsRes.json() : {}; if (connectionsRes.ok) { const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId); setConnections(filtered); @@ -90,6 +94,10 @@ export default function ProviderDetailPage() { if (proxyPoolsRes.ok) { 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) { 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(() => { fetchConnections(); fetchAliases(); @@ -703,28 +754,27 @@ export default function ProviderDetailPage() {

Connections

- {!isCompatible && ( -
- {providerId === "iflow" && ( - - )} - -
- )} + {/* Round Robin toggle */} +
+ Round Robin + + {providerStrategy === "round-robin" && ( +
+ Sticky: + handleStickyLimitChange(e.target.value)} + placeholder="1" + className="w-14 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary" + /> +
+ )} +
{connections.length === 0 ? ( @@ -750,6 +800,28 @@ export default function ProviderDetailPage() { ) : ( <> {connectionsList} + {!isCompatible && ( +
+ {providerId === "iflow" && ( + + )} + +
+ )} )}
diff --git a/src/lib/localDb.js b/src/lib/localDb.js index cad6c9eb..d7e9a1b6 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -53,6 +53,7 @@ const defaultData = { tunnelEnabled: false, tunnelUrl: "", stickyRoundRobinLimit: 3, + providerStrategies: {}, requireLogin: true, observabilityEnabled: true, observabilityMaxRecords: 1000, @@ -80,6 +81,7 @@ function cloneDefaultData() { tunnelEnabled: false, tunnelUrl: "", stickyRoundRobinLimit: 3, + providerStrategies: {}, requireLogin: true, observabilityEnabled: true, observabilityMaxRecords: 1000, diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 04eb51a8..12a9d6ce 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -156,8 +156,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re } // Log account selection - const accountId = credentials.connectionId.slice(0, 8); - log.info("AUTH", `Using ${provider} account: ${accountId}...`); + log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`); 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); 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; lastError = result.error; lastStatus = result.status; diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js index a8a68e01..a9e02d1b 100644 --- a/src/sse/handlers/embeddings.js +++ b/src/sse/handlers/embeddings.js @@ -103,8 +103,7 @@ export async function handleEmbeddings(request) { return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable"); } - const accountId = credentials.connectionId.slice(0, 8); - log.info("AUTH", `Using ${provider} account: ${accountId}...`); + log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`); 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); 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; lastError = result.error; lastStatus = result.status; diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 3daf41b8..c5589a30 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -72,11 +72,13 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul } 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; 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 const byRecency = [...availableConnections].sort((a, b) => { @@ -178,7 +180,8 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr }); 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) { console.error(`❌ ${provider} [${status}]: ${reason}`); @@ -228,7 +231,8 @@ export async function clearAccountError(connectionId, currentConnection, model = } 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"}`); } /**