From 88faba150a008259ef552efcbabdddf899a9faf3 Mon Sep 17 00:00:00 2001 From: luulam Date: Fri, 31 Jul 2026 10:37:45 +0700 Subject: [PATCH] feat(dashboard): model test-all with connection selector, usage by provider, combo enable toggle and showOnlyComboModels setting - provider detail: Test All Models button runs every model (built-in + custom) with an optional connection selector; failed models can be disabled in bulk - bulk selected-model test now runs all connections in parallel (Promise.all) - usage overview: new 'Usage by Provider' table view (default) and a Provider column in Recent Requests; byProvider now tracks lastUsed - combos: per-combo enable/disable toggle; disabled combos are skipped by the routing engine (getComboModels/getComboModelsFromData) and model listing - settings: 'Only show combo models' toggle filters ModelSelectModal and the /v1/models response to models present in enabled combos --- open-sse/services/combo.js | 871 +-- src/app/(dashboard)/dashboard/combos/page.js | 1342 +++-- src/app/(dashboard)/dashboard/profile/page.js | 2553 +++++---- .../dashboard/providers/[id]/page.js | 4798 +++++++++-------- src/app/api/v1/models/route.js | 989 ++-- src/lib/db/repos/combosRepo.js | 119 +- src/lib/db/repos/usageRepo.js | 1618 +++--- src/lib/db/schema.js | 273 +- src/shared/components/ModelSelectModal.js | 1345 +++-- src/shared/components/UsageStats.js | 1147 ++-- src/sse/services/model.js | 139 +- 11 files changed, 8689 insertions(+), 6505 deletions(-) diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 9216ab2f..6f6dc926 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -19,66 +19,86 @@ const TOOL_RESULT_PREFIX = "[Tool result: "; // on tools: drop the request's tools, turn tool/function results into assistant // text, and inline assistant tool_calls names instead of the structured field. function flattenToolHistory(messages) { - return messages - .filter((msg) => msg) - .map((msg) => { - if (msg.role === "tool" || msg.role === "function") { - return { role: "assistant", content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]` }; - } - if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) { - const { tool_calls, ...rest } = msg; - const names = tool_calls.map((c) => c?.function?.name || c?.name || "tool").join(", "); - const base = extractTextContent(rest.content) || (typeof rest.content === "string" ? rest.content : ""); - return { ...rest, content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]` }; - } - if (Array.isArray(msg.content)) { - const hasToolUse = msg.content.some((c) => c.type === "tool_use"); - const hasToolResult = msg.content.some((c) => c.type === "tool_result"); - if (hasToolUse || hasToolResult) { - const textParts = []; - const toolNames = []; - const toolResults = []; - for (const block of msg.content) { - if (block.type === "text" && block.text) textParts.push(block.text); - if (block.type === "tool_use") toolNames.push(block.name || "tool"); - if (block.type === "tool_result") toolResults.push(extractTextContent(block.content) || String(block.content ?? "")); - } - const { ...rest } = msg; - let newContent = textParts.join("\n"); - if (toolNames.length > 0) { - newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_CALL_PREFIX}${toolNames.join(", ")}]`; - } - if (toolResults.length > 0) { - newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_RESULT_PREFIX}${toolResults.join("\n")}]`; - } - return { ...rest, content: newContent }; - } - } - return msg; - }); + return messages + .filter((msg) => msg) + .map((msg) => { + if (msg.role === "tool" || msg.role === "function") { + return { + role: "assistant", + content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]`, + }; + } + if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) { + const { tool_calls, ...rest } = msg; + const names = tool_calls + .map((c) => c?.function?.name || c?.name || "tool") + .join(", "); + const base = + extractTextContent(rest.content) || + (typeof rest.content === "string" ? rest.content : ""); + return { + ...rest, + content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]`, + }; + } + if (Array.isArray(msg.content)) { + const hasToolUse = msg.content.some((c) => c.type === "tool_use"); + const hasToolResult = msg.content.some((c) => c.type === "tool_result"); + if (hasToolUse || hasToolResult) { + const textParts = []; + const toolNames = []; + const toolResults = []; + for (const block of msg.content) { + if (block.type === "text" && block.text) textParts.push(block.text); + if (block.type === "tool_use") toolNames.push(block.name || "tool"); + if (block.type === "tool_result") + toolResults.push( + extractTextContent(block.content) || + String(block.content ?? ""), + ); + } + const { ...rest } = msg; + let newContent = textParts.join("\n"); + if (toolNames.length > 0) { + newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_CALL_PREFIX}${toolNames.join(", ")}]`; + } + if (toolResults.length > 0) { + newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_RESULT_PREFIX}${toolResults.join("\n")}]`; + } + return { ...rest, content: newContent }; + } + } + return msg; + }); } // Reorder combo models by capability fit. Stable; never drops a model (fallback intact). // Tier 0: satisfies all hard + all soft. Tier 1: all hard only. Tier 2: rest. export function reorderByCapabilities(models, required) { - if (!required || required.size === 0 || !Array.isArray(models) || models.length <= 1) return models; - const hard = [...required].filter((c) => HARD_CAPS.has(c)); - const soft = [...required].filter((c) => !HARD_CAPS.has(c)); + if ( + !required || + required.size === 0 || + !Array.isArray(models) || + models.length <= 1 + ) + return models; + const hard = [...required].filter((c) => HARD_CAPS.has(c)); + const soft = [...required].filter((c) => !HARD_CAPS.has(c)); - const tierOf = (m) => { - const slash = typeof m === "string" ? m.indexOf("/") : -1; - const provider = slash > 0 ? m.slice(0, slash) : ""; - const model = slash > 0 ? m.slice(slash + 1) : m; - const caps = getCapabilitiesForModel(provider, model); - if (!hard.every((c) => caps[c] === true)) return 2; - return soft.every((c) => caps[c] === true) ? 0 : 1; - }; + const tierOf = (m) => { + const slash = typeof m === "string" ? m.indexOf("/") : -1; + const provider = slash > 0 ? m.slice(0, slash) : ""; + const model = slash > 0 ? m.slice(slash + 1) : m; + const caps = getCapabilitiesForModel(provider, model); + if (!hard.every((c) => caps[c] === true)) return 2; + return soft.every((c) => caps[c] === true) ? 0 : 1; + }; - // Stable sort by tier (Array.prototype.sort is stable in modern engines). - return models - .map((m, i) => ({ m, i, t: tierOf(m) })) - .sort((a, b) => a.t - b.t || a.i - b.i) - .map((x) => x.m); + // Stable sort by tier (Array.prototype.sort is stable in modern engines). + return models + .map((m, i) => ({ m, i, t: tierOf(m) })) + .sort((a, b) => a.t - b.t || a.i - b.i) + .map((x) => x.m); } /** @@ -92,58 +112,61 @@ const comboRotationState = new Map(); // so we return all of them. History media (older turns) must not pin the combo // to a vision model — those get stripped + placeholdered downstream instead. function trailingUserItems(arr) { - if (!Array.isArray(arr) || arr.length === 0) return []; - const isAssistant = (r) => r === "assistant" || r === "model"; - let i = arr.length - 1; - while (i >= 0 && !isAssistant(arr[i]?.role)) i--; - return arr.slice(i + 1); + if (!Array.isArray(arr) || arr.length === 0) return []; + const isAssistant = (r) => r === "assistant" || r === "model"; + let i = arr.length - 1; + while (i >= 0 && !isAssistant(arr[i]?.role)) i--; + return arr.slice(i + 1); } // Detect which capabilities a request needs. Modalities (vision/pdf) are scanned // only on the current user turn; "search" is request-wide (lives in tools). // Returns a Set of: "vision" | "pdf" | "search". export function detectRequiredCapabilities(body) { - const required = new Set(); - if (!body || typeof body !== "object") return required; + const required = new Set(); + if (!body || typeof body !== "object") return required; - const scanBlock = (b) => { - if (!b || typeof b !== "object") return; - const t = b.type; - if (t === "image_url" || t === "image" || t === "input_image") required.add("vision"); - if (t === "file" || t === "document" || t === "input_file") required.add("pdf"); - // gemini parts: inlineData/fileData carry a mime - const mime = b.inlineData?.mimeType || b.fileData?.mimeType; - if (typeof mime === "string" && mime.startsWith("image/")) required.add("vision"); - if (mime === "application/pdf") required.add("pdf"); - }; + const scanBlock = (b) => { + if (!b || typeof b !== "object") return; + const t = b.type; + if (t === "image_url" || t === "image" || t === "input_image") + required.add("vision"); + if (t === "file" || t === "document" || t === "input_file") + required.add("pdf"); + // gemini parts: inlineData/fileData carry a mime + const mime = b.inlineData?.mimeType || b.fileData?.mimeType; + if (typeof mime === "string" && mime.startsWith("image/")) + required.add("vision"); + if (mime === "application/pdf") required.add("pdf"); + }; - const scanContent = (content) => { - if (Array.isArray(content)) for (const b of content) scanBlock(b); - }; + const scanContent = (content) => { + if (Array.isArray(content)) for (const b of content) scanBlock(b); + }; - // Modalities: current user turn only (trailing user run across each known shape). - for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude - for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses - const contents = body.contents || body.request?.contents; // gemini / antigravity - for (const c of trailingUserItems(contents)) scanContent(c.parts); + // Modalities: current user turn only (trailing user run across each known shape). + for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude + for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses + const contents = body.contents || body.request?.contents; // gemini / antigravity + for (const c of trailingUserItems(contents)) scanContent(c.parts); - // search: temporarily disabled in auto-switch (feature not wired yet). + // search: temporarily disabled in auto-switch (feature not wired yet). - return required; + return required; } function normalizeStickyLimit(stickyLimit) { - const parsed = Number.parseInt(stickyLimit, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; + const parsed = Number.parseInt(stickyLimit, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; } function rotateModelsFromIndex(models, currentIndex) { - const rotatedModels = [...models]; - for (let i = 0; i < currentIndex; i++) { - const moved = rotatedModels.shift(); - rotatedModels.push(moved); - } - return rotatedModels; + const rotatedModels = [...models]; + for (let i = 0; i < currentIndex; i++) { + const moved = rotatedModels.shift(); + rotatedModels.push(moved); + } + return rotatedModels; } /** @@ -155,34 +178,35 @@ function rotateModelsFromIndex(models, currentIndex) { * @returns {string[]} Rotated models array */ export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) { - if (!models || models.length <= 1 || strategy !== "round-robin") { - return models; - } + if (!models || models.length <= 1 || strategy !== "round-robin") { + return models; + } - const rotationKey = comboName || "__default__"; - const normalizedStickyLimit = normalizeStickyLimit(stickyLimit); - const existingState = comboRotationState.get(rotationKey); - const state = typeof existingState === "number" - ? { index: existingState, consecutiveUseCount: 0 } - : (existingState || { index: 0, consecutiveUseCount: 0 }); + const rotationKey = comboName || "__default__"; + const normalizedStickyLimit = normalizeStickyLimit(stickyLimit); + const existingState = comboRotationState.get(rotationKey); + const state = + typeof existingState === "number" + ? { index: existingState, consecutiveUseCount: 0 } + : existingState || { index: 0, consecutiveUseCount: 0 }; - const currentIndex = state.index % models.length; - const rotatedModels = rotateModelsFromIndex(models, currentIndex); - const nextUseCount = state.consecutiveUseCount + 1; + const currentIndex = state.index % models.length; + const rotatedModels = rotateModelsFromIndex(models, currentIndex); + const nextUseCount = state.consecutiveUseCount + 1; - if (nextUseCount >= normalizedStickyLimit) { - comboRotationState.set(rotationKey, { - index: (currentIndex + 1) % models.length, - consecutiveUseCount: 0, - }); - } else { - comboRotationState.set(rotationKey, { - index: currentIndex, - consecutiveUseCount: nextUseCount, - }); - } + if (nextUseCount >= normalizedStickyLimit) { + comboRotationState.set(rotationKey, { + index: (currentIndex + 1) % models.length, + consecutiveUseCount: 0, + }); + } else { + comboRotationState.set(rotationKey, { + index: currentIndex, + consecutiveUseCount: nextUseCount, + }); + } - return rotatedModels; + return rotatedModels; } /** @@ -190,8 +214,8 @@ export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) { * @param {string} [comboName] - Combo name to reset; omit to clear all */ export function resetComboRotation(comboName) { - if (comboName) comboRotationState.delete(comboName); - else comboRotationState.clear(); + if (comboName) comboRotationState.delete(comboName); + else comboRotationState.clear(); } /** @@ -201,17 +225,24 @@ export function resetComboRotation(comboName) { * @returns {string[]|null} Array of models or null if not a combo */ export function getComboModelsFromData(modelStr, combosData) { - // Don't check if it's in provider/model format - if (modelStr.includes("/")) return null; - - // Handle both array and object formats - const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []); - - const combo = combos.find(c => c.name === modelStr); - if (combo && combo.models && combo.models.length > 0) { - return combo.models; - } - return null; + // Don't check if it's in provider/model format + if (modelStr.includes("/")) return null; + + // Handle both array and object formats + const combos = Array.isArray(combosData) + ? combosData + : combosData?.combos || []; + + const combo = combos.find((c) => c.name === modelStr); + if ( + combo && + combo.enabled !== false && + combo.models && + combo.models.length > 0 + ) { + return combo.models; + } + return null; } /** @@ -226,108 +257,159 @@ export function getComboModelsFromData(modelStr, combosData) { * @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching * @returns {Promise} */ -export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) { - // Apply rotation strategy if enabled - let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); +export async function handleComboChat({ + body, + models, + handleSingleModel, + log, + comboName, + comboStrategy, + comboStickyLimit = 1, + autoSwitch = true, +}) { + // Apply rotation strategy if enabled + let rotatedModels = getRotatedModels( + models, + comboName, + comboStrategy, + comboStickyLimit, + ); - // Auto-switch: float models that satisfy the request's required capabilities to the front. - if (autoSwitch) { - const required = detectRequiredCapabilities(body); - if (required.size > 0) { - const reordered = reorderByCapabilities(rotatedModels, required); - if (reordered[0] !== rotatedModels[0]) { - log.info("COMBO", `auto-switch for [${[...required].join(",")}] → ${reordered[0]}`); - } - rotatedModels = reordered; - } - } - - let lastError = null; - let earliestRetryAfter = null; - let lastStatus = null; + // Auto-switch: float models that satisfy the request's required capabilities to the front. + if (autoSwitch) { + const required = detectRequiredCapabilities(body); + if (required.size > 0) { + const reordered = reorderByCapabilities(rotatedModels, required); + if (reordered[0] !== rotatedModels[0]) { + log.info( + "COMBO", + `auto-switch for [${[...required].join(",")}] → ${reordered[0]}`, + ); + } + rotatedModels = reordered; + } + } - for (let i = 0; i < rotatedModels.length; i++) { - const modelStr = rotatedModels[i]; - log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`); + let lastError = null; + let earliestRetryAfter = null; + let lastStatus = null; - try { - const result = await handleSingleModel(body, modelStr); - - // Success (2xx) - return response - if (result.ok) { - log.info("COMBO", `Model ${modelStr} succeeded`); - return result; - } + for (let i = 0; i < rotatedModels.length; i++) { + const modelStr = rotatedModels[i]; + log.info( + "COMBO", + `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`, + ); - // Extract error info from response - let errorText = result.statusText || ""; - let retryAfter = null; - try { - const errorBody = await result.clone().json(); - errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; - retryAfter = errorBody?.retryAfter || null; - } catch { - // Ignore JSON parse errors - } + try { + const result = await handleSingleModel(body, modelStr); - // Track earliest retryAfter across all combo models - if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) { - earliestRetryAfter = retryAfter; - } + // Success (2xx) - return response + if (result.ok) { + log.info("COMBO", `Model ${modelStr} succeeded`); + return result; + } - // Normalize error text to string (Worker-safe) - if (typeof errorText !== "string") { - try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); } - } + // Extract error info from response + let errorText = result.statusText || ""; + let retryAfter = null; + try { + const errorBody = await result.clone().json(); + errorText = + errorBody?.error?.message || + errorBody?.error || + errorBody?.message || + errorText; + retryAfter = errorBody?.retryAfter || null; + } catch { + // Ignore JSON parse errors + } - // Check if should fallback to next model - const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText); + // Track earliest retryAfter across all combo models + if ( + retryAfter && + (!earliestRetryAfter || + new Date(retryAfter) < new Date(earliestRetryAfter)) + ) { + earliestRetryAfter = retryAfter; + } - if (!shouldFallback) { - log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status }); - return result; - } + // Normalize error text to string (Worker-safe) + if (typeof errorText !== "string") { + try { + errorText = JSON.stringify(errorText); + } catch { + errorText = String(errorText); + } + } - // For transient errors (503/502/504), wait for cooldown before falling through - // so a briefly-overloaded provider gets a chance to recover rather than being - // skipped immediately (fixes: combo falls through on transient 503) - if (cooldownMs && cooldownMs > 0 && cooldownMs <= 5000 && - (result.status === 503 || result.status === 502 || result.status === 504)) { - log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`); - await new Promise(r => setTimeout(r, cooldownMs)); - } + // Check if should fallback to next model + const { shouldFallback, cooldownMs } = checkFallbackError( + result.status, + errorText, + ); - // Fallback to next model - lastError = errorText || String(result.status); - if (!lastStatus) lastStatus = result.status; - log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); - } catch (error) { - // Catch unexpected exceptions to ensure fallback continues - lastError = error.message || String(error); - if (!lastStatus) lastStatus = 500; - log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError }); - } - } + if (!shouldFallback) { + log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { + status: result.status, + }); + return result; + } - // All models failed - // Use 503 (Service Unavailable) rather than 406 (Not Acceptable) — 406 implies - // the request itself is invalid, but here the providers are simply unavailable - // or have no active credentials. 503 is more accurate and retryable by clients. - const allDisabled = lastError && lastError.toLowerCase().includes("no credentials"); - const status = allDisabled ? 503 : (lastStatus || 503); - const msg = lastError || "All combo models unavailable"; + // For transient errors (503/502/504), wait for cooldown before falling through + // so a briefly-overloaded provider gets a chance to recover rather than being + // skipped immediately (fixes: combo falls through on transient 503) + if ( + cooldownMs && + cooldownMs > 0 && + cooldownMs <= 5000 && + (result.status === 503 || + result.status === 502 || + result.status === 504) + ) { + log.info( + "COMBO", + `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`, + ); + await new Promise((r) => setTimeout(r, cooldownMs)); + } - if (earliestRetryAfter) { - const retryHuman = formatRetryAfter(earliestRetryAfter); - log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`); - return unavailableResponse(status, msg, earliestRetryAfter, retryHuman); - } + // Fallback to next model + lastError = errorText || String(result.status); + if (!lastStatus) lastStatus = result.status; + log.warn("COMBO", `Model ${modelStr} failed, trying next`, { + status: result.status, + }); + } catch (error) { + // Catch unexpected exceptions to ensure fallback continues + lastError = error.message || String(error); + if (!lastStatus) lastStatus = 500; + log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { + error: lastError, + }); + } + } - log.warn("COMBO", `All models failed | ${msg}`); - return new Response( - JSON.stringify({ error: { message: msg } }), - { status, headers: { "Content-Type": "application/json" } } - ); + // All models failed + // Use 503 (Service Unavailable) rather than 406 (Not Acceptable) — 406 implies + // the request itself is invalid, but here the providers are simply unavailable + // or have no active credentials. 503 is more accurate and retryable by clients. + const allDisabled = + lastError && lastError.toLowerCase().includes("no credentials"); + const status = allDisabled ? 503 : lastStatus || 503; + const msg = lastError || "All combo models unavailable"; + + if (earliestRetryAfter) { + const retryHuman = formatRetryAfter(earliestRetryAfter); + log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`); + return unavailableResponse(status, msg, earliestRetryAfter, retryHuman); + } + + log.warn("COMBO", `All models failed | ${msg}`); + return new Response(JSON.stringify({ error: { message: msg } }), { + status, + headers: { "Content-Type": "application/json" }, + }); } /** @@ -337,37 +419,40 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co * leaf content→string step reuses the translator's own extractTextContent. */ function extractPanelText(json) { - if (!json || typeof json !== "object") return ""; + if (!json || typeof json !== "object") return ""; - // OpenAI chat completion - const choice = json.choices?.[0]; - if (choice) { - const msg = choice.message ?? choice.delta ?? {}; - const t = extractTextContent(msg.content); - if (t.trim()) return t; - if (typeof choice.text === "string" && choice.text.trim()) return choice.text; - } + // OpenAI chat completion + const choice = json.choices?.[0]; + if (choice) { + const msg = choice.message ?? choice.delta ?? {}; + const t = extractTextContent(msg.content); + if (t.trim()) return t; + if (typeof choice.text === "string" && choice.text.trim()) + return choice.text; + } - // Claude messages (text blocks share OpenAI's {type:"text"} shape) - const claudeText = extractTextContent(json.content); - if (claudeText.trim()) return claudeText; + // Claude messages (text blocks share OpenAI's {type:"text"} shape) + const claudeText = extractTextContent(json.content); + if (claudeText.trim()) return claudeText; - // Gemini (parts carry .text without a type discriminator) - const parts = json.candidates?.[0]?.content?.parts; - if (Array.isArray(parts)) { - const t = parts.map((p) => p?.text || "").join(""); - if (t.trim()) return t; - } + // Gemini (parts carry .text without a type discriminator) + const parts = json.candidates?.[0]?.content?.parts; + if (Array.isArray(parts)) { + const t = parts.map((p) => p?.text || "").join(""); + if (t.trim()) return t; + } - // OpenAI Responses API - if (Array.isArray(json.output)) { - const t = json.output - .flatMap((o) => (Array.isArray(o.content) ? o.content.map((c) => c?.text || "") : [])) - .join(""); - if (t.trim()) return t; - } + // OpenAI Responses API + if (Array.isArray(json.output)) { + const t = json.output + .flatMap((o) => + Array.isArray(o.content) ? o.content.map((c) => c?.text || "") : [], + ) + .join(""); + if (t.trim()) return t; + } - return ""; + return ""; } /** @@ -375,17 +460,17 @@ function extractPanelText(json) { * Preserves the original conversation + system prompt so the judge has full context. */ function appendUserTurn(body, text) { - const next = { ...body }; - if (Array.isArray(body.messages)) { - next.messages = [...body.messages, { role: "user", content: text }]; - } else if (Array.isArray(body.input)) { - next.input = [...body.input, { role: "user", content: text }]; - } else if (Array.isArray(body.contents)) { - next.contents = [...body.contents, { role: "user", parts: [{ text }] }]; - } else { - next.messages = [{ role: "user", content: text }]; - } - return next; + const next = { ...body }; + if (Array.isArray(body.messages)) { + next.messages = [...body.messages, { role: "user", content: text }]; + } else if (Array.isArray(body.input)) { + next.input = [...body.input, { role: "user", content: text }]; + } else if (Array.isArray(body.contents)) { + next.contents = [...body.contents, { role: "user", parts: [{ text }] }]; + } else { + next.messages = [{ role: "user", content: text }]; + } + return next; } /** @@ -398,40 +483,46 @@ function appendUserTurn(body, text) { * reputation of a model brand. */ function buildJudgePrompt(answers) { - const panel = answers - .map((a, i) => `[Source ${i + 1}]\n${a.text}`) - .join("\n\n"); + const panel = answers + .map((a, i) => `[Source ${i + 1}]\n${a.text}`) + .join("\n\n"); - return [ - `You are the JUDGE in a model-fusion panel. ${answers.length} expert models independently answered the user's most recent request. Their responses are below, anonymized by source.`, - "", - "Do NOT mention that multiple models were used, and do NOT refer to the sources. Produce ONE authoritative final answer addressed directly to the user.", - "", - "First, internally analyze the panel along these dimensions: consensus (points most sources agree on — treat as higher-confidence), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed. Then write the best possible final answer grounded in that analysis — more complete and correct than any single response, with no filler.", - "", - "=== PANEL RESPONSES ===", - panel, - "=== END PANEL RESPONSES ===", - "", - "Now write the final answer to the user's original request.", - ].join("\n"); + return [ + `You are the JUDGE in a model-fusion panel. ${answers.length} expert models independently answered the user's most recent request. Their responses are below, anonymized by source.`, + "", + "Do NOT mention that multiple models were used, and do NOT refer to the sources. Produce ONE authoritative final answer addressed directly to the user.", + "", + "First, internally analyze the panel along these dimensions: consensus (points most sources agree on — treat as higher-confidence), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed. Then write the best possible final answer grounded in that analysis — more complete and correct than any single response, with no filler.", + "", + "=== PANEL RESPONSES ===", + panel, + "=== END PANEL RESPONSES ===", + "", + "Now write the final answer to the user's original request.", + ].join("\n"); } // Fusion tuning. Overridable per-combo via settings.comboStrategies[name]. const FUSION_DEFAULTS = { - minPanel: 2, // answers needed before stragglers get a grace window - stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached - panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever + minPanel: 2, // answers needed before stragglers get a grace window + stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached + panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever }; // Resolve a Response (or {__error}) within ms; the loser keeps running but is ignored. function withTimeout(promise, ms) { - return new Promise((resolve) => { - const t = setTimeout(() => resolve({ __timeout: true }), ms); - Promise.resolve(promise) - .then((v) => { clearTimeout(t); resolve(v); }) - .catch((e) => { clearTimeout(t); resolve({ __error: e }); }); - }); + return new Promise((resolve) => { + const t = setTimeout(() => resolve({ __timeout: true }), ms); + Promise.resolve(promise) + .then((v) => { + clearTimeout(t); + resolve(v); + }) + .catch((e) => { + clearTimeout(t); + resolve({ __error: e }); + }); + }); } /** @@ -441,33 +532,41 @@ function withTimeout(promise, ms) { * still preferring a full panel when everyone is fast. Bounded by a hard timeout. * Returns a sparse array aligned to `calls` (undefined = not yet / dropped). */ -function collectPanel(calls, { minPanel, stragglerGraceMs, panelHardTimeoutMs }) { - return new Promise((resolve) => { - const out = new Array(calls.length); - let settled = 0; - let ok = 0; - let finished = false; - let graceTimer = null; - const finish = () => { - if (finished) return; - finished = true; - clearTimeout(hardTimer); - if (graceTimer) clearTimeout(graceTimer); - resolve(out); - }; - const hardTimer = setTimeout(finish, panelHardTimeoutMs); - calls.forEach((p, i) => { - Promise.resolve(p) - .then((v) => { out[i] = v; }) - .catch((e) => { out[i] = { __error: e }; }) - .finally(() => { - settled++; - if (out[i] && out[i].ok) ok++; - if (settled === calls.length) return finish(); - if (ok >= minPanel && !graceTimer) graceTimer = setTimeout(finish, stragglerGraceMs); - }); - }); - }); +function collectPanel( + calls, + { minPanel, stragglerGraceMs, panelHardTimeoutMs }, +) { + return new Promise((resolve) => { + const out = new Array(calls.length); + let settled = 0; + let ok = 0; + let finished = false; + let graceTimer = null; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(hardTimer); + if (graceTimer) clearTimeout(graceTimer); + resolve(out); + }; + const hardTimer = setTimeout(finish, panelHardTimeoutMs); + calls.forEach((p, i) => { + Promise.resolve(p) + .then((v) => { + out[i] = v; + }) + .catch((e) => { + out[i] = { __error: e }; + }) + .finally(() => { + settled++; + if (out[i] && out[i].ok) ok++; + if (settled === calls.length) return finish(); + if (ok >= minPanel && !graceTimer) + graceTimer = setTimeout(finish, stragglerGraceMs); + }); + }); + }); } /** @@ -493,79 +592,111 @@ function collectPanel(calls, { minPanel, stragglerGraceMs, panelHardTimeoutMs }) * @param {Object} [options.tuning] - Override FUSION_DEFAULTS (minPanel, grace, timeout) * @returns {Promise} */ -export async function handleFusionChat({ body, models, handleSingleModel, log, comboName, judgeModel, tuning }) { - const panel = Array.isArray(models) ? models.filter(Boolean) : []; - if (panel.length === 0) { - return new Response( - JSON.stringify({ error: { message: "Fusion combo has no models" } }), - { status: 400, headers: { "Content-Type": "application/json" } } - ); - } +export async function handleFusionChat({ + body, + models, + handleSingleModel, + log, + comboName, + judgeModel, + tuning, +}) { + const panel = Array.isArray(models) ? models.filter(Boolean) : []; + if (panel.length === 0) { + return new Response( + JSON.stringify({ error: { message: "Fusion combo has no models" } }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } - // A single-model fusion has nothing to fuse — just answer directly. - if (panel.length === 1) { - return handleSingleModel(body, panel[0]); - } + // A single-model fusion has nothing to fuse — just answer directly. + if (panel.length === 1) { + return handleSingleModel(body, panel[0]); + } - const cfg = { ...FUSION_DEFAULTS, ...(tuning || {}) }; - const minPanel = Math.min(Math.max(2, cfg.minPanel), panel.length); - const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0]; - log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`); + const cfg = { ...FUSION_DEFAULTS, ...(tuning || {}) }; + const minPanel = Math.min(Math.max(2, cfg.minPanel), panel.length); + const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0]; + log.info( + "FUSION", + `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`, + ); - // 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose). - const { tools, tool_choice, ...rest } = body; - const panelBody = { ...rest, stream: false }; + // 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose). + const { tools, tool_choice, ...rest } = body; + const panelBody = { ...rest, stream: false }; - // Flatten tool turns to prose so panel models keep context without emitting tool_calls. - if (Array.isArray(panelBody.messages)) { - panelBody.messages = flattenToolHistory(panelBody.messages); - } else if (Array.isArray(panelBody.input)) { - panelBody.input = flattenToolHistory(panelBody.input); - } + // Flatten tool turns to prose so panel models keep context without emitting tool_calls. + if (Array.isArray(panelBody.messages)) { + panelBody.messages = flattenToolHistory(panelBody.messages); + } else if (Array.isArray(panelBody.input)) { + panelBody.input = flattenToolHistory(panelBody.input); + } - const t0 = Date.now(); - const calls = panel.map((m) => withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs)); - const settled = await collectPanel(calls, { ...cfg, minPanel }); - log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`); + const t0 = Date.now(); + const calls = panel.map((m) => + withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs), + ); + const settled = await collectPanel(calls, { ...cfg, minPanel }); + log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`); - // 2. Collect successful answers. - const answers = []; - for (let i = 0; i < settled.length; i++) { - const res = settled[i]; - const model = panel[i]; - if (!res) { log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`); continue; } - if (res.__timeout) { log.warn("FUSION", `Panel ${model} timed out`); continue; } - if (res.__error) { log.warn("FUSION", `Panel ${model} threw`, { error: res.__error?.message || String(res.__error) }); continue; } - if (!res.ok) { log.warn("FUSION", `Panel ${model} failed`, { status: res.status }); continue; } - try { - const json = await res.clone().json(); - const text = extractPanelText(json); - if (text) { - answers.push({ model, text }); - log.info("FUSION", `Panel ${model} ok (${text.length} chars)`); - } else { - log.warn("FUSION", `Panel ${model} returned empty content`); - } - } catch (e) { - log.warn("FUSION", `Panel ${model} unparseable`, { error: e.message || String(e) }); - } - } + // 2. Collect successful answers. + const answers = []; + for (let i = 0; i < settled.length; i++) { + const res = settled[i]; + const model = panel[i]; + if (!res) { + log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`); + continue; + } + if (res.__timeout) { + log.warn("FUSION", `Panel ${model} timed out`); + continue; + } + if (res.__error) { + log.warn("FUSION", `Panel ${model} threw`, { + error: res.__error?.message || String(res.__error), + }); + continue; + } + if (!res.ok) { + log.warn("FUSION", `Panel ${model} failed`, { status: res.status }); + continue; + } + try { + const json = await res.clone().json(); + const text = extractPanelText(json); + if (text) { + answers.push({ model, text }); + log.info("FUSION", `Panel ${model} ok (${text.length} chars)`); + } else { + log.warn("FUSION", `Panel ${model} returned empty content`); + } + } catch (e) { + log.warn("FUSION", `Panel ${model} unparseable`, { + error: e.message || String(e), + }); + } + } - // 3. Degrade gracefully when the panel is too thin to fuse. - if (answers.length === 0) { - log.warn("FUSION", "All panel models failed"); - return new Response( - JSON.stringify({ error: { message: "All fusion panel models failed" } }), - { status: 503, headers: { "Content-Type": "application/json" } } - ); - } - if (answers.length === 1) { - log.info("FUSION", `Only ${answers[0].model} succeeded — answering directly (no fusion)`); - return handleSingleModel(body, answers[0].model); - } + // 3. Degrade gracefully when the panel is too thin to fuse. + if (answers.length === 0) { + log.warn("FUSION", "All panel models failed"); + return new Response( + JSON.stringify({ error: { message: "All fusion panel models failed" } }), + { status: 503, headers: { "Content-Type": "application/json" } }, + ); + } + if (answers.length === 1) { + log.info( + "FUSION", + `Only ${answers[0].model} succeeded — answering directly (no fusion)`, + ); + return handleSingleModel(body, answers[0].model); + } - // 4. Judge analyzes + writes one final answer (streams to client if requested). - const judgeBody = appendUserTurn(body, buildJudgePrompt(answers)); - log.info("FUSION", `Judging ${answers.length} answers with ${judge}`); - return handleSingleModel(judgeBody, judge); + // 4. Judge analyzes + writes one final answer (streams to client if requested). + const judgeBody = appendUserTurn(body, buildJudgePrompt(answers)); + log.info("FUSION", `Judging ${answers.length} answers with ${judge}`); + return handleSingleModel(judgeBody, judge); } diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index fb4c17d6..59fb50f4 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -1,654 +1,818 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; -import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; -import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components"; +import { + restrictToVerticalAxis, + restrictToParentElement, +} from "@dnd-kit/modifiers"; +import { + Card, + Button, + Modal, + Input, + CardSkeleton, + ModelSelectModal, + ConfirmModal, + CapacityBadges, + Select, + Toggle, +} from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useModelCaps } from "@/shared/hooks/useModelCaps"; -import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; +import { + isOpenAICompatibleProvider, + isAnthropicCompatibleProvider, +} from "@/shared/constants/providers"; // Validate combo name: only a-z, A-Z, 0-9, -, _ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; export default function CombosPage() { - const [combos, setCombos] = useState([]); - const [loading, setLoading] = useState(true); - const [showCreateModal, setShowCreateModal] = useState(false); - const [editingCombo, setEditingCombo] = useState(null); - const [activeProviders, setActiveProviders] = useState([]); - const [comboStrategies, setComboStrategies] = useState({}); - const { getCaps } = useModelCaps(); - const [confirmState, setConfirmState] = useState(null); - const { copied, copy } = useCopyToClipboard(); + const [combos, setCombos] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingCombo, setEditingCombo] = useState(null); + const [activeProviders, setActiveProviders] = useState([]); + const [comboStrategies, setComboStrategies] = useState({}); + const { getCaps } = useModelCaps(); + const [confirmState, setConfirmState] = useState(null); + const { copied, copy } = useCopyToClipboard(); - useEffect(() => { - fetchData(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + fetchData(); + }, []); - const fetchData = async () => { - try { - const [combosRes, providersRes, settingsRes] = await Promise.all([ - fetch("/api/combos"), - fetch("/api/providers"), - fetch("/api/settings"), - ]); - const combosData = await combosRes.json(); - const providersData = await providersRes.json(); - const settingsData = settingsRes.ok ? await settingsRes.json() : {}; - - // Only LLM combos here - webSearch/webFetch combos belong to media-providers/web - if (combosRes.ok) setCombos((combosData.combos || []).filter(c => !c.kind || c.kind === "llm")); - if (providersRes.ok) { - setActiveProviders(providersData.connections || []); - } - setComboStrategies(settingsData.comboStrategies || {}); - } catch (error) { - console.log("Error fetching data:", error); - } finally { - setLoading(false); - } - }; + const fetchData = async () => { + try { + const [combosRes, providersRes, settingsRes] = await Promise.all([ + fetch("/api/combos"), + fetch("/api/providers"), + fetch("/api/settings"), + ]); + const combosData = await combosRes.json(); + const providersData = await providersRes.json(); + const settingsData = settingsRes.ok ? await settingsRes.json() : {}; - const handleCreate = async (data) => { - try { - const res = await fetch("/api/combos", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - if (res.ok) { - await fetchData(); - setShowCreateModal(false); - } else { - const err = await res.json(); - alert(err.error || "Failed to create combo"); - } - } catch (error) { - console.log("Error creating combo:", error); - } - }; + // Only LLM combos here - webSearch/webFetch combos belong to media-providers/web + if (combosRes.ok) + setCombos( + (combosData.combos || []).filter((c) => !c.kind || c.kind === "llm"), + ); + if (providersRes.ok) { + setActiveProviders(providersData.connections || []); + } + setComboStrategies(settingsData.comboStrategies || {}); + } catch (error) { + console.log("Error fetching data:", error); + } finally { + setLoading(false); + } + }; - const handleUpdate = async (id, data) => { - try { - const res = await fetch(`/api/combos/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - if (res.ok) { - await fetchData(); - setEditingCombo(null); - } else { - const err = await res.json(); - alert(err.error || "Failed to update combo"); - } - } catch (error) { - console.log("Error updating combo:", error); - } - }; + const handleCreate = async (data) => { + try { + const res = await fetch("/api/combos", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (res.ok) { + await fetchData(); + setShowCreateModal(false); + } else { + const err = await res.json(); + alert(err.error || "Failed to create combo"); + } + } catch (error) { + console.log("Error creating combo:", error); + } + }; - const handleDelete = async (id) => { - setConfirmState({ - title: "Delete Combo", - message: "Delete this combo?", - onConfirm: async () => { - setConfirmState(null); - try { - const res = await fetch(`/api/combos/${id}`, { method: "DELETE" }); - if (res.ok) { - setCombos(combos.filter(c => c.id !== id)); - } - } catch (error) { - console.log("Error deleting combo:", error); - } - } - }); - }; + const handleUpdate = async (id, data) => { + try { + const res = await fetch(`/api/combos/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (res.ok) { + await fetchData(); + setEditingCombo(null); + } else { + const err = await res.json(); + alert(err.error || "Failed to update combo"); + } + } catch (error) { + console.log("Error updating combo:", error); + } + }; - // Merge a per-combo strategy patch into settings.comboStrategies. Passing an empty - // patch (strategy back to default "fallback") drops the entry entirely. - const handleSetComboStrategy = async (comboName, patch) => { - try { - const updated = { ...comboStrategies }; - const next = { ...(updated[comboName] || {}), ...patch }; - // Prune to keep settings clean: default fallback with no extras = no entry. - if (!next.fallbackStrategy || next.fallbackStrategy === "fallback") { - delete updated[comboName]; - } else { - updated[comboName] = next; - } + const handleDelete = async (id) => { + setConfirmState({ + title: "Delete Combo", + message: "Delete this combo?", + onConfirm: async () => { + setConfirmState(null); + try { + const res = await fetch(`/api/combos/${id}`, { method: "DELETE" }); + if (res.ok) { + setCombos(combos.filter((c) => c.id !== id)); + } + } catch (error) { + console.log("Error deleting combo:", error); + } + }, + }); + }; - await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ comboStrategies: updated }), - }); + // Merge a per-combo strategy patch into settings.comboStrategies. Passing an empty + // patch (strategy back to default "fallback") drops the entry entirely. + const handleSetComboStrategy = async (comboName, patch) => { + try { + const updated = { ...comboStrategies }; + const next = { ...(updated[comboName] || {}), ...patch }; + // Prune to keep settings clean: default fallback with no extras = no entry. + if (!next.fallbackStrategy || next.fallbackStrategy === "fallback") { + delete updated[comboName]; + } else { + updated[comboName] = next; + } - setComboStrategies(updated); - } catch (error) { - console.log("Error updating combo strategy:", error); - } - }; + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ comboStrategies: updated }), + }); - if (loading) { - return ( -
- - -
- ); - } + setComboStrategies(updated); + } catch (error) { + console.log("Error updating combo strategy:", error); + } + }; - return ( -
- {/* Header */} -
-
-

- Group models under one name, then pick a strategy per combo: -

-
    -
  • Fallback — tries models in order (next on failure)
  • -
  • Round Robin — rotates models across requests to spread load
  • -
  • Fusion — queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)
  • -
  • Capacity auto-switch — sends image/PDF/audio requests to a model that supports them first
  • -
-
- -
+ if (loading) { + return ( +
+ + +
+ ); + } - {/* Combos List */} - {combos.length === 0 ? ( - -
-
- layers -
-

No combos yet

-

Create model combos with fallback support

- -
-
- ) : ( -
- {combos.map((combo) => ( - setEditingCombo(combo)} - onDelete={() => handleDelete(combo.id)} - strategy={comboStrategies[combo.name] || {}} - onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)} - /> - ))} -
- )} + return ( +
+ {/* Header */} +
+
+

+ Group models under one name, then pick a strategy per combo: +

+
    +
  • + Fallback — + tries models in order (next on failure) +
  • +
  • + Round Robin — + rotates models across requests to spread load +
  • +
  • + Fusion — + queries all models in parallel, then a judge synthesizes one + answer. Best quality, but costs the most: every request bills all + panel models + the judge (N+1 calls) +
  • +
  • + + Capacity auto-switch + {" "} + — sends image/PDF/audio requests to a model that supports them + first +
  • +
+
+ +
- {/* Create Modal - Use key to force remount and reset state */} - {showCreateModal && ( - setShowCreateModal(false)} - onSave={handleCreate} - activeProviders={activeProviders} - /> - )} + {/* Combos List */} + {combos.length === 0 ? ( + +
+
+ + layers + +
+

No combos yet

+

+ Create model combos with fallback support +

+ +
+
+ ) : ( +
+ {combos.map((combo) => ( + setEditingCombo(combo)} + onDelete={() => handleDelete(combo.id)} + onToggleEnabled={(enabled) => handleUpdate(combo.id, { enabled })} + strategy={comboStrategies[combo.name] || {}} + onSetStrategy={(patch) => + handleSetComboStrategy(combo.name, patch) + } + /> + ))} +
+ )} - {editingCombo && ( - setEditingCombo(null)} - onSave={(data) => handleUpdate(editingCombo.id, data)} - activeProviders={activeProviders} - /> - )} + {/* Create Modal - Use key to force remount and reset state */} + {showCreateModal && ( + setShowCreateModal(false)} + onSave={handleCreate} + activeProviders={activeProviders} + /> + )} - {/* Confirm Delete Modal */} - setConfirmState(null)} - onConfirm={confirmState?.onConfirm} - title={confirmState?.title || "Confirm"} - message={confirmState?.message} - variant="danger" - /> -
- ); + {editingCombo && ( + setEditingCombo(null)} + onSave={(data) => handleUpdate(editingCombo.id, data)} + activeProviders={activeProviders} + /> + )} + + {/* Confirm Delete Modal */} + setConfirmState(null)} + onConfirm={confirmState?.onConfirm} + title={confirmState?.title || "Confirm"} + message={confirmState?.message} + variant="danger" + /> +
+ ); } const STRATEGY_OPTIONS = [ - { value: "fallback", label: "Fallback — try in order" }, - { value: "round-robin", label: "Round Robin — rotate" }, - { value: "fusion", label: "Fusion — panel + judge" }, + { value: "fallback", label: "Fallback — try in order" }, + { value: "round-robin", label: "Round Robin — rotate" }, + { value: "fusion", label: "Fusion — panel + judge" }, ]; -function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) { - const [showJudgeSelect, setShowJudgeSelect] = useState(false); - const current = strategy.fallbackStrategy || "fallback"; - const judge = strategy.judgeModel || ""; - const isFusion = current === "fusion"; +function ComboCard({ + combo, + getCaps, + activeProviders = [], + copied, + onCopy, + onEdit, + onDelete, + strategy = {}, + onSetStrategy, + onToggleEnabled, +}) { + const [showJudgeSelect, setShowJudgeSelect] = useState(false); + const current = strategy.fallbackStrategy || "fallback"; + const judge = strategy.judgeModel || ""; + const isFusion = current === "fusion"; - return ( - -
-
-
- layers -
-
- {combo.name} -
- {combo.models.length === 0 ? ( - No models - ) : ( - combo.models.slice(0, 3).map((model, index) => ( - - {model} - - - )) - )} - {combo.models.length > 3 && ( - +{combo.models.length - 3} more - )} -
- {/* Fusion: judge picker (Auto = first model) */} - {isFusion && ( -
- Judge - - {judge && ( - - )} -
- )} -
-
+ return ( + +
+
+
+ + layers + +
+
+ + {combo.name} + +
+ {combo.models.length === 0 ? ( + + No models + + ) : ( + combo.models.slice(0, 3).map((model, index) => ( + + {model} + + + )) + )} + {combo.models.length > 3 && ( + + +{combo.models.length - 3} more + + )} +
+ {/* Fusion: judge picker (Auto = first model) */} + {isFusion && ( +
+ + Judge + + + {judge && ( + + )} +
+ )} +
+
- {/* Actions */} -
- {/* Strategy selector — always visible */} -
- + onSetStrategy({ fallbackStrategy: e.target.value }) + } + selectClassName="py-1.5 text-xs" + /> +
-
- - - -
-
-
+ {onToggleEnabled && ( + onToggleEnabled(checked)} + /> + )} +
+ + + +
+
+ - {/* Judge model picker (single-select; combo members make natural judges too) */} - {showJudgeSelect && ( - setShowJudgeSelect(false)} - onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} - activeProviders={activeProviders} - title="Select Judge Model" - addedModelValues={judge ? [judge] : []} - closeOnSelect={true} - /> - )} -
- ); + {/* Judge model picker (single-select; combo members make natural judges too) */} + {showJudgeSelect && ( + setShowJudgeSelect(false)} + onSelect={(m) => { + onSetStrategy({ judgeModel: m?.value || "" }); + setShowJudgeSelect(false); + }} + activeProviders={activeProviders} + title="Select Judge Model" + addedModelValues={judge ? [judge] : []} + closeOnSelect={true} + /> + )} + + ); } -function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) { - const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({ id }); - const style = { - transform: CSS.Transform.toString(transform), - // no transition — prevents the CSS settle animation fighting React's re-render on drop - opacity: isDragging ? 0.4 : 1, - zIndex: isDragging ? 999 : undefined, - }; - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(model); - const commit = () => { - const trimmed = draft.trim(); - if (trimmed && trimmed !== model) onEdit(trimmed); - else setDraft(model); - setEditing(false); - }; +function ModelItem({ + id, + index, + model, + isFirst, + isLast, + onEdit, + onMoveUp, + onMoveDown, + onRemove, +}) { + const { attributes, listeners, setNodeRef, transform, isDragging } = + useSortable({ id }); + const style = { + transform: CSS.Transform.toString(transform), + // no transition — prevents the CSS settle animation fighting React's re-render on drop + opacity: isDragging ? 0.4 : 1, + zIndex: isDragging ? 999 : undefined, + }; + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(model); + const commit = () => { + const trimmed = draft.trim(); + if (trimmed && trimmed !== model) onEdit(trimmed); + else setDraft(model); + setEditing(false); + }; - const handleKeyDown = (e) => { - if (e.key === "Enter") commit(); - if (e.key === "Escape") { setDraft(model); setEditing(false); } - }; + const handleKeyDown = (e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { + setDraft(model); + setEditing(false); + } + }; - return ( -
- {/* Drag handle */} - + return ( +
+ {/* Drag handle */} + - {/* Index badge */} - {index + 1} + {/* Index badge */} + + {index + 1} + - {/* Inline editable model value */} - {editing ? ( - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={handleKeyDown} - className="min-w-0 flex-1 rounded border border-primary/40 bg-white px-1.5 py-0.5 font-mono text-xs text-text-main outline-none dark:bg-black/20" - /> - ) : ( -
setEditing(true)} - title="Click to edit" - > - {model} -
- )} + {/* Inline editable model value */} + {editing ? ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={handleKeyDown} + className="min-w-0 flex-1 rounded border border-primary/40 bg-white px-1.5 py-0.5 font-mono text-xs text-text-main outline-none dark:bg-black/20" + /> + ) : ( +
setEditing(true)} + title="Click to edit" + > + {model} +
+ )} - {/* Priority arrows */} -
- - -
+ {/* Priority arrows */} +
+ + +
- {/* Remove */} - -
- ); + {/* Remove */} + +
+ ); } -function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null }) { - // Initialize state with combo values - key prop on parent handles reset on remount - const [name, setName] = useState(combo?.name || ""); - const [models, setModels] = useState(combo?.models || []); - const [showModelSelect, setShowModelSelect] = useState(false); - const [saving, setSaving] = useState(false); - const [nameError, setNameError] = useState(""); - const [modelAliases, setModelAliases] = useState({}); +function ComboFormModal({ + isOpen, + combo, + onClose, + onSave, + activeProviders, + kindFilter = null, +}) { + // Initialize state with combo values - key prop on parent handles reset on remount + const [name, setName] = useState(combo?.name || ""); + const [models, setModels] = useState(combo?.models || []); + const [showModelSelect, setShowModelSelect] = useState(false); + const [saving, setSaving] = useState(false); + const [nameError, setNameError] = useState(""); + const [modelAliases, setModelAliases] = useState({}); - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) - ); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); - // Use stable index-based IDs so duplicates and similar names are handled correctly - const modelItems = models.map((model, i) => ({ uid: `item-${i}`, model })); + // Use stable index-based IDs so duplicates and similar names are handled correctly + const modelItems = models.map((model, i) => ({ uid: `item-${i}`, model })); - const handleDragEnd = (event) => { - const { active, over } = event; - if (over && active.id !== over.id) { - const oldIndex = modelItems.findIndex((m) => m.uid === active.id); - const newIndex = modelItems.findIndex((m) => m.uid === over.id); - if (oldIndex !== -1 && newIndex !== -1) { - setModels((prev) => arrayMove(prev, oldIndex, newIndex)); - } - } - }; + const handleDragEnd = (event) => { + const { active, over } = event; + if (over && active.id !== over.id) { + const oldIndex = modelItems.findIndex((m) => m.uid === active.id); + const newIndex = modelItems.findIndex((m) => m.uid === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + setModels((prev) => arrayMove(prev, oldIndex, newIndex)); + } + } + }; - const fetchModalData = async () => { - try { - const aliasesRes = await fetch("/api/models/alias"); - if (!aliasesRes.ok) return; - const aliasesData = await aliasesRes.json(); - setModelAliases(aliasesData.aliases || {}); - } catch (error) { - console.error("Error fetching modal data:", error); - } - }; + const fetchModalData = async () => { + try { + const aliasesRes = await fetch("/api/models/alias"); + if (!aliasesRes.ok) return; + const aliasesData = await aliasesRes.json(); + setModelAliases(aliasesData.aliases || {}); + } catch (error) { + console.error("Error fetching modal data:", error); + } + }; - useEffect(() => { - if (isOpen) fetchModalData(); - }, [isOpen]); + useEffect(() => { + if (isOpen) fetchModalData(); + }, [isOpen]); - const validateName = (value) => { - if (!value.trim()) { - setNameError("Name is required"); - return false; - } - if (!VALID_NAME_REGEX.test(value)) { - setNameError("Only letters, numbers, -, _ and . allowed"); - return false; - } - setNameError(""); - return true; - }; + const validateName = (value) => { + if (!value.trim()) { + setNameError("Name is required"); + return false; + } + if (!VALID_NAME_REGEX.test(value)) { + setNameError("Only letters, numbers, -, _ and . allowed"); + return false; + } + setNameError(""); + return true; + }; - const handleNameChange = (e) => { - const value = e.target.value; - setName(value); - if (value) validateName(value); - else setNameError(""); - }; + const handleNameChange = (e) => { + const value = e.target.value; + setName(value); + if (value) validateName(value); + else setNameError(""); + }; - const handleAddModel = (model) => { - if (!models.includes(model.value)) { - setModels([...models, model.value]); - } - }; + const handleAddModel = (model) => { + if (!models.includes(model.value)) { + setModels([...models, model.value]); + } + }; - const handleDeselectModel = (model) => { - setModels(models.filter((m) => m !== model.value)); - }; + const handleDeselectModel = (model) => { + setModels(models.filter((m) => m !== model.value)); + }; - const handleRemoveModel = (index) => { - setModels(models.filter((_, i) => i !== index)); - }; + const handleRemoveModel = (index) => { + setModels(models.filter((_, i) => i !== index)); + }; - const handleMoveUp = (index) => { - if (index === 0) return; - const newModels = [...models]; - [newModels[index - 1], newModels[index]] = [newModels[index], newModels[index - 1]]; - setModels(newModels); - }; + const handleMoveUp = (index) => { + if (index === 0) return; + const newModels = [...models]; + [newModels[index - 1], newModels[index]] = [ + newModels[index], + newModels[index - 1], + ]; + setModels(newModels); + }; - const handleMoveDown = (index) => { - if (index === models.length - 1) return; - const newModels = [...models]; - [newModels[index], newModels[index + 1]] = [newModels[index + 1], newModels[index]]; - setModels(newModels); - }; + const handleMoveDown = (index) => { + if (index === models.length - 1) return; + const newModels = [...models]; + [newModels[index], newModels[index + 1]] = [ + newModels[index + 1], + newModels[index], + ]; + setModels(newModels); + }; - const handleSave = async () => { - if (!validateName(name)) return; - setSaving(true); - await onSave({ name: name.trim(), models }); - setSaving(false); - }; + const handleSave = async () => { + if (!validateName(name)) return; + setSaving(true); + await onSave({ name: name.trim(), models }); + setSaving(false); + }; - const isEdit = !!combo; + const isEdit = !!combo; - return ( - <> - -
- {/* Name */} -
- -

- Only letters, numbers, -, _ and . allowed -

-
+ return ( + <> + +
+ {/* Name */} +
+ +

+ Only letters, numbers, -, _ and . allowed +

+
- {/* Models */} -
- + {/* Models */} +
+ - {models.length === 0 ? ( -
- layers -

No models added yet

-
- ) : ( - - m.uid)} strategy={verticalListSortingStrategy}> -
- {modelItems.map(({ uid, model }, index) => ( - { - const updated = [...models]; - updated[index] = newVal; - setModels(updated); - }} - onMoveUp={() => handleMoveUp(index)} - onMoveDown={() => handleMoveDown(index)} - onRemove={() => handleRemoveModel(index)} - /> - ))} -
-
-
- )} + {models.length === 0 ? ( +
+ + layers + +

No models added yet

+
+ ) : ( + + m.uid)} + strategy={verticalListSortingStrategy} + > +
+ {modelItems.map(({ uid, model }, index) => ( + { + const updated = [...models]; + updated[index] = newVal; + setModels(updated); + }} + onMoveUp={() => handleMoveUp(index)} + onMoveDown={() => handleMoveDown(index)} + onRemove={() => handleRemoveModel(index)} + /> + ))} +
+
+
+ )} - {/* Add Model button */} - -
+ {/* Add Model button */} + +
- {/* Actions */} -
- - -
-
-
+ {/* Actions */} +
+ + +
+
+
- {/* Model Select Modal */} - {showModelSelect && ( - setShowModelSelect(false)} - onSelect={handleAddModel} - onDeselect={handleDeselectModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Add Model to Combo" - kindFilter={kindFilter} - addedModelValues={models} - closeOnSelect={false} - /> - )} - - ); + {/* Model Select Modal */} + {showModelSelect && ( + setShowModelSelect(false)} + onSelect={handleAddModel} + onDeselect={handleDeselectModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Add Model to Combo" + kindFilter={kindFilter} + addedModelValues={models} + closeOnSelect={false} + /> + )} + + ); } diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index f0d55adf..da62da9f 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -11,1228 +11,1533 @@ import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config"; import { LOCALE_FLAGS } from "@/shared/constants/locales"; function getLocaleFromCookie() { - if (typeof document === "undefined") return "en"; - const cookie = document.cookie - .split(";") - .find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`)); - const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : "en"; - return normalizeLocale(value); + if (typeof document === "undefined") return "en"; + const cookie = document.cookie + .split(";") + .find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`)); + const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : "en"; + return normalizeLocale(value); } export default function ProfilePage() { - const { theme, setTheme, isDark } = useTheme(); - const [locale, setLocale] = useState("en"); - const [langOpen, setLangOpen] = useState(false); - const [shutdownOpen, setShutdownOpen] = useState(false); - const [isShuttingDown, setIsShuttingDown] = useState(false); - const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" }); - const [loading, setLoading] = useState(true); - const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" }); - const [passStatus, setPassStatus] = useState({ type: "", message: "" }); - const [passLoading, setPassLoading] = useState(false); - const [dbLoading, setDbLoading] = useState(false); - const [dbStatus, setDbStatus] = useState({ type: "", message: "" }); - const [dbAuth, setDbAuth] = useState({ open: false, mode: "", password: "" }); - const pendingImportRef = useRef(null); - const [oidcForm, setOidcForm] = useState({ - authMode: "password", - oidcIssuerUrl: "", - oidcClientId: "", - oidcScopes: "openid profile email", - oidcLoginLabel: "Sign in with OIDC", - }); - const [oidcClientSecret, setOidcClientSecret] = useState(""); - const [oidcStatus, setOidcStatus] = useState({ type: "", message: "" }); - const [oidcLoading, setOidcLoading] = useState(false); - const [oidcTestLoading, setOidcTestLoading] = useState(false); - const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" }); - const [oidcRedirectUri, setOidcRedirectUri] = useState("/api/auth/oidc/callback"); - const [oidcExpanded, setOidcExpanded] = useState(false); - const importFileRef = useRef(null); - const [proxyForm, setProxyForm] = useState({ - outboundProxyEnabled: false, - outboundProxyUrl: "", - outboundNoProxy: "", - }); - const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" }); - const [proxyLoading, setProxyLoading] = useState(false); - const [proxyTestLoading, setProxyTestLoading] = useState(false); + const { theme, setTheme, isDark } = useTheme(); + const [locale, setLocale] = useState("en"); + const [langOpen, setLangOpen] = useState(false); + const [shutdownOpen, setShutdownOpen] = useState(false); + const [isShuttingDown, setIsShuttingDown] = useState(false); + const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" }); + const [loading, setLoading] = useState(true); + const [passwords, setPasswords] = useState({ + current: "", + new: "", + confirm: "", + }); + const [passStatus, setPassStatus] = useState({ type: "", message: "" }); + const [passLoading, setPassLoading] = useState(false); + const [dbLoading, setDbLoading] = useState(false); + const [dbStatus, setDbStatus] = useState({ type: "", message: "" }); + const [dbAuth, setDbAuth] = useState({ open: false, mode: "", password: "" }); + const pendingImportRef = useRef(null); + const [oidcForm, setOidcForm] = useState({ + authMode: "password", + oidcIssuerUrl: "", + oidcClientId: "", + oidcScopes: "openid profile email", + oidcLoginLabel: "Sign in with OIDC", + }); + const [oidcClientSecret, setOidcClientSecret] = useState(""); + const [oidcStatus, setOidcStatus] = useState({ type: "", message: "" }); + const [oidcLoading, setOidcLoading] = useState(false); + const [oidcTestLoading, setOidcTestLoading] = useState(false); + const [oidcTestStatus, setOidcTestStatus] = useState({ + type: "", + message: "", + }); + const [oidcRedirectUri, setOidcRedirectUri] = useState( + "/api/auth/oidc/callback", + ); + const [oidcExpanded, setOidcExpanded] = useState(false); + const importFileRef = useRef(null); + const [proxyForm, setProxyForm] = useState({ + outboundProxyEnabled: false, + outboundProxyUrl: "", + outboundNoProxy: "", + }); + const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" }); + const [proxyLoading, setProxyLoading] = useState(false); + const [proxyTestLoading, setProxyTestLoading] = useState(false); - useEffect(() => { - setLocale(getLocaleFromCookie()); - }, [langOpen]); + useEffect(() => { + setLocale(getLocaleFromCookie()); + }, [langOpen]); - useEffect(() => { - fetch("/api/settings") - .then((res) => res.json()) - .then((data) => { - setSettings(data); - setOidcForm({ - authMode: data?.authMode || "password", - oidcIssuerUrl: data?.oidcIssuerUrl || "", - oidcClientId: data?.oidcClientId || "", - oidcScopes: data?.oidcScopes || "openid profile email", - oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC", - }); - setOidcClientSecret(""); - if (data?.authMode === "oidc" || data?.authMode === "both") setOidcExpanded(true); - setProxyForm({ - outboundProxyEnabled: data?.outboundProxyEnabled === true, - outboundProxyUrl: data?.outboundProxyUrl || "", - outboundNoProxy: data?.outboundNoProxy || "", - }); - setLoading(false); - }) - .catch((err) => { - console.error("Failed to fetch settings:", err); - setLoading(false); - }); - }, []); + useEffect(() => { + fetch("/api/settings") + .then((res) => res.json()) + .then((data) => { + setSettings(data); + setOidcForm({ + authMode: data?.authMode || "password", + oidcIssuerUrl: data?.oidcIssuerUrl || "", + oidcClientId: data?.oidcClientId || "", + oidcScopes: data?.oidcScopes || "openid profile email", + oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC", + }); + setOidcClientSecret(""); + if (data?.authMode === "oidc" || data?.authMode === "both") + setOidcExpanded(true); + setProxyForm({ + outboundProxyEnabled: data?.outboundProxyEnabled === true, + outboundProxyUrl: data?.outboundProxyUrl || "", + outboundNoProxy: data?.outboundNoProxy || "", + }); + setLoading(false); + }) + .catch((err) => { + console.error("Failed to fetch settings:", err); + setLoading(false); + }); + }, []); - useEffect(() => { - if (typeof window !== "undefined") { - setOidcRedirectUri(`${window.location.origin}/api/auth/oidc/callback`); - } - }, []); + useEffect(() => { + if (typeof window !== "undefined") { + setOidcRedirectUri(`${window.location.origin}/api/auth/oidc/callback`); + } + }, []); - const updateOutboundProxy = async (e) => { - e.preventDefault(); - if (settings.outboundProxyEnabled !== true) return; - setProxyLoading(true); - setProxyStatus({ type: "", message: "" }); + const updateOutboundProxy = async (e) => { + e.preventDefault(); + if (settings.outboundProxyEnabled !== true) return; + setProxyLoading(true); + setProxyStatus({ type: "", message: "" }); - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - outboundProxyUrl: proxyForm.outboundProxyUrl, - outboundNoProxy: proxyForm.outboundNoProxy, - }), - }); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + outboundProxyUrl: proxyForm.outboundProxyUrl, + outboundNoProxy: proxyForm.outboundNoProxy, + }), + }); - const data = await res.json(); - if (res.ok) { - setSettings((prev) => ({ ...prev, ...data })); - setProxyStatus({ type: "success", message: "Proxy settings applied" }); - } else { - setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" }); - } - } catch (err) { - setProxyStatus({ type: "error", message: "An error occurred" }); - } finally { - setProxyLoading(false); - } - }; + const data = await res.json(); + if (res.ok) { + setSettings((prev) => ({ ...prev, ...data })); + setProxyStatus({ type: "success", message: "Proxy settings applied" }); + } else { + setProxyStatus({ + type: "error", + message: data.error || "Failed to update proxy settings", + }); + } + } catch (err) { + setProxyStatus({ type: "error", message: "An error occurred" }); + } finally { + setProxyLoading(false); + } + }; - const testOutboundProxy = async () => { - if (settings.outboundProxyEnabled !== true) return; + const testOutboundProxy = async () => { + if (settings.outboundProxyEnabled !== true) return; - const proxyUrl = (proxyForm.outboundProxyUrl || "").trim(); - if (!proxyUrl) { - setProxyStatus({ type: "error", message: "Please enter a Proxy URL to test" }); - return; - } + const proxyUrl = (proxyForm.outboundProxyUrl || "").trim(); + if (!proxyUrl) { + setProxyStatus({ + type: "error", + message: "Please enter a Proxy URL to test", + }); + return; + } - setProxyTestLoading(true); - setProxyStatus({ type: "", message: "" }); + setProxyTestLoading(true); + setProxyStatus({ type: "", message: "" }); - try { - const res = await fetch("/api/settings/proxy-test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ proxyUrl }), - }); + try { + const res = await fetch("/api/settings/proxy-test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxyUrl }), + }); - const data = await res.json(); - if (res.ok && data?.ok) { - setProxyStatus({ - type: "success", - message: `Proxy test OK (${data.status}) in ${data.elapsedMs}ms`, - }); - } else { - setProxyStatus({ - type: "error", - message: data?.error || "Proxy test failed", - }); - } - } catch (err) { - setProxyStatus({ type: "error", message: "An error occurred" }); - } finally { - setProxyTestLoading(false); - } - }; + const data = await res.json(); + if (res.ok && data?.ok) { + setProxyStatus({ + type: "success", + message: `Proxy test OK (${data.status}) in ${data.elapsedMs}ms`, + }); + } else { + setProxyStatus({ + type: "error", + message: data?.error || "Proxy test failed", + }); + } + } catch (err) { + setProxyStatus({ type: "error", message: "An error occurred" }); + } finally { + setProxyTestLoading(false); + } + }; - const updateOutboundProxyEnabled = async (outboundProxyEnabled) => { - setProxyLoading(true); - setProxyStatus({ type: "", message: "" }); + const updateOutboundProxyEnabled = async (outboundProxyEnabled) => { + setProxyLoading(true); + setProxyStatus({ type: "", message: "" }); - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ outboundProxyEnabled }), - }); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outboundProxyEnabled }), + }); - const data = await res.json(); - if (res.ok) { - setSettings((prev) => ({ ...prev, ...data })); - setProxyForm((prev) => ({ ...prev, outboundProxyEnabled: data?.outboundProxyEnabled === true })); - setProxyStatus({ - type: "success", - message: outboundProxyEnabled ? "Proxy enabled" : "Proxy disabled", - }); - } else { - setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" }); - } - } catch (err) { - setProxyStatus({ type: "error", message: "An error occurred" }); - } finally { - setProxyLoading(false); - } - }; + const data = await res.json(); + if (res.ok) { + setSettings((prev) => ({ ...prev, ...data })); + setProxyForm((prev) => ({ + ...prev, + outboundProxyEnabled: data?.outboundProxyEnabled === true, + })); + setProxyStatus({ + type: "success", + message: outboundProxyEnabled ? "Proxy enabled" : "Proxy disabled", + }); + } else { + setProxyStatus({ + type: "error", + message: data.error || "Failed to update proxy settings", + }); + } + } catch (err) { + setProxyStatus({ type: "error", message: "An error occurred" }); + } finally { + setProxyLoading(false); + } + }; - const handlePasswordChange = async (e) => { - e.preventDefault(); - if (passwords.new !== passwords.confirm) { - setPassStatus({ type: "error", message: "Passwords do not match" }); - return; - } + const handlePasswordChange = async (e) => { + e.preventDefault(); + if (passwords.new !== passwords.confirm) { + setPassStatus({ type: "error", message: "Passwords do not match" }); + return; + } - setPassLoading(true); - setPassStatus({ type: "", message: "" }); + setPassLoading(true); + setPassStatus({ type: "", message: "" }); - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - currentPassword: passwords.current, - newPassword: passwords.new, - }), - }); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword: passwords.current, + newPassword: passwords.new, + }), + }); - const data = await res.json(); + const data = await res.json(); - if (res.ok) { - setPassStatus({ type: "success", message: "Password updated successfully" }); - setPasswords({ current: "", new: "", confirm: "" }); - } else { - setPassStatus({ type: "error", message: data.error || "Failed to update password" }); - } - } catch (err) { - setPassStatus({ type: "error", message: "An error occurred" }); - } finally { - setPassLoading(false); - } - }; + if (res.ok) { + setPassStatus({ + type: "success", + message: "Password updated successfully", + }); + setPasswords({ current: "", new: "", confirm: "" }); + } else { + setPassStatus({ + type: "error", + message: data.error || "Failed to update password", + }); + } + } catch (err) { + setPassStatus({ type: "error", message: "An error occurred" }); + } finally { + setPassLoading(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); - } - }; + 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); + } + }; - const updateComboStrategy = async (strategy) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ comboStrategy: strategy }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, comboStrategy: strategy })); - } - } catch (err) { - console.error("Failed to update combo strategy:", err); - } - }; + const updateComboStrategy = async (strategy) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ comboStrategy: strategy }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, comboStrategy: strategy })); + } + } catch (err) { + console.error("Failed to update combo strategy:", err); + } + }; - const handleGlobalTimeoutChange = async (e) => { - const raw = e.target.value.replace(/[^0-9]/g, ""); - const numTimeout = parseInt(raw, 10); - const patchValue = (raw !== "" && Number.isFinite(numTimeout) && numTimeout > 0) ? numTimeout : null; + const handleGlobalTimeoutChange = async (e) => { + const raw = e.target.value.replace(/[^0-9]/g, ""); + const numTimeout = parseInt(raw, 10); + const patchValue = + raw !== "" && Number.isFinite(numTimeout) && numTimeout > 0 + ? numTimeout + : null; - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ defaultTimeoutMs: patchValue }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, defaultTimeoutMs: patchValue })); - } - } catch (err) { - console.error("Failed to update default timeout:", err); - } - }; + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ defaultTimeoutMs: patchValue }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, defaultTimeoutMs: patchValue })); + } + } catch (err) { + console.error("Failed to update default timeout:", err); + } + }; - const updateStickyLimit = async (limit) => { - const numLimit = parseInt(limit); - if (isNaN(numLimit) || numLimit < 1) return; + const updateStickyLimit = async (limit) => { + const numLimit = parseInt(limit); + if (isNaN(numLimit) || numLimit < 1) return; - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ stickyRoundRobinLimit: numLimit }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, stickyRoundRobinLimit: numLimit })); - } - } catch (err) { - console.error("Failed to update sticky limit:", err); - } - }; + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stickyRoundRobinLimit: numLimit }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, stickyRoundRobinLimit: numLimit })); + } + } catch (err) { + console.error("Failed to update sticky limit:", err); + } + }; - const updateComboStickyLimit = async (limit) => { - const numLimit = parseInt(limit); - if (isNaN(numLimit) || numLimit < 1) return; + const updateComboStickyLimit = async (limit) => { + const numLimit = parseInt(limit); + if (isNaN(numLimit) || numLimit < 1) return; - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ comboStickyRoundRobinLimit: numLimit }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, comboStickyRoundRobinLimit: numLimit })); - } - } catch (err) { - console.error("Failed to update combo sticky limit:", err); - } - }; + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ comboStickyRoundRobinLimit: numLimit }), + }); + if (res.ok) { + setSettings((prev) => ({ + ...prev, + comboStickyRoundRobinLimit: numLimit, + })); + } + } catch (err) { + console.error("Failed to update combo sticky limit:", err); + } + }; - const updateRequireLogin = async (requireLogin) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ requireLogin }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, requireLogin })); - } - } catch (err) { - console.error("Failed to update require login:", err); - } - }; + const updateRequireLogin = async (requireLogin) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requireLogin }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, requireLogin })); + } + } catch (err) { + console.error("Failed to update require login:", err); + } + }; - const updateOidcForm = (field, value) => { - setOidcForm((prev) => ({ ...prev, [field]: value })); - }; + const updateOidcForm = (field, value) => { + setOidcForm((prev) => ({ ...prev, [field]: value })); + }; - const saveOidcSettings = async (authMode = oidcForm.authMode || "password") => { - const issuerUrl = oidcForm.oidcIssuerUrl.trim(); - const clientId = oidcForm.oidcClientId.trim(); - const scopes = oidcForm.oidcScopes.trim(); - const loginLabel = oidcForm.oidcLoginLabel.trim(); - const secret = oidcClientSecret.trim(); + const saveOidcSettings = async ( + authMode = oidcForm.authMode || "password", + ) => { + const issuerUrl = oidcForm.oidcIssuerUrl.trim(); + const clientId = oidcForm.oidcClientId.trim(); + const scopes = oidcForm.oidcScopes.trim(); + const loginLabel = oidcForm.oidcLoginLabel.trim(); + const secret = oidcClientSecret.trim(); - if (authMode !== "password" && (!issuerUrl || !clientId || !secret) && !settings.oidcConfigured) { - setOidcStatus({ type: "error", message: "Issuer URL, client ID, and client secret are required to enable OIDC." }); - return; - } + if ( + authMode !== "password" && + (!issuerUrl || !clientId || !secret) && + !settings.oidcConfigured + ) { + setOidcStatus({ + type: "error", + message: + "Issuer URL, client ID, and client secret are required to enable OIDC.", + }); + return; + } - setOidcLoading(true); - setOidcStatus({ type: "", message: "" }); - setOidcTestStatus({ type: "", message: "" }); + setOidcLoading(true); + setOidcStatus({ type: "", message: "" }); + setOidcTestStatus({ type: "", message: "" }); - try { - const payload = { - authMode, - oidcIssuerUrl: issuerUrl, - oidcClientId: clientId, - oidcScopes: scopes || "openid profile email", - oidcLoginLabel: loginLabel || "Sign in with OIDC", - }; - if (secret) { - payload.oidcClientSecret = secret; - } + try { + const payload = { + authMode, + oidcIssuerUrl: issuerUrl, + oidcClientId: clientId, + oidcScopes: scopes || "openid profile email", + oidcLoginLabel: loginLabel || "Sign in with OIDC", + }; + if (secret) { + payload.oidcClientSecret = secret; + } - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); - const data = await res.json(); - if (res.ok) { - setSettings((prev) => ({ ...prev, ...data })); - setOidcForm({ - authMode: data?.authMode || authMode, - oidcIssuerUrl: data?.oidcIssuerUrl || issuerUrl, - oidcClientId: data?.oidcClientId || clientId, - oidcScopes: data?.oidcScopes || scopes || "openid profile email", - oidcLoginLabel: data?.oidcLoginLabel || loginLabel || "Sign in with OIDC", - }); - setOidcClientSecret(""); - setOidcStatus({ - type: "success", - message: - authMode === "oidc" - ? "OIDC login enabled" - : authMode === "both" - ? "Password and OIDC login enabled" - : "OIDC settings saved", - }); - } else { - setOidcStatus({ type: "error", message: data.error || "Failed to save OIDC settings" }); - } - } catch (err) { - setOidcStatus({ type: "error", message: "An error occurred" }); - } finally { - setOidcLoading(false); - } - }; + const data = await res.json(); + if (res.ok) { + setSettings((prev) => ({ ...prev, ...data })); + setOidcForm({ + authMode: data?.authMode || authMode, + oidcIssuerUrl: data?.oidcIssuerUrl || issuerUrl, + oidcClientId: data?.oidcClientId || clientId, + oidcScopes: data?.oidcScopes || scopes || "openid profile email", + oidcLoginLabel: + data?.oidcLoginLabel || loginLabel || "Sign in with OIDC", + }); + setOidcClientSecret(""); + setOidcStatus({ + type: "success", + message: + authMode === "oidc" + ? "OIDC login enabled" + : authMode === "both" + ? "Password and OIDC login enabled" + : "OIDC settings saved", + }); + } else { + setOidcStatus({ + type: "error", + message: data.error || "Failed to save OIDC settings", + }); + } + } catch (err) { + setOidcStatus({ type: "error", message: "An error occurred" }); + } finally { + setOidcLoading(false); + } + }; - const testOidcConnection = async () => { - const issuerUrl = oidcForm.oidcIssuerUrl.trim(); - const clientId = oidcForm.oidcClientId.trim(); - const scopes = oidcForm.oidcScopes.trim(); - const secret = oidcClientSecret.trim(); + const testOidcConnection = async () => { + const issuerUrl = oidcForm.oidcIssuerUrl.trim(); + const clientId = oidcForm.oidcClientId.trim(); + const scopes = oidcForm.oidcScopes.trim(); + const secret = oidcClientSecret.trim(); - if (!issuerUrl || !clientId) { - setOidcTestStatus({ type: "error", message: "Issuer URL and client ID are required to test the connection." }); - return; - } + if (!issuerUrl || !clientId) { + setOidcTestStatus({ + type: "error", + message: + "Issuer URL and client ID are required to test the connection.", + }); + return; + } - setOidcTestLoading(true); - setOidcStatus({ type: "", message: "" }); - setOidcTestStatus({ type: "", message: "" }); + setOidcTestLoading(true); + setOidcStatus({ type: "", message: "" }); + setOidcTestStatus({ type: "", message: "" }); - try { - const saveRes = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - authMode: oidcForm.authMode || settings.authMode || "password", - oidcIssuerUrl: issuerUrl, - oidcClientId: clientId, - oidcScopes: scopes || "openid profile email", - oidcLoginLabel: oidcForm.oidcLoginLabel.trim() || "Sign in with OIDC", - ...(secret ? { oidcClientSecret: secret } : {}), - }), - }); + try { + const saveRes = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + authMode: oidcForm.authMode || settings.authMode || "password", + oidcIssuerUrl: issuerUrl, + oidcClientId: clientId, + oidcScopes: scopes || "openid profile email", + oidcLoginLabel: oidcForm.oidcLoginLabel.trim() || "Sign in with OIDC", + ...(secret ? { oidcClientSecret: secret } : {}), + }), + }); - const saved = await saveRes.json().catch(() => ({})); - if (!saveRes.ok) { - setOidcTestStatus({ - type: "error", - message: saved.error || "Failed to save OIDC settings before testing", - }); - return; - } + const saved = await saveRes.json().catch(() => ({})); + if (!saveRes.ok) { + setOidcTestStatus({ + type: "error", + message: saved.error || "Failed to save OIDC settings before testing", + }); + return; + } - const res = await fetch("/api/auth/oidc/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - issuerUrl: saved.oidcIssuerUrl || issuerUrl, - clientId: saved.oidcClientId || clientId, - scopes: saved.oidcScopes || scopes || "openid profile email", - }), - }); + const res = await fetch("/api/auth/oidc/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + issuerUrl: saved.oidcIssuerUrl || issuerUrl, + clientId: saved.oidcClientId || clientId, + scopes: saved.oidcScopes || scopes || "openid profile email", + }), + }); - const data = await res.json().catch(() => ({})); - if (res.ok && data?.ok) { - const statusMessage = data.clientSecretTested - ? data.clientSecretValid === true - ? `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret validated too.` - : `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret was not checked.` - : `Connection OK. Discovery loaded from ${data.issuerUrl}.`; - setOidcTestStatus({ - type: "success", - message: statusMessage, - }); - } else { - setOidcTestStatus({ type: "error", message: data.error || "OIDC connection test failed" }); - } - } catch (err) { - setOidcTestStatus({ type: "error", message: "An error occurred" }); - } finally { - setOidcTestLoading(false); - } - }; + const data = await res.json().catch(() => ({})); + if (res.ok && data?.ok) { + const statusMessage = data.clientSecretTested + ? data.clientSecretValid === true + ? `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret validated too.` + : `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret was not checked.` + : `Connection OK. Discovery loaded from ${data.issuerUrl}.`; + setOidcTestStatus({ + type: "success", + message: statusMessage, + }); + } else { + setOidcTestStatus({ + type: "error", + message: data.error || "OIDC connection test failed", + }); + } + } catch (err) { + setOidcTestStatus({ type: "error", message: "An error occurred" }); + } finally { + setOidcTestLoading(false); + } + }; - const updateObservabilityEnabled = async (enabled) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enableObservability: enabled }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, enableObservability: enabled })); - } - } catch (err) { - console.error("Failed to update enableObservability:", err); - } - }; + const updateObservabilityEnabled = async (enabled) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enableObservability: enabled }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, enableObservability: enabled })); + } + } catch (err) { + console.error("Failed to update enableObservability:", err); + } + }; - const reloadSettings = async () => { - try { - const res = await fetch("/api/settings"); - if (!res.ok) return; - const data = await res.json(); - setSettings(data); - } catch (err) { - console.error("Failed to reload settings:", err); - } - }; + const updateShowOnlyComboModels = async (showOnlyComboModels) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ showOnlyComboModels }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, showOnlyComboModels })); + } + } catch (err) { + console.error("Failed to update showOnlyComboModels:", err); + } + }; - const handleExportDatabase = async (password) => { - setDbLoading(true); - setDbStatus({ type: "", message: "" }); - try { - const res = await fetch("/api/settings/database", { - headers: { "x-9r-password": password }, - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || "Failed to export database"); - } + const reloadSettings = async () => { + try { + const res = await fetch("/api/settings"); + if (!res.ok) return; + const data = await res.json(); + setSettings(data); + } catch (err) { + console.error("Failed to reload settings:", err); + } + }; - const payload = await res.json(); - const content = JSON.stringify(payload, null, 2); - const blob = new Blob([content], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - const stamp = new Date().toISOString().replace(/[.:]/g, "-"); - anchor.href = url; - anchor.download = `9router-backup-${stamp}.json`; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - URL.revokeObjectURL(url); + const handleExportDatabase = async (password) => { + setDbLoading(true); + setDbStatus({ type: "", message: "" }); + try { + const res = await fetch("/api/settings/database", { + headers: { "x-9r-password": password }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Failed to export database"); + } - setDbStatus({ type: "success", message: "Database backup downloaded" }); - } catch (err) { - setDbStatus({ type: "error", message: err.message || "Failed to export database" }); - } finally { - setDbLoading(false); - } - }; + const payload = await res.json(); + const content = JSON.stringify(payload, null, 2); + const blob = new Blob([content], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + const stamp = new Date().toISOString().replace(/[.:]/g, "-"); + anchor.href = url; + anchor.download = `9router-backup-${stamp}.json`; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); - const handleImportDatabase = (event) => { - const file = event.target.files?.[0]; - if (importFileRef.current) importFileRef.current.value = ""; - if (!file) return; - pendingImportRef.current = file; - setDbStatus({ type: "", message: "" }); - setDbAuth({ open: true, mode: "import", password: "" }); - }; + setDbStatus({ type: "success", message: "Database backup downloaded" }); + } catch (err) { + setDbStatus({ + type: "error", + message: err.message || "Failed to export database", + }); + } finally { + setDbLoading(false); + } + }; - const runImportDatabase = async (password) => { - const file = pendingImportRef.current; - if (!file) return; - setDbLoading(true); - try { - const raw = await file.text(); - const payload = JSON.parse(raw); + const handleImportDatabase = (event) => { + const file = event.target.files?.[0]; + if (importFileRef.current) importFileRef.current.value = ""; + if (!file) return; + pendingImportRef.current = file; + setDbStatus({ type: "", message: "" }); + setDbAuth({ open: true, mode: "import", password: "" }); + }; - const res = await fetch("/api/settings/database", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...payload, password }), - }); + const runImportDatabase = async (password) => { + const file = pendingImportRef.current; + if (!file) return; + setDbLoading(true); + try { + const raw = await file.text(); + const payload = JSON.parse(raw); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - throw new Error(data.error || "Failed to import database"); - } + const res = await fetch("/api/settings/database", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...payload, password }), + }); - await reloadSettings(); - setDbStatus({ type: "success", message: "Database imported successfully" }); - } catch (err) { - setDbStatus({ type: "error", message: err.message || "Invalid backup file" }); - } finally { - pendingImportRef.current = null; - setDbLoading(false); - } - }; + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || "Failed to import database"); + } - // Confirm password modal, then run export or import. - const handleDbAuthConfirm = async () => { - const { mode, password } = dbAuth; - setDbAuth({ open: false, mode: "", password: "" }); - if (mode === "export") await handleExportDatabase(password); - else if (mode === "import") await runImportDatabase(password); - }; + await reloadSettings(); + setDbStatus({ + type: "success", + message: "Database imported successfully", + }); + } catch (err) { + setDbStatus({ + type: "error", + message: err.message || "Invalid backup file", + }); + } finally { + pendingImportRef.current = null; + setDbLoading(false); + } + }; - const observabilityEnabled = settings.enableObservability === true; + // Confirm password modal, then run export or import. + const handleDbAuthConfirm = async () => { + const { mode, password } = dbAuth; + setDbAuth({ open: false, mode: "", password: "" }); + if (mode === "export") await handleExportDatabase(password); + else if (mode === "import") await runImportDatabase(password); + }; - const handleShutdown = async () => { - setIsShuttingDown(true); - try { - await fetch("/api/version/shutdown", { method: "POST" }); - } catch (e) { - // Expected to fail as server shuts down; ignore error - } - setIsShuttingDown(false); - setShutdownOpen(false); - }; + const observabilityEnabled = settings.enableObservability === true; - const handleLogout = async () => { - try { - const res = await fetch("/api/auth/logout", { method: "POST" }); - if (res.ok) { - window.location.assign("/login"); - } - } catch (err) { - console.error("Failed to logout:", err); - } - }; + const handleShutdown = async () => { + setIsShuttingDown(true); + try { + await fetch("/api/version/shutdown", { method: "POST" }); + } catch (e) { + // Expected to fail as server shuts down; ignore error + } + setIsShuttingDown(false); + setShutdownOpen(false); + }; - return ( -
-
- {/* Local Mode Info */} - -
-
-
- computer -
-
-

Local Mode

-

Running on your machine

-
-
-
- {["light", "dark", "system"].map((option) => ( - - ))} -
-
-
-
-
-

Database Location

-

~/.9router/db/data.sqlite

-
-
-
- - - -
- {dbStatus.message && ( -

- {dbStatus.message} -

- )} -
-
+ const handleLogout = async () => { + try { + const res = await fetch("/api/auth/logout", { method: "POST" }); + if (res.ok) { + window.location.assign("/login"); + } + } catch (err) { + console.error("Failed to logout:", err); + } + }; - {/* Language */} - -
-
- language -
-

Language

-
- -
+ return ( +
+
+ {/* Local Mode Info */} + +
+
+
+ + computer + +
+
+

Local Mode

+

+ Running on your machine +

+
+
+
+ {["light", "dark", "system"].map((option) => ( + + ))} +
+
+
+
+
+

+ Database Location +

+

+ ~/.9router/db/data.sqlite +

+
+
+
+ + + +
+ {dbStatus.message && ( +

+ {dbStatus.message} +

+ )} +
+
- {/* Security */} - -
-
- shield -
-

Security

-
-
-
-
-

Require login

-

- When ON, dashboard requires password. When OFF, access without login. -

-
- updateRequireLogin(!settings.requireLogin)} - disabled={loading} - /> -
- {settings.requireLogin === true && ( -
- {settings.hasPassword && ( -
- - setPasswords({ ...passwords, current: e.target.value })} - required - /> -
- )} - {/* {!settings.hasPassword && ( + {/* Language */} + +
+
+ + language + +
+

Language

+
+ +
+ + {/* Security */} + +
+
+ + shield + +
+

Security

+
+
+
+
+

+ Require login +

+

+ When ON, dashboard requires password. When OFF, access without + login. +

+
+ updateRequireLogin(!settings.requireLogin)} + disabled={loading} + /> +
+ {settings.requireLogin === true && ( + + {settings.hasPassword && ( +
+ + + setPasswords({ ...passwords, current: e.target.value }) + } + required + /> +
+ )} + {/* {!settings.hasPassword && (

Setting password for the first time. Leave current password empty or use default: 123456

)} */} -
-
- - setPasswords({ ...passwords, new: e.target.value })} - required - /> -
-
- - setPasswords({ ...passwords, confirm: e.target.value })} - required - /> -
-
+
+
+ + + setPasswords({ ...passwords, new: e.target.value }) + } + required + /> +
+
+ + + setPasswords({ ...passwords, confirm: e.target.value }) + } + required + /> +
+
- {passStatus.message && ( -

- {passStatus.message} -

- )} + {passStatus.message && ( +

+ {passStatus.message} +

+ )} -
- -
- - )} -
-
+
+ +
+ + )} +
+
- {/* OIDC */} - - - {oidcExpanded && ( -
-

- Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys. -

+ {/* OIDC */} + + + {oidcExpanded && ( +
+

+ Use Authentik or any OIDC provider to sign in to the dashboard. + You can enable password-only, OIDC-only, or both for the + dashboard; model API access still uses API keys. +

-
- -
- {[ - { - value: "password", - title: "Password only", - desc: "Keep the legacy password login.", - }, - { - value: "oidc", - title: "OIDC only", - desc: "Require OIDC for dashboard access.", - }, - { - value: "both", - title: "Both", - desc: "Allow either password or OIDC.", - }, - ].map((option) => { - const active = oidcForm.authMode === option.value; - return ( - - ); - })} -
-
+
+ +
+ {[ + { + value: "password", + title: "Password only", + desc: "Keep the legacy password login.", + }, + { + value: "oidc", + title: "OIDC only", + desc: "Require OIDC for dashboard access.", + }, + { + value: "both", + title: "Both", + desc: "Allow either password or OIDC.", + }, + ].map((option) => { + const active = oidcForm.authMode === option.value; + return ( + + ); + })} +
+
-
-
- - updateOidcForm("oidcIssuerUrl", e.target.value)} - disabled={loading || oidcLoading} - /> -
+
+
+ + + updateOidcForm("oidcIssuerUrl", e.target.value) + } + disabled={loading || oidcLoading} + /> +
-
- - updateOidcForm("oidcClientId", e.target.value)} - disabled={loading || oidcLoading} - /> -
+
+ + + updateOidcForm("oidcClientId", e.target.value) + } + disabled={loading || oidcLoading} + /> +
-
- - setOidcClientSecret(e.target.value)} - disabled={loading || oidcLoading} - /> -

This value is write-only after saving.

-
+
+ + setOidcClientSecret(e.target.value)} + disabled={loading || oidcLoading} + /> +

+ This value is write-only after saving. +

+
-
- - updateOidcForm("oidcScopes", e.target.value)} - disabled={loading || oidcLoading} - /> -
+
+ + + updateOidcForm("oidcScopes", e.target.value) + } + disabled={loading || oidcLoading} + /> +
-
- - updateOidcForm("oidcLoginLabel", e.target.value)} - disabled={loading || oidcLoading} - /> -
-
+
+ + + updateOidcForm("oidcLoginLabel", e.target.value) + } + disabled={loading || oidcLoading} + /> +
+
-
-

Redirect URI

- {oidcRedirectUri} -
+
+

Redirect URI

+ + {oidcRedirectUri} + +
-
- - -
+
+ + +
- {oidcTestStatus.message && ( -

- {oidcTestStatus.message} -

- )} + {oidcTestStatus.message && ( +

+ {oidcTestStatus.message} +

+ )} - {oidcStatus.message && ( -

- {oidcStatus.message} -

- )} + {oidcStatus.message && ( +

+ {oidcStatus.message} +

+ )} - {settings.authMode === "oidc" && ( -

- OIDC login is currently active. Password login is disabled until you switch back. -

- )} + {settings.authMode === "oidc" && ( +

+ OIDC login is currently active. Password login is disabled + until you switch back. +

+ )} - {settings.authMode === "both" && ( -

- Password and OIDC login are both active. -

- )} -
- )} -
+ {settings.authMode === "both" && ( +

+ Password and OIDC login are both active. +

+ )} +
+ )} +
- {/* Routing Preferences */} - -
-
- route -
-

Routing Strategy

-
-
-
-
-

Round Robin

-

- Cycle through accounts to distribute load -

-
- updateFallbackStrategy(settings.fallbackStrategy === "round-robin" ? "fill-first" : "round-robin")} - disabled={loading} - /> -
+ {/* Routing Preferences */} + +
+
+ + route + +
+

+ Routing Strategy +

+
+
+
+
+

Round Robin

+

+ Cycle through accounts to distribute load +

+
+ + updateFallbackStrategy( + settings.fallbackStrategy === "round-robin" + ? "fill-first" + : "round-robin", + ) + } + disabled={loading} + /> +
- {/* Sticky Round Robin Limit */} - {settings.fallbackStrategy === "round-robin" && ( -
-
-

Sticky Limit

-

- Calls per account before switching -

-
- updateStickyLimit(e.target.value)} - disabled={loading} - className="w-16 sm:w-20 text-center shrink-0" - /> -
- )} + {/* Sticky Round Robin Limit */} + {settings.fallbackStrategy === "round-robin" && ( +
+
+

+ Sticky Limit +

+

+ Calls per account before switching +

+
+ updateStickyLimit(e.target.value)} + disabled={loading} + className="w-16 sm:w-20 text-center shrink-0" + /> +
+ )} - {/* Combo Round Robin */} -
-
-

Combo Round Robin

-

- Cycle through providers in combos instead of always starting with first -

-
- updateComboStrategy(settings.comboStrategy === "round-robin" ? "fallback" : "round-robin")} - disabled={loading} - /> -
+ {/* Combo Round Robin */} +
+
+

+ Combo Round Robin +

+

+ Cycle through providers in combos instead of always starting + with first +

+
+ + updateComboStrategy( + settings.comboStrategy === "round-robin" + ? "fallback" + : "round-robin", + ) + } + disabled={loading} + /> +
- {/* Combo Sticky Round Robin Limit */} - {settings.comboStrategy === "round-robin" && ( -
-
-

Combo Sticky Limit

-

- Calls per combo model before switching -

-
- updateComboStickyLimit(e.target.value)} - disabled={loading} - className="w-20 text-center" - /> -
- )} + {/* Combo Sticky Round Robin Limit */} + {settings.comboStrategy === "round-robin" && ( +
+
+

Combo Sticky Limit

+

+ Calls per combo model before switching +

+
+ updateComboStickyLimit(e.target.value)} + disabled={loading} + className="w-20 text-center" + /> +
+ )} -

- {settings.fallbackStrategy === "round-robin" - ? `Currently distributing requests across all available accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.` - : "Currently using accounts in priority order (Fill First)."} - {settings.comboStrategy === "round-robin" - ? ` Combos rotate after ${settings.comboStickyRoundRobinLimit || 1} call${(settings.comboStickyRoundRobinLimit || 1) === 1 ? "" : "s"} per model.` - : " Combos always start with their first model."} -

-
-
+

+ {settings.fallbackStrategy === "round-robin" + ? `Currently distributing requests across all available accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.` + : "Currently using accounts in priority order (Fill First)."} + {settings.comboStrategy === "round-robin" + ? ` Combos rotate after ${settings.comboStickyRoundRobinLimit || 1} call${(settings.comboStickyRoundRobinLimit || 1) === 1 ? "" : "s"} per model.` + : " Combos always start with their first model."} +

+
+
- {/* Default Timeout — global default for all providers */} - -
-
- timer -
-

Default Connect Timeout

-
-
-
-
-

All Providers

-

- Timeout for upstream connect (applies globally unless overridden per provider). Set to 0 or leave empty for system default (60s). -

-
-
- - ms -
-
-

- {settings.defaultTimeoutMs - ? `All providers will wait up to ${settings.defaultTimeoutMs}ms for a connection.` - : "Using system default (60s) — configure per-provider timeout on each provider's detail page for fine-grained control."} -

-
-
+ {/* Default Timeout — global default for all providers */} + +
+
+ + timer + +
+

+ Default Connect Timeout +

+
+
+
+
+

+ All Providers +

+

+ Timeout for upstream connect (applies globally unless + overridden per provider). Set to 0 or leave empty for system + default (60s). +

+
+
+ + ms +
+
+

+ {settings.defaultTimeoutMs + ? `All providers will wait up to ${settings.defaultTimeoutMs}ms for a connection.` + : "Using system default (60s) — configure per-provider timeout on each provider's detail page for fine-grained control."} +

+
+
- {/* Network */} - -
-
- wifi -
-

Network

-
+ {/* Network */} + +
+
+ + wifi + +
+

Network

+
-
-
-
-

Outbound Proxy

-

Enable proxy for OAuth + provider outbound requests.

-
- updateOutboundProxyEnabled(!(settings.outboundProxyEnabled === true))} - disabled={loading || proxyLoading} - /> -
+
+
+
+

+ Outbound Proxy +

+

+ Enable proxy for OAuth + provider outbound requests. +

+
+ + updateOutboundProxyEnabled( + !(settings.outboundProxyEnabled === true), + ) + } + disabled={loading || proxyLoading} + /> +
- {settings.outboundProxyEnabled === true && ( -
-
- - setProxyForm((prev) => ({ ...prev, outboundProxyUrl: e.target.value }))} - disabled={loading || proxyLoading} - /> -

Leave empty to inherit existing env proxy (if any).

-
+ {settings.outboundProxyEnabled === true && ( + +
+ + + setProxyForm((prev) => ({ + ...prev, + outboundProxyUrl: e.target.value, + })) + } + disabled={loading || proxyLoading} + /> +

+ Leave empty to inherit existing env proxy (if any). +

+
-
- - setProxyForm((prev) => ({ ...prev, outboundNoProxy: e.target.value }))} - disabled={loading || proxyLoading} - /> -

Comma-separated hostnames/domains to bypass the proxy.

-
+
+ + + setProxyForm((prev) => ({ + ...prev, + outboundNoProxy: e.target.value, + })) + } + disabled={loading || proxyLoading} + /> +

+ Comma-separated hostnames/domains to bypass the proxy. +

+
-
- - -
-
- )} +
+ + +
+ + )} - {proxyStatus.message && ( -

- {proxyStatus.message} -

- )} -
- + {proxyStatus.message && ( +

+ {proxyStatus.message} +

+ )} +
+
- {/* Observability Settings */} - -
-
- monitoring -
-

Observability

-
-
-
-

Enable Observability

-

- Record request details for inspection in the logs view -

-
- -
-
+ {/* Model Display Settings */} + +
+
+ + visibility + +
+

+ Model Display +

+
+
+
+

+ Only show combo models +

+

+ When ON, the model selector only shows models that have been + added to at least one active combo. Individual provider models + are hidden. +

+
+ +
+
- {/* Account actions */} -
- - -
+ {/* Observability Settings */} + +
+
+ + monitoring + +
+

+ Observability +

+
+
+
+

+ Enable Observability +

+

+ Record request details for inspection in the logs view +

+
+ +
+
- {/* App Info */} -
-

{APP_CONFIG.name} v{APP_CONFIG.version}

-

Local Mode - All data stored on your machine

-
-
+ {/* Account actions */} +
+ + +
- { - setLangOpen(false); - setLocale(next); - }} - /> - setShutdownOpen(false)} - onConfirm={handleShutdown} - title="Close Proxy" - message="Are you sure you want to close the proxy server?" - confirmText="Close" - cancelText="Cancel" - variant="danger" - loading={isShuttingDown} - /> + {/* App Info */} +
+

+ {APP_CONFIG.name} v{APP_CONFIG.version} +

+

Local Mode - All data stored on your machine

+
+
- setDbAuth({ open: false, mode: "", password: "" })} - title="Confirm Password" - size="sm" - footer={ - <> - - - - } - > -

- Enter your current password to {dbAuth.mode === "export" ? "export" : "import"} the database. -

- setDbAuth((s) => ({ ...s, password: e.target.value }))} - onKeyDown={(e) => { if (e.key === "Enter" && dbAuth.password) handleDbAuthConfirm(); }} - placeholder="Current password" - autoFocus - /> -
-
- ); + { + setLangOpen(false); + setLocale(next); + }} + /> + setShutdownOpen(false)} + onConfirm={handleShutdown} + title="Close Proxy" + message="Are you sure you want to close the proxy server?" + confirmText="Close" + cancelText="Cancel" + variant="danger" + loading={isShuttingDown} + /> + + setDbAuth({ open: false, mode: "", password: "" })} + title="Confirm Password" + size="sm" + footer={ + <> + + + + } + > +

+ Enter your current password to{" "} + {dbAuth.mode === "export" ? "export" : "import"} the database. +

+ + setDbAuth((s) => ({ ...s, password: e.target.value })) + } + onKeyDown={(e) => { + if (e.key === "Enter" && dbAuth.password) handleDbAuthConfirm(); + }} + placeholder="Current password" + autoFocus + /> +
+
+ ); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 6720eef6..2ec75f1c 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -4,9 +4,39 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; -import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon"; -import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components"; -import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers"; +import { + getProviderIconSrc, + markProviderIconMissing, +} from "@/shared/utils/providerIcon"; +import { + Card, + Button, + Badge, + Input, + Modal, + CardSkeleton, + OAuthModal, + KiroOAuthWrapper, + CursorAuthModal, + IFlowCookieModal, + GitLabAuthModal, + Toggle, + Select, + EditConnectionModal, + NoAuthProxyCard, + ConfirmModal, +} from "@/shared/components"; +import { + OAUTH_PROVIDERS, + APIKEY_PROVIDERS, + FREE_PROVIDERS, + FREE_TIER_PROVIDERS, + WEB_COOKIE_PROVIDERS, + getProviderAlias, + isOpenAICompatibleProvider, + isAnthropicCompatibleProvider, + AI_PROVIDERS, +} from "@/shared/constants/providers"; import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; @@ -26,2113 +56,2669 @@ import BulkImportCodexModal from "./BulkImportCodexModal"; const ONE_BY_ONE_DELAY_MS = 1000; const AUTO_PING_SETTINGS_KEYS = { - claude: "claudeAutoPing", - codex: "codexAutoPing", + claude: "claudeAutoPing", + codex: "codexAutoPing", }; function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } export default function ProviderDetailPage() { - const params = useParams(); - const router = useRouter(); - const providerId = params.id; - const { getCaps } = useModelCaps(); - const [connections, setConnections] = useState([]); - const [loading, setLoading] = useState(true); - const [providerNode, setProviderNode] = useState(null); - const [proxyPools, setProxyPools] = useState([]); - const [showOAuthModal, setShowOAuthModal] = useState(false); - const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false); - const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); - const [addConnectionError, setAddConnectionError] = useState(""); - const [showBulkImportCodex, setShowBulkImportCodex] = useState(false); - const [showEditModal, setShowEditModal] = useState(false); - const [showEditNodeModal, setShowEditNodeModal] = useState(false); - const [showBulkProxyModal, setShowBulkProxyModal] = useState(false); - const [selectedConnection, setSelectedConnection] = useState(null); - const [modelAliases, setModelAliases] = useState({}); - const [customModels, setCustomModels] = useState([]); - const [headerImgError, setHeaderImgError] = useState(false); - const [modelTestResults, setModelTestResults] = useState({}); - const [modelsTestError, setModelsTestError] = useState(""); - const [testingModelIds, setTestingModelIds] = useState(() => new Set()); - const [showAddCustomModel, setShowAddCustomModel] = useState(false); - const [selectedConnectionIds, setSelectedConnectionIds] = useState([]); - const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__"); - const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false); - const [providerStrategy, setProviderStrategy] = useState(null); - const [providerStickyLimit, setProviderStickyLimit] = useState(""); - const [thinkingMode, setThinkingMode] = useState("auto"); - const [providerTimeout, setProviderTimeout] = useState(""); - const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} }); - const [suggestedModels, setSuggestedModels] = useState([]); - const [liveModels, setLiveModels] = useState([]); - const [kiloFreeModels, setKiloFreeModels] = useState([]); - const [disabledModelIds, setDisabledModelIds] = useState([]); - const [confirmState, setConfirmState] = useState(null); - const [showAgRiskModal, setShowAgRiskModal] = useState(false); - const [oneByOneRunning, setOneByOneRunning] = useState(false); - const [oneByOneStopping, setOneByOneStopping] = useState(false); - const [oneByOneCurrentConnectionId, setOneByOneCurrentConnectionId] = useState(null); - const [oneByOneResults, setOneByOneResults] = useState({}); - const [oneByOneSummary, setOneByOneSummary] = useState(null); - const stopOneByOneRef = useRef(false); - // Multi-select model test (real /api/models/test call, pinned per connection) - const [showBulkModelTestModal, setShowBulkModelTestModal] = useState(false); - const [bulkTestModelId, setBulkTestModelId] = useState(""); - const [manualBulkTestModelId, setManualBulkTestModelId] = useState(""); - const [importingQoderModels, setImportingQoderModels] = useState(false); - const [fetchingCompatibleModels, setFetchingCompatibleModels] = useState(false); - const { copied, copy } = useCopyToClipboard(); - - const AG_RISK_STORAGE_KEY = "ag_risk_confirmed"; - - const openOAuthConnection = () => { - setShowOAuthModal(true); - }; - - const triggerOAuthConnection = () => { - if (providerId === "antigravity" && typeof window !== "undefined") { - const confirmed = window.localStorage.getItem(AG_RISK_STORAGE_KEY) === "true"; - if (!confirmed) { - setShowAgRiskModal(true); - return; - } - } - if (isOAuth) { - openOAuthConnection(); - return; - } - setAddConnectionError(""); - setShowAddApiKeyModal(true); - }; - - const triggerApiKeyConnection = () => { - setAddConnectionError(""); - setShowAddApiKeyModal(true); - }; - - const triggerAddConnection = () => { - if (isOAuth) { - triggerOAuthConnection(); - return; - } - triggerApiKeyConnection(); - }; - - const handleAgRiskConfirm = () => { - if (typeof window !== "undefined") { - window.localStorage.setItem(AG_RISK_STORAGE_KEY, "true"); - } - setShowAgRiskModal(false); - if (isOAuth) { - openOAuthConnection(); - return; - } - triggerApiKeyConnection(); - }; - - const providerInfo = providerNode - ? { - id: providerNode.id, - name: providerNode.name || (providerNode.type === "anthropic-compatible" ? "Anthropic Compatible" : "OpenAI Compatible"), - color: providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F", - textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC", - apiType: providerNode.apiType, - baseUrl: providerNode.baseUrl, - type: providerNode.type, - } - : (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId] || WEB_COOKIE_PROVIDERS[providerId]); - const authModes = providerInfo?.authModes || []; - const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId] || authModes.includes("oauth"); - const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey"); - const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth; - const staticModels = getModelsByProviderId(providerId); - const models = providerId === "cursor" && liveModels.length > 0 - ? liveModels - : staticModels; - const providerAlias = getProviderAlias(providerId); - - const isOpenAICompatible = isOpenAICompatibleProvider(providerId); - const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId); - const isCompatible = isOpenAICompatible || isAnthropicCompatible; - const hasDualAuthModes = !isCompatible && isOAuth && supportsApiKeyAuth; - const oauthConnectionLabel = - providerId === "xai" ? "Grok Build OAuth" - : providerId === "grok-cli" ? "Grok CLI Device Login" - : providerId === "kimi" ? "Kimi Coding OAuth" - : "OAuth"; - const apiKeyConnectionLabel = - providerId === "xai" ? "xAI API Key" - : providerId === "kimi" ? "Kimi API Key" - : "API Key"; - // Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it. - const resolveThinkingSuffix = (modelId) => { - if (!thinkingMode || thinkingMode === "auto") return null; - const levels = getThinkingLevels(providerId, modelId); - return levels && levels.includes(thinkingMode) ? thinkingMode : null; - }; - const providerStorageAlias = isCompatible ? providerId : providerAlias; - // Union of levels across this provider's reasoning models — drives the level picker options. - // Include custom models too (e.g. manually added gpt-5.6-sol → max). - const providerThinkingLevels = (() => { - const set = new Set(); - const seen = new Set(); - const addLevels = (modelId) => { - if (!modelId || seen.has(modelId)) return; - seen.add(modelId); - const lv = getThinkingLevels(providerId, modelId); - if (lv) lv.forEach((l) => { if (l !== "none") set.add(l); }); - }; - for (const m of models) addLevels(m.id); - for (const m of kiloFreeModels) addLevels(m.id); - for (const entry of customModels) { - if (entry.providerAlias !== providerStorageAlias) continue; - if ((entry.kind || entry.type || "llm") !== "llm") continue; - addLevels(entry.id); - } - return set.size ? ["auto", ...[...set]] : null; - })(); - const providerDisplayAlias = isCompatible - ? (providerNode?.prefix || providerId) - : providerAlias; - - // Models available for "Test Model" action on each connection (pinned to that account). - const connectionTestModels = (() => { - const disabledSet = new Set(disabledModelIds); - const llmBuiltIns = models - .filter((m) => { - const k = getModelKind(m); - return (!k || k === "llm") && !disabledSet.has(m.id); - }) - .map((m) => ({ id: m.id, name: m.name || m.id })); - const customRows = getProviderCustomModelRows({ - customModels, - modelAliases, - providerAlias: providerStorageAlias, - builtInModels: models, - type: "llm", - }).map((m) => ({ id: m.id, name: m.name || m.id })); - const kiloRows = kiloFreeModels - .filter((m) => !models.some((b) => b.id === m.id) && !disabledSet.has(m.id)) - .map((m) => ({ id: m.id, name: m.name || m.id })); - const seen = new Set(); - const out = []; - for (const m of [...customRows, ...llmBuiltIns, ...kiloRows]) { - if (!m?.id || seen.has(m.id)) continue; - seen.add(m.id); - out.push(m); - } - return out; - })(); - - const fetchDisabledModels = useCallback(async () => { - try { - const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" }); - const data = await res.json(); - if (res.ok) setDisabledModelIds(data.ids || []); - } catch (error) { - console.log("Error fetching disabled models:", error); - } - }, [providerStorageAlias]); - - const handleDisableModel = async (modelId) => { - try { - const res = await fetch("/api/models/disabled", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ providerAlias: providerStorageAlias, ids: [modelId] }), - }); - if (res.ok) await fetchDisabledModels(); - } catch (error) { - console.log("Error disabling model:", error); - } - }; - - const handleEnableModel = async (modelId) => { - try { - const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}&id=${encodeURIComponent(modelId)}`, { method: "DELETE" }); - if (res.ok) await fetchDisabledModels(); - } catch (error) { - console.log("Error enabling model:", error); - } - }; - - const handleDisableAll = async (ids) => { - if (!ids.length) return; - setConfirmState({ - title: "Disable All Models", - message: `Disable all ${ids.length} model(s)?`, - onConfirm: async () => { - setConfirmState(null); - try { - const res = await fetch("/api/models/disabled", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ providerAlias: providerStorageAlias, ids }), - }); - if (res.ok) await fetchDisabledModels(); - } catch (error) { - console.log("Error disabling all models:", error); - } - } - }); - }; - - const handleEnableAll = async () => { - try { - const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { method: "DELETE" }); - if (res.ok) await fetchDisabledModels(); - } catch (error) { - console.log("Error enabling all models:", error); - } - }; - - // Define callbacks BEFORE the useEffect that uses them - const fetchAliases = useCallback(async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) { - setModelAliases(data.aliases || {}); - } - } catch (error) { - console.log("Error fetching aliases:", error); - } - }, []); - - const fetchCustomModels = useCallback(async () => { - try { - const res = await fetch("/api/models/custom", { cache: "no-store" }); - const data = await res.json(); - if (res.ok) { - setCustomModels(data.models || []); - } - } catch (error) { - console.log("Error fetching custom models:", error); - } - }, []); - - // Fetch free models from Kilo API for kilocode provider - useEffect(() => { - if (providerId !== "kilocode") return; - fetch("/api/providers/kilo/free-models") - .then((res) => res.json()) - .then((data) => { if (data.models?.length) setKiloFreeModels(data.models); }) - .catch(() => {}); - }, [providerId]); - - const fetchConnections = useCallback(async () => { - try { - 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); - } - 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"); - // Load per-provider thinking config - const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {}; - setThinkingMode(thinkingCfg.mode || "auto"); - // Load per-provider connect timeout - const timeoutCfg = (settingsData.providerTimeouts || {})[providerId] || {}; - setProviderTimeout(timeoutCfg.timeoutMs != null ? String(timeoutCfg.timeoutMs) : ""); - const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId]; - const apCfg = autoPingSettingsKey ? settingsData[autoPingSettingsKey] || {} : {}; - setAutoPing({ enabled: apCfg.enabled === true, connections: apCfg.connections || {} }); - if (nodesRes.ok) { - let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null; - - // Newly created compatible nodes can be briefly unavailable on one worker. - // Retry a few times before showing "Provider not found". - if (!node && isCompatible) { - for (let attempt = 0; attempt < 3; attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 150)); - const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" }); - if (!retryRes.ok) continue; - const retryData = await retryRes.json(); - node = (retryData.nodes || []).find((entry) => entry.id === providerId) || null; - if (node) break; - } - } - - setProviderNode(node); - } - } catch (error) { - console.log("Error fetching connections:", error); - } finally { - setLoading(false); - } - }, [providerId, isCompatible]); - - const handleUpdateNode = async (formData) => { - try { - const res = await fetch(`/api/provider-nodes/${providerId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - const data = await res.json(); - if (res.ok) { - setProviderNode(data.node); - await fetchConnections(); - setShowEditNodeModal(false); - } - } catch (error) { - console.log("Error updating provider node:", error); - } - }; - - 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); - }; - - const saveThinkingConfig = async (mode) => { - try { - const settingsRes = await fetch("/api/settings", { cache: "no-store" }); - const settingsData = settingsRes.ok ? await settingsRes.json() : {}; - const current = settingsData.providerThinking || {}; - const updated = { ...current }; - if (!mode || mode === "auto") { - delete updated[providerId]; - } else { - updated[providerId] = { mode }; - } - await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ providerThinking: updated }), - }); - } catch (error) { - console.log("Error saving thinking config:", error); - } - }; - - const handleThinkingModeChange = (mode) => { - setThinkingMode(mode); - saveThinkingConfig(mode); - }; - - const saveProviderTimeout = async (ms) => { - try { - const settingsRes = await fetch("/api/settings", { cache: "no-store" }); - const settingsData = settingsRes.ok ? await settingsRes.json() : {}; - const current = settingsData.providerTimeouts || {}; - const updated = { ...current }; - if (!ms || ms === "") { - delete updated[providerId]; - } else { - const timeoutMs = parseInt(ms, 10); - if (Number.isFinite(timeoutMs) && timeoutMs > 0) { - updated[providerId] = { timeoutMs }; - } else { - delete updated[providerId]; - } - } - await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ providerTimeouts: updated }), - }); - } catch (error) { - console.log("Error saving provider timeout:", error); - } - }; - - const handleTimeoutChange = (value) => { - const cleaned = value.replace(/[^0-9]/g, ""); - setProviderTimeout(cleaned); - saveProviderTimeout(cleaned); - }; - - const saveAutoPing = async (next) => { - const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId]; - if (!autoPingSettingsKey) return; - - setAutoPing(next); - try { - await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ [autoPingSettingsKey]: next }), - }); - } catch (error) { - console.log("Error saving auto-ping config:", error); - } - }; - - const handleAutoPingConnection = (connectionId, on) => { - saveAutoPing({ ...autoPing, connections: { ...autoPing.connections, [connectionId]: on } }); - }; - - useEffect(() => { - fetchConnections(); - fetchAliases(); - fetchCustomModels(); - fetchDisabledModels(); - }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]); - - // Cursor's model availability is account-specific and changes frequently. - // Load the active account's live catalog for the dashboard; the static - // registry remains the fallback while the request is pending or unavailable. - useEffect(() => { - if (providerId !== "cursor") { - setLiveModels([]); - return; - } - - const connection = connections.find((item) => item.isActive !== false); - if (!connection?.id) { - setLiveModels([]); - return; - } - - let cancelled = false; - fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" }) - .then(async (res) => ({ ok: res.ok, data: await res.json() })) - .then(({ ok, data }) => { - if (!cancelled && ok && Array.isArray(data.models) && data.models.length > 0) { - setLiveModels(data.models); - } - }) - .catch(() => {}); - - return () => { cancelled = true; }; - }, [providerId, connections]); - - // Fetch suggested models from provider's public API (if configured) - useEffect(() => { - const fetcher = (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId])?.modelsFetcher; - if (!fetcher) return; - fetchSuggestedModels(fetcher).then(setSuggestedModels); - }, [providerId]); - - const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => { - const fullModel = `${providerAliasOverride}/${modelId}`; - try { - const res = await fetch("/api/models/alias", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: fullModel, alias }), - }); - if (res.ok) { - await fetchAliases(); - } else { - const data = await res.json(); - alert(data.error || "Failed to set alias"); - } - } catch (error) { - console.log("Error setting alias:", error); - } - }; - - const handleDeleteAlias = async (alias) => { - try { - const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, { - method: "DELETE", - }); - if (res.ok) { - await fetchAliases(); - } - } catch (error) { - console.log("Error deleting alias:", error); - } - }; - - const handleAddCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias) => { - try { - const res = await fetch("/api/models/custom", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ providerAlias: providerAliasOverride, id: modelId, type }), - }); - if (res.ok) { - await fetchCustomModels(); - if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("customModelChanged")); - } else { - const data = await res.json(); - alert(data.error || "Failed to add custom model"); - } - } catch (error) { - console.log("Error adding custom model:", error); - } - }; - - const handleDeleteCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias) => { - try { - const params = new URLSearchParams({ providerAlias: providerAliasOverride, id: modelId, type }); - const res = await fetch(`/api/models/custom?${params}`, { method: "DELETE" }); - if (res.ok) { - await fetchCustomModels(); - if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("customModelChanged")); - } - } catch (error) { - console.log("Error deleting custom model:", error); - } - }; - - // Fetch Qoder model list and automatically add to available models - const handleImportQoderModels = async () => { - if (importingQoderModels) return; - const activeConnection = connections.find((conn) => conn.isActive !== false); - if (!activeConnection) { - alert(translate("Please add an active Qoder connection first")); - return; - } - - setImportingQoderModels(true); - try { - const res = await fetch(`/api/providers/${activeConnection.id}/models`); - const data = await res.json(); - if (!res.ok) { - alert(data.error || translate("Failed to fetch models")); - return; - } - const models = data.models || []; - if (models.length === 0) { - alert(translate("No models returned")); - return; - } - - let importedCount = 0; - for (const model of models) { - const modelId = model.id || model.name; - if (!modelId) continue; - - // Qoder model ID format may be "qoder/auto" or "auto", need to remove prefix - const cleanModelId = modelId.replace(/^qoder\//, ""); - const alreadyExists = customModels.some( - (entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanModelId && (entry.kind || entry.type || "llm") === "llm" - ) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanModelId}`); - if (alreadyExists) { - continue; - } - - await handleAddCustomModel(cleanModelId, "llm", providerStorageAlias); - importedCount += 1; - } - - if (importedCount === 0) { - alert(translate("All models already exist, no new models added")); - } else { - alert(translate("Successfully added") + ` ${importedCount} ` + translate("models")); - } - } catch (error) { - console.log("Error importing Qoder models:", error); - alert(translate("Error fetching models") + ": " + error.message); - } finally { - setImportingQoderModels(false); - } - }; - - const handleRunOneByOneTest = async (connectionIds = null) => { - if (oneByOneRunning || connections.length === 0) return; - - // Optional multi-select scope: only test checked connections; default = all. - const idFilter = Array.isArray(connectionIds) && connectionIds.length > 0 - ? new Set(connectionIds) - : null; - const targets = idFilter - ? connections.filter((connection) => idFilter.has(connection.id)) - : connections; - if (targets.length === 0) return; - - const queuedState = Object.fromEntries( - targets.map((connection) => [connection.id, { state: "queued", error: null }]), - ); - - stopOneByOneRef.current = false; - setOneByOneRunning(true); - setOneByOneStopping(false); - setOneByOneCurrentConnectionId(null); - setOneByOneResults(queuedState); - setOneByOneSummary({ total: targets.length, completed: 0, passed: 0, failed: 0, stopped: false }); - - let passed = 0; - let failed = 0; - - try { - for (let index = 0; index < targets.length; index += 1) { - if (stopOneByOneRef.current) { - setOneByOneSummary({ - total: targets.length, - completed: index, - passed, - failed, - stopped: true, - }); - break; - } - - const connection = targets[index]; - setOneByOneCurrentConnectionId(connection.id); - setOneByOneResults((prev) => ({ - ...prev, - [connection.id]: { state: "testing", error: null }, - })); - - try { - const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" }); - const data = await res.json(); - const valid = !!data.valid; - - if (valid) { - passed += 1; - } else { - failed += 1; - } - - setOneByOneResults((prev) => ({ - ...prev, - [connection.id]: { - state: valid ? "success" : "failed", - error: valid ? null : (data.error || null), - }, - })); - } catch (error) { - failed += 1; - setOneByOneResults((prev) => ({ - ...prev, - [connection.id]: { - state: "failed", - error: error.message || "Test failed", - }, - })); - } - - setOneByOneSummary({ - total: targets.length, - completed: index + 1, - passed, - failed, - stopped: false, - }); - - if (index < targets.length - 1) { - await sleep(ONE_BY_ONE_DELAY_MS); - } - } - } finally { - setOneByOneCurrentConnectionId(null); - setOneByOneRunning(false); - setOneByOneStopping(false); - stopOneByOneRef.current = false; - } - }; - - const openBulkModelTestModal = () => { - if (selectedConnectionIds.length === 0 || oneByOneRunning) return; - const defaultModelId = connectionTestModels[0]?.id || ""; - setBulkTestModelId(defaultModelId); - setManualBulkTestModelId(""); - setShowBulkModelTestModal(true); - }; - - const resolveBulkTestModelId = () => { - if (connectionTestModels.length > 0) return bulkTestModelId?.trim() || ""; - return manualBulkTestModelId?.trim() || ""; - }; - - // Real model call for selected accounts only (pins each connection via connectionId). - const handleRunSelectedModelTest = async () => { - if (oneByOneRunning || selectedConnectionIds.length === 0) return; - - const modelId = resolveBulkTestModelId(); - if (!modelId) return; - - const targets = connections.filter((connection) => selectedConnectionIds.includes(connection.id)); - if (targets.length === 0) return; - - setShowBulkModelTestModal(false); - - const fullModel = `${providerStorageAlias}/${modelId}`; - const queuedState = Object.fromEntries( - targets.map((connection) => [connection.id, { state: "queued", error: null }]), - ); - - stopOneByOneRef.current = false; - setOneByOneRunning(true); - setOneByOneStopping(false); - setOneByOneCurrentConnectionId(null); - setOneByOneResults(queuedState); - setOneByOneSummary({ total: targets.length, completed: 0, passed: 0, failed: 0, stopped: false }); - - let passed = 0; - let failed = 0; - - try { - for (let index = 0; index < targets.length; index += 1) { - if (stopOneByOneRef.current) { - setOneByOneSummary({ - total: targets.length, - completed: index, - passed, - failed, - stopped: true, - }); - break; - } - - const connection = targets[index]; - setOneByOneCurrentConnectionId(connection.id); - setOneByOneResults((prev) => ({ - ...prev, - [connection.id]: { state: "testing", error: null }, - })); - - try { - const res = await fetch("/api/models/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: fullModel, - connectionId: connection.id, - }), - }); - const data = await res.json().catch(() => ({})); - const ok = res.ok && !!data.ok; - - if (ok) { - passed += 1; - } else { - failed += 1; - } - - setOneByOneResults((prev) => ({ - ...prev, - [connection.id]: { - state: ok ? "success" : "failed", - error: ok - ? null - : (data.error || (res.ok ? "Model not reachable" : `HTTP ${res.status}`)), - }, - })); - } catch (error) { - failed += 1; - setOneByOneResults((prev) => ({ - ...prev, - [connection.id]: { - state: "failed", - error: error.message || "Test failed", - }, - })); - } - - setOneByOneSummary({ - total: targets.length, - completed: index + 1, - passed, - failed, - stopped: false, - }); - - if (index < targets.length - 1) { - await sleep(ONE_BY_ONE_DELAY_MS); - } - } - } finally { - setOneByOneCurrentConnectionId(null); - setOneByOneRunning(false); - setOneByOneStopping(false); - stopOneByOneRef.current = false; - } - }; - - const handleStopOneByOneTest = () => { - if (!oneByOneRunning) return; - stopOneByOneRef.current = true; - setOneByOneStopping(true); - }; - - const handleDelete = async (id) => { - setConfirmState({ - title: "Delete Connection", - message: "Delete this connection?", - onConfirm: async () => { - setConfirmState(null); - try { - const res = await fetch(`/api/providers/${id}`, { method: "DELETE" }); - if (res.ok) { - setConnections(prev => prev.filter(c => c.id !== id)); - } - } catch (error) { - console.log("Error deleting connection:", error); - } - } - }); - }; - - const handleBulkDelete = () => { - const count = selectedConnectionIds.length; - if (count === 0) return; - setConfirmState({ - title: `Delete ${count} Connection${count > 1 ? "s" : ""}`, - message: `Delete ${count} connection${count > 1 ? "s" : ""}? This cannot be undone.`, - onConfirm: async () => { - setConfirmState(null); - let failed = 0; - const idsToDelete = [...selectedConnectionIds]; - for (const id of idsToDelete) { - try { - const res = await fetch(`/api/providers/${id}`, { method: "DELETE" }); - if (!res.ok) failed += 1; - } catch (error) { - console.log("Error deleting connection:", error); - failed += 1; - } - } - setConnections(prev => prev.filter(c => !idsToDelete.includes(c.id))); - setSelectedConnectionIds([]); - if (failed > 0) alert(`Deleted ${idsToDelete.length - failed} connection(s), ${failed} failed.`); - } - }); - }; - - const handleOAuthSuccess = () => { - fetchConnections(); - setShowOAuthModal(false); - }; - - const handleIFlowCookieSuccess = () => { - fetchConnections(); - setShowIFlowCookieModal(false); - }; - - const handleSaveApiKey = async (formData) => { - setAddConnectionError(""); - try { - const res = await fetch("/api/providers", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider: providerId, ...formData }), - }); - - let data = null; - try { - data = await res.json(); - } catch { - data = null; - } - - if (res.ok) { - await fetchConnections(); - setShowAddApiKeyModal(false); - return; - } - - setAddConnectionError(data?.error || "Failed to save connection"); - } catch (error) { - console.log("Error saving connection:", error); - setAddConnectionError("Failed to save connection"); - } - }; - - const handleUpdateConnection = async (formData) => { - try { - const res = await fetch(`/api/providers/${selectedConnection.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - if (res.ok) { - await fetchConnections(); - setShowEditModal(false); - } - } catch (error) { - console.log("Error updating connection:", error); - } - }; - - const handleUpdateConnectionStatus = async (id, isActive) => { - try { - const res = await fetch(`/api/providers/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isActive }), - }); - if (res.ok) { - setConnections(prev => prev.map(c => c.id === id ? { ...c, isActive } : c)); - } - } catch (error) { - console.log("Error updating connection status:", error); - } - }; - - const handleSwapPriority = async (index1, index2) => { - // Optimistic update state - const newConnections = [...connections]; - [newConnections[index1], newConnections[index2]] = [newConnections[index2], newConnections[index1]]; - setConnections(newConnections); - - try { - await Promise.all([ - fetch(`/api/providers/${newConnections[index1].id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ priority: index1 }), - }), - fetch(`/api/providers/${newConnections[index2].id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ priority: index2 }), - }), - ]); - } catch (error) { - console.log("Error swapping priority:", error); - await fetchConnections(); - } - }; - - const selectedConnections = connections.filter((conn) => selectedConnectionIds.includes(conn.id)); - const allSelected = connections.length > 0 && selectedConnectionIds.length === connections.length; - - const toggleSelectConnection = (connectionId) => { - setSelectedConnectionIds((prev) => ( - prev.includes(connectionId) - ? prev.filter((id) => id !== connectionId) - : [...prev, connectionId] - )); - }; - - const toggleSelectAllConnections = () => { - if (allSelected) { - setSelectedConnectionIds([]); - return; - } - setSelectedConnectionIds(connections.map((conn) => conn.id)); - }; - - const clearSelection = () => { - setSelectedConnectionIds([]); - setBulkProxyPoolId("__none__"); - }; - - useEffect(() => { - setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id))); - }, [connections]); - - const selectedProxySummary = (() => { - if (selectedConnections.length === 0) return ""; - const poolIds = new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__")); - if (poolIds.size === 1) { - const onlyId = [...poolIds][0]; - if (onlyId === "__none__") return "All selected currently unbound"; - const pool = proxyPools.find((p) => p.id === onlyId); - return `All selected currently bound to ${pool?.name || onlyId}`; - } - return "Selected connections have mixed proxy bindings"; - })(); - - const openBulkProxyModal = () => { - if (selectedConnections.length === 0) return; - const uniquePoolIds = [...new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__"))]; - setBulkProxyPoolId(uniquePoolIds.length === 1 ? uniquePoolIds[0] : "__none__"); - setShowBulkProxyModal(true); - }; - - const closeBulkProxyModal = () => { - if (bulkUpdatingProxy) return; - setShowBulkProxyModal(false); - }; - - const applyProxyAssignments = async (assignments) => { - setBulkUpdatingProxy(true); - try { - let failed = 0; - for (const { connectionId, proxyPoolId } of assignments) { - try { - const res = await fetch(`/api/providers/${connectionId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ proxyPoolId }), - }); - if (!res.ok) failed += 1; - } catch (e) { - console.log("Error applying proxy for", connectionId, e); - failed += 1; - } - } - if (failed > 0) alert(`Updated with ${failed} failed request(s).`); - await fetchConnections(); - setShowBulkProxyModal(false); - } finally { - setBulkUpdatingProxy(false); - } - }; - - const handleApplySinglePool = (proxyPoolId) => { - const targets = connections.map((c) => ({ connectionId: c.id, proxyPoolId })); - return applyProxyAssignments(targets); - }; - - const handleApplyOneToOne = () => { - const activePools = proxyPools.filter((p) => p.isActive === true); - if (activePools.length === 0) { - alert("No active proxy pools available."); - return; - } - const targets = connections.map((c, i) => ({ - connectionId: c.id, - proxyPoolId: activePools[i % activePools.length].id, - })); - return applyProxyAssignments(targets); - }; - - - const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId); - - const connectionsList = ( -
- {connections - .map((conn, index) => ( -
-
- toggleSelectConnection(conn.id)} - className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" - /> -
-
- handleSwapPriority(index, index - 1)} - onMoveDown={() => handleSwapPriority(index, index + 1)} - onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} - autoPing={AUTO_PING_SETTINGS_KEYS[providerId] && conn.authType === "oauth" ? { - on: autoPing.connections[conn.id] === true, - onToggle: (on) => handleAutoPingConnection(conn.id, on), - provider: providerId, - } : null} - onUpdateProxy={async (proxyPoolId) => { - try { - const res = await fetch(`/api/providers/${conn.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ proxyPoolId: proxyPoolId || null }), - }); - if (res.ok) { - setConnections(prev => prev.map(c => - c.id === conn.id - ? { ...c, providerSpecificData: { ...c.providerSpecificData, proxyPoolId: proxyPoolId || null } } - : c - )); - } - } catch (error) { - console.log("Error updating proxy:", error); - } - }} - onEdit={() => { - setSelectedConnection(conn); - setShowEditModal(true); - }} - onDelete={() => handleDelete(conn.id)} - oneByOneStatus={oneByOneResults[conn.id] || null} - testModels={connectionTestModels} - providerAlias={providerStorageAlias} - /> -
-
- ))} -
- ); - - const activePools = proxyPools.filter((p) => p.isActive === true); - - const bulkActionModal = ( - -
-
- - - {proxyPools.map((pool) => ( - - ))} -
- - {bulkUpdatingProxy &&

Applying...

} - - -
-
- ); - - const handleTestModel = async (modelId) => { - if (testingModelIds.has(modelId)) return; - setTestingModelIds((prev) => new Set(prev).add(modelId)); - try { - const res = await fetch("/api/models/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: `${providerStorageAlias}/${modelId}` }), - }); - const data = await res.json(); - setModelTestResults((prev) => ({ ...prev, [modelId]: data.ok ? "ok" : "error" })); - setModelsTestError(data.ok ? "" : (data.error || "Model not reachable")); - } catch { - setModelTestResults((prev) => ({ ...prev, [modelId]: "error" })); - setModelsTestError("Network error"); - } finally { - setTestingModelIds((prev) => { const n = new Set(prev); n.delete(modelId); return n; }); - } - }; - - const renderModelsSection = () => { - if (isCompatible) { - return ( - handleAddCustomModel(modelId, "llm", providerStorageAlias)} - onDeleteCustomModel={(modelId) => handleDeleteCustomModel(modelId, "llm", providerStorageAlias)} - onFetchModels={async () => { - if (fetchingCompatibleModels || connections.length === 0) return; - setFetchingCompatibleModels(true); - const activeConnection = connections.find((conn) => conn.isActive !== false) || connections[0]; - if (!activeConnection) { - setFetchingCompatibleModels(false); - return; - } - try { - const res = await fetch(`/api/providers/${activeConnection.id}/models`); - const data = await res.json(); - if (!res.ok) { - alert(data.error || "Failed to fetch models"); - return; - } - const models = data.models || []; - if (models.length === 0) { - alert("No models returned from /models endpoint."); - return; - } - let importedCount = 0; - for (const model of models) { - const modelId = model.id || model.name || model.model; - if (!modelId) continue; - const cleanId = modelId.replace(/^qoder\//, ""); - const alreadyExists = customModels.some( - (entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanId - ) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanId}`); - if (alreadyExists) continue; - await handleAddCustomModel(cleanId, "llm", providerStorageAlias); - importedCount += 1; - } - if (importedCount === 0) { - alert("All models already exist, no new models added."); - } - } catch (error) { - console.log("Error fetching models:", error); - alert("Error fetching models: " + error.message); - } finally { - setFetchingCompatibleModels(false); - } - }} - fetchingModels={fetchingCompatibleModels} - connections={connections} - isAnthropic={isAnthropicCompatible} - /> - ); - } - // Combine hardcoded models with Kilo free models (deduplicated) - // Exclude non-llm models (embedding, tts, etc.) — they have dedicated pages under media-providers - const allModels = [ - ...models, - ...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), - ].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }); - const disabledSet = new Set(disabledModelIds); - const displayModels = allModels.filter((m) => !disabledSet.has(m.id)); - const disabledDisplayModels = allModels.filter((m) => disabledSet.has(m.id)); - const customModelRows = getProviderCustomModelRows({ - customModels, - modelAliases, - providerAlias: providerStorageAlias, - builtInModels: models, - type: "llm", - }); - - return ( -
- {/* Custom models first */} - {customModelRows.map((model) => ( - {}} - onDeleteAlias={() => { - if (model.source === "custom") { - handleDeleteCustomModel(model.id, "llm", providerStorageAlias); - } else { - handleDeleteAlias(model.alias); - } - }} - testStatus={modelTestResults[model.id]} - onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined} - isTesting={testingModelIds.has(model.id)} - isCustom - isFree={false} - caps={getCaps(`${providerId}/${model.id}`)} - thinkingSuffix={resolveThinkingSuffix(model.id)} - /> - ))} - - {displayModels.map((model) => { - const fullModel = `${providerStorageAlias}/${model.id}`; - const oldFormatModel = `${providerId}/${model.id}`; - const existingAlias = Object.entries(modelAliases).find( - ([, m]) => m === fullModel || m === oldFormatModel - )?.[0]; - return ( - handleSetAlias(model.id, alias, providerStorageAlias)} - onDeleteAlias={() => handleDeleteAlias(existingAlias)} - testStatus={modelTestResults[model.id]} - onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined} - isTesting={testingModelIds.has(model.id)} - isFree={model.isFree} - onDisable={() => handleDisableModel(model.id)} - caps={getCaps(`${providerId}/${model.id}`)} - thinkingSuffix={resolveThinkingSuffix(model.id)} - /> - ); - })} - - {/* Add model button — inline, same style as model chips */} - - - {/* Import Qoder models button — only show for qoder provider */} - {providerId === "qoder" && connections.some((conn) => conn.isActive !== false) && ( - - )} - - {/* Suggested models from provider API — show only models not yet added */} - {suggestedModels.length > 0 && (() => { - const addedFullModels = new Set([ - ...Object.values(modelAliases), - ...customModelRows.map((model) => model.fullModel), - ]); - const hardcodedIds = new Set(models.map((m) => m.id)); - const notAdded = suggestedModels.filter( - (m) => !addedFullModels.has(`${providerStorageAlias}/${m.id}`) && !hardcodedIds.has(m.id) - ); - if (notAdded.length === 0) return null; - return ( -
-

Suggested free models (≥200k context):

-
- {notAdded.map((m) => ( - - ))} -
-
- ); - })()} - - {/* Disabled models — restorable */} - {disabledDisplayModels.length > 0 && ( -
-

Disabled models ({disabledDisplayModels.length}):

-
- {disabledDisplayModels.map((m) => ( - - ))} -
-
- )} -
- ); - }; - - if (loading) { - return ( -
- - -
- ); -} - - if (!providerInfo) { - return ( -
-

Provider not found

- - Back to Providers - -
- ); - } - - // Determine icon path: OpenAI Compatible providers use specialized icons - const getHeaderIconPath = () => { - if (isOpenAICompatible && providerInfo.apiType) { - return providerInfo.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; - } - if (isAnthropicCompatible) { - return "/providers/anthropic-m.png"; - } - return getProviderIconSrc(providerInfo.id); - }; - - return ( -
- {/* Header */} -
- - arrow_back - Back to Providers - -
-
- {headerImgError || !getHeaderIconPath() ? ( - - {providerInfo.textIcon || providerInfo.id.slice(0, 2).toUpperCase()} - - ) : ( - {providerInfo.name} { - markProviderIconMissing(providerInfo.id); - setHeaderImgError(true); - }} - loading="lazy" - decoding="async" - /> - )} -
-
-
-

{providerInfo.name}

- {(providerInfo.notice?.apiKeyUrl || providerInfo.notice?.signupUrl || providerInfo.website) && ( - - open_in_new - {providerInfo.notice?.apiKeyUrl ? "Get API Key" : "Sign up / Learn more"} - - )} -
-

- {connections.length} connection{connections.length === 1 ? "" : "s"} -

-
-
-
- - {providerInfo.deprecated && ( -
- warning -

{providerInfo.deprecationNotice}

-
- )} - - {providerInfo.notice?.text && !providerInfo.deprecated && ( -
- info -

{providerInfo.notice.text}

- {providerInfo.notice.apiKeyUrl && ( - - Get API Key → - - )} -
- )} - - {isCompatible && providerNode && ( - -
-
-

{isAnthropicCompatible ? "Anthropic Compatible Details" : "OpenAI Compatible Details"}

-

- {isAnthropicCompatible ? "Messages API" : (providerNode.apiType === "responses" ? "Responses API" : "Chat Completions")} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/ - {isAnthropicCompatible ? "messages" : (providerNode.apiType === "responses" ? "responses" : "chat/completions")} -

-
-
- - - -
-
-
- )} - - {/* Connections */} - {isFreeNoAuth ? ( - - ) : ( - -
-

Connections

-
- {connections.length > 0 && proxyPools.length > 0 && ( - - )} - {connections.length > 0 && ( - <> - {selectedConnectionIds.length > 0 && ( - <> - - - - )} - - {oneByOneRunning && ( - - )} - - )} - {/* Connect Timeout */} -
- Connect Timeout -
- handleTimeoutChange(e.target.value)} - placeholder="default" - className="w-20 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary" - /> - ms -
-
- {/* 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 ? ( -
-
-
- {isOAuth ? "lock" : "key"} -
-
-

No connections yet

- {hasDualAuthModes && ( -

- Choose {oauthConnectionLabel} or {apiKeyConnectionLabel}. -

- )} -
-
-
- {hasDualAuthModes ? ( - <> - - - - ) : ( - <> - {!isCompatible && providerId === "iflow" && ( - - )} - {providerId === "codex" && ( - - )} - - - )} -
-
- ) : ( - <> - {oneByOneSummary && ( -
-
- Total: {oneByOneSummary.total} - Completed: {oneByOneSummary.completed} - Passed: {oneByOneSummary.passed} - Failed: {oneByOneSummary.failed} - {oneByOneSummary.stopped && ( - Stopped - )} - {oneByOneRunning && oneByOneCurrentConnectionId && ( - Running: {connections.find((conn) => conn.id === oneByOneCurrentConnectionId)?.name || oneByOneCurrentConnectionId} - )} -
-
- )} - {connections.length > 0 && ( -
- -
- )} - {connectionsList} - {!isCompatible && ( -
- {providerId === "iflow" && ( - - )} - {providerId === "codex" && ( - - )} - {hasDualAuthModes ? ( - <> - - - - ) : ( - - )} -
- )} - - )} -
- )} - - {/* Models */} - -
-
-

- {"Available Models"} -

- {providerThinkingLevels && ( - - )} -
- {!isCompatible && (() => { - const allIds = [ - ...models, - ...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), - ].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }).map((m) => m.id); - const activeIds = allIds.filter((id) => !disabledModelIds.includes(id)); - return ( -
- {disabledModelIds.length > 0 && ( - - )} - {activeIds.length > 0 && ( - - )} -
- ); - })()} -
- {!!modelsTestError && ( -

{modelsTestError}

- )} - {renderModelsSection()} -
- - {bulkActionModal} - - {/* Modals */} - {providerId === "kiro" ? ( - setShowOAuthModal(false)} - /> - ) : providerId === "cursor" ? ( - setShowOAuthModal(false)} - /> - ) : providerId === "gitlab" ? ( - setShowOAuthModal(false)} - /> - ) : ( - setShowOAuthModal(false)} - /> - )} - {providerId === "iflow" && ( - setShowIFlowCookieModal(false)} - /> - )} - c.name).filter(Boolean)} - onSave={handleSaveApiKey} - onBulkDone={fetchConnections} - onClose={() => { - setAddConnectionError(""); - setShowAddApiKeyModal(false); - }} - /> - setShowEditModal(false)} - /> - {isCompatible && ( - setShowEditNodeModal(false)} - isAnthropic={isAnthropicCompatible} - /> - )} - {!isCompatible && ( - { - await handleAddCustomModel(modelId, "llm", providerStorageAlias); - setShowAddCustomModel(false); - }} - onClose={() => setShowAddCustomModel(false)} - /> - )} - - {providerId === "codex" && ( - setShowBulkImportCodex(false)} - onSuccess={fetchConnections} - /> - )} - - {/* Bulk model test — real /api/models/test call, pinned per selected connection */} - 1 ? "s" : ""})`} - onClose={() => { - if (oneByOneRunning) return; - setShowBulkModelTestModal(false); - }} - > -
-

- Call one model once per selected account. Each request is pinned to that connection only. -

- - {connectionTestModels.length > 0 ? ( -
- - -
- ) : ( -
- - setManualBulkTestModelId(e.target.value)} - placeholder="e.g. grok-4" - className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm font-mono focus:border-primary focus:outline-none" - /> -
- )} - -
- - -
-
-
- - {/* AG Risk Confirmation Modal */} - setShowAgRiskModal(false)} - onConfirm={handleAgRiskConfirm} - title="Risk Notice" - message={providerInfo?.deprecationNotice} - confirmText="I Understand, Continue" - cancelText="Cancel" - variant="danger" - /> - - {/* Confirm Modal */} - setConfirmState(null)} - onConfirm={confirmState?.onConfirm} - title={confirmState?.title || "Confirm"} - message={confirmState?.message} - variant="danger" - /> -
- ); + const params = useParams(); + const router = useRouter(); + const providerId = params.id; + const { getCaps } = useModelCaps(); + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [providerNode, setProviderNode] = useState(null); + const [proxyPools, setProxyPools] = useState([]); + const [showOAuthModal, setShowOAuthModal] = useState(false); + const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false); + const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); + const [addConnectionError, setAddConnectionError] = useState(""); + const [showBulkImportCodex, setShowBulkImportCodex] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [showEditNodeModal, setShowEditNodeModal] = useState(false); + const [showBulkProxyModal, setShowBulkProxyModal] = useState(false); + const [selectedConnection, setSelectedConnection] = useState(null); + const [modelAliases, setModelAliases] = useState({}); + const [customModels, setCustomModels] = useState([]); + const [headerImgError, setHeaderImgError] = useState(false); + const [modelTestResults, setModelTestResults] = useState({}); + const [modelsTestError, setModelsTestError] = useState(""); + const [testingModelIds, setTestingModelIds] = useState(() => new Set()); + const [showAddCustomModel, setShowAddCustomModel] = useState(false); + const [selectedConnectionIds, setSelectedConnectionIds] = useState([]); + const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__"); + const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false); + const [providerStrategy, setProviderStrategy] = useState(null); + const [providerStickyLimit, setProviderStickyLimit] = useState(""); + const [thinkingMode, setThinkingMode] = useState("auto"); + const [providerTimeout, setProviderTimeout] = useState(""); + const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} }); + const [suggestedModels, setSuggestedModels] = useState([]); + const [liveModels, setLiveModels] = useState([]); + const [kiloFreeModels, setKiloFreeModels] = useState([]); + const [disabledModelIds, setDisabledModelIds] = useState([]); + const [confirmState, setConfirmState] = useState(null); + const [showAgRiskModal, setShowAgRiskModal] = useState(false); + const [oneByOneRunning, setOneByOneRunning] = useState(false); + const [oneByOneStopping, setOneByOneStopping] = useState(false); + const [oneByOneCurrentConnectionId, setOneByOneCurrentConnectionId] = + useState(null); + const [oneByOneResults, setOneByOneResults] = useState({}); + const [oneByOneSummary, setOneByOneSummary] = useState(null); + const stopOneByOneRef = useRef(false); + // Multi-select model test (real /api/models/test call, pinned per connection) + const [showBulkModelTestModal, setShowBulkModelTestModal] = useState(false); + const [bulkTestModelId, setBulkTestModelId] = useState(""); + const [manualBulkTestModelId, setManualBulkTestModelId] = useState(""); + // Test All for non-compatible models + const [testAllModelsRunning, setTestAllModelsRunning] = useState(false); + const [testAllModelsResults, setTestAllModelsResults] = useState(null); + const [testAllModelsFailedIds, setTestAllModelsFailedIds] = useState([]); + const [testAllSelectedConnectionId, setTestAllSelectedConnectionId] = + useState("__default__"); + const testAllModelsStopRef = useRef(false); + const [testAllModelsCleaning, setTestAllModelsCleaning] = useState(false); + const [importingQoderModels, setImportingQoderModels] = useState(false); + const [fetchingCompatibleModels, setFetchingCompatibleModels] = + useState(false); + const { copied, copy } = useCopyToClipboard(); + + const AG_RISK_STORAGE_KEY = "ag_risk_confirmed"; + + const openOAuthConnection = () => { + setShowOAuthModal(true); + }; + + const triggerOAuthConnection = () => { + if (providerId === "antigravity" && typeof window !== "undefined") { + const confirmed = + window.localStorage.getItem(AG_RISK_STORAGE_KEY) === "true"; + if (!confirmed) { + setShowAgRiskModal(true); + return; + } + } + if (isOAuth) { + openOAuthConnection(); + return; + } + setAddConnectionError(""); + setShowAddApiKeyModal(true); + }; + + const triggerApiKeyConnection = () => { + setAddConnectionError(""); + setShowAddApiKeyModal(true); + }; + + const triggerAddConnection = () => { + if (isOAuth) { + triggerOAuthConnection(); + return; + } + triggerApiKeyConnection(); + }; + + const handleAgRiskConfirm = () => { + if (typeof window !== "undefined") { + window.localStorage.setItem(AG_RISK_STORAGE_KEY, "true"); + } + setShowAgRiskModal(false); + if (isOAuth) { + openOAuthConnection(); + return; + } + triggerApiKeyConnection(); + }; + + const providerInfo = providerNode + ? { + id: providerNode.id, + name: + providerNode.name || + (providerNode.type === "anthropic-compatible" + ? "Anthropic Compatible" + : "OpenAI Compatible"), + color: + providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F", + textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC", + apiType: providerNode.apiType, + baseUrl: providerNode.baseUrl, + type: providerNode.type, + } + : OAUTH_PROVIDERS[providerId] || + APIKEY_PROVIDERS[providerId] || + FREE_PROVIDERS[providerId] || + FREE_TIER_PROVIDERS[providerId] || + WEB_COOKIE_PROVIDERS[providerId]; + const authModes = providerInfo?.authModes || []; + const isOAuth = + !!OAUTH_PROVIDERS[providerId] || + !!FREE_PROVIDERS[providerId] || + authModes.includes("oauth"); + const supportsApiKeyAuth = + !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey"); + const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth; + const staticModels = getModelsByProviderId(providerId); + const models = + providerId === "cursor" && liveModels.length > 0 + ? liveModels + : staticModels; + const providerAlias = getProviderAlias(providerId); + + const isOpenAICompatible = isOpenAICompatibleProvider(providerId); + const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId); + const isCompatible = isOpenAICompatible || isAnthropicCompatible; + const hasDualAuthModes = !isCompatible && isOAuth && supportsApiKeyAuth; + const oauthConnectionLabel = + providerId === "xai" + ? "Grok Build OAuth" + : providerId === "grok-cli" + ? "Grok CLI Device Login" + : providerId === "kimi" + ? "Kimi Coding OAuth" + : "OAuth"; + const apiKeyConnectionLabel = + providerId === "xai" + ? "xAI API Key" + : providerId === "kimi" + ? "Kimi API Key" + : "API Key"; + // Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it. + const resolveThinkingSuffix = (modelId) => { + if (!thinkingMode || thinkingMode === "auto") return null; + const levels = getThinkingLevels(providerId, modelId); + return levels && levels.includes(thinkingMode) ? thinkingMode : null; + }; + const providerStorageAlias = isCompatible ? providerId : providerAlias; + // Union of levels across this provider's reasoning models — drives the level picker options. + // Include custom models too (e.g. manually added gpt-5.6-sol → max). + const providerThinkingLevels = (() => { + const set = new Set(); + const seen = new Set(); + const addLevels = (modelId) => { + if (!modelId || seen.has(modelId)) return; + seen.add(modelId); + const lv = getThinkingLevels(providerId, modelId); + if (lv) + lv.forEach((l) => { + if (l !== "none") set.add(l); + }); + }; + for (const m of models) addLevels(m.id); + for (const m of kiloFreeModels) addLevels(m.id); + for (const entry of customModels) { + if (entry.providerAlias !== providerStorageAlias) continue; + if ((entry.kind || entry.type || "llm") !== "llm") continue; + addLevels(entry.id); + } + return set.size ? ["auto", ...[...set]] : null; + })(); + const providerDisplayAlias = isCompatible + ? providerNode?.prefix || providerId + : providerAlias; + + // Models available for "Test Model" action on each connection (pinned to that account). + const connectionTestModels = (() => { + const disabledSet = new Set(disabledModelIds); + const llmBuiltIns = models + .filter((m) => { + const k = getModelKind(m); + return (!k || k === "llm") && !disabledSet.has(m.id); + }) + .map((m) => ({ id: m.id, name: m.name || m.id })); + const customRows = getProviderCustomModelRows({ + customModels, + modelAliases, + providerAlias: providerStorageAlias, + builtInModels: models, + type: "llm", + }).map((m) => ({ id: m.id, name: m.name || m.id })); + const kiloRows = kiloFreeModels + .filter( + (m) => !models.some((b) => b.id === m.id) && !disabledSet.has(m.id), + ) + .map((m) => ({ id: m.id, name: m.name || m.id })); + const seen = new Set(); + const out = []; + for (const m of [...customRows, ...llmBuiltIns, ...kiloRows]) { + if (!m?.id || seen.has(m.id)) continue; + seen.add(m.id); + out.push(m); + } + return out; + })(); + + const fetchDisabledModels = useCallback(async () => { + try { + const res = await fetch( + `/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, + { cache: "no-store" }, + ); + const data = await res.json(); + if (res.ok) setDisabledModelIds(data.ids || []); + } catch (error) { + console.log("Error fetching disabled models:", error); + } + }, [providerStorageAlias]); + + const handleDisableModel = async (modelId) => { + try { + const res = await fetch("/api/models/disabled", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerAlias: providerStorageAlias, + ids: [modelId], + }), + }); + if (res.ok) await fetchDisabledModels(); + } catch (error) { + console.log("Error disabling model:", error); + } + }; + + const handleEnableModel = async (modelId) => { + try { + const res = await fetch( + `/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}&id=${encodeURIComponent(modelId)}`, + { method: "DELETE" }, + ); + if (res.ok) await fetchDisabledModels(); + } catch (error) { + console.log("Error enabling model:", error); + } + }; + + const handleDisableAll = async (ids) => { + if (!ids.length) return; + setConfirmState({ + title: "Disable All Models", + message: `Disable all ${ids.length} model(s)?`, + onConfirm: async () => { + setConfirmState(null); + try { + const res = await fetch("/api/models/disabled", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerAlias: providerStorageAlias, ids }), + }); + if (res.ok) await fetchDisabledModels(); + } catch (error) { + console.log("Error disabling all models:", error); + } + }, + }); + }; + + const handleEnableAll = async () => { + try { + const res = await fetch( + `/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, + { method: "DELETE" }, + ); + if (res.ok) await fetchDisabledModels(); + } catch (error) { + console.log("Error enabling all models:", error); + } + }; + + // Define callbacks BEFORE the useEffect that uses them + const fetchAliases = useCallback(async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) { + setModelAliases(data.aliases || {}); + } + } catch (error) { + console.log("Error fetching aliases:", error); + } + }, []); + + const fetchCustomModels = useCallback(async () => { + try { + const res = await fetch("/api/models/custom", { cache: "no-store" }); + const data = await res.json(); + if (res.ok) { + setCustomModels(data.models || []); + } + } catch (error) { + console.log("Error fetching custom models:", error); + } + }, []); + + // Fetch free models from Kilo API for kilocode provider + useEffect(() => { + if (providerId !== "kilocode") return; + fetch("/api/providers/kilo/free-models") + .then((res) => res.json()) + .then((data) => { + if (data.models?.length) setKiloFreeModels(data.models); + }) + .catch(() => {}); + }, [providerId]); + + const fetchConnections = useCallback(async () => { + try { + 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); + } + 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", + ); + // Load per-provider thinking config + const thinkingCfg = + (settingsData.providerThinking || {})[providerId] || {}; + setThinkingMode(thinkingCfg.mode || "auto"); + // Load per-provider connect timeout + const timeoutCfg = + (settingsData.providerTimeouts || {})[providerId] || {}; + setProviderTimeout( + timeoutCfg.timeoutMs != null ? String(timeoutCfg.timeoutMs) : "", + ); + const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId]; + const apCfg = autoPingSettingsKey + ? settingsData[autoPingSettingsKey] || {} + : {}; + setAutoPing({ + enabled: apCfg.enabled === true, + connections: apCfg.connections || {}, + }); + if (nodesRes.ok) { + let node = + (nodesData.nodes || []).find((entry) => entry.id === providerId) || + null; + + // Newly created compatible nodes can be briefly unavailable on one worker. + // Retry a few times before showing "Provider not found". + if (!node && isCompatible) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 150)); + const retryRes = await fetch("/api/provider-nodes", { + cache: "no-store", + }); + if (!retryRes.ok) continue; + const retryData = await retryRes.json(); + node = + (retryData.nodes || []).find( + (entry) => entry.id === providerId, + ) || null; + if (node) break; + } + } + + setProviderNode(node); + } + } catch (error) { + console.log("Error fetching connections:", error); + } finally { + setLoading(false); + } + }, [providerId, isCompatible]); + + const handleUpdateNode = async (formData) => { + try { + const res = await fetch(`/api/provider-nodes/${providerId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + const data = await res.json(); + if (res.ok) { + setProviderNode(data.node); + await fetchConnections(); + setShowEditNodeModal(false); + } + } catch (error) { + console.log("Error updating provider node:", error); + } + }; + + 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); + }; + + const saveThinkingConfig = async (mode) => { + try { + const settingsRes = await fetch("/api/settings", { cache: "no-store" }); + const settingsData = settingsRes.ok ? await settingsRes.json() : {}; + const current = settingsData.providerThinking || {}; + const updated = { ...current }; + if (!mode || mode === "auto") { + delete updated[providerId]; + } else { + updated[providerId] = { mode }; + } + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerThinking: updated }), + }); + } catch (error) { + console.log("Error saving thinking config:", error); + } + }; + + const handleThinkingModeChange = (mode) => { + setThinkingMode(mode); + saveThinkingConfig(mode); + }; + + const saveProviderTimeout = async (ms) => { + try { + const settingsRes = await fetch("/api/settings", { cache: "no-store" }); + const settingsData = settingsRes.ok ? await settingsRes.json() : {}; + const current = settingsData.providerTimeouts || {}; + const updated = { ...current }; + if (!ms || ms === "") { + delete updated[providerId]; + } else { + const timeoutMs = parseInt(ms, 10); + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + updated[providerId] = { timeoutMs }; + } else { + delete updated[providerId]; + } + } + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerTimeouts: updated }), + }); + } catch (error) { + console.log("Error saving provider timeout:", error); + } + }; + + const handleTimeoutChange = (value) => { + const cleaned = value.replace(/[^0-9]/g, ""); + setProviderTimeout(cleaned); + saveProviderTimeout(cleaned); + }; + + const saveAutoPing = async (next) => { + const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId]; + if (!autoPingSettingsKey) return; + + setAutoPing(next); + try { + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [autoPingSettingsKey]: next }), + }); + } catch (error) { + console.log("Error saving auto-ping config:", error); + } + }; + + const handleAutoPingConnection = (connectionId, on) => { + saveAutoPing({ + ...autoPing, + connections: { ...autoPing.connections, [connectionId]: on }, + }); + }; + + useEffect(() => { + fetchConnections(); + fetchAliases(); + fetchCustomModels(); + fetchDisabledModels(); + }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]); + + // Cursor's model availability is account-specific and changes frequently. + // Load the active account's live catalog for the dashboard; the static + // registry remains the fallback while the request is pending or unavailable. + useEffect(() => { + if (providerId !== "cursor") { + setLiveModels([]); + return; + } + + const connection = connections.find((item) => item.isActive !== false); + if (!connection?.id) { + setLiveModels([]); + return; + } + + let cancelled = false; + fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" }) + .then(async (res) => ({ ok: res.ok, data: await res.json() })) + .then(({ ok, data }) => { + if ( + !cancelled && + ok && + Array.isArray(data.models) && + data.models.length > 0 + ) { + setLiveModels(data.models); + } + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [providerId, connections]); + + // Fetch suggested models from provider's public API (if configured) + useEffect(() => { + const fetcher = ( + OAUTH_PROVIDERS[providerId] || + APIKEY_PROVIDERS[providerId] || + FREE_PROVIDERS[providerId] || + FREE_TIER_PROVIDERS[providerId] + )?.modelsFetcher; + if (!fetcher) return; + fetchSuggestedModels(fetcher).then(setSuggestedModels); + }, [providerId]); + + const handleSetAlias = async ( + modelId, + alias, + providerAliasOverride = providerAlias, + ) => { + const fullModel = `${providerAliasOverride}/${modelId}`; + try { + const res = await fetch("/api/models/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: fullModel, alias }), + }); + if (res.ok) { + await fetchAliases(); + } else { + const data = await res.json(); + alert(data.error || "Failed to set alias"); + } + } catch (error) { + console.log("Error setting alias:", error); + } + }; + + const handleDeleteAlias = async (alias) => { + try { + const res = await fetch( + `/api/models/alias?alias=${encodeURIComponent(alias)}`, + { + method: "DELETE", + }, + ); + if (res.ok) { + await fetchAliases(); + } + } catch (error) { + console.log("Error deleting alias:", error); + } + }; + + const handleAddCustomModel = async ( + modelId, + type = "llm", + providerAliasOverride = providerStorageAlias, + ) => { + try { + const res = await fetch("/api/models/custom", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerAlias: providerAliasOverride, + id: modelId, + type, + }), + }); + if (res.ok) { + await fetchCustomModels(); + if (typeof window !== "undefined") + window.dispatchEvent(new CustomEvent("customModelChanged")); + } else { + const data = await res.json(); + alert(data.error || "Failed to add custom model"); + } + } catch (error) { + console.log("Error adding custom model:", error); + } + }; + + const handleDeleteCustomModel = async ( + modelId, + type = "llm", + providerAliasOverride = providerStorageAlias, + ) => { + try { + const params = new URLSearchParams({ + providerAlias: providerAliasOverride, + id: modelId, + type, + }); + const res = await fetch(`/api/models/custom?${params}`, { + method: "DELETE", + }); + if (res.ok) { + await fetchCustomModels(); + if (typeof window !== "undefined") + window.dispatchEvent(new CustomEvent("customModelChanged")); + } + } catch (error) { + console.log("Error deleting custom model:", error); + } + }; + + // Fetch Qoder model list and automatically add to available models + const handleImportQoderModels = async () => { + if (importingQoderModels) return; + const activeConnection = connections.find( + (conn) => conn.isActive !== false, + ); + if (!activeConnection) { + alert(translate("Please add an active Qoder connection first")); + return; + } + + setImportingQoderModels(true); + try { + const res = await fetch(`/api/providers/${activeConnection.id}/models`); + const data = await res.json(); + if (!res.ok) { + alert(data.error || translate("Failed to fetch models")); + return; + } + const models = data.models || []; + if (models.length === 0) { + alert(translate("No models returned")); + return; + } + + let importedCount = 0; + for (const model of models) { + const modelId = model.id || model.name; + if (!modelId) continue; + + // Qoder model ID format may be "qoder/auto" or "auto", need to remove prefix + const cleanModelId = modelId.replace(/^qoder\//, ""); + const alreadyExists = + customModels.some( + (entry) => + entry.providerAlias === providerStorageAlias && + entry.id === cleanModelId && + (entry.kind || entry.type || "llm") === "llm", + ) || + Object.values(modelAliases).includes( + `${providerStorageAlias}/${cleanModelId}`, + ); + if (alreadyExists) { + continue; + } + + await handleAddCustomModel(cleanModelId, "llm", providerStorageAlias); + importedCount += 1; + } + + if (importedCount === 0) { + alert(translate("All models already exist, no new models added")); + } else { + alert( + translate("Successfully added") + + ` ${importedCount} ` + + translate("models"), + ); + } + } catch (error) { + console.log("Error importing Qoder models:", error); + alert(translate("Error fetching models") + ": " + error.message); + } finally { + setImportingQoderModels(false); + } + }; + + const handleRunOneByOneTest = async (connectionIds = null) => { + if (oneByOneRunning || connections.length === 0) return; + + // Optional multi-select scope: only test checked connections; default = all. + const idFilter = + Array.isArray(connectionIds) && connectionIds.length > 0 + ? new Set(connectionIds) + : null; + const targets = idFilter + ? connections.filter((connection) => idFilter.has(connection.id)) + : connections; + if (targets.length === 0) return; + + const queuedState = Object.fromEntries( + targets.map((connection) => [ + connection.id, + { state: "queued", error: null }, + ]), + ); + + stopOneByOneRef.current = false; + setOneByOneRunning(true); + setOneByOneStopping(false); + setOneByOneCurrentConnectionId(null); + setOneByOneResults(queuedState); + setOneByOneSummary({ + total: targets.length, + completed: 0, + passed: 0, + failed: 0, + stopped: false, + }); + + let passed = 0; + let failed = 0; + + try { + for (let index = 0; index < targets.length; index += 1) { + if (stopOneByOneRef.current) { + setOneByOneSummary({ + total: targets.length, + completed: index, + passed, + failed, + stopped: true, + }); + break; + } + + const connection = targets[index]; + setOneByOneCurrentConnectionId(connection.id); + setOneByOneResults((prev) => ({ + ...prev, + [connection.id]: { state: "testing", error: null }, + })); + + try { + const res = await fetch(`/api/providers/${connection.id}/test`, { + method: "POST", + }); + const data = await res.json(); + const valid = !!data.valid; + + if (valid) { + passed += 1; + } else { + failed += 1; + } + + setOneByOneResults((prev) => ({ + ...prev, + [connection.id]: { + state: valid ? "success" : "failed", + error: valid ? null : data.error || null, + }, + })); + } catch (error) { + failed += 1; + setOneByOneResults((prev) => ({ + ...prev, + [connection.id]: { + state: "failed", + error: error.message || "Test failed", + }, + })); + } + + setOneByOneSummary({ + total: targets.length, + completed: index + 1, + passed, + failed, + stopped: false, + }); + + if (index < targets.length - 1) { + await sleep(ONE_BY_ONE_DELAY_MS); + } + } + } finally { + setOneByOneCurrentConnectionId(null); + setOneByOneRunning(false); + setOneByOneStopping(false); + stopOneByOneRef.current = false; + } + }; + + const openBulkModelTestModal = () => { + if (selectedConnectionIds.length === 0 || oneByOneRunning) return; + const defaultModelId = connectionTestModels[0]?.id || ""; + setBulkTestModelId(defaultModelId); + setManualBulkTestModelId(""); + setShowBulkModelTestModal(true); + }; + + const resolveBulkTestModelId = () => { + if (connectionTestModels.length > 0) return bulkTestModelId?.trim() || ""; + return manualBulkTestModelId?.trim() || ""; + }; + + // Real model call for selected accounts only (pins each connection via connectionId). + const handleRunSelectedModelTest = async () => { + if (oneByOneRunning || selectedConnectionIds.length === 0) return; + + const modelId = resolveBulkTestModelId(); + if (!modelId) return; + + const targets = connections.filter((connection) => + selectedConnectionIds.includes(connection.id), + ); + if (targets.length === 0) return; + + setShowBulkModelTestModal(false); + + const fullModel = `${providerStorageAlias}/${modelId}`; + const runningState = Object.fromEntries( + targets.map((connection) => [ + connection.id, + { state: "testing", error: null }, + ]), + ); + + setOneByOneRunning(true); + setOneByOneResults(runningState); + setOneByOneSummary(null); + + try { + const results = await Promise.all( + targets.map(async (connection) => { + try { + const res = await fetch("/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: fullModel, + connectionId: connection.id, + }), + }); + const data = await res.json().catch(() => ({})); + const ok = res.ok && !!data.ok; + + return { + connectionId: connection.id, + state: ok ? "success" : "failed", + error: ok + ? null + : data.error || + (res.ok ? "Model not reachable" : `HTTP ${res.status}`), + }; + } catch (error) { + return { + connectionId: connection.id, + state: "failed", + error: error.message || "Test failed", + }; + } + }), + ); + + const passed = results.filter((r) => r.state === "success").length; + const failed = results.filter((r) => r.state === "failed").length; + + setOneByOneResults( + Object.fromEntries(results.map((r) => [r.connectionId, r])), + ); + setOneByOneSummary({ + total: targets.length, + completed: targets.length, + passed, + failed, + stopped: false, + }); + } finally { + setOneByOneRunning(false); + } + }; + + const handleStopOneByOneTest = () => { + if (!oneByOneRunning) return; + stopOneByOneRef.current = true; + setOneByOneStopping(true); + }; + + const handleDelete = async (id) => { + setConfirmState({ + title: "Delete Connection", + message: "Delete this connection?", + onConfirm: async () => { + setConfirmState(null); + try { + const res = await fetch(`/api/providers/${id}`, { method: "DELETE" }); + if (res.ok) { + setConnections((prev) => prev.filter((c) => c.id !== id)); + } + } catch (error) { + console.log("Error deleting connection:", error); + } + }, + }); + }; + + const handleBulkDelete = () => { + const count = selectedConnectionIds.length; + if (count === 0) return; + setConfirmState({ + title: `Delete ${count} Connection${count > 1 ? "s" : ""}`, + message: `Delete ${count} connection${count > 1 ? "s" : ""}? This cannot be undone.`, + onConfirm: async () => { + setConfirmState(null); + let failed = 0; + const idsToDelete = [...selectedConnectionIds]; + for (const id of idsToDelete) { + try { + const res = await fetch(`/api/providers/${id}`, { + method: "DELETE", + }); + if (!res.ok) failed += 1; + } catch (error) { + console.log("Error deleting connection:", error); + failed += 1; + } + } + setConnections((prev) => + prev.filter((c) => !idsToDelete.includes(c.id)), + ); + setSelectedConnectionIds([]); + if (failed > 0) + alert( + `Deleted ${idsToDelete.length - failed} connection(s), ${failed} failed.`, + ); + }, + }); + }; + + const handleOAuthSuccess = () => { + fetchConnections(); + setShowOAuthModal(false); + }; + + const handleIFlowCookieSuccess = () => { + fetchConnections(); + setShowIFlowCookieModal(false); + }; + + const handleSaveApiKey = async (formData) => { + setAddConnectionError(""); + try { + const res = await fetch("/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId, ...formData }), + }); + + let data = null; + try { + data = await res.json(); + } catch { + data = null; + } + + if (res.ok) { + await fetchConnections(); + setShowAddApiKeyModal(false); + return; + } + + setAddConnectionError(data?.error || "Failed to save connection"); + } catch (error) { + console.log("Error saving connection:", error); + setAddConnectionError("Failed to save connection"); + } + }; + + const handleUpdateConnection = async (formData) => { + try { + const res = await fetch(`/api/providers/${selectedConnection.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + if (res.ok) { + await fetchConnections(); + setShowEditModal(false); + } + } catch (error) { + console.log("Error updating connection:", error); + } + }; + + const handleUpdateConnectionStatus = async (id, isActive) => { + try { + const res = await fetch(`/api/providers/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive }), + }); + if (res.ok) { + setConnections((prev) => + prev.map((c) => (c.id === id ? { ...c, isActive } : c)), + ); + } + } catch (error) { + console.log("Error updating connection status:", error); + } + }; + + const handleSwapPriority = async (index1, index2) => { + // Optimistic update state + const newConnections = [...connections]; + [newConnections[index1], newConnections[index2]] = [ + newConnections[index2], + newConnections[index1], + ]; + setConnections(newConnections); + + try { + await Promise.all([ + fetch(`/api/providers/${newConnections[index1].id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ priority: index1 }), + }), + fetch(`/api/providers/${newConnections[index2].id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ priority: index2 }), + }), + ]); + } catch (error) { + console.log("Error swapping priority:", error); + await fetchConnections(); + } + }; + + const selectedConnections = connections.filter((conn) => + selectedConnectionIds.includes(conn.id), + ); + const allSelected = + connections.length > 0 && + selectedConnectionIds.length === connections.length; + + const toggleSelectConnection = (connectionId) => { + setSelectedConnectionIds((prev) => + prev.includes(connectionId) + ? prev.filter((id) => id !== connectionId) + : [...prev, connectionId], + ); + }; + + const toggleSelectAllConnections = () => { + if (allSelected) { + setSelectedConnectionIds([]); + return; + } + setSelectedConnectionIds(connections.map((conn) => conn.id)); + }; + + const clearSelection = () => { + setSelectedConnectionIds([]); + setBulkProxyPoolId("__none__"); + }; + + useEffect(() => { + setSelectedConnectionIds((prev) => + prev.filter((id) => connections.some((conn) => conn.id === id)), + ); + }, [connections]); + + const selectedProxySummary = (() => { + if (selectedConnections.length === 0) return ""; + const poolIds = new Set( + selectedConnections.map( + (conn) => conn.providerSpecificData?.proxyPoolId || "__none__", + ), + ); + if (poolIds.size === 1) { + const onlyId = [...poolIds][0]; + if (onlyId === "__none__") return "All selected currently unbound"; + const pool = proxyPools.find((p) => p.id === onlyId); + return `All selected currently bound to ${pool?.name || onlyId}`; + } + return "Selected connections have mixed proxy bindings"; + })(); + + const openBulkProxyModal = () => { + if (selectedConnections.length === 0) return; + const uniquePoolIds = [ + ...new Set( + selectedConnections.map( + (conn) => conn.providerSpecificData?.proxyPoolId || "__none__", + ), + ), + ]; + setBulkProxyPoolId( + uniquePoolIds.length === 1 ? uniquePoolIds[0] : "__none__", + ); + setShowBulkProxyModal(true); + }; + + const closeBulkProxyModal = () => { + if (bulkUpdatingProxy) return; + setShowBulkProxyModal(false); + }; + + const applyProxyAssignments = async (assignments) => { + setBulkUpdatingProxy(true); + try { + let failed = 0; + for (const { connectionId, proxyPoolId } of assignments) { + try { + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxyPoolId }), + }); + if (!res.ok) failed += 1; + } catch (e) { + console.log("Error applying proxy for", connectionId, e); + failed += 1; + } + } + if (failed > 0) alert(`Updated with ${failed} failed request(s).`); + await fetchConnections(); + setShowBulkProxyModal(false); + } finally { + setBulkUpdatingProxy(false); + } + }; + + const handleApplySinglePool = (proxyPoolId) => { + const targets = connections.map((c) => ({ + connectionId: c.id, + proxyPoolId, + })); + return applyProxyAssignments(targets); + }; + + const handleApplyOneToOne = () => { + const activePools = proxyPools.filter((p) => p.isActive === true); + if (activePools.length === 0) { + alert("No active proxy pools available."); + return; + } + const targets = connections.map((c, i) => ({ + connectionId: c.id, + proxyPoolId: activePools[i % activePools.length].id, + })); + return applyProxyAssignments(targets); + }; + + const isSelected = (connectionId) => + selectedConnectionIds.includes(connectionId); + + const connectionsList = ( +
+ {connections.map((conn, index) => ( +
+
+ toggleSelectConnection(conn.id)} + className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" + /> +
+
+ handleSwapPriority(index, index - 1)} + onMoveDown={() => handleSwapPriority(index, index + 1)} + onToggleActive={(isActive) => + handleUpdateConnectionStatus(conn.id, isActive) + } + autoPing={ + AUTO_PING_SETTINGS_KEYS[providerId] && conn.authType === "oauth" + ? { + on: autoPing.connections[conn.id] === true, + onToggle: (on) => handleAutoPingConnection(conn.id, on), + provider: providerId, + } + : null + } + onUpdateProxy={async (proxyPoolId) => { + try { + const res = await fetch(`/api/providers/${conn.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxyPoolId: proxyPoolId || null }), + }); + if (res.ok) { + setConnections((prev) => + prev.map((c) => + c.id === conn.id + ? { + ...c, + providerSpecificData: { + ...c.providerSpecificData, + proxyPoolId: proxyPoolId || null, + }, + } + : c, + ), + ); + } + } catch (error) { + console.log("Error updating proxy:", error); + } + }} + onEdit={() => { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onDelete={() => handleDelete(conn.id)} + oneByOneStatus={oneByOneResults[conn.id] || null} + testModels={connectionTestModels} + providerAlias={providerStorageAlias} + /> +
+
+ ))} +
+ ); + + const activePools = proxyPools.filter((p) => p.isActive === true); + + const bulkActionModal = ( + +
+
+ + + {proxyPools.map((pool) => ( + + ))} +
+ + {bulkUpdatingProxy && ( +

Applying...

+ )} + + +
+
+ ); + + const handleTestModel = async (modelId) => { + if (testingModelIds.has(modelId)) return; + setTestingModelIds((prev) => new Set(prev).add(modelId)); + try { + const res = await fetch("/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: `${providerStorageAlias}/${modelId}` }), + }); + const data = await res.json(); + setModelTestResults((prev) => ({ + ...prev, + [modelId]: data.ok ? "ok" : "error", + })); + setModelsTestError(data.ok ? "" : data.error || "Model not reachable"); + } catch { + setModelTestResults((prev) => ({ ...prev, [modelId]: "error" })); + setModelsTestError("Network error"); + } finally { + setTestingModelIds((prev) => { + const n = new Set(prev); + n.delete(modelId); + return n; + }); + } + }; + + const handleTestAllModels = async () => { + if (testAllModelsRunning || connections.length === 0) return; + + // Determine models to test: built-in (minus disabled) + custom + const allM = [ + ...models, + ...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), + ].filter((m) => { + const k = getModelKind(m); + return !k || k === "llm"; + }); + const disabledSet = new Set(disabledModelIds); + const builtInActive = allM.filter((m) => !disabledSet.has(m.id)); + const customRows = getProviderCustomModelRows({ + customModels, + modelAliases, + providerAlias: providerStorageAlias, + builtInModels: models, + type: "llm", + }); + const activeModels = [ + ...builtInActive, + ...customRows.map((r) => ({ id: r.id })), + ]; + + // Connection to use + const connectionId = + testAllSelectedConnectionId && + testAllSelectedConnectionId !== "__default__" + ? testAllSelectedConnectionId + : null; + + testAllModelsStopRef.current = false; + setTestAllModelsRunning(true); + setTestAllModelsResults(null); + setTestAllModelsFailedIds([]); + setModelTestResults({}); + + const currentFailedIds = []; + let passed = 0; + let failed = 0; + + for (const model of activeModels) { + if (testAllModelsStopRef.current) break; + + setTestingModelIds((prev) => new Set(prev).add(model.id)); + await sleep(100); + + try { + const body = { model: `${providerStorageAlias}/${model.id}` }; + if (connectionId) body.connectionId = connectionId; + const res = await fetch("/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + const ok = data.ok; + setModelTestResults((prev) => ({ + ...prev, + [model.id]: ok ? "ok" : "error", + })); + if (ok) passed++; + else { + failed++; + currentFailedIds.push(model.id); + } + } catch { + setModelTestResults((prev) => ({ ...prev, [model.id]: "error" })); + failed++; + currentFailedIds.push(model.id); + } + + setTestingModelIds((prev) => { + const n = new Set(prev); + n.delete(model.id); + return n; + }); + setTestAllModelsResults({ passed, failed, total: activeModels.length }); + setTestAllModelsFailedIds([...currentFailedIds]); + + if ( + !testAllModelsStopRef.current && + model !== activeModels[activeModels.length - 1] + ) { + await sleep(ONE_BY_ONE_DELAY_MS); + } + } + + setTestAllModelsRunning(false); + }; + + const renderModelsSection = () => { + if (isCompatible) { + return ( + + handleAddCustomModel(modelId, "llm", providerStorageAlias) + } + onDeleteCustomModel={(modelId) => + handleDeleteCustomModel(modelId, "llm", providerStorageAlias) + } + onFetchModels={async () => { + if (fetchingCompatibleModels || connections.length === 0) return; + setFetchingCompatibleModels(true); + const activeConnection = + connections.find((conn) => conn.isActive !== false) || + connections[0]; + if (!activeConnection) { + setFetchingCompatibleModels(false); + return; + } + try { + const res = await fetch( + `/api/providers/${activeConnection.id}/models`, + ); + const data = await res.json(); + if (!res.ok) { + alert(data.error || "Failed to fetch models"); + return; + } + const models = data.models || []; + if (models.length === 0) { + alert("No models returned from /models endpoint."); + return; + } + let importedCount = 0; + for (const model of models) { + const modelId = model.id || model.name || model.model; + if (!modelId) continue; + const cleanId = modelId.replace(/^qoder\//, ""); + const alreadyExists = + customModels.some( + (entry) => + entry.providerAlias === providerStorageAlias && + entry.id === cleanId, + ) || + Object.values(modelAliases).includes( + `${providerStorageAlias}/${cleanId}`, + ); + if (alreadyExists) continue; + await handleAddCustomModel( + cleanId, + "llm", + providerStorageAlias, + ); + importedCount += 1; + } + if (importedCount === 0) { + alert("All models already exist, no new models added."); + } + } catch (error) { + console.log("Error fetching models:", error); + alert("Error fetching models: " + error.message); + } finally { + setFetchingCompatibleModels(false); + } + }} + fetchingModels={fetchingCompatibleModels} + connections={connections} + isAnthropic={isAnthropicCompatible} + /> + ); + } + // Combine hardcoded models with Kilo free models (deduplicated) + // Exclude non-llm models (embedding, tts, etc.) — they have dedicated pages under media-providers + const allModels = [ + ...models, + ...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), + ].filter((m) => { + const k = getModelKind(m); + return !k || k === "llm"; + }); + const disabledSet = new Set(disabledModelIds); + const displayModels = allModels.filter((m) => !disabledSet.has(m.id)); + const disabledDisplayModels = allModels.filter((m) => + disabledSet.has(m.id), + ); + const customModelRows = getProviderCustomModelRows({ + customModels, + modelAliases, + providerAlias: providerStorageAlias, + builtInModels: models, + type: "llm", + }); + + const allModelIds = [ + ...displayModels.map((m) => m.id), + ...customModelRows.map((m) => m.id), + ]; + const testableModelCount = displayModels.length + customModelRows.length; + + return ( +
+ {/* Test All bar */} + {connections.length > 0 && allModelIds.length > 0 && ( +
+ {/* Connection selector */} + + + {testAllModelsRunning && ( + + )} +
+ )} + + {/* Test All results + Clean button */} + {testAllModelsResults && !testAllModelsRunning && ( +
+ + {testAllModelsResults.failed === 0 ? "check_circle" : "warning"} + + + {testAllModelsResults.passed} passed,{" "} + {testAllModelsResults.failed} failed + + {testAllModelsFailedIds.length > 0 && ( + + )} +
+ )} + + {/* Test All progress bar */} + {testAllModelsRunning && ( +
+ + progress_activity + + + Testing...{" "} + {testAllModelsResults + ? testAllModelsResults.passed + testAllModelsResults.failed + : 0} + /{testableModelCount} + +
+ )} + +
+ {/* Custom models first */} + {customModelRows.map((model) => ( + {}} + onDeleteAlias={() => { + if (model.source === "custom") { + handleDeleteCustomModel( + model.id, + "llm", + providerStorageAlias, + ); + } else { + handleDeleteAlias(model.alias); + } + }} + testStatus={modelTestResults[model.id]} + onTest={ + connections.length > 0 || isFreeNoAuth + ? () => handleTestModel(model.id) + : undefined + } + isTesting={testingModelIds.has(model.id)} + isCustom + isFree={false} + caps={getCaps(`${providerId}/${model.id}`)} + thinkingSuffix={resolveThinkingSuffix(model.id)} + /> + ))} + + {displayModels.map((model) => { + const fullModel = `${providerStorageAlias}/${model.id}`; + const oldFormatModel = `${providerId}/${model.id}`; + const existingAlias = Object.entries(modelAliases).find( + ([, m]) => m === fullModel || m === oldFormatModel, + )?.[0]; + return ( + + handleSetAlias(model.id, alias, providerStorageAlias) + } + onDeleteAlias={() => handleDeleteAlias(existingAlias)} + testStatus={modelTestResults[model.id]} + onTest={ + connections.length > 0 || isFreeNoAuth + ? () => handleTestModel(model.id) + : undefined + } + isTesting={testingModelIds.has(model.id)} + isFree={model.isFree} + onDisable={() => handleDisableModel(model.id)} + caps={getCaps(`${providerId}/${model.id}`)} + thinkingSuffix={resolveThinkingSuffix(model.id)} + /> + ); + })} + + {/* Add model button — inline, same style as model chips */} + + + {/* Import Qoder models button — only show for qoder provider */} + {providerId === "qoder" && + connections.some((conn) => conn.isActive !== false) && ( + + )} + + {/* Suggested models from provider API — show only models not yet added */} + {suggestedModels.length > 0 && + (() => { + const addedFullModels = new Set([ + ...Object.values(modelAliases), + ...customModelRows.map((model) => model.fullModel), + ]); + const hardcodedIds = new Set(models.map((m) => m.id)); + const notAdded = suggestedModels.filter( + (m) => + !addedFullModels.has(`${providerStorageAlias}/${m.id}`) && + !hardcodedIds.has(m.id), + ); + if (notAdded.length === 0) return null; + return ( +
+

+ Suggested free models (≥200k context): +

+
+ {notAdded.map((m) => ( + + ))} +
+
+ ); + })()} + + {/* Disabled models — restorable */} + {disabledDisplayModels.length > 0 && ( +
+

+ Disabled models ({disabledDisplayModels.length}): +

+
+ {disabledDisplayModels.map((m) => ( + + ))} +
+
+ )} +
+
+ ); + }; + + if (loading) { + return ( +
+ + +
+ ); + } + + if (!providerInfo) { + return ( +
+

Provider not found

+ + Back to Providers + +
+ ); + } + + // Determine icon path: OpenAI Compatible providers use specialized icons + const getHeaderIconPath = () => { + if (isOpenAICompatible && providerInfo.apiType) { + return providerInfo.apiType === "responses" + ? "/providers/oai-r.png" + : "/providers/oai-cc.png"; + } + if (isAnthropicCompatible) { + return "/providers/anthropic-m.png"; + } + return getProviderIconSrc(providerInfo.id); + }; + + return ( +
+ {/* Header */} +
+ + arrow_back + Back to Providers + +
+
+ {headerImgError || !getHeaderIconPath() ? ( + + {providerInfo.textIcon || + providerInfo.id.slice(0, 2).toUpperCase()} + + ) : ( + {providerInfo.name} { + markProviderIconMissing(providerInfo.id); + setHeaderImgError(true); + }} + loading="lazy" + decoding="async" + /> + )} +
+
+
+

+ {providerInfo.name} +

+ {(providerInfo.notice?.apiKeyUrl || + providerInfo.notice?.signupUrl || + providerInfo.website) && ( + + + open_in_new + + {providerInfo.notice?.apiKeyUrl + ? "Get API Key" + : "Sign up / Learn more"} + + )} +
+

+ {connections.length} connection + {connections.length === 1 ? "" : "s"} +

+
+
+
+ + {providerInfo.deprecated && ( +
+ + warning + +

+ {providerInfo.deprecationNotice} +

+
+ )} + + {providerInfo.notice?.text && !providerInfo.deprecated && ( +
+ + info + +

+ {providerInfo.notice.text} +

+ {providerInfo.notice.apiKeyUrl && ( + + Get API Key → + + )} +
+ )} + + {isCompatible && providerNode && ( + +
+
+

+ {isAnthropicCompatible + ? "Anthropic Compatible Details" + : "OpenAI Compatible Details"} +

+

+ {isAnthropicCompatible + ? "Messages API" + : providerNode.apiType === "responses" + ? "Responses API" + : "Chat Completions"}{" "} + · {(providerNode.baseUrl || "").replace(/\/$/, "")}/ + {isAnthropicCompatible + ? "messages" + : providerNode.apiType === "responses" + ? "responses" + : "chat/completions"} +

+
+
+ + + +
+
+
+ )} + + {/* Connections */} + {isFreeNoAuth ? ( + + ) : ( + +
+

Connections

+
+ {connections.length > 0 && proxyPools.length > 0 && ( + + )} + {connections.length > 0 && ( + <> + {selectedConnectionIds.length > 0 && ( + <> + + + + )} + + {oneByOneRunning && ( + + )} + + )} + {/* Connect Timeout */} +
+ + Connect Timeout + +
+ handleTimeoutChange(e.target.value)} + placeholder="default" + className="w-20 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary" + /> + ms +
+
+ {/* 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 ? ( +
+
+
+ + {isOAuth ? "lock" : "key"} + +
+
+

No connections yet

+ {hasDualAuthModes && ( +

+ Choose {oauthConnectionLabel} or {apiKeyConnectionLabel}. +

+ )} +
+
+
+ {hasDualAuthModes ? ( + <> + + + + ) : ( + <> + {!isCompatible && providerId === "iflow" && ( + + )} + {providerId === "codex" && ( + + )} + + + )} +
+
+ ) : ( + <> + {oneByOneSummary && ( +
+
+ Total: {oneByOneSummary.total} + Completed: {oneByOneSummary.completed} + Passed: {oneByOneSummary.passed} + Failed: {oneByOneSummary.failed} + {oneByOneSummary.stopped && ( + + Stopped + + )} + {oneByOneRunning && oneByOneCurrentConnectionId && ( + + Running:{" "} + {connections.find( + (conn) => conn.id === oneByOneCurrentConnectionId, + )?.name || oneByOneCurrentConnectionId} + + )} +
+
+ )} + {connections.length > 0 && ( +
+ +
+ )} + {connectionsList} + {!isCompatible && ( +
+ {providerId === "iflow" && ( + + )} + {providerId === "codex" && ( + + )} + {hasDualAuthModes ? ( + <> + + + + ) : ( + + )} +
+ )} + + )} +
+ )} + + {/* Models */} + +
+
+

{"Available Models"}

+ {providerThinkingLevels && ( + + )} +
+ {!isCompatible && + (() => { + const allIds = [ + ...models, + ...kiloFreeModels.filter( + (fm) => !models.some((m) => m.id === fm.id), + ), + ] + .filter((m) => { + const k = getModelKind(m); + return !k || k === "llm"; + }) + .map((m) => m.id); + const activeIds = allIds.filter( + (id) => !disabledModelIds.includes(id), + ); + return ( +
+ {disabledModelIds.length > 0 && ( + + )} + {activeIds.length > 0 && ( + + )} +
+ ); + })()} +
+ {!!modelsTestError && ( +

+ {modelsTestError} +

+ )} + {renderModelsSection()} +
+ + {bulkActionModal} + + {/* Modals */} + {providerId === "kiro" ? ( + setShowOAuthModal(false)} + /> + ) : providerId === "cursor" ? ( + setShowOAuthModal(false)} + /> + ) : providerId === "gitlab" ? ( + setShowOAuthModal(false)} + /> + ) : ( + setShowOAuthModal(false)} + /> + )} + {providerId === "iflow" && ( + setShowIFlowCookieModal(false)} + /> + )} + c.name).filter(Boolean)} + onSave={handleSaveApiKey} + onBulkDone={fetchConnections} + onClose={() => { + setAddConnectionError(""); + setShowAddApiKeyModal(false); + }} + /> + setShowEditModal(false)} + /> + {isCompatible && ( + setShowEditNodeModal(false)} + isAnthropic={isAnthropicCompatible} + /> + )} + {!isCompatible && ( + { + await handleAddCustomModel(modelId, "llm", providerStorageAlias); + setShowAddCustomModel(false); + }} + onClose={() => setShowAddCustomModel(false)} + /> + )} + + {providerId === "codex" && ( + setShowBulkImportCodex(false)} + onSuccess={fetchConnections} + /> + )} + + {/* Bulk model test — real /api/models/test call, pinned per selected connection */} + 1 ? "s" : ""})`} + onClose={() => { + if (oneByOneRunning) return; + setShowBulkModelTestModal(false); + }} + > +
+

+ Call one model once per selected account. Each request is pinned to + that connection only. +

+ + {connectionTestModels.length > 0 ? ( +
+ + +
+ ) : ( +
+ + setManualBulkTestModelId(e.target.value)} + placeholder="e.g. grok-4" + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm font-mono focus:border-primary focus:outline-none" + /> +
+ )} + +
+ + +
+
+
+ + {/* AG Risk Confirmation Modal */} + setShowAgRiskModal(false)} + onConfirm={handleAgRiskConfirm} + title="Risk Notice" + message={providerInfo?.deprecationNotice} + confirmText="I Understand, Continue" + cancelText="Cancel" + variant="danger" + /> + + {/* Confirm Modal */} + setConfirmState(null)} + onConfirm={confirmState?.onConfirm} + title={confirmState?.title || "Confirm"} + message={confirmState?.message} + variant="danger" + /> +
+ ); } diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 26c9d010..10dfb39d 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -1,11 +1,21 @@ -import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS, getModelKind } from "@/shared/constants/models"; import { - AI_PROVIDERS, - getProviderAlias, - isAnthropicCompatibleProvider, - isOpenAICompatibleProvider, + PROVIDER_MODELS, + PROVIDER_ID_TO_ALIAS, + getModelKind, +} from "@/shared/constants/models"; +import { + AI_PROVIDERS, + getProviderAlias, + isAnthropicCompatibleProvider, + isOpenAICompatibleProvider, } from "@/shared/constants/providers"; -import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb"; +import { + getProviderConnections, + getCombos, + getCustomModels, + getModelAliases, + getSettings, +} from "@/lib/localDb"; import { getDisabledModels } from "@/lib/disabledModelsDb"; import { resolveKiroModels } from "open-sse/services/kiroModels.js"; import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; @@ -17,116 +27,136 @@ import { resolveCursorModels } from "open-sse/services/cursorModels.js"; import { resolveZedModels } from "open-sse/shared/zedAuth.js"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; -import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; +import { + capabilitiesFromServiceKind, + getCapabilitiesForModel, +} from "open-sse/providers/capabilities.js"; // Per-provider live model resolvers. Each receives a connection record and // returns { models: [{ id, name? }, ...] } | null on failure. // Adding a provider here makes /v1/models prefer the live catalog for it. const LIVE_MODEL_RESOLVERS = { - kiro: async (conn) => { - const result = await resolveKiroModels({ - accessToken: conn.accessToken, - refreshToken: conn.refreshToken, - providerSpecificData: conn.providerSpecificData || {} - }, { log: console }); - return result?.models?.length ? { models: result.models } : null; - }, - qoder: async (conn) => { - const result = await resolveQoderModels({ - accessToken: conn.accessToken, - refreshToken: conn.refreshToken, - email: conn.email, - displayName: conn.displayName, - providerSpecificData: conn.providerSpecificData || {} - }); - if (!result?.models?.length) return null; - return { - models: result.models.map((m) => ({ id: m.id, name: m.name })), - }; - }, - kimchi: async (conn) => { - const result = await resolveKimchiModels({ - accessToken: conn.accessToken, - apiKey: conn.apiKey, - providerSpecificData: conn.providerSpecificData || {} - }, { log: console }); - return result?.models?.length ? { models: result.models } : null; - }, - github: async (conn) => { - const result = await resolveCopilotModels({ - accessToken: conn.accessToken, - refreshToken: conn.refreshToken, - providerSpecificData: conn.providerSpecificData || {} - }, { - log: console, - onCredentialsRefreshed: async (refreshed) => { - await updateProviderCredentials(conn.id, { - copilotToken: refreshed.copilotToken, - copilotTokenExpiresAt: refreshed.copilotTokenExpiresAt, - existingProviderSpecificData: conn.providerSpecificData || {}, - }); - }, - }); - return result?.models?.length ? { models: result.models } : null; - }, - clinepass: async (conn) => { - const result = await resolveClinepassModels({ - accessToken: conn.accessToken, - apiKey: conn.apiKey, - }); - return result?.models?.length ? { models: result.models } : null; - }, - "grok-cli": async (conn) => { - const proxy = await resolveConnectionProxyConfig(conn.providerSpecificData || {}); - const result = await resolveGrokCliModels({ - ...conn, - connectionId: conn.id, - }, { - log: console, - proxyOptions: { - connectionProxyEnabled: proxy.connectionProxyEnabled === true, - connectionProxyUrl: proxy.connectionProxyUrl || "", - connectionNoProxy: proxy.connectionNoProxy || "", - vercelRelayUrl: proxy.vercelRelayUrl || "", - strictProxy: proxy.strictProxy === true, - }, - onCredentialsRefreshed: async (refreshed) => { - await updateProviderCredentials(conn.id, { - ...refreshed, - existingProviderSpecificData: conn.providerSpecificData || {}, - }); - }, - }); - return result?.models?.length ? { models: result.models } : null; - }, - cursor: async (conn) => { - const result = await resolveCursorModels({ - accessToken: conn.accessToken, - providerSpecificData: conn.providerSpecificData || {}, - }, { log: console }); - return result?.models?.length ? { models: result.models } : null; - }, - zed: async (conn) => { - const result = await resolveZedModels({ - accessToken: conn.accessToken, - providerSpecificData: conn.providerSpecificData || {}, - }); - if (!result?.models?.length) return null; - return { - models: result.models - .filter((m) => !m.isDisabled) - .map((m) => ({ - id: m.id, - name: m.name, - capabilities: m.supportsTools ? { tools: true } : undefined, - })), - }; - }, + kiro: async (conn) => { + const result = await resolveKiroModels( + { + accessToken: conn.accessToken, + refreshToken: conn.refreshToken, + providerSpecificData: conn.providerSpecificData || {}, + }, + { log: console }, + ); + return result?.models?.length ? { models: result.models } : null; + }, + qoder: async (conn) => { + const result = await resolveQoderModels({ + accessToken: conn.accessToken, + refreshToken: conn.refreshToken, + email: conn.email, + displayName: conn.displayName, + providerSpecificData: conn.providerSpecificData || {}, + }); + if (!result?.models?.length) return null; + return { + models: result.models.map((m) => ({ id: m.id, name: m.name })), + }; + }, + kimchi: async (conn) => { + const result = await resolveKimchiModels( + { + accessToken: conn.accessToken, + apiKey: conn.apiKey, + providerSpecificData: conn.providerSpecificData || {}, + }, + { log: console }, + ); + return result?.models?.length ? { models: result.models } : null; + }, + github: async (conn) => { + const result = await resolveCopilotModels( + { + accessToken: conn.accessToken, + refreshToken: conn.refreshToken, + providerSpecificData: conn.providerSpecificData || {}, + }, + { + log: console, + onCredentialsRefreshed: async (refreshed) => { + await updateProviderCredentials(conn.id, { + copilotToken: refreshed.copilotToken, + copilotTokenExpiresAt: refreshed.copilotTokenExpiresAt, + existingProviderSpecificData: conn.providerSpecificData || {}, + }); + }, + }, + ); + return result?.models?.length ? { models: result.models } : null; + }, + clinepass: async (conn) => { + const result = await resolveClinepassModels({ + accessToken: conn.accessToken, + apiKey: conn.apiKey, + }); + return result?.models?.length ? { models: result.models } : null; + }, + "grok-cli": async (conn) => { + const proxy = await resolveConnectionProxyConfig( + conn.providerSpecificData || {}, + ); + const result = await resolveGrokCliModels( + { + ...conn, + connectionId: conn.id, + }, + { + log: console, + proxyOptions: { + connectionProxyEnabled: proxy.connectionProxyEnabled === true, + connectionProxyUrl: proxy.connectionProxyUrl || "", + connectionNoProxy: proxy.connectionNoProxy || "", + vercelRelayUrl: proxy.vercelRelayUrl || "", + strictProxy: proxy.strictProxy === true, + }, + onCredentialsRefreshed: async (refreshed) => { + await updateProviderCredentials(conn.id, { + ...refreshed, + existingProviderSpecificData: conn.providerSpecificData || {}, + }); + }, + }, + ); + return result?.models?.length ? { models: result.models } : null; + }, + cursor: async (conn) => { + const result = await resolveCursorModels( + { + accessToken: conn.accessToken, + providerSpecificData: conn.providerSpecificData || {}, + }, + { log: console }, + ); + return result?.models?.length ? { models: result.models } : null; + }, + zed: async (conn) => { + const result = await resolveZedModels({ + accessToken: conn.accessToken, + providerSpecificData: conn.providerSpecificData || {}, + }); + if (!result?.models?.length) return null; + return { + models: result.models + .filter((m) => !m.isDisabled) + .map((m) => ({ + id: m.id, + name: m.name, + capabilities: m.supportsTools ? { tools: true } : undefined, + })), + }; + }, }; const parseOpenAIStyleModels = (data) => { - if (Array.isArray(data)) return data; - return data?.data || data?.models || data?.results || []; + if (Array.isArray(data)) return data; + return data?.data || data?.models || data?.results || []; }; // Header sent by fetchCompatibleModelIds to detect cross-instance /models fetches @@ -139,102 +169,107 @@ const LLM_KIND = "llm"; // Map per-model `type` field (in PROVIDER_MODELS) to service kind. // Models without `type` are treated as LLM. const MODEL_TYPE_TO_KIND = { - image: "image", - tts: "tts", - embedding: "embedding", - stt: "stt", - imageToText: "imageToText", - video: "video", + image: "image", + tts: "tts", + embedding: "embedding", + stt: "stt", + imageToText: "imageToText", + video: "video", }; function modelKind(model) { - const k = model?.kind || model?.type; - if (!k) return LLM_KIND; - return MODEL_TYPE_TO_KIND[k] || LLM_KIND; + const k = model?.kind || model?.type; + if (!k) return LLM_KIND; + return MODEL_TYPE_TO_KIND[k] || LLM_KIND; } // For dynamic/unknown model IDs (compatible providers, alias map, custom models) // fall back to provider-level kind matching when per-model type is unavailable. function inferKindFromUnknownModelId(modelId) { - const lower = String(modelId).toLowerCase(); - if (/embed/.test(lower)) return "embedding"; - if (/tts|speech|audio|voice/.test(lower)) return "tts"; - if (/image|imagen|dall-?e|flux|sdxl|sd-|stable-diffusion/.test(lower)) return "image"; - return LLM_KIND; + const lower = String(modelId).toLowerCase(); + if (/embed/.test(lower)) return "embedding"; + if (/tts|speech|audio|voice/.test(lower)) return "tts"; + if (/image|imagen|dall-?e|flux|sdxl|sd-|stable-diffusion/.test(lower)) + return "image"; + return LLM_KIND; } async function fetchCompatibleModelIds(connection) { - if (!connection?.apiKey) return []; + if (!connection?.apiKey) return []; - const baseUrl = typeof connection?.providerSpecificData?.baseUrl === "string" - ? connection.providerSpecificData.baseUrl.trim().replace(/\/$/, "") - : ""; + const baseUrl = + typeof connection?.providerSpecificData?.baseUrl === "string" + ? connection.providerSpecificData.baseUrl.trim().replace(/\/$/, "") + : ""; - if (!baseUrl) return []; + if (!baseUrl) return []; - let url = `${baseUrl}/models`; - const headers = { - "Content-Type": "application/json", - }; + let url = `${baseUrl}/models`; + const headers = { + "Content-Type": "application/json", + }; - if (isOpenAICompatibleProvider(connection.provider)) { - headers.Authorization = `Bearer ${connection.apiKey}`; - } else if (isAnthropicCompatibleProvider(connection.provider)) { - if (url.endsWith("/messages/models")) { - url = url.slice(0, -9); - } else if (url.endsWith("/messages")) { - url = `${url.slice(0, -9)}/models`; - } - headers["x-api-key"] = connection.apiKey; - headers["anthropic-version"] = "2023-06-01"; - headers.Authorization = `Bearer ${connection.apiKey}`; - } else { - return []; - } + if (isOpenAICompatibleProvider(connection.provider)) { + headers.Authorization = `Bearer ${connection.apiKey}`; + } else if (isAnthropicCompatibleProvider(connection.provider)) { + if (url.endsWith("/messages/models")) { + url = url.slice(0, -9); + } else if (url.endsWith("/messages")) { + url = `${url.slice(0, -9)}/models`; + } + headers["x-api-key"] = connection.apiKey; + headers["anthropic-version"] = "2023-06-01"; + headers.Authorization = `Bearer ${connection.apiKey}`; + } else { + return []; + } - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - const response = await fetch(url, { - method: "GET", - headers: { ...headers, [INTERNAL_MODELS_FETCH_HEADER]: "1" }, - cache: "no-store", - signal: controller.signal, - }); - clearTimeout(timeoutId); + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + const response = await fetch(url, { + method: "GET", + headers: { ...headers, [INTERNAL_MODELS_FETCH_HEADER]: "1" }, + cache: "no-store", + signal: controller.signal, + }); + clearTimeout(timeoutId); - if (!response.ok) return []; + if (!response.ok) return []; - const data = await response.json(); - const rawModels = parseOpenAIStyleModels(data); + const data = await response.json(); + const rawModels = parseOpenAIStyleModels(data); - return Array.from( - new Set( - rawModels - .map((model) => model?.id || model?.name || model?.model) - .filter((modelId) => typeof modelId === "string" && modelId.trim() !== "") - ) - ); - } catch { - return []; - } + return Array.from( + new Set( + rawModels + .map((model) => model?.id || model?.name || model?.model) + .filter( + (modelId) => typeof modelId === "string" && modelId.trim() !== "", + ), + ), + ); + } catch { + return []; + } } // Provider matches kindFilter when its serviceKinds intersect the requested kinds. // LLM is the default kind for providers missing serviceKinds. function providerMatchesKinds(providerId, kindFilter) { - const provider = AI_PROVIDERS[providerId]; - const kinds = Array.isArray(provider?.serviceKinds) && provider.serviceKinds.length > 0 - ? provider.serviceKinds - : [LLM_KIND]; - return kindFilter.some((k) => kinds.includes(k)); + const provider = AI_PROVIDERS[providerId]; + const kinds = + Array.isArray(provider?.serviceKinds) && provider.serviceKinds.length > 0 + ? provider.serviceKinds + : [LLM_KIND]; + return kindFilter.some((k) => kinds.includes(k)); } // Combo matches kindFilter when its `kind` field is in the list. // Combos with no kind are treated as LLM. function comboMatchesKinds(combo, kindFilter) { - const kind = combo?.kind || LLM_KIND; - return kindFilter.includes(kind); + const kind = combo?.kind || LLM_KIND; + return kindFilter.includes(kind); } /** @@ -242,295 +277,351 @@ function comboMatchesKinds(combo, kindFilter) { * @param {string[]} kindFilter - List of service kinds to include (e.g. ["llm"], ["webSearch","webFetch"]). */ export async function buildModelsList(kindFilter, options = {}) { - // When this header is present, the /v1/models request came from another - // 9router instance's fetchCompatibleModelIds — skip dynamic fetch to break - // cross-instance recursive loops. - const skipDynamicFetch = options.skipDynamicFetch === true; - let connections = []; - try { - connections = await getProviderConnections(); - connections = connections.filter(c => c.isActive !== false); - } catch (e) { - console.log("Could not fetch providers, returning all models"); - } + // When this header is present, the /v1/models request came from another + // 9router instance's fetchCompatibleModelIds — skip dynamic fetch to break + // cross-instance recursive loops. + const skipDynamicFetch = options.skipDynamicFetch === true; + let connections = []; + try { + connections = await getProviderConnections(); + connections = connections.filter((c) => c.isActive !== false); + } catch (e) { + console.log("Could not fetch providers, returning all models"); + } - let combos = []; - try { - combos = await getCombos(); - } catch (e) { - console.log("Could not fetch combos"); - } + let combos = []; + try { + combos = await getCombos(); + } catch (e) { + console.log("Could not fetch combos"); + } - let customModels = []; - try { - customModels = await getCustomModels(); - } catch (e) { - console.log("Could not fetch custom models"); - } + let customModels = []; + try { + customModels = await getCustomModels(); + } catch (e) { + console.log("Could not fetch custom models"); + } - let modelAliases = {}; - try { - modelAliases = await getModelAliases(); - } catch (e) { - console.log("Could not fetch model aliases"); - } + let modelAliases = {}; + try { + modelAliases = await getModelAliases(); + } catch (e) { + console.log("Could not fetch model aliases"); + } - let disabledByAlias = {}; - try { - disabledByAlias = await getDisabledModels(); - } catch (e) { - console.log("Could not fetch disabled models"); - } - const isDisabled = (alias, modelId) => Array.isArray(disabledByAlias[alias]) && disabledByAlias[alias].includes(modelId); + let disabledByAlias = {}; + try { + disabledByAlias = await getDisabledModels(); + } catch (e) { + console.log("Could not fetch disabled models"); + } + const isDisabled = (alias, modelId) => + Array.isArray(disabledByAlias[alias]) && + disabledByAlias[alias].includes(modelId); - const activeConnectionByProvider = new Map(); - for (const conn of connections) { - if (!activeConnectionByProvider.has(conn.provider)) { - activeConnectionByProvider.set(conn.provider, conn); - } - } + const activeConnectionByProvider = new Map(); + for (const conn of connections) { + if (!activeConnectionByProvider.has(conn.provider)) { + activeConnectionByProvider.set(conn.provider, conn); + } + } - const models = []; + const models = []; - // Combos first (filtered by kind). Web combos expose `kind` so AI knows search vs fetch. - for (const combo of combos) { - if (!comboMatchesKinds(combo, kindFilter)) continue; - const entry = { - id: combo.name, - object: "model", - owned_by: "combo", - }; - if (combo.kind === "webSearch" || combo.kind === "webFetch") { - entry.kind = combo.kind; - } - models.push(entry); - } + // Combos first (filtered by kind). Web combos expose `kind` so AI knows search vs fetch. + for (const combo of combos) { + if (!comboMatchesKinds(combo, kindFilter)) continue; + const entry = { + id: combo.name, + object: "model", + owned_by: "combo", + }; + if (combo.kind === "webSearch" || combo.kind === "webFetch") { + entry.kind = combo.kind; + } + models.push(entry); + } - if (connections.length === 0) { - // DB unavailable -> return static models, filtered by per-model kind - const aliasToProviderId = Object.fromEntries( - Object.entries(PROVIDER_ID_TO_ALIAS).map(([id, alias]) => [alias, id]) - ); - for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) { - const providerId = aliasToProviderId[alias] || alias; - if (!providerMatchesKinds(providerId, kindFilter)) continue; - for (const model of providerModels) { - if (!kindFilter.includes(modelKind(model))) continue; - if (isDisabled(alias, model.id)) continue; - models.push({ - id: `${alias}/${model.id}`, - object: "model", - owned_by: alias, - }); - } - } + if (connections.length === 0) { + // DB unavailable -> return static models, filtered by per-model kind + const aliasToProviderId = Object.fromEntries( + Object.entries(PROVIDER_ID_TO_ALIAS).map(([id, alias]) => [alias, id]), + ); + for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) { + const providerId = aliasToProviderId[alias] || alias; + if (!providerMatchesKinds(providerId, kindFilter)) continue; + for (const model of providerModels) { + if (!kindFilter.includes(modelKind(model))) continue; + if (isDisabled(alias, model.id)) continue; + models.push({ + id: `${alias}/${model.id}`, + object: "model", + owned_by: alias, + }); + } + } - for (const customModel of customModels) { - if (!customModel?.id || (customModel.type && customModel.type !== "llm")) continue; - // Custom models without active connection are LLM-only by current schema - if (!kindFilter.includes(LLM_KIND)) continue; - const providerAlias = customModel.providerAlias; - if (!providerAlias) continue; + for (const customModel of customModels) { + if (!customModel?.id || (customModel.type && customModel.type !== "llm")) + continue; + // Custom models without active connection are LLM-only by current schema + if (!kindFilter.includes(LLM_KIND)) continue; + const providerAlias = customModel.providerAlias; + if (!providerAlias) continue; - const modelId = String(customModel.id).trim(); - if (!modelId) continue; + const modelId = String(customModel.id).trim(); + if (!modelId) continue; - models.push({ - id: `${providerAlias}/${modelId}`, - object: "model", - owned_by: providerAlias, - }); - } - } else { - for (const [providerId, conn] of activeConnectionByProvider.entries()) { - if (!providerMatchesKinds(providerId, kindFilter)) continue; + models.push({ + id: `${providerAlias}/${modelId}`, + object: "model", + owned_by: providerAlias, + }); + } + } else { + for (const [providerId, conn] of activeConnectionByProvider.entries()) { + if (!providerMatchesKinds(providerId, kindFilter)) continue; - const staticAlias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; - const outputAlias = ( - conn?.providerSpecificData?.prefix - || getProviderAlias(providerId) - || staticAlias - ).trim(); - const providerModels = PROVIDER_MODELS[staticAlias] || []; - const enabledModels = conn?.providerSpecificData?.enabledModels; - const hasExplicitEnabledModels = - Array.isArray(enabledModels) && enabledModels.length > 0; - const isCompatibleProvider = - isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId); + const staticAlias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; + const outputAlias = ( + conn?.providerSpecificData?.prefix || + getProviderAlias(providerId) || + staticAlias + ).trim(); + const providerModels = PROVIDER_MODELS[staticAlias] || []; + const enabledModels = conn?.providerSpecificData?.enabledModels; + const hasExplicitEnabledModels = + Array.isArray(enabledModels) && enabledModels.length > 0; + const isCompatibleProvider = + isOpenAICompatibleProvider(providerId) || + isAnthropicCompatibleProvider(providerId); - // Build kind lookup for static models so we can filter even when only IDs are exposed - const staticModelKindById = new Map( - providerModels.map((m) => [m.id, modelKind(m)]) - ); - let liveModelKindById = new Map(); - let liveCapabilitiesById = new Map(); + // Build kind lookup for static models so we can filter even when only IDs are exposed + const staticModelKindById = new Map( + providerModels.map((m) => [m.id, modelKind(m)]), + ); + let liveModelKindById = new Map(); + let liveCapabilitiesById = new Map(); - let rawModelIds = hasExplicitEnabledModels - ? Array.from( - new Set( - enabledModels.filter( - (modelId) => typeof modelId === "string" && modelId.trim() !== "", - ), - ), - ) - : providerModels.map((model) => model.id); + let rawModelIds = hasExplicitEnabledModels + ? Array.from( + new Set( + enabledModels.filter( + (modelId) => + typeof modelId === "string" && modelId.trim() !== "", + ), + ), + ) + : providerModels.map((model) => model.id); - if (isCompatibleProvider && rawModelIds.length === 0 && !skipDynamicFetch) { - rawModelIds = await fetchCompatibleModelIds(conn); - } + if ( + isCompatibleProvider && + rawModelIds.length === 0 && + !skipDynamicFetch + ) { + rawModelIds = await fetchCompatibleModelIds(conn); + } - // Config-driven live catalog override (e.g. Kiro returns dynamic - // -thinking/-agentic variants per account). On failure, fall back to - // whatever rawModelIds already holds. - const liveResolver = LIVE_MODEL_RESOLVERS[providerId]; - if (liveResolver && !hasExplicitEnabledModels) { - try { - const live = await liveResolver(conn); - if (live?.models?.length) { - rawModelIds = live.models.map((m) => m.id); - liveModelKindById = new Map( - live.models - .filter((m) => m?.id) - .map((m) => [m.id, modelKind(m)]) - ); - liveCapabilitiesById = new Map( - live.models - .filter((m) => m?.id && m.capabilities) - .map((m) => [m.id, m.capabilities]) - ); - } - } catch (err) { - console.log(`Live model fetch failed for ${providerId}: ${err?.message || err}`); - } - } + // Config-driven live catalog override (e.g. Kiro returns dynamic + // -thinking/-agentic variants per account). On failure, fall back to + // whatever rawModelIds already holds. + const liveResolver = LIVE_MODEL_RESOLVERS[providerId]; + if (liveResolver && !hasExplicitEnabledModels) { + try { + const live = await liveResolver(conn); + if (live?.models?.length) { + rawModelIds = live.models.map((m) => m.id); + liveModelKindById = new Map( + live.models.filter((m) => m?.id).map((m) => [m.id, modelKind(m)]), + ); + liveCapabilitiesById = new Map( + live.models + .filter((m) => m?.id && m.capabilities) + .map((m) => [m.id, m.capabilities]), + ); + } + } catch (err) { + console.log( + `Live model fetch failed for ${providerId}: ${err?.message || err}`, + ); + } + } - const modelIds = rawModelIds - .map((modelId) => { - if (modelId.startsWith(`${outputAlias}/`)) { - return modelId.slice(outputAlias.length + 1); - } - if (modelId.startsWith(`${staticAlias}/`)) { - return modelId.slice(staticAlias.length + 1); - } - if (modelId.startsWith(`${providerId}/`)) { - return modelId.slice(providerId.length + 1); - } - return modelId; - }) - .filter((modelId) => typeof modelId === "string" && modelId.trim() !== ""); + const modelIds = rawModelIds + .map((modelId) => { + if (modelId.startsWith(`${outputAlias}/`)) { + return modelId.slice(outputAlias.length + 1); + } + if (modelId.startsWith(`${staticAlias}/`)) { + return modelId.slice(staticAlias.length + 1); + } + if (modelId.startsWith(`${providerId}/`)) { + return modelId.slice(providerId.length + 1); + } + return modelId; + }) + .filter( + (modelId) => typeof modelId === "string" && modelId.trim() !== "", + ); - const customModelKindById = new Map(); - const customModelIds = customModels - .filter((m) => { - if (!m?.id) return false; - const kind = getModelKind(m) || LLM_KIND; - // imageToText custom models are vision-capable chat models: expose them - // both in the default LLM list and in /v1/models/image-to-text. - if (!kindFilter.includes(kind) && !(kind === "imageToText" && kindFilter.includes(LLM_KIND))) return false; - const alias = m.providerAlias; - return alias === staticAlias || alias === outputAlias || alias === providerId; - }) - .map((m) => { - const modelId = String(m.id).trim(); - if (modelId) customModelKindById.set(modelId, getModelKind(m) || LLM_KIND); - return modelId; - }) - .filter((modelId) => modelId !== ""); + const customModelKindById = new Map(); + const customModelIds = customModels + .filter((m) => { + if (!m?.id) return false; + const kind = getModelKind(m) || LLM_KIND; + // imageToText custom models are vision-capable chat models: expose them + // both in the default LLM list and in /v1/models/image-to-text. + if ( + !kindFilter.includes(kind) && + !(kind === "imageToText" && kindFilter.includes(LLM_KIND)) + ) + return false; + const alias = m.providerAlias; + return ( + alias === staticAlias || + alias === outputAlias || + alias === providerId + ); + }) + .map((m) => { + const modelId = String(m.id).trim(); + if (modelId) + customModelKindById.set(modelId, getModelKind(m) || LLM_KIND); + return modelId; + }) + .filter((modelId) => modelId !== ""); - const aliasModelIds = Object.values(modelAliases || {}) - .filter((fullModel) => { - if (typeof fullModel !== "string" || !fullModel.includes("/")) return false; - return ( - fullModel.startsWith(`${outputAlias}/`) || - fullModel.startsWith(`${staticAlias}/`) || - fullModel.startsWith(`${providerId}/`) - ); - }) - .map((fullModel) => { - if (fullModel.startsWith(`${outputAlias}/`)) { - return fullModel.slice(outputAlias.length + 1); - } - if (fullModel.startsWith(`${staticAlias}/`)) { - return fullModel.slice(staticAlias.length + 1); - } - if (fullModel.startsWith(`${providerId}/`)) { - return fullModel.slice(providerId.length + 1); - } - return fullModel; - }) - .filter((modelId) => typeof modelId === "string" && modelId.trim() !== ""); + const aliasModelIds = Object.values(modelAliases || {}) + .filter((fullModel) => { + if (typeof fullModel !== "string" || !fullModel.includes("/")) + return false; + return ( + fullModel.startsWith(`${outputAlias}/`) || + fullModel.startsWith(`${staticAlias}/`) || + fullModel.startsWith(`${providerId}/`) + ); + }) + .map((fullModel) => { + if (fullModel.startsWith(`${outputAlias}/`)) { + return fullModel.slice(outputAlias.length + 1); + } + if (fullModel.startsWith(`${staticAlias}/`)) { + return fullModel.slice(staticAlias.length + 1); + } + if (fullModel.startsWith(`${providerId}/`)) { + return fullModel.slice(providerId.length + 1); + } + return fullModel; + }) + .filter( + (modelId) => typeof modelId === "string" && modelId.trim() !== "", + ); - const mergedModelIds = Array.from(new Set([...modelIds, ...customModelIds, ...aliasModelIds])); + const mergedModelIds = Array.from( + new Set([...modelIds, ...customModelIds, ...aliasModelIds]), + ); - for (const modelId of mergedModelIds) { - // Resolve kind: prefer custom/live metadata, then static, then ID heuristics. - const customKind = customModelKindById.get(modelId); - const liveKind = liveModelKindById.get(modelId); - const kind = customKind || liveKind || staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId); - // imageToText custom models stay in the LLM list (vision-capable chat models) - const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND); - if (!kindFilter.includes(kind) && !allowAsLlm) continue; - if (isDisabled(outputAlias, modelId) || isDisabled(staticAlias, modelId)) continue; + for (const modelId of mergedModelIds) { + // Resolve kind: prefer custom/live metadata, then static, then ID heuristics. + const customKind = customModelKindById.get(modelId); + const liveKind = liveModelKindById.get(modelId); + const kind = + customKind || + liveKind || + staticModelKindById.get(modelId) || + inferKindFromUnknownModelId(modelId); + // imageToText custom models stay in the LLM list (vision-capable chat models) + const allowAsLlm = + kind === "imageToText" && kindFilter.includes(LLM_KIND); + if (!kindFilter.includes(kind) && !allowAsLlm) continue; + if ( + isDisabled(outputAlias, modelId) || + isDisabled(staticAlias, modelId) + ) + continue; - const model = { - id: `${outputAlias}/${modelId}`, - object: "model", - owned_by: outputAlias, - }; - // Live-catalog resolvers (kiro/qoder/github/clinepass) mostly only return - // { id, name } — no per-model capability data. Fall back to the same - // pattern-matched capabilities the dashboard uses (useModelCaps.js) so - // dynamically-discovered LLM models still surface vision/reasoning/search/tools. - const caps = liveCapabilitiesById.get(modelId) - || capabilitiesFromServiceKind(customKind || liveKind) - || (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null); - if (caps) model.capabilities = caps; - models.push(model); - } + const model = { + id: `${outputAlias}/${modelId}`, + object: "model", + owned_by: outputAlias, + }; + // Live-catalog resolvers (kiro/qoder/github/clinepass) mostly only return + // { id, name } — no per-model capability data. Fall back to the same + // pattern-matched capabilities the dashboard uses (useModelCaps.js) so + // dynamically-discovered LLM models still surface vision/reasoning/search/tools. + const caps = + liveCapabilitiesById.get(modelId) || + capabilitiesFromServiceKind(customKind || liveKind) || + (kind === LLM_KIND + ? getCapabilitiesForModel(providerId, modelId) + : null); + if (caps) model.capabilities = caps; + models.push(model); + } - // Web search/fetch — provider IS the model, expose as {alias}/search and/or {alias}/fetch with explicit kind - const providerInfo = AI_PROVIDERS[providerId]; - if (kindFilter.includes("webSearch") && providerInfo?.searchConfig) { - models.push({ - id: `${outputAlias}/search`, - object: "model", - kind: "webSearch", - owned_by: outputAlias, - }); - } - if (kindFilter.includes("webFetch") && providerInfo?.fetchConfig) { - models.push({ - id: `${outputAlias}/fetch`, - object: "model", - kind: "webFetch", - owned_by: outputAlias, - }); - } - } - } + // Web search/fetch — provider IS the model, expose as {alias}/search and/or {alias}/fetch with explicit kind + const providerInfo = AI_PROVIDERS[providerId]; + if (kindFilter.includes("webSearch") && providerInfo?.searchConfig) { + models.push({ + id: `${outputAlias}/search`, + object: "model", + kind: "webSearch", + owned_by: outputAlias, + }); + } + if (kindFilter.includes("webFetch") && providerInfo?.fetchConfig) { + models.push({ + id: `${outputAlias}/fetch`, + object: "model", + kind: "webFetch", + owned_by: outputAlias, + }); + } + } + } - const dedupedModels = []; - const seenModelIds = new Set(); - for (const model of models) { - if (!model?.id || seenModelIds.has(model.id)) continue; - seenModelIds.add(model.id); - dedupedModels.push(model); - } + const dedupedModels = []; + const seenModelIds = new Set(); + for (const model of models) { + if (!model?.id || seenModelIds.has(model.id)) continue; + seenModelIds.add(model.id); + dedupedModels.push(model); + } - return dedupedModels; + // Filter to only combo models when showOnlyComboModels is enabled + try { + const settings = await getSettings(); + if (settings?.showOnlyComboModels === true) { + // Collect all model IDs that appear in enabled combos + const comboModelValues = new Set(); + for (const c of combos) { + if (c.enabled === false) continue; + for (const m of c.models || []) { + comboModelValues.add(m); + } + } + // Keep only combos and models that are in combo model list + return dedupedModels.filter( + (m) => m.owned_by === "combo" || comboModelValues.has(m.id), + ); + } + } catch {} + + return dedupedModels; } /** * Handle CORS preflight */ export async function OPTIONS() { - return new Response(null, { - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "*", - }, - }); + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); } /** @@ -538,18 +629,22 @@ export async function OPTIONS() { * For other capabilities use /v1/models/{kind} (image, tts, stt, embedding, image-to-text, web). */ export async function GET(request) { - try { - // Detect cross-instance recursive /models fetch (another 9router fetching our /models) - const skipDynamicFetch = request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1"; - const data = await buildModelsList([LLM_KIND], { skipDynamicFetch }); - return Response.json({ object: "list", data }, { - headers: { "Access-Control-Allow-Origin": "*" }, - }); - } catch (error) { - console.log("Error fetching models:", error); - return Response.json( - { error: { message: error.message, type: "server_error" } }, - { status: 500 } - ); - } + try { + // Detect cross-instance recursive /models fetch (another 9router fetching our /models) + const skipDynamicFetch = + request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1"; + const data = await buildModelsList([LLM_KIND], { skipDynamicFetch }); + return Response.json( + { object: "list", data }, + { + headers: { "Access-Control-Allow-Origin": "*" }, + }, + ); + } catch (error) { + console.log("Error fetching models:", error); + return Response.json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 }, + ); + } } diff --git a/src/lib/db/repos/combosRepo.js b/src/lib/db/repos/combosRepo.js index 11e72a33..78dd5a5f 100644 --- a/src/lib/db/repos/combosRepo.js +++ b/src/lib/db/repos/combosRepo.js @@ -3,71 +3,92 @@ import { getAdapter } from "../driver.js"; import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; function rowToCombo(row) { - if (!row) return null; - return { - id: row.id, - name: row.name, - kind: row.kind, - models: parseJson(row.models, []), - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; + if (!row) return null; + return { + id: row.id, + name: row.name, + kind: row.kind, + models: parseJson(row.models, []), + enabled: row.enabled === 1 || row.enabled === true, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; } export async function getCombos() { - const db = await getAdapter(); - const rows = db.all(`SELECT * FROM combos ORDER BY createdAt ASC`); - return rows.map(rowToCombo); + const db = await getAdapter(); + const rows = db.all(`SELECT * FROM combos ORDER BY createdAt ASC`); + return rows.map(rowToCombo); } export async function getComboById(id) { - const db = await getAdapter(); - const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]); - return rowToCombo(row); + const db = await getAdapter(); + const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]); + return rowToCombo(row); } export async function getComboByName(name) { - const db = await getAdapter(); - const row = db.get(`SELECT * FROM combos WHERE name = ?`, [name]); - return rowToCombo(row); + const db = await getAdapter(); + const row = db.get(`SELECT * FROM combos WHERE name = ?`, [name]); + return rowToCombo(row); } export async function createCombo(data) { - const db = await getAdapter(); - const now = new Date().toISOString(); - const combo = { - id: uuidv4(), - name: data.name, - kind: data.kind || null, - models: data.models || [], - createdAt: now, - updatedAt: now, - }; - db.run( - `INSERT INTO combos(id, name, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`, - [combo.id, combo.name, combo.kind, stringifyJson(combo.models), combo.createdAt, combo.updatedAt] - ); - return combo; + const db = await getAdapter(); + const now = new Date().toISOString(); + const combo = { + id: uuidv4(), + name: data.name, + kind: data.kind || null, + models: data.models || [], + enabled: data.enabled !== false, + createdAt: now, + updatedAt: now, + }; + db.run( + `INSERT INTO combos(id, name, kind, models, enabled, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`, + [ + combo.id, + combo.name, + combo.kind, + stringifyJson(combo.models), + combo.enabled !== false ? 1 : 0, + combo.createdAt, + combo.updatedAt, + ], + ); + return combo; } export async function updateCombo(id, data) { - const db = await getAdapter(); - let result = null; - db.transaction(() => { - const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]); - if (!row) return; - const merged = { ...rowToCombo(row), ...data, updatedAt: new Date().toISOString() }; - db.run( - `UPDATE combos SET name = ?, kind = ?, models = ?, updatedAt = ? WHERE id = ?`, - [merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id] - ); - result = merged; - }); - return result; + const db = await getAdapter(); + let result = null; + db.transaction(() => { + const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]); + if (!row) return; + const merged = { + ...rowToCombo(row), + ...data, + updatedAt: new Date().toISOString(), + }; + db.run( + `UPDATE combos SET name = ?, kind = ?, models = ?, enabled = ?, updatedAt = ? WHERE id = ?`, + [ + merged.name, + merged.kind, + stringifyJson(merged.models || []), + merged.enabled ? 1 : 0, + merged.updatedAt, + id, + ], + ); + result = merged; + }); + return result; } export async function deleteCombo(id) { - const db = await getAdapter(); - const res = db.run(`DELETE FROM combos WHERE id = ?`, [id]); - return (res?.changes ?? 0) > 0; + const db = await getAdapter(); + const res = db.run(`DELETE FROM combos WHERE id = ?`, [id]); + return (res?.changes ?? 0) > 0; } diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index 6b1966cb..eb39be29 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -4,27 +4,36 @@ import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; import { getMeta, setMeta } from "../helpers/metaStore.js"; function maskApiKey(key) { - if (!key || typeof key !== "string") return null; - if (key.length <= 8) return key.charAt(0) + "***"; - return key.slice(0, 8) + "***"; + if (!key || typeof key !== "string") return null; + if (key.length <= 8) return key.charAt(0) + "***"; + return key.slice(0, 8) + "***"; } const PENDING_TIMEOUT_MS = 60 * 1000; const RING_CAP = 50; const CONN_CACHE_TTL_MS = 30 * 1000; -const PERIOD_MS = { "24h": 86400000, "7d": 604800000, "30d": 2592000000, "60d": 5184000000 }; +const PERIOD_MS = { + "24h": 86400000, + "7d": 604800000, + "30d": 2592000000, + "60d": 5184000000, +}; // In-memory state shared across Next.js modules -if (!global._pendingRequests) global._pendingRequests = { byModel: {}, byAccount: {} }; -if (!global._lastErrorProvider) global._lastErrorProvider = { provider: "", ts: 0 }; +if (!global._pendingRequests) + global._pendingRequests = { byModel: {}, byAccount: {} }; +if (!global._lastErrorProvider) + global._lastErrorProvider = { provider: "", ts: 0 }; if (!global._statsEmitter) { - global._statsEmitter = new EventEmitter(); - global._statsEmitter.setMaxListeners(50); + global._statsEmitter = new EventEmitter(); + global._statsEmitter.setMaxListeners(50); } if (!global._pendingTimers) global._pendingTimers = {}; if (!global._recentRing) global._recentRing = { items: [], initialized: false }; -if (!global._connectionMapCache) global._connectionMapCache = { map: {}, ts: 0 }; -if (!global._statsEmitTimers) global._statsEmitTimers = { pending: null, update: null }; +if (!global._connectionMapCache) + global._connectionMapCache = { map: {}, ts: 0 }; +if (!global._statsEmitTimers) + global._statsEmitTimers = { pending: null, update: null }; const pendingRequests = global._pendingRequests; const lastErrorProvider = global._lastErrorProvider; @@ -36,226 +45,296 @@ const statsEmitTimers = global._statsEmitTimers; export const statsEmitter = global._statsEmitter; function scheduleStatsEvent(event, delayMs = 150) { - const key = event === "update" ? "update" : "pending"; - if (statsEmitTimers[key]) return; - statsEmitTimers[key] = setTimeout(() => { - statsEmitTimers[key] = null; - statsEmitter.emit(event); - }, delayMs); - statsEmitTimers[key]?.unref?.(); + const key = event === "update" ? "update" : "pending"; + if (statsEmitTimers[key]) return; + statsEmitTimers[key] = setTimeout(() => { + statsEmitTimers[key] = null; + statsEmitter.emit(event); + }, delayMs); + statsEmitTimers[key]?.unref?.(); } function getLocalDateKey(timestamp) { - const d = timestamp ? new Date(timestamp) : new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const d = timestamp ? new Date(timestamp) : new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function addToCounter(target, key, values) { - if (!target[key]) target[key] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0 }; - target[key].requests += values.requests || 1; - target[key].promptTokens += values.promptTokens || 0; - target[key].completionTokens += values.completionTokens || 0; - target[key].cachedTokens += values.cachedTokens || 0; - target[key].cost += values.cost || 0; - if (values.meta) Object.assign(target[key], values.meta); + if (!target[key]) + target[key] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + }; + target[key].requests += values.requests || 1; + target[key].promptTokens += values.promptTokens || 0; + target[key].completionTokens += values.completionTokens || 0; + target[key].cachedTokens += values.cachedTokens || 0; + target[key].cost += values.cost || 0; + if (values.meta) Object.assign(target[key], values.meta); } function aggregateEntryToDay(day, entry) { - const promptTokens = entry.tokens?.prompt_tokens || entry.tokens?.input_tokens || 0; - const completionTokens = entry.tokens?.completion_tokens || entry.tokens?.output_tokens || 0; - const cachedTokens = entry.tokens?.cached_tokens || entry.tokens?.cache_read_input_tokens || 0; - const cost = entry.cost || 0; - const vals = { promptTokens, completionTokens, cachedTokens, cost }; + const promptTokens = + entry.tokens?.prompt_tokens || entry.tokens?.input_tokens || 0; + const completionTokens = + entry.tokens?.completion_tokens || entry.tokens?.output_tokens || 0; + const cachedTokens = + entry.tokens?.cached_tokens || entry.tokens?.cache_read_input_tokens || 0; + const cost = entry.cost || 0; + const vals = { promptTokens, completionTokens, cachedTokens, cost }; - day.requests = (day.requests || 0) + 1; - day.promptTokens = (day.promptTokens || 0) + promptTokens; - day.completionTokens = (day.completionTokens || 0) + completionTokens; - day.cachedTokens = (day.cachedTokens || 0) + cachedTokens; - day.cost = (day.cost || 0) + cost; + day.requests = (day.requests || 0) + 1; + day.promptTokens = (day.promptTokens || 0) + promptTokens; + day.completionTokens = (day.completionTokens || 0) + completionTokens; + day.cachedTokens = (day.cachedTokens || 0) + cachedTokens; + day.cost = (day.cost || 0) + cost; - day.byProvider ||= {}; - day.byModel ||= {}; - day.byAccount ||= {}; - day.byApiKey ||= {}; - day.byEndpoint ||= {}; + day.byProvider ||= {}; + day.byModel ||= {}; + day.byAccount ||= {}; + day.byApiKey ||= {}; + day.byEndpoint ||= {}; - if (entry.provider) addToCounter(day.byProvider, entry.provider, vals); + if (entry.provider) addToCounter(day.byProvider, entry.provider, vals); - const modelKey = entry.provider ? `${entry.model}|${entry.provider}` : entry.model; - addToCounter(day.byModel, modelKey, { ...vals, meta: { rawModel: entry.model, provider: entry.provider } }); + const modelKey = entry.provider + ? `${entry.model}|${entry.provider}` + : entry.model; + addToCounter(day.byModel, modelKey, { + ...vals, + meta: { rawModel: entry.model, provider: entry.provider }, + }); - if (entry.connectionId) { - addToCounter(day.byAccount, entry.connectionId, { ...vals, meta: { rawModel: entry.model, provider: entry.provider } }); - } + if (entry.connectionId) { + addToCounter(day.byAccount, entry.connectionId, { + ...vals, + meta: { rawModel: entry.model, provider: entry.provider }, + }); + } - const apiKeyVal = entry.apiKey && typeof entry.apiKey === "string" ? entry.apiKey : "local-no-key"; - const akModelKey = `${apiKeyVal}|${entry.model}|${entry.provider || "unknown"}`; - addToCounter(day.byApiKey, akModelKey, { ...vals, meta: { rawModel: entry.model, provider: entry.provider, apiKey: entry.apiKey || null } }); + const apiKeyVal = + entry.apiKey && typeof entry.apiKey === "string" + ? entry.apiKey + : "local-no-key"; + const akModelKey = `${apiKeyVal}|${entry.model}|${entry.provider || "unknown"}`; + addToCounter(day.byApiKey, akModelKey, { + ...vals, + meta: { + rawModel: entry.model, + provider: entry.provider, + apiKey: entry.apiKey || null, + }, + }); - const endpoint = entry.endpoint || "Unknown"; - const epKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`; - addToCounter(day.byEndpoint, epKey, { ...vals, meta: { endpoint, rawModel: entry.model, provider: entry.provider } }); + const endpoint = entry.endpoint || "Unknown"; + const epKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`; + addToCounter(day.byEndpoint, epKey, { + ...vals, + meta: { endpoint, rawModel: entry.model, provider: entry.provider }, + }); } function pushToRing(entry) { - recentRing.items.push(entry); - if (recentRing.items.length > RING_CAP) { - recentRing.items = recentRing.items.slice(-RING_CAP); - } + recentRing.items.push(entry); + if (recentRing.items.length > RING_CAP) { + recentRing.items = recentRing.items.slice(-RING_CAP); + } } async function getConnectionMapCached() { - if (Date.now() - connCache.ts < CONN_CACHE_TTL_MS) return connCache.map; - try { - const { getProviderConnections } = await import("./connectionsRepo.js"); - const all = await getProviderConnections(); - const map = {}; - for (const c of all) map[c.id] = c.name || c.email || c.id; - connCache.map = map; - connCache.ts = Date.now(); - } catch {} - return connCache.map; + if (Date.now() - connCache.ts < CONN_CACHE_TTL_MS) return connCache.map; + try { + const { getProviderConnections } = await import("./connectionsRepo.js"); + const all = await getProviderConnections(); + const map = {}; + for (const c of all) map[c.id] = c.name || c.email || c.id; + connCache.map = map; + connCache.ts = Date.now(); + } catch {} + return connCache.map; } async function ensureRingInitialized() { - if (recentRing.initialized) return; - recentRing.initialized = true; - try { - const db = await getAdapter(); - const rows = db.all(`SELECT timestamp, provider, model, connectionId, apiKey, endpoint, cost, status, tokens FROM usageHistory ORDER BY id DESC LIMIT ?`, [RING_CAP]); - recentRing.items = rows.reverse().map((r) => ({ - timestamp: r.timestamp, provider: r.provider, model: r.model, connectionId: r.connectionId, - apiKey: r.apiKey, endpoint: r.endpoint, cost: r.cost, status: r.status, - tokens: parseJson(r.tokens, {}), - })); - } catch {} + if (recentRing.initialized) return; + recentRing.initialized = true; + try { + const db = await getAdapter(); + const rows = db.all( + `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, cost, status, tokens FROM usageHistory ORDER BY id DESC LIMIT ?`, + [RING_CAP], + ); + recentRing.items = rows.reverse().map((r) => ({ + timestamp: r.timestamp, + provider: r.provider, + model: r.model, + connectionId: r.connectionId, + apiKey: r.apiKey, + endpoint: r.endpoint, + cost: r.cost, + status: r.status, + tokens: parseJson(r.tokens, {}), + })); + } catch {} } async function calculateCost(provider, model, tokens) { - if (!tokens || !provider || !model) return 0; - try { - const { getPricingForModel } = await import("./pricingRepo.js"); - const pricing = await getPricingForModel(provider, model); - if (!pricing) return 0; + if (!tokens || !provider || !model) return 0; + try { + const { getPricingForModel } = await import("./pricingRepo.js"); + const pricing = await getPricingForModel(provider, model); + if (!pricing) return 0; - // Delegate the actual math to the single source of truth (avoids the two - // copies drifting apart — see open-sse/providers/pricing.js for the - // cache-inclusive prompt_tokens convention this assumes). - const { calculateCostFromTokens } = await import("open-sse/providers/pricing.js"); - return calculateCostFromTokens(tokens, pricing); - } catch (e) { - console.error("Error calculating cost:", e); - return 0; - } + // Delegate the actual math to the single source of truth (avoids the two + // copies drifting apart — see open-sse/providers/pricing.js for the + // cache-inclusive prompt_tokens convention this assumes). + const { calculateCostFromTokens } = await import( + "open-sse/providers/pricing.js" + ); + return calculateCostFromTokens(tokens, pricing); + } catch (e) { + console.error("Error calculating cost:", e); + return 0; + } } -export function trackPendingRequest(model, provider, connectionId, started, error = false) { - const modelKey = provider ? `${model} (${provider})` : model; - const timerKey = `${connectionId}|${modelKey}`; +export function trackPendingRequest( + model, + provider, + connectionId, + started, + error = false, +) { + const modelKey = provider ? `${model} (${provider})` : model; + const timerKey = `${connectionId}|${modelKey}`; - if (!pendingRequests.byModel[modelKey]) pendingRequests.byModel[modelKey] = 0; - pendingRequests.byModel[modelKey] = Math.max(0, pendingRequests.byModel[modelKey] + (started ? 1 : -1)); - if (pendingRequests.byModel[modelKey] === 0) delete pendingRequests.byModel[modelKey]; + if (!pendingRequests.byModel[modelKey]) pendingRequests.byModel[modelKey] = 0; + pendingRequests.byModel[modelKey] = Math.max( + 0, + pendingRequests.byModel[modelKey] + (started ? 1 : -1), + ); + if (pendingRequests.byModel[modelKey] === 0) + delete pendingRequests.byModel[modelKey]; - if (connectionId) { - if (!pendingRequests.byAccount[connectionId]) pendingRequests.byAccount[connectionId] = {}; - if (!pendingRequests.byAccount[connectionId][modelKey]) pendingRequests.byAccount[connectionId][modelKey] = 0; - pendingRequests.byAccount[connectionId][modelKey] = Math.max(0, pendingRequests.byAccount[connectionId][modelKey] + (started ? 1 : -1)); - if (pendingRequests.byAccount[connectionId][modelKey] === 0) { - delete pendingRequests.byAccount[connectionId][modelKey]; - if (Object.keys(pendingRequests.byAccount[connectionId]).length === 0) { - delete pendingRequests.byAccount[connectionId]; - } - } - } + if (connectionId) { + if (!pendingRequests.byAccount[connectionId]) + pendingRequests.byAccount[connectionId] = {}; + if (!pendingRequests.byAccount[connectionId][modelKey]) + pendingRequests.byAccount[connectionId][modelKey] = 0; + pendingRequests.byAccount[connectionId][modelKey] = Math.max( + 0, + pendingRequests.byAccount[connectionId][modelKey] + (started ? 1 : -1), + ); + if (pendingRequests.byAccount[connectionId][modelKey] === 0) { + delete pendingRequests.byAccount[connectionId][modelKey]; + if (Object.keys(pendingRequests.byAccount[connectionId]).length === 0) { + delete pendingRequests.byAccount[connectionId]; + } + } + } - if (started) { - clearTimeout(pendingTimers[timerKey]); - pendingTimers[timerKey] = setTimeout(() => { - delete pendingTimers[timerKey]; - if (pendingRequests.byModel[modelKey] > 0) pendingRequests.byModel[modelKey] = 0; - if (connectionId && pendingRequests.byAccount[connectionId]?.[modelKey] > 0) { - pendingRequests.byAccount[connectionId][modelKey] = 0; - } - scheduleStatsEvent("pending"); - }, PENDING_TIMEOUT_MS); - } else { - clearTimeout(pendingTimers[timerKey]); - delete pendingTimers[timerKey]; - } + if (started) { + clearTimeout(pendingTimers[timerKey]); + pendingTimers[timerKey] = setTimeout(() => { + delete pendingTimers[timerKey]; + if (pendingRequests.byModel[modelKey] > 0) + pendingRequests.byModel[modelKey] = 0; + if ( + connectionId && + pendingRequests.byAccount[connectionId]?.[modelKey] > 0 + ) { + pendingRequests.byAccount[connectionId][modelKey] = 0; + } + scheduleStatsEvent("pending"); + }, PENDING_TIMEOUT_MS); + } else { + clearTimeout(pendingTimers[timerKey]); + delete pendingTimers[timerKey]; + } - if (!started && error && provider) { - lastErrorProvider.provider = provider.toLowerCase(); - lastErrorProvider.ts = Date.now(); - } + if (!started && error && provider) { + lastErrorProvider.provider = provider.toLowerCase(); + lastErrorProvider.ts = Date.now(); + } - // [PENDING] console line removed; lifecycle is visible via "▶" and "📊 done" lines - scheduleStatsEvent("pending"); + // [PENDING] console line removed; lifecycle is visible via "▶" and "📊 done" lines + scheduleStatsEvent("pending"); } export async function getActiveRequests() { - const activeRequests = []; - const connectionMap = await getConnectionMapCached(); + const activeRequests = []; + const connectionMap = await getConnectionMapCached(); - for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) { - for (const [modelKey, count] of Object.entries(models)) { - if (count > 0) { - const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`; - const match = modelKey.match(/^(.*) \((.*)\)$/); - activeRequests.push({ - model: match ? match[1] : modelKey, - provider: match ? match[2] : "unknown", - account: accountName, count, - }); - } - } - } + for (const [connectionId, models] of Object.entries( + pendingRequests.byAccount, + )) { + for (const [modelKey, count] of Object.entries(models)) { + if (count > 0) { + const accountName = + connectionMap[connectionId] || + `Account ${connectionId.slice(0, 8)}...`; + const match = modelKey.match(/^(.*) \((.*)\)$/); + activeRequests.push({ + model: match ? match[1] : modelKey, + provider: match ? match[2] : "unknown", + account: accountName, + count, + }); + } + } + } - await ensureRingInitialized(); - const seen = new Set(); - const recentRequests = [...recentRing.items] - .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) - .map((e) => { - const t = e.tokens || {}; - return { - timestamp: e.timestamp, model: e.model, provider: e.provider || "", - promptTokens: t.prompt_tokens || t.input_tokens || 0, - completionTokens: t.completion_tokens || t.output_tokens || 0, - status: e.status || "ok", - }; - }) - .filter((e) => { - if (e.promptTokens === 0 && e.completionTokens === 0) return false; - const minute = e.timestamp ? e.timestamp.slice(0, 16) : ""; - const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }) - .slice(0, 20); + await ensureRingInitialized(); + const seen = new Set(); + const recentRequests = [...recentRing.items] + .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) + .map((e) => { + const t = e.tokens || {}; + return { + timestamp: e.timestamp, + model: e.model, + provider: e.provider || "", + promptTokens: t.prompt_tokens || t.input_tokens || 0, + completionTokens: t.completion_tokens || t.output_tokens || 0, + status: e.status || "ok", + }; + }) + .filter((e) => { + if (e.promptTokens === 0 && e.completionTokens === 0) return false; + const minute = e.timestamp ? e.timestamp.slice(0, 16) : ""; + const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, 20); - const errorProvider = (Date.now() - lastErrorProvider.ts < 10000) ? lastErrorProvider.provider : ""; - return { activeRequests, recentRequests, errorProvider }; + const errorProvider = + Date.now() - lastErrorProvider.ts < 10000 ? lastErrorProvider.provider : ""; + return { activeRequests, recentRequests, errorProvider }; } export async function saveRequestUsage(entry) { - try { - const db = await getAdapter(); + try { + const db = await getAdapter(); - if (!entry.timestamp) entry.timestamp = new Date().toISOString(); - entry.cost = await calculateCost(entry.provider, entry.model, entry.tokens); + if (!entry.timestamp) entry.timestamp = new Date().toISOString(); + entry.cost = await calculateCost(entry.provider, entry.model, entry.tokens); - const tokens = entry.tokens || {}; - const promptTokens = tokens.prompt_tokens || tokens.input_tokens || 0; - const completionTokens = tokens.completion_tokens || tokens.output_tokens || 0; + const tokens = entry.tokens || {}; + const promptTokens = tokens.prompt_tokens || tokens.input_tokens || 0; + const completionTokens = + tokens.completion_tokens || tokens.output_tokens || 0; - let inserted = false; + let inserted = false; - // All 3 writes (history insert, daily upsert, lifetime counter) in ONE transaction. - // better-sqlite3 is sync → no JS yield mid-transaction → no race in same process. - db.transaction(() => { - const existing = db.get( - `SELECT id, endpoint FROM usageHistory + // All 3 writes (history insert, daily upsert, lifetime counter) in ONE transaction. + // better-sqlite3 is sync → no JS yield mid-transaction → no race in same process. + db.transaction(() => { + const existing = db.get( + `SELECT id, endpoint FROM usageHistory WHERE timestamp = ? AND COALESCE(provider, '') = COALESCE(?, '') AND COALESCE(model, '') = COALESCE(?, '') @@ -264,509 +343,808 @@ export async function saveRequestUsage(entry) { AND promptTokens = ? AND completionTokens = ? ORDER BY id DESC LIMIT 1`, - [ - entry.timestamp, entry.provider || null, entry.model || null, - entry.connectionId || null, entry.apiKey || null, - promptTokens, completionTokens, - ] - ); + [ + entry.timestamp, + entry.provider || null, + entry.model || null, + entry.connectionId || null, + entry.apiKey || null, + promptTokens, + completionTokens, + ], + ); - if (existing) { - if (!existing.endpoint && entry.endpoint) { - db.run(`UPDATE usageHistory SET endpoint = ? WHERE id = ?`, [entry.endpoint, existing.id]); - } - return; - } + if (existing) { + if (!existing.endpoint && entry.endpoint) { + db.run(`UPDATE usageHistory SET endpoint = ? WHERE id = ?`, [ + entry.endpoint, + existing.id, + ]); + } + return; + } - db.run( - `INSERT INTO usageHistory(timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, status, tokens, meta) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - entry.timestamp, entry.provider || null, entry.model || null, - entry.connectionId || null, entry.apiKey || null, entry.endpoint || null, - promptTokens, completionTokens, entry.cost || 0, entry.status || "ok", - stringifyJson(tokens), stringifyJson({}), - ] - ); + db.run( + `INSERT INTO usageHistory(timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, status, tokens, meta) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + entry.timestamp, + entry.provider || null, + entry.model || null, + entry.connectionId || null, + entry.apiKey || null, + entry.endpoint || null, + promptTokens, + completionTokens, + entry.cost || 0, + entry.status || "ok", + stringifyJson(tokens), + stringifyJson({}), + ], + ); - const dateKey = getLocalDateKey(entry.timestamp); - const row = db.get(`SELECT data FROM usageDaily WHERE dateKey = ?`, [dateKey]); - const day = row ? parseJson(row.data, {}) : { - requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, - byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {}, - }; - aggregateEntryToDay(day, entry); - db.run(`INSERT INTO usageDaily(dateKey, data) VALUES(?, ?) ON CONFLICT(dateKey) DO UPDATE SET data = excluded.data`, [dateKey, stringifyJson(day)]); + const dateKey = getLocalDateKey(entry.timestamp); + const row = db.get(`SELECT data FROM usageDaily WHERE dateKey = ?`, [ + dateKey, + ]); + const day = row + ? parseJson(row.data, {}) + : { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cost: 0, + byProvider: {}, + byModel: {}, + byAccount: {}, + byApiKey: {}, + byEndpoint: {}, + }; + aggregateEntryToDay(day, entry); + db.run( + `INSERT INTO usageDaily(dateKey, data) VALUES(?, ?) ON CONFLICT(dateKey) DO UPDATE SET data = excluded.data`, + [dateKey, stringifyJson(day)], + ); - // Atomic counter increment in same transaction - const cur = db.get(`SELECT value FROM _meta WHERE key = 'totalRequestsLifetime'`); - const next = (cur ? parseInt(cur.value, 10) : 0) + 1; - db.run(`INSERT INTO _meta(key, value) VALUES('totalRequestsLifetime', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(next)]); - inserted = true; - }); + // Atomic counter increment in same transaction + const cur = db.get( + `SELECT value FROM _meta WHERE key = 'totalRequestsLifetime'`, + ); + const next = (cur ? parseInt(cur.value, 10) : 0) + 1; + db.run( + `INSERT INTO _meta(key, value) VALUES('totalRequestsLifetime', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [String(next)], + ); + inserted = true; + }); - if (inserted) { - pushToRing(entry); - scheduleStatsEvent("update", 250); - } - } catch (e) { - console.error("Failed to save usage stats:", e); - } + if (inserted) { + pushToRing(entry); + scheduleStatsEvent("update", 250); + } + } catch (e) { + console.error("Failed to save usage stats:", e); + } } export async function getUsageHistory(filter = {}) { - const db = await getAdapter(); - const conds = []; - const params = []; + const db = await getAdapter(); + const conds = []; + const params = []; - if (filter.provider) { conds.push("provider = ?"); params.push(filter.provider); } - if (filter.model) { conds.push("model = ?"); params.push(filter.model); } - if (filter.startDate) { conds.push("timestamp >= ?"); params.push(new Date(filter.startDate).toISOString()); } - if (filter.endDate) { conds.push("timestamp <= ?"); params.push(new Date(filter.endDate).toISOString()); } + if (filter.provider) { + conds.push("provider = ?"); + params.push(filter.provider); + } + if (filter.model) { + conds.push("model = ?"); + params.push(filter.model); + } + if (filter.startDate) { + conds.push("timestamp >= ?"); + params.push(new Date(filter.startDate).toISOString()); + } + if (filter.endDate) { + conds.push("timestamp <= ?"); + params.push(new Date(filter.endDate).toISOString()); + } - const where = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; - const rows = db.all(`SELECT timestamp, provider, model, connectionId, apiKey, endpoint, cost, status, tokens FROM usageHistory ${where} ORDER BY id ASC`, params); + const where = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; + const rows = db.all( + `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, cost, status, tokens FROM usageHistory ${where} ORDER BY id ASC`, + params, + ); - return rows.map((r) => ({ - timestamp: r.timestamp, provider: r.provider, model: r.model, - connectionId: r.connectionId, apiKeyMasked: maskApiKey(r.apiKey), endpoint: r.endpoint, - cost: r.cost, status: r.status, tokens: parseJson(r.tokens, {}), - })); + return rows.map((r) => ({ + timestamp: r.timestamp, + provider: r.provider, + model: r.model, + connectionId: r.connectionId, + apiKeyMasked: maskApiKey(r.apiKey), + endpoint: r.endpoint, + cost: r.cost, + status: r.status, + tokens: parseJson(r.tokens, {}), + })); } function loadDaysInRange(adapter, maxDays) { - if (maxDays == null) { - return adapter.all(`SELECT dateKey, data FROM usageDaily`); - } - const today = new Date(); - const cutoff = new Date(today.getFullYear(), today.getMonth(), today.getDate() - maxDays + 1); - const cutoffKey = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-${String(cutoff.getDate()).padStart(2, "0")}`; - return adapter.all(`SELECT dateKey, data FROM usageDaily WHERE dateKey >= ?`, [cutoffKey]); + if (maxDays == null) { + return adapter.all(`SELECT dateKey, data FROM usageDaily`); + } + const today = new Date(); + const cutoff = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - maxDays + 1, + ); + const cutoffKey = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-${String(cutoff.getDate()).padStart(2, "0")}`; + return adapter.all( + `SELECT dateKey, data FROM usageDaily WHERE dateKey >= ?`, + [cutoffKey], + ); } export async function getUsageStats(period = "all") { - const db = await getAdapter(); + const db = await getAdapter(); - const [{ getProviderConnections }, { getApiKeys }, { getProviderNodes }] = await Promise.all([ - import("./connectionsRepo.js"), - import("./apiKeysRepo.js"), - import("./nodesRepo.js"), - ]); + const [{ getProviderConnections }, { getApiKeys }, { getProviderNodes }] = + await Promise.all([ + import("./connectionsRepo.js"), + import("./apiKeysRepo.js"), + import("./nodesRepo.js"), + ]); - let allConnections = []; - try { allConnections = await getProviderConnections(); } catch {} - const connectionMap = {}; - for (const c of allConnections) connectionMap[c.id] = c.name || c.email || c.id; + let allConnections = []; + try { + allConnections = await getProviderConnections(); + } catch {} + const connectionMap = {}; + for (const c of allConnections) + connectionMap[c.id] = c.name || c.email || c.id; - const providerNodeNameMap = {}; - try { - const nodes = await getProviderNodes(); - for (const n of nodes) if (n.id && n.name) providerNodeNameMap[n.id] = n.name; - } catch {} + const providerNodeNameMap = {}; + try { + const nodes = await getProviderNodes(); + for (const n of nodes) + if (n.id && n.name) providerNodeNameMap[n.id] = n.name; + } catch {} - let allApiKeys = []; - try { allApiKeys = await getApiKeys(); } catch {} - const apiKeyMap = {}; - for (const k of allApiKeys) apiKeyMap[k.key] = { name: k.name, id: k.id, createdAt: k.createdAt }; + let allApiKeys = []; + try { + allApiKeys = await getApiKeys(); + } catch {} + const apiKeyMap = {}; + for (const k of allApiKeys) + apiKeyMap[k.key] = { name: k.name, id: k.id, createdAt: k.createdAt }; - // recentRequests from live history (last 100 entries enough for 20 deduped) - const recentRows = db.all(`SELECT timestamp, provider, model, tokens, status FROM usageHistory ORDER BY id DESC LIMIT 100`); - const seen = new Set(); - const recentRequests = recentRows - .map((r) => { - const t = parseJson(r.tokens, {}) || {}; - return { - timestamp: r.timestamp, model: r.model, provider: r.provider || "", - promptTokens: t.prompt_tokens || t.input_tokens || 0, - completionTokens: t.completion_tokens || t.output_tokens || 0, - cachedTokens: t.cached_tokens || t.cache_read_input_tokens || 0, - status: r.status || "ok", - }; - }) - .filter((e) => { - if (e.promptTokens === 0 && e.completionTokens === 0) return false; - const minute = e.timestamp ? e.timestamp.slice(0, 16) : ""; - const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }) - .slice(0, 20); + // recentRequests from live history (last 100 entries enough for 20 deduped) + const recentRows = db.all( + `SELECT timestamp, provider, model, tokens, status FROM usageHistory ORDER BY id DESC LIMIT 100`, + ); + const seen = new Set(); + const recentRequests = recentRows + .map((r) => { + const t = parseJson(r.tokens, {}) || {}; + return { + timestamp: r.timestamp, + model: r.model, + provider: r.provider || "", + promptTokens: t.prompt_tokens || t.input_tokens || 0, + completionTokens: t.completion_tokens || t.output_tokens || 0, + cachedTokens: t.cached_tokens || t.cache_read_input_tokens || 0, + status: r.status || "ok", + }; + }) + .filter((e) => { + if (e.promptTokens === 0 && e.completionTokens === 0) return false; + const minute = e.timestamp ? e.timestamp.slice(0, 16) : ""; + const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, 20); - const stats = { - totalRequests: 0, - totalPromptTokens: 0, totalCompletionTokens: 0, totalCachedTokens: 0, totalCost: 0, - byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {}, - last10Minutes: [], - pending: pendingRequests, - activeRequests: [], - recentRequests, - errorProvider: (Date.now() - lastErrorProvider.ts < 10000) ? lastErrorProvider.provider : "", - }; + const stats = { + totalRequests: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + totalCachedTokens: 0, + totalCost: 0, + byProvider: {}, + byModel: {}, + byAccount: {}, + byApiKey: {}, + byEndpoint: {}, + last10Minutes: [], + pending: pendingRequests, + activeRequests: [], + recentRequests, + errorProvider: + Date.now() - lastErrorProvider.ts < 10000 + ? lastErrorProvider.provider + : "", + }; - // Active requests - for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) { - for (const [modelKey, count] of Object.entries(models)) { - if (count > 0) { - const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`; - const match = modelKey.match(/^(.*) \((.*)\)$/); - stats.activeRequests.push({ - model: match ? match[1] : modelKey, - provider: match ? match[2] : "unknown", - account: accountName, count, - }); - } - } - } + // Active requests + for (const [connectionId, models] of Object.entries( + pendingRequests.byAccount, + )) { + for (const [modelKey, count] of Object.entries(models)) { + if (count > 0) { + const accountName = + connectionMap[connectionId] || + `Account ${connectionId.slice(0, 8)}...`; + const match = modelKey.match(/^(.*) \((.*)\)$/); + stats.activeRequests.push({ + model: match ? match[1] : modelKey, + provider: match ? match[2] : "unknown", + account: accountName, + count, + }); + } + } + } - // last10Minutes — query 10min window - const now = new Date(); - const currentMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000); - const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000); - const bucketMap = {}; - for (let i = 0; i < 10; i++) { - const ts = currentMinuteStart.getTime() - (9 - i) * 60 * 1000; - bucketMap[ts] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; - stats.last10Minutes.push(bucketMap[ts]); - } - const recent10 = db.all( - `SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ? AND timestamp <= ?`, - [tenMinutesAgo.toISOString(), now.toISOString()] - ); - for (const r of recent10) { - const tt = new Date(r.timestamp).getTime(); - const minuteStart = Math.floor(tt / 60000) * 60000; - if (bucketMap[minuteStart]) { - bucketMap[minuteStart].requests++; - bucketMap[minuteStart].promptTokens += r.promptTokens || 0; - bucketMap[minuteStart].completionTokens += r.completionTokens || 0; - bucketMap[minuteStart].cost += r.cost || 0; - } - } + // last10Minutes — query 10min window + const now = new Date(); + const currentMinuteStart = new Date( + Math.floor(now.getTime() / 60000) * 60000, + ); + const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000); + const bucketMap = {}; + for (let i = 0; i < 10; i++) { + const ts = currentMinuteStart.getTime() - (9 - i) * 60 * 1000; + bucketMap[ts] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cost: 0, + }; + stats.last10Minutes.push(bucketMap[ts]); + } + const recent10 = db.all( + `SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ? AND timestamp <= ?`, + [tenMinutesAgo.toISOString(), now.toISOString()], + ); + for (const r of recent10) { + const tt = new Date(r.timestamp).getTime(); + const minuteStart = Math.floor(tt / 60000) * 60000; + if (bucketMap[minuteStart]) { + bucketMap[minuteStart].requests++; + bucketMap[minuteStart].promptTokens += r.promptTokens || 0; + bucketMap[minuteStart].completionTokens += r.completionTokens || 0; + bucketMap[minuteStart].cost += r.cost || 0; + } + } - const useDailySummary = period !== "24h" && period !== "today"; + const useDailySummary = period !== "24h" && period !== "today"; - if (useDailySummary) { - const periodDays = { "7d": 7, "30d": 30, "60d": 60 }; - const maxDays = periodDays[period] || null; - const dayRows = loadDaysInRange(db, maxDays); + if (useDailySummary) { + const periodDays = { "7d": 7, "30d": 30, "60d": 60 }; + const maxDays = periodDays[period] || null; + const dayRows = loadDaysInRange(db, maxDays); - for (const dr of dayRows) { - const dateKey = dr.dateKey; - const day = parseJson(dr.data, {}); - stats.totalPromptTokens += day.promptTokens || 0; - stats.totalCompletionTokens += day.completionTokens || 0; - stats.totalCachedTokens += day.cachedTokens || 0; - stats.totalCost += day.cost || 0; + for (const dr of dayRows) { + const dateKey = dr.dateKey; + const day = parseJson(dr.data, {}); + stats.totalPromptTokens += day.promptTokens || 0; + stats.totalCompletionTokens += day.completionTokens || 0; + stats.totalCachedTokens += day.cachedTokens || 0; + stats.totalCost += day.cost || 0; - for (const [prov, p] of Object.entries(day.byProvider || {})) { - if (!stats.byProvider[prov]) stats.byProvider[prov] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0 }; - stats.byProvider[prov].requests += p.requests || 0; - stats.byProvider[prov].promptTokens += p.promptTokens || 0; - stats.byProvider[prov].completionTokens += p.completionTokens || 0; - stats.byProvider[prov].cachedTokens += p.cachedTokens || 0; - stats.byProvider[prov].cost += p.cost || 0; - } + for (const [prov, p] of Object.entries(day.byProvider || {})) { + if (!stats.byProvider[prov]) + stats.byProvider[prov] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + lastUsed: dateKey, + }; + stats.byProvider[prov].requests += p.requests || 0; + stats.byProvider[prov].promptTokens += p.promptTokens || 0; + stats.byProvider[prov].completionTokens += p.completionTokens || 0; + stats.byProvider[prov].cachedTokens += p.cachedTokens || 0; + stats.byProvider[prov].cost += p.cost || 0; + if (dateKey > (stats.byProvider[prov].lastUsed || "")) + stats.byProvider[prov].lastUsed = dateKey; + } - for (const [mk, m] of Object.entries(day.byModel || {})) { - const rawModel = m.rawModel || mk.split("|")[0]; - const provider = m.provider || mk.split("|")[1] || ""; - const statsKey = provider ? `${rawModel} (${provider})` : rawModel; - const providerDisplayName = providerNodeNameMap[provider] || provider; - if (!stats.byModel[statsKey]) { - stats.byModel[statsKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel, provider: providerDisplayName, lastUsed: dateKey }; - } - stats.byModel[statsKey].requests += m.requests || 0; - stats.byModel[statsKey].promptTokens += m.promptTokens || 0; - stats.byModel[statsKey].completionTokens += m.completionTokens || 0; - stats.byModel[statsKey].cachedTokens += m.cachedTokens || 0; - stats.byModel[statsKey].cost += m.cost || 0; - if (dateKey > (stats.byModel[statsKey].lastUsed || "")) stats.byModel[statsKey].lastUsed = dateKey; - } + for (const [mk, m] of Object.entries(day.byModel || {})) { + const rawModel = m.rawModel || mk.split("|")[0]; + const provider = m.provider || mk.split("|")[1] || ""; + const statsKey = provider ? `${rawModel} (${provider})` : rawModel; + const providerDisplayName = providerNodeNameMap[provider] || provider; + if (!stats.byModel[statsKey]) { + stats.byModel[statsKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + rawModel, + provider: providerDisplayName, + lastUsed: dateKey, + }; + } + stats.byModel[statsKey].requests += m.requests || 0; + stats.byModel[statsKey].promptTokens += m.promptTokens || 0; + stats.byModel[statsKey].completionTokens += m.completionTokens || 0; + stats.byModel[statsKey].cachedTokens += m.cachedTokens || 0; + stats.byModel[statsKey].cost += m.cost || 0; + if (dateKey > (stats.byModel[statsKey].lastUsed || "")) + stats.byModel[statsKey].lastUsed = dateKey; + } - for (const [connId, a] of Object.entries(day.byAccount || {})) { - const accountName = connectionMap[connId] || `Account ${connId.slice(0, 8)}...`; - const rawModel = a.rawModel || ""; - const provider = a.provider || ""; - const providerDisplayName = providerNodeNameMap[provider] || provider; - const accountKey = `${rawModel} (${provider} - ${accountName})`; - if (!stats.byAccount[accountKey]) { - stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel, provider: providerDisplayName, connectionId: connId, accountName, lastUsed: dateKey }; - } - stats.byAccount[accountKey].requests += a.requests || 0; - stats.byAccount[accountKey].promptTokens += a.promptTokens || 0; - stats.byAccount[accountKey].completionTokens += a.completionTokens || 0; - stats.byAccount[accountKey].cachedTokens += a.cachedTokens || 0; - stats.byAccount[accountKey].cost += a.cost || 0; - if (dateKey > (stats.byAccount[accountKey].lastUsed || "")) stats.byAccount[accountKey].lastUsed = dateKey; - } + for (const [connId, a] of Object.entries(day.byAccount || {})) { + const accountName = + connectionMap[connId] || `Account ${connId.slice(0, 8)}...`; + const rawModel = a.rawModel || ""; + const provider = a.provider || ""; + const providerDisplayName = providerNodeNameMap[provider] || provider; + const accountKey = `${rawModel} (${provider} - ${accountName})`; + if (!stats.byAccount[accountKey]) { + stats.byAccount[accountKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + rawModel, + provider: providerDisplayName, + connectionId: connId, + accountName, + lastUsed: dateKey, + }; + } + stats.byAccount[accountKey].requests += a.requests || 0; + stats.byAccount[accountKey].promptTokens += a.promptTokens || 0; + stats.byAccount[accountKey].completionTokens += a.completionTokens || 0; + stats.byAccount[accountKey].cachedTokens += a.cachedTokens || 0; + stats.byAccount[accountKey].cost += a.cost || 0; + if (dateKey > (stats.byAccount[accountKey].lastUsed || "")) + stats.byAccount[accountKey].lastUsed = dateKey; + } - for (const [akKey, ak] of Object.entries(day.byApiKey || {})) { - const rawModel = ak.rawModel || ""; - const provider = ak.provider || ""; - const providerDisplayName = providerNodeNameMap[provider] || provider; - const apiKeyVal = ak.apiKey; - const keyInfo = apiKeyVal ? apiKeyMap[apiKeyVal] : null; - const keyName = keyInfo?.name || (apiKeyVal ? apiKeyVal.slice(0, 8) + "..." : "Local (No API Key)"); - const apiKeyMasked = maskApiKey(apiKeyVal); - const apiKeyKey = apiKeyMasked || "local-no-key"; - if (!stats.byApiKey[akKey]) { - stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey, lastUsed: dateKey }; - } - stats.byApiKey[akKey].requests += ak.requests || 0; - stats.byApiKey[akKey].promptTokens += ak.promptTokens || 0; - stats.byApiKey[akKey].completionTokens += ak.completionTokens || 0; - stats.byApiKey[akKey].cachedTokens += ak.cachedTokens || 0; - stats.byApiKey[akKey].cost += ak.cost || 0; - if (dateKey > (stats.byApiKey[akKey].lastUsed || "")) stats.byApiKey[akKey].lastUsed = dateKey; - } + for (const [akKey, ak] of Object.entries(day.byApiKey || {})) { + const rawModel = ak.rawModel || ""; + const provider = ak.provider || ""; + const providerDisplayName = providerNodeNameMap[provider] || provider; + const apiKeyVal = ak.apiKey; + const keyInfo = apiKeyVal ? apiKeyMap[apiKeyVal] : null; + const keyName = + keyInfo?.name || + (apiKeyVal ? apiKeyVal.slice(0, 8) + "..." : "Local (No API Key)"); + const apiKeyMasked = maskApiKey(apiKeyVal); + const apiKeyKey = apiKeyMasked || "local-no-key"; + if (!stats.byApiKey[akKey]) { + stats.byApiKey[akKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + rawModel, + provider: providerDisplayName, + apiKeyMasked, + keyName, + apiKeyKey, + lastUsed: dateKey, + }; + } + stats.byApiKey[akKey].requests += ak.requests || 0; + stats.byApiKey[akKey].promptTokens += ak.promptTokens || 0; + stats.byApiKey[akKey].completionTokens += ak.completionTokens || 0; + stats.byApiKey[akKey].cachedTokens += ak.cachedTokens || 0; + stats.byApiKey[akKey].cost += ak.cost || 0; + if (dateKey > (stats.byApiKey[akKey].lastUsed || "")) + stats.byApiKey[akKey].lastUsed = dateKey; + } - for (const [epKey, ep] of Object.entries(day.byEndpoint || {})) { - const endpoint = ep.endpoint || epKey.split("|")[0] || "Unknown"; - const rawModel = ep.rawModel || ""; - const provider = ep.provider || ""; - const providerDisplayName = providerNodeNameMap[provider] || provider; - if (!stats.byEndpoint[epKey]) { - stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, endpoint, rawModel, provider: providerDisplayName, lastUsed: dateKey }; - } - stats.byEndpoint[epKey].requests += ep.requests || 0; - stats.byEndpoint[epKey].promptTokens += ep.promptTokens || 0; - stats.byEndpoint[epKey].completionTokens += ep.completionTokens || 0; - stats.byEndpoint[epKey].cachedTokens += ep.cachedTokens || 0; - stats.byEndpoint[epKey].cost += ep.cost || 0; - if (dateKey > (stats.byEndpoint[epKey].lastUsed || "")) stats.byEndpoint[epKey].lastUsed = dateKey; - } - } + for (const [epKey, ep] of Object.entries(day.byEndpoint || {})) { + const endpoint = ep.endpoint || epKey.split("|")[0] || "Unknown"; + const rawModel = ep.rawModel || ""; + const provider = ep.provider || ""; + const providerDisplayName = providerNodeNameMap[provider] || provider; + if (!stats.byEndpoint[epKey]) { + stats.byEndpoint[epKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + endpoint, + rawModel, + provider: providerDisplayName, + lastUsed: dateKey, + }; + } + stats.byEndpoint[epKey].requests += ep.requests || 0; + stats.byEndpoint[epKey].promptTokens += ep.promptTokens || 0; + stats.byEndpoint[epKey].completionTokens += ep.completionTokens || 0; + stats.byEndpoint[epKey].cachedTokens += ep.cachedTokens || 0; + stats.byEndpoint[epKey].cost += ep.cost || 0; + if (dateKey > (stats.byEndpoint[epKey].lastUsed || "")) + stats.byEndpoint[epKey].lastUsed = dateKey; + } + } - // Overlay precise lastUsed timestamps from history - const overlayCutoff = maxDays ? Date.now() - maxDays * 86400000 : 0; - const histRows = db.all( - `SELECT timestamp, provider, model, connectionId, apiKey, endpoint FROM usageHistory WHERE timestamp >= ?`, - [new Date(overlayCutoff).toISOString()] - ); - for (const e of histRows) { - const ts = e.timestamp; - const modelKey = e.provider ? `${e.model} (${e.provider})` : e.model; - if (stats.byModel[modelKey] && new Date(ts) > new Date(stats.byModel[modelKey].lastUsed)) stats.byModel[modelKey].lastUsed = ts; + // Overlay precise lastUsed timestamps from history + for (const e of histRows) { + if ( + stats.byProvider[e.provider] && + new Date(e.timestamp) > new Date(stats.byProvider[e.provider].lastUsed) + ) + stats.byProvider[e.provider].lastUsed = e.timestamp; + } + const overlayCutoff = maxDays ? Date.now() - maxDays * 86400000 : 0; + const histRows = db.all( + `SELECT timestamp, provider, model, connectionId, apiKey, endpoint FROM usageHistory WHERE timestamp >= ?`, + [new Date(overlayCutoff).toISOString()], + ); + for (const e of histRows) { + const ts = e.timestamp; + const modelKey = e.provider ? `${e.model} (${e.provider})` : e.model; + if ( + stats.byModel[modelKey] && + new Date(ts) > new Date(stats.byModel[modelKey].lastUsed) + ) + stats.byModel[modelKey].lastUsed = ts; - if (e.connectionId) { - const accountName = connectionMap[e.connectionId] || `Account ${e.connectionId.slice(0, 8)}...`; - const accountKey = `${e.model} (${e.provider} - ${accountName})`; - if (stats.byAccount[accountKey] && new Date(ts) > new Date(stats.byAccount[accountKey].lastUsed)) stats.byAccount[accountKey].lastUsed = ts; - } + if (e.connectionId) { + const accountName = + connectionMap[e.connectionId] || + `Account ${e.connectionId.slice(0, 8)}...`; + const accountKey = `${e.model} (${e.provider} - ${accountName})`; + if ( + stats.byAccount[accountKey] && + new Date(ts) > new Date(stats.byAccount[accountKey].lastUsed) + ) + stats.byAccount[accountKey].lastUsed = ts; + } - const apiKeyKey = (e.apiKey && typeof e.apiKey === "string") - ? `${e.apiKey}|${e.model}|${e.provider || "unknown"}` - : "local-no-key"; - if (stats.byApiKey[apiKeyKey] && new Date(ts) > new Date(stats.byApiKey[apiKeyKey].lastUsed)) stats.byApiKey[apiKeyKey].lastUsed = ts; + const apiKeyKey = + e.apiKey && typeof e.apiKey === "string" + ? `${e.apiKey}|${e.model}|${e.provider || "unknown"}` + : "local-no-key"; + if ( + stats.byApiKey[apiKeyKey] && + new Date(ts) > new Date(stats.byApiKey[apiKeyKey].lastUsed) + ) + stats.byApiKey[apiKeyKey].lastUsed = ts; - const endpoint = e.endpoint || "Unknown"; - const endpointKey = `${endpoint}|${e.model}|${e.provider || "unknown"}`; - if (stats.byEndpoint[endpointKey] && new Date(ts) > new Date(stats.byEndpoint[endpointKey].lastUsed)) stats.byEndpoint[endpointKey].lastUsed = ts; - } - } else { - // 24h / today: live history - let cutoff; - if (period === "today") { - const startOfDay = new Date(); - startOfDay.setHours(0, 0, 0, 0); - cutoff = startOfDay.toISOString(); - } else { - cutoff = new Date(Date.now() - PERIOD_MS["24h"]).toISOString(); - } - const filtered = db.all( - `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`, - [cutoff] - ); + const endpoint = e.endpoint || "Unknown"; + const endpointKey = `${endpoint}|${e.model}|${e.provider || "unknown"}`; + if ( + stats.byEndpoint[endpointKey] && + new Date(ts) > new Date(stats.byEndpoint[endpointKey].lastUsed) + ) + stats.byEndpoint[endpointKey].lastUsed = ts; + } + } else { + // 24h / today: live history + let cutoff; + if (period === "today") { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + cutoff = startOfDay.toISOString(); + } else { + cutoff = new Date(Date.now() - PERIOD_MS["24h"]).toISOString(); + } + const filtered = db.all( + `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`, + [cutoff], + ); - for (const r of filtered) { - const tokens = parseJson(r.tokens, {}) || {}; - const promptTokens = tokens.prompt_tokens || 0; - const completionTokens = tokens.completion_tokens || 0; - const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0; - const entryCost = r.cost || 0; - const providerDisplayName = providerNodeNameMap[r.provider] || r.provider; + for (const r of filtered) { + const tokens = parseJson(r.tokens, {}) || {}; + const promptTokens = tokens.prompt_tokens || 0; + const completionTokens = tokens.completion_tokens || 0; + const cachedTokens = + tokens.cached_tokens || tokens.cache_read_input_tokens || 0; + const entryCost = r.cost || 0; + const providerDisplayName = providerNodeNameMap[r.provider] || r.provider; - stats.totalPromptTokens += promptTokens; - stats.totalCompletionTokens += completionTokens; - stats.totalCachedTokens += cachedTokens; - stats.totalCost += entryCost; + stats.totalPromptTokens += promptTokens; + stats.totalCompletionTokens += completionTokens; + stats.totalCachedTokens += cachedTokens; + stats.totalCost += entryCost; - if (!stats.byProvider[r.provider]) stats.byProvider[r.provider] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0 }; - stats.byProvider[r.provider].requests++; - stats.byProvider[r.provider].promptTokens += promptTokens; - stats.byProvider[r.provider].completionTokens += completionTokens; - stats.byProvider[r.provider].cachedTokens += cachedTokens; - stats.byProvider[r.provider].cost += entryCost; + if (!stats.byProvider[r.provider]) + stats.byProvider[r.provider] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + lastUsed: r.timestamp, + }; + stats.byProvider[r.provider].requests++; + stats.byProvider[r.provider].promptTokens += promptTokens; + stats.byProvider[r.provider].completionTokens += completionTokens; + stats.byProvider[r.provider].cachedTokens += cachedTokens; + stats.byProvider[r.provider].cost += entryCost; + if ( + new Date(r.timestamp) > new Date(stats.byProvider[r.provider].lastUsed) + ) + stats.byProvider[r.provider].lastUsed = r.timestamp; - const modelKey = r.provider ? `${r.model} (${r.provider})` : r.model; - if (!stats.byModel[modelKey]) { - stats.byModel[modelKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp }; - } - stats.byModel[modelKey].requests++; - stats.byModel[modelKey].promptTokens += promptTokens; - stats.byModel[modelKey].completionTokens += completionTokens; - stats.byModel[modelKey].cachedTokens += cachedTokens; - stats.byModel[modelKey].cost += entryCost; - if (new Date(r.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) stats.byModel[modelKey].lastUsed = r.timestamp; + const modelKey = r.provider ? `${r.model} (${r.provider})` : r.model; + if (!stats.byModel[modelKey]) { + stats.byModel[modelKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + rawModel: r.model, + provider: providerDisplayName, + lastUsed: r.timestamp, + }; + } + stats.byModel[modelKey].requests++; + stats.byModel[modelKey].promptTokens += promptTokens; + stats.byModel[modelKey].completionTokens += completionTokens; + stats.byModel[modelKey].cachedTokens += cachedTokens; + stats.byModel[modelKey].cost += entryCost; + if (new Date(r.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) + stats.byModel[modelKey].lastUsed = r.timestamp; - if (r.connectionId) { - const accountName = connectionMap[r.connectionId] || `Account ${r.connectionId.slice(0, 8)}...`; - const accountKey = `${r.model} (${r.provider} - ${accountName})`; - if (!stats.byAccount[accountKey]) { - stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, connectionId: r.connectionId, accountName, lastUsed: r.timestamp }; - } - stats.byAccount[accountKey].requests++; - stats.byAccount[accountKey].promptTokens += promptTokens; - stats.byAccount[accountKey].completionTokens += completionTokens; - stats.byAccount[accountKey].cachedTokens += cachedTokens; - stats.byAccount[accountKey].cost += entryCost; - if (new Date(r.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) stats.byAccount[accountKey].lastUsed = r.timestamp; - } + if (r.connectionId) { + const accountName = + connectionMap[r.connectionId] || + `Account ${r.connectionId.slice(0, 8)}...`; + const accountKey = `${r.model} (${r.provider} - ${accountName})`; + if (!stats.byAccount[accountKey]) { + stats.byAccount[accountKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + rawModel: r.model, + provider: providerDisplayName, + connectionId: r.connectionId, + accountName, + lastUsed: r.timestamp, + }; + } + stats.byAccount[accountKey].requests++; + stats.byAccount[accountKey].promptTokens += promptTokens; + stats.byAccount[accountKey].completionTokens += completionTokens; + stats.byAccount[accountKey].cachedTokens += cachedTokens; + stats.byAccount[accountKey].cost += entryCost; + if ( + new Date(r.timestamp) > new Date(stats.byAccount[accountKey].lastUsed) + ) + stats.byAccount[accountKey].lastUsed = r.timestamp; + } - if (r.apiKey && typeof r.apiKey === "string") { - const keyInfo = apiKeyMap[r.apiKey]; - const keyName = keyInfo?.name || r.apiKey.slice(0, 8) + "..."; - const apiKeyMasked = maskApiKey(r.apiKey); - const akKey = `${apiKeyMasked}|${r.model}|${r.provider || "unknown"}`; - if (!stats.byApiKey[akKey]) { - stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey: apiKeyMasked, lastUsed: r.timestamp }; - } - const ake = stats.byApiKey[akKey]; - ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cachedTokens += cachedTokens; ake.cost += entryCost; - if (new Date(r.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = r.timestamp; - } else { - if (!stats.byApiKey["local-no-key"]) { - stats.byApiKey["local-no-key"] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked: null, keyName: "Local (No API Key)", apiKeyKey: "local-no-key", lastUsed: r.timestamp }; - } - const ake = stats.byApiKey["local-no-key"]; - ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cachedTokens += cachedTokens; ake.cost += entryCost; - if (new Date(r.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = r.timestamp; - } + if (r.apiKey && typeof r.apiKey === "string") { + const keyInfo = apiKeyMap[r.apiKey]; + const keyName = keyInfo?.name || r.apiKey.slice(0, 8) + "..."; + const apiKeyMasked = maskApiKey(r.apiKey); + const akKey = `${apiKeyMasked}|${r.model}|${r.provider || "unknown"}`; + if (!stats.byApiKey[akKey]) { + stats.byApiKey[akKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + rawModel: r.model, + provider: providerDisplayName, + apiKeyMasked, + keyName, + apiKeyKey: apiKeyMasked, + lastUsed: r.timestamp, + }; + } + const ake = stats.byApiKey[akKey]; + ake.requests++; + ake.promptTokens += promptTokens; + ake.completionTokens += completionTokens; + ake.cachedTokens += cachedTokens; + ake.cost += entryCost; + if (new Date(r.timestamp) > new Date(ake.lastUsed)) + ake.lastUsed = r.timestamp; + } else { + if (!stats.byApiKey["local-no-key"]) { + stats.byApiKey["local-no-key"] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + rawModel: r.model, + provider: providerDisplayName, + apiKeyMasked: null, + keyName: "Local (No API Key)", + apiKeyKey: "local-no-key", + lastUsed: r.timestamp, + }; + } + const ake = stats.byApiKey["local-no-key"]; + ake.requests++; + ake.promptTokens += promptTokens; + ake.completionTokens += completionTokens; + ake.cachedTokens += cachedTokens; + ake.cost += entryCost; + if (new Date(r.timestamp) > new Date(ake.lastUsed)) + ake.lastUsed = r.timestamp; + } - const endpoint = r.endpoint || "Unknown"; - const epKey = `${endpoint}|${r.model}|${r.provider || "unknown"}`; - if (!stats.byEndpoint[epKey]) { - stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, endpoint, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp }; - } - const epe = stats.byEndpoint[epKey]; - epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cachedTokens += cachedTokens; epe.cost += entryCost; - if (new Date(r.timestamp) > new Date(epe.lastUsed)) epe.lastUsed = r.timestamp; - } - } + const endpoint = r.endpoint || "Unknown"; + const epKey = `${endpoint}|${r.model}|${r.provider || "unknown"}`; + if (!stats.byEndpoint[epKey]) { + stats.byEndpoint[epKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + cost: 0, + endpoint, + rawModel: r.model, + provider: providerDisplayName, + lastUsed: r.timestamp, + }; + } + const epe = stats.byEndpoint[epKey]; + epe.requests++; + epe.promptTokens += promptTokens; + epe.completionTokens += completionTokens; + epe.cachedTokens += cachedTokens; + epe.cost += entryCost; + if (new Date(r.timestamp) > new Date(epe.lastUsed)) + epe.lastUsed = r.timestamp; + } + } - stats.totalRequests = Object.values(stats.byProvider).reduce((sum, p) => sum + (p.requests || 0), 0); - return stats; + stats.totalRequests = Object.values(stats.byProvider).reduce( + (sum, p) => sum + (p.requests || 0), + 0, + ); + return stats; } export async function getChartData(period = "7d") { - const db = await getAdapter(); - const now = Date.now(); + const db = await getAdapter(); + const now = Date.now(); - if (period === "today") { - const bucketCount = 24; - const bucketMs = 3600000; - const startOfDay = new Date(); - startOfDay.setHours(0, 0, 0, 0); - const startTime = startOfDay.getTime(); - const endTime = startTime + bucketCount * bucketMs; - const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); - const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 })); + if (period === "today") { + const bucketCount = 24; + const bucketMs = 3600000; + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + const startTime = startOfDay.getTime(); + const endTime = startTime + bucketCount * bucketMs; + const labelFn = (ts) => + new Date(ts).toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + const buckets = Array.from({ length: bucketCount }, (_, i) => ({ + label: labelFn(startTime + i * bucketMs), + tokens: 0, + cost: 0, + })); - const rows = db.all( - `SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`, - [new Date(startTime).toISOString()] - ); - for (const r of rows) { - const t = new Date(r.timestamp).getTime(); - if (t < startTime || t >= endTime) continue; - const idx = Math.floor((t - startTime) / bucketMs); - if (idx >= 0 && idx < bucketCount) { - buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0); - buckets[idx].cost += r.cost || 0; - } - } - return buckets; - } + const rows = db.all( + `SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`, + [new Date(startTime).toISOString()], + ); + for (const r of rows) { + const t = new Date(r.timestamp).getTime(); + if (t < startTime || t >= endTime) continue; + const idx = Math.floor((t - startTime) / bucketMs); + if (idx >= 0 && idx < bucketCount) { + buckets[idx].tokens += + (r.promptTokens || 0) + (r.completionTokens || 0); + buckets[idx].cost += r.cost || 0; + } + } + return buckets; + } - if (period === "24h") { - const bucketCount = 24; - const bucketMs = 3600000; - const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); - const startTime = now - bucketCount * bucketMs; - const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 })); + if (period === "24h") { + const bucketCount = 24; + const bucketMs = 3600000; + const labelFn = (ts) => + new Date(ts).toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + const startTime = now - bucketCount * bucketMs; + const buckets = Array.from({ length: bucketCount }, (_, i) => ({ + label: labelFn(startTime + i * bucketMs), + tokens: 0, + cost: 0, + })); - const rows = db.all( - `SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`, - [new Date(startTime).toISOString()] - ); - for (const r of rows) { - const t = new Date(r.timestamp).getTime(); - if (t < startTime || t > now) continue; - const idx = Math.min(Math.floor((t - startTime) / bucketMs), bucketCount - 1); - buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0); - buckets[idx].cost += r.cost || 0; - } - return buckets; - } + const rows = db.all( + `SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`, + [new Date(startTime).toISOString()], + ); + for (const r of rows) { + const t = new Date(r.timestamp).getTime(); + if (t < startTime || t > now) continue; + const idx = Math.min( + Math.floor((t - startTime) / bucketMs), + bucketCount - 1, + ); + buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0); + buckets[idx].cost += r.cost || 0; + } + return buckets; + } - const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60; - const today = new Date(); - const labelFn = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60; + const today = new Date(); + const labelFn = (d) => + d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - // Build map of dateKey → day data - const dayRows = loadDaysInRange(db, bucketCount); - const dayMap = {}; - for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {}); + // Build map of dateKey → day data + const dayRows = loadDaysInRange(db, bucketCount); + const dayMap = {}; + for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {}); - return Array.from({ length: bucketCount }, (_, i) => { - const d = new Date(today); - d.setDate(d.getDate() - (bucketCount - 1 - i)); - const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - const dayData = dayMap[dateKey]; - return { - label: labelFn(d), - tokens: dayData ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) : 0, - cost: dayData ? (dayData.cost || 0) : 0, - }; - }); + return Array.from({ length: bucketCount }, (_, i) => { + const d = new Date(today); + d.setDate(d.getDate() - (bucketCount - 1 - i)); + const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const dayData = dayMap[dateKey]; + return { + label: labelFn(d), + tokens: dayData + ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) + : 0, + cost: dayData ? dayData.cost || 0 : 0, + }; + }); } function formatLogDate(date = new Date()) { - const pad = (n) => String(n).padStart(2, "0"); - return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + const pad = (n) => String(n).padStart(2, "0"); + return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } // No-op: request log is now derived from usageHistory table on read. export async function appendRequestLog() {} export async function getRecentLogs(limit = 200) { - try { - const db = await getAdapter(); - const rows = db.all( - `SELECT timestamp, provider, model, connectionId, promptTokens, completionTokens, status, tokens FROM usageHistory ORDER BY id DESC LIMIT ?`, - [limit], - ); - if (!rows.length) return []; + try { + const db = await getAdapter(); + const rows = db.all( + `SELECT timestamp, provider, model, connectionId, promptTokens, completionTokens, status, tokens FROM usageHistory ORDER BY id DESC LIMIT ?`, + [limit], + ); + if (!rows.length) return []; - const connMap = {}; - try { - const { getProviderConnections } = await import("./connectionsRepo.js"); - const connections = await getProviderConnections(); - for (const c of connections) connMap[c.id] = c.name || c.email || ""; - } catch {} + const connMap = {}; + try { + const { getProviderConnections } = await import("./connectionsRepo.js"); + const connections = await getProviderConnections(); + for (const c of connections) connMap[c.id] = c.name || c.email || ""; + } catch {} - return rows.map((r) => { - const ts = formatLogDate(new Date(r.timestamp)); - const p = r.provider?.toUpperCase() || "-"; - const m = r.model || "-"; - const account = connMap[r.connectionId] || (r.connectionId ? r.connectionId.slice(0, 8) : "-"); - const tk = r.tokens ? parseJson(r.tokens, {}) : {}; - const sent = r.promptTokens ?? tk.prompt_tokens ?? "-"; - const received = r.completionTokens ?? tk.completion_tokens ?? "-"; - return `${ts} | ${m} | ${p} | ${account} | ${sent} | ${received} | ${r.status || "-"}`; - }); - } catch (e) { - console.error("[usageRepo] getRecentLogs failed:", e.message); - return []; - } + return rows.map((r) => { + const ts = formatLogDate(new Date(r.timestamp)); + const p = r.provider?.toUpperCase() || "-"; + const m = r.model || "-"; + const account = + connMap[r.connectionId] || + (r.connectionId ? r.connectionId.slice(0, 8) : "-"); + const tk = r.tokens ? parseJson(r.tokens, {}) : {}; + const sent = r.promptTokens ?? tk.prompt_tokens ?? "-"; + const received = r.completionTokens ?? tk.completion_tokens ?? "-"; + return `${ts} | ${m} | ${p} | ${account} | ${sent} | ${received} | ${r.status || "-"}`; + }); + } catch (e) { + console.error("[usageRepo] getRecentLogs failed:", e.message); + return []; + } } diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index 099386c2..ddbdcede 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -19,143 +19,144 @@ PRAGMA busy_timeout = 5000; // auto-add missing tables/columns/indexes after versioned migrations. // For destructive changes (drop/rename/type-change), write a migration file. export const TABLES = { - _meta: { - columns: { - key: "TEXT PRIMARY KEY", - value: "TEXT NOT NULL", - }, - }, - settings: { - columns: { - id: "INTEGER PRIMARY KEY CHECK (id = 1)", - data: "TEXT NOT NULL", - }, - }, - providerConnections: { - columns: { - id: "TEXT PRIMARY KEY", - provider: "TEXT NOT NULL", - authType: "TEXT NOT NULL", - name: "TEXT", - email: "TEXT", - priority: "INTEGER", - isActive: "INTEGER DEFAULT 1", - data: "TEXT NOT NULL", - createdAt: "TEXT NOT NULL", - updatedAt: "TEXT NOT NULL", - }, - indexes: [ - "CREATE INDEX IF NOT EXISTS idx_pc_provider ON providerConnections(provider)", - "CREATE INDEX IF NOT EXISTS idx_pc_provider_active ON providerConnections(provider, isActive)", - "CREATE INDEX IF NOT EXISTS idx_pc_priority ON providerConnections(provider, priority)", - ], - }, - providerNodes: { - columns: { - id: "TEXT PRIMARY KEY", - type: "TEXT", - name: "TEXT", - data: "TEXT NOT NULL", - createdAt: "TEXT NOT NULL", - updatedAt: "TEXT NOT NULL", - }, - indexes: ["CREATE INDEX IF NOT EXISTS idx_pn_type ON providerNodes(type)"], - }, - proxyPools: { - columns: { - id: "TEXT PRIMARY KEY", - isActive: "INTEGER DEFAULT 1", - testStatus: "TEXT", - data: "TEXT NOT NULL", - createdAt: "TEXT NOT NULL", - updatedAt: "TEXT NOT NULL", - }, - indexes: [ - "CREATE INDEX IF NOT EXISTS idx_pp_active ON proxyPools(isActive)", - "CREATE INDEX IF NOT EXISTS idx_pp_status ON proxyPools(testStatus)", - ], - }, - apiKeys: { - columns: { - id: "TEXT PRIMARY KEY", - key: "TEXT UNIQUE NOT NULL", - name: "TEXT", - machineId: "TEXT", - isActive: "INTEGER DEFAULT 1", - createdAt: "TEXT NOT NULL", - }, - indexes: ["CREATE INDEX IF NOT EXISTS idx_ak_key ON apiKeys(key)"], - }, - combos: { - columns: { - id: "TEXT PRIMARY KEY", - name: "TEXT UNIQUE NOT NULL", - kind: "TEXT", - models: "TEXT NOT NULL", - createdAt: "TEXT NOT NULL", - updatedAt: "TEXT NOT NULL", - }, - indexes: ["CREATE INDEX IF NOT EXISTS idx_combo_name ON combos(name)"], - }, - kv: { - columns: { - scope: "TEXT NOT NULL", - key: "TEXT NOT NULL", - value: "TEXT NOT NULL", - }, - primaryKey: "PRIMARY KEY (scope, key)", - indexes: ["CREATE INDEX IF NOT EXISTS idx_kv_scope ON kv(scope)"], - }, - usageHistory: { - columns: { - id: "INTEGER PRIMARY KEY AUTOINCREMENT", - timestamp: "TEXT NOT NULL", - provider: "TEXT", - model: "TEXT", - connectionId: "TEXT", - apiKey: "TEXT", - endpoint: "TEXT", - promptTokens: "INTEGER DEFAULT 0", - completionTokens: "INTEGER DEFAULT 0", - cost: "REAL DEFAULT 0", - status: "TEXT", - tokens: "TEXT", - meta: "TEXT", - }, - indexes: [ - "CREATE INDEX IF NOT EXISTS idx_uh_ts ON usageHistory(timestamp DESC)", - "CREATE INDEX IF NOT EXISTS idx_uh_provider ON usageHistory(provider)", - "CREATE INDEX IF NOT EXISTS idx_uh_model ON usageHistory(model)", - "CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)", - ], - }, - usageDaily: { - columns: { - dateKey: "TEXT PRIMARY KEY", - data: "TEXT NOT NULL", - }, - }, - requestDetails: { - columns: { - id: "TEXT PRIMARY KEY", - timestamp: "TEXT NOT NULL", - provider: "TEXT", - model: "TEXT", - connectionId: "TEXT", - status: "TEXT", - data: "TEXT NOT NULL", - }, - indexes: [ - "CREATE INDEX IF NOT EXISTS idx_rd_ts ON requestDetails(timestamp DESC)", - "CREATE INDEX IF NOT EXISTS idx_rd_provider ON requestDetails(provider)", - "CREATE INDEX IF NOT EXISTS idx_rd_model ON requestDetails(model)", - "CREATE INDEX IF NOT EXISTS idx_rd_conn ON requestDetails(connectionId)", - ], - }, + _meta: { + columns: { + key: "TEXT PRIMARY KEY", + value: "TEXT NOT NULL", + }, + }, + settings: { + columns: { + id: "INTEGER PRIMARY KEY CHECK (id = 1)", + data: "TEXT NOT NULL", + }, + }, + providerConnections: { + columns: { + id: "TEXT PRIMARY KEY", + provider: "TEXT NOT NULL", + authType: "TEXT NOT NULL", + name: "TEXT", + email: "TEXT", + priority: "INTEGER", + isActive: "INTEGER DEFAULT 1", + data: "TEXT NOT NULL", + createdAt: "TEXT NOT NULL", + updatedAt: "TEXT NOT NULL", + }, + indexes: [ + "CREATE INDEX IF NOT EXISTS idx_pc_provider ON providerConnections(provider)", + "CREATE INDEX IF NOT EXISTS idx_pc_provider_active ON providerConnections(provider, isActive)", + "CREATE INDEX IF NOT EXISTS idx_pc_priority ON providerConnections(provider, priority)", + ], + }, + providerNodes: { + columns: { + id: "TEXT PRIMARY KEY", + type: "TEXT", + name: "TEXT", + data: "TEXT NOT NULL", + createdAt: "TEXT NOT NULL", + updatedAt: "TEXT NOT NULL", + }, + indexes: ["CREATE INDEX IF NOT EXISTS idx_pn_type ON providerNodes(type)"], + }, + proxyPools: { + columns: { + id: "TEXT PRIMARY KEY", + isActive: "INTEGER DEFAULT 1", + testStatus: "TEXT", + data: "TEXT NOT NULL", + createdAt: "TEXT NOT NULL", + updatedAt: "TEXT NOT NULL", + }, + indexes: [ + "CREATE INDEX IF NOT EXISTS idx_pp_active ON proxyPools(isActive)", + "CREATE INDEX IF NOT EXISTS idx_pp_status ON proxyPools(testStatus)", + ], + }, + apiKeys: { + columns: { + id: "TEXT PRIMARY KEY", + key: "TEXT UNIQUE NOT NULL", + name: "TEXT", + machineId: "TEXT", + isActive: "INTEGER DEFAULT 1", + createdAt: "TEXT NOT NULL", + }, + indexes: ["CREATE INDEX IF NOT EXISTS idx_ak_key ON apiKeys(key)"], + }, + combos: { + columns: { + id: "TEXT PRIMARY KEY", + name: "TEXT UNIQUE NOT NULL", + kind: "TEXT", + models: "TEXT NOT NULL", + enabled: "INTEGER NOT NULL DEFAULT 1", + createdAt: "TEXT NOT NULL", + updatedAt: "TEXT NOT NULL", + }, + indexes: ["CREATE INDEX IF NOT EXISTS idx_combo_name ON combos(name)"], + }, + kv: { + columns: { + scope: "TEXT NOT NULL", + key: "TEXT NOT NULL", + value: "TEXT NOT NULL", + }, + primaryKey: "PRIMARY KEY (scope, key)", + indexes: ["CREATE INDEX IF NOT EXISTS idx_kv_scope ON kv(scope)"], + }, + usageHistory: { + columns: { + id: "INTEGER PRIMARY KEY AUTOINCREMENT", + timestamp: "TEXT NOT NULL", + provider: "TEXT", + model: "TEXT", + connectionId: "TEXT", + apiKey: "TEXT", + endpoint: "TEXT", + promptTokens: "INTEGER DEFAULT 0", + completionTokens: "INTEGER DEFAULT 0", + cost: "REAL DEFAULT 0", + status: "TEXT", + tokens: "TEXT", + meta: "TEXT", + }, + indexes: [ + "CREATE INDEX IF NOT EXISTS idx_uh_ts ON usageHistory(timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_uh_provider ON usageHistory(provider)", + "CREATE INDEX IF NOT EXISTS idx_uh_model ON usageHistory(model)", + "CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)", + ], + }, + usageDaily: { + columns: { + dateKey: "TEXT PRIMARY KEY", + data: "TEXT NOT NULL", + }, + }, + requestDetails: { + columns: { + id: "TEXT PRIMARY KEY", + timestamp: "TEXT NOT NULL", + provider: "TEXT", + model: "TEXT", + connectionId: "TEXT", + status: "TEXT", + data: "TEXT NOT NULL", + }, + indexes: [ + "CREATE INDEX IF NOT EXISTS idx_rd_ts ON requestDetails(timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_rd_provider ON requestDetails(provider)", + "CREATE INDEX IF NOT EXISTS idx_rd_model ON requestDetails(model)", + "CREATE INDEX IF NOT EXISTS idx_rd_conn ON requestDetails(connectionId)", + ], + }, }; export function buildCreateTableSql(name, def) { - const cols = Object.entries(def.columns).map(([k, v]) => `${k} ${v}`); - if (def.primaryKey) cols.push(def.primaryKey); - return `CREATE TABLE IF NOT EXISTS ${name} (${cols.join(", ")})`; + const cols = Object.entries(def.columns).map(([k, v]) => `${k} ${v}`); + if (def.primaryKey) cols.push(def.primaryKey); + return `CREATE TABLE IF NOT EXISTS ${name} (${cols.join(", ")})`; } diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 3419140b..b21859ff 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -7,619 +7,868 @@ import ProviderIcon from "./ProviderIcon"; import CapacityBadges from "./CapacityBadges"; import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; -import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, AI_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, getProviderAlias } from "@/shared/constants/providers"; +import { + OAUTH_PROVIDERS, + APIKEY_PROVIDERS, + FREE_PROVIDERS, + FREE_TIER_PROVIDERS, + AI_PROVIDERS, + isOpenAICompatibleProvider, + isAnthropicCompatibleProvider, + getProviderAlias, +} from "@/shared/constants/providers"; // Provider order: OAuth first, then Free Tier, then API Key (matches dashboard/providers) const PROVIDER_ORDER = [ - ...Object.keys(OAUTH_PROVIDERS), - ...Object.keys(FREE_PROVIDERS), - ...Object.keys(FREE_TIER_PROVIDERS), - ...Object.keys(APIKEY_PROVIDERS), + ...Object.keys(OAUTH_PROVIDERS), + ...Object.keys(FREE_PROVIDERS), + ...Object.keys(FREE_TIER_PROVIDERS), + ...Object.keys(APIKEY_PROVIDERS), ]; // Providers that need no auth — always show in model selector -const NO_AUTH_PROVIDER_IDS = Object.keys(FREE_PROVIDERS).filter(id => FREE_PROVIDERS[id].noAuth); +const NO_AUTH_PROVIDER_IDS = Object.keys(FREE_PROVIDERS).filter( + (id) => FREE_PROVIDERS[id].noAuth, +); export default function ModelSelectModal({ - isOpen, - onClose, - onSelect, - onDeselect, - selectedModel, - activeProviders = [], - title = "Select Model", - modelAliases = {}, - kindFilter = null, - addedModelValues = [], - closeOnSelect = true, + isOpen, + onClose, + onSelect, + onDeselect, + selectedModel, + activeProviders = [], + title = "Select Model", + modelAliases = {}, + kindFilter = null, + addedModelValues = [], + closeOnSelect = true, }) { - // Filter activeProviders by serviceKinds when kindFilter set (e.g. "webSearch", "webFetch") - const filteredActiveProviders = useMemo(() => { - if (!kindFilter) return activeProviders; - return activeProviders.filter((p) => { - const info = AI_PROVIDERS[p.provider]; - const kinds = info?.serviceKinds || ["llm"]; - return kinds.includes(kindFilter); - }); - }, [activeProviders, kindFilter]); - const { getCaps } = useModelCaps(); - const [searchQuery, setSearchQuery] = useState(""); - const [combos, setCombos] = useState([]); - const [providerNodes, setProviderNodes] = useState([]); - const [customModels, setCustomModels] = useState([]); - const [disabledModels, setDisabledModels] = useState({}); - const [cursorModels, setCursorModels] = useState([]); + // Filter activeProviders by serviceKinds when kindFilter set (e.g. "webSearch", "webFetch") + const filteredActiveProviders = useMemo(() => { + if (!kindFilter) return activeProviders; + return activeProviders.filter((p) => { + const info = AI_PROVIDERS[p.provider]; + const kinds = info?.serviceKinds || ["llm"]; + return kinds.includes(kindFilter); + }); + }, [activeProviders, kindFilter]); + const { getCaps } = useModelCaps(); + const [searchQuery, setSearchQuery] = useState(""); + const [combos, setCombos] = useState([]); + const [providerNodes, setProviderNodes] = useState([]); + const [customModels, setCustomModels] = useState([]); + const [disabledModels, setDisabledModels] = useState({}); + const [cursorModels, setCursorModels] = useState([]); + const [showOnlyComboModels, setShowOnlyComboModels] = useState(false); - // Cursor exposes the usable catalog per account. Keep the static catalog only - // as a fallback, since it quickly becomes stale and different accounts can - // have different model entitlements. - const cursorConnectionIds = useMemo( - () => activeProviders - .filter((provider) => provider.provider === "cursor" && provider.id) - .map((provider) => provider.id), - [activeProviders], - ); + // Cursor exposes the usable catalog per account. Keep the static catalog only + // as a fallback, since it quickly becomes stale and different accounts can + // have different model entitlements. + const cursorConnectionIds = useMemo( + () => + activeProviders + .filter((provider) => provider.provider === "cursor" && provider.id) + .map((provider) => provider.id), + [activeProviders], + ); - useEffect(() => { - if (!isOpen || cursorConnectionIds.length === 0) { - setCursorModels([]); - return undefined; - } + useEffect(() => { + if (!isOpen || cursorConnectionIds.length === 0) { + setCursorModels([]); + return undefined; + } - let cancelled = false; - Promise.all(cursorConnectionIds.map(async (connectionId) => { - const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" }); - if (!response.ok) return []; - const data = await response.json(); - return Array.isArray(data.models) ? data.models : []; - })) - .then((modelLists) => { - if (cancelled) return; - const seen = new Set(); - setCursorModels(modelLists.flat().filter((model) => { - if (!model?.id || seen.has(model.id)) return false; - seen.add(model.id); - return true; - })); - }) - .catch((error) => { - // Do not hide the static fallback when the account catalog is unavailable. - console.warn("Unable to load Cursor models for selector:", error); - if (!cancelled) setCursorModels([]); - }); + let cancelled = false; + Promise.all( + cursorConnectionIds.map(async (connectionId) => { + const response = await fetch(`/api/providers/${connectionId}/models`, { + cache: "no-store", + }); + if (!response.ok) return []; + const data = await response.json(); + return Array.isArray(data.models) ? data.models : []; + }), + ) + .then((modelLists) => { + if (cancelled) return; + const seen = new Set(); + setCursorModels( + modelLists.flat().filter((model) => { + if (!model?.id || seen.has(model.id)) return false; + seen.add(model.id); + return true; + }), + ); + }) + .catch((error) => { + // Do not hide the static fallback when the account catalog is unavailable. + console.warn("Unable to load Cursor models for selector:", error); + if (!cancelled) setCursorModels([]); + }); - return () => { cancelled = true; }; - }, [isOpen, cursorConnectionIds]); + return () => { + cancelled = true; + }; + }, [isOpen, cursorConnectionIds]); - const fetchCombos = async () => { - try { - const res = await fetch("/api/combos"); - if (!res.ok) throw new Error(`Failed to fetch combos: ${res.status}`); - const data = await res.json(); - setCombos(data.combos || []); - } catch (error) { - console.error("Error fetching combos:", error); - setCombos([]); - } - }; + const fetchCombos = async () => { + try { + const res = await fetch("/api/combos"); + if (!res.ok) throw new Error(`Failed to fetch combos: ${res.status}`); + const data = await res.json(); + setCombos(data.combos || []); + } catch (error) { + console.error("Error fetching combos:", error); + setCombos([]); + } + }; - useEffect(() => { - if (isOpen) fetchCombos(); - }, [isOpen]); + useEffect(() => { + if (isOpen) fetchCombos(); + }, [isOpen]); - const fetchProviderNodes = async () => { - try { - const res = await fetch("/api/provider-nodes"); - if (!res.ok) throw new Error(`Failed to fetch provider nodes: ${res.status}`); - const data = await res.json(); - setProviderNodes(data.nodes || []); - } catch (error) { - console.error("Error fetching provider nodes:", error); - setProviderNodes([]); - } - }; + const fetchProviderNodes = async () => { + try { + const res = await fetch("/api/provider-nodes"); + if (!res.ok) + throw new Error(`Failed to fetch provider nodes: ${res.status}`); + const data = await res.json(); + setProviderNodes(data.nodes || []); + } catch (error) { + console.error("Error fetching provider nodes:", error); + setProviderNodes([]); + } + }; - useEffect(() => { - if (isOpen) fetchProviderNodes(); - }, [isOpen]); + useEffect(() => { + if (isOpen) fetchProviderNodes(); + }, [isOpen]); - const fetchCustomModels = async () => { - try { - const res = await fetch("/api/models/custom"); - if (!res.ok) throw new Error(`Failed to fetch custom models: ${res.status}`); - const data = await res.json(); - setCustomModels(data.models || []); - } catch (error) { - console.error("Error fetching custom models:", error); - setCustomModels([]); - } - }; + const fetchCustomModels = async () => { + try { + const res = await fetch("/api/models/custom"); + if (!res.ok) + throw new Error(`Failed to fetch custom models: ${res.status}`); + const data = await res.json(); + setCustomModels(data.models || []); + } catch (error) { + console.error("Error fetching custom models:", error); + setCustomModels([]); + } + }; - useEffect(() => { - if (isOpen) fetchCustomModels(); - }, [isOpen]); + useEffect(() => { + if (isOpen) fetchCustomModels(); + }, [isOpen]); - const fetchDisabledModels = async () => { - try { - const res = await fetch("/api/models/disabled"); - if (!res.ok) throw new Error(`Failed to fetch disabled models: ${res.status}`); - const data = await res.json(); - setDisabledModels(data.disabled || {}); - } catch (error) { - console.error("Error fetching disabled models:", error); - setDisabledModels({}); - } - }; + const fetchDisabledModels = async () => { + try { + const res = await fetch("/api/models/disabled"); + if (!res.ok) + throw new Error(`Failed to fetch disabled models: ${res.status}`); + const data = await res.json(); + setDisabledModels(data.disabled || {}); + } catch (error) { + console.error("Error fetching disabled models:", error); + setDisabledModels({}); + } + }; - useEffect(() => { - if (isOpen) fetchDisabledModels(); - }, [isOpen]); + useEffect(() => { + if (isOpen) fetchDisabledModels(); + }, [isOpen]); - const allProviders = useMemo(() => ({ ...OAUTH_PROVIDERS, ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...APIKEY_PROVIDERS }), []); + // Fetch showOnlyComboModels setting + useEffect(() => { + if (!isOpen) return; + fetch("/api/settings") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + setShowOnlyComboModels(data?.showOnlyComboModels === true); + }) + .catch(() => {}); + }, [isOpen]); - // Group models by provider with priority order - const groupedModels = useMemo(() => { - const groups = {}; + const allProviders = useMemo( + () => ({ + ...OAUTH_PROVIDERS, + ...FREE_PROVIDERS, + ...FREE_TIER_PROVIDERS, + ...APIKEY_PROVIDERS, + }), + [], + ); - // Kinds where the provider IS the model (no per-model selection needed) - const PROVIDER_AS_MODEL_KINDS = new Set(["webSearch", "webFetch"]); - // Kinds that map directly to model.type field - const TYPED_KINDS = new Set(["image", "tts", "stt", "embedding", "imageToText"]); - // For these kinds, providers without hardcoded models can still be picked (provider-as-model fallback) - const ALLOW_PROVIDER_FALLBACK_KINDS = new Set(["tts", "image", "webFetch"]); + // Group models by provider with priority order + const groupedModels = useMemo(() => { + const groups = {}; - // Filter a models[] array by kindFilter (keep only matching kind) - const filterByKind = (models) => { - // No kindFilter means the LLM selector. Keep custom models visible because - // user-added models may have typed capabilities (for example imageToText) - // while still being valid chat/combo targets. - if (!kindFilter) return models.filter((m) => m.isPlaceholder || m.isCustom || !getModelKind(m) || getModelKind(m) === "llm"); - if (!TYPED_KINDS.has(kindFilter)) return models; - return models.filter((m) => m.isPlaceholder || getModelKind(m) === kindFilter); - }; + // Kinds where the provider IS the model (no per-model selection needed) + const PROVIDER_AS_MODEL_KINDS = new Set(["webSearch", "webFetch"]); + // Kinds that map directly to model.type field + const TYPED_KINDS = new Set([ + "image", + "tts", + "stt", + "embedding", + "imageToText", + ]); + // For these kinds, providers without hardcoded models can still be picked (provider-as-model fallback) + const ALLOW_PROVIDER_FALLBACK_KINDS = new Set(["tts", "image", "webFetch"]); - // Get all active provider IDs from connections (filtered by kindFilter if set) - const activeConnectionIds = filteredActiveProviders.map(p => p.provider); + // Filter a models[] array by kindFilter (keep only matching kind) + const filterByKind = (models) => { + // No kindFilter means the LLM selector. Keep custom models visible because + // user-added models may have typed capabilities (for example imageToText) + // while still being valid chat/combo targets. + if (!kindFilter) + return models.filter( + (m) => + m.isPlaceholder || + m.isCustom || + !getModelKind(m) || + getModelKind(m) === "llm", + ); + if (!TYPED_KINDS.has(kindFilter)) return models; + return models.filter( + (m) => m.isPlaceholder || getModelKind(m) === kindFilter, + ); + }; - // No-auth providers: filter by kindFilter as well - const noAuthIds = kindFilter - ? NO_AUTH_PROVIDER_IDS.filter((id) => (AI_PROVIDERS[id]?.serviceKinds || ["llm"]).includes(kindFilter)) - : NO_AUTH_PROVIDER_IDS; + // Get all active provider IDs from connections (filtered by kindFilter if set) + const activeConnectionIds = filteredActiveProviders.map((p) => p.provider); - // Only show connected providers (including both standard and custom) - const providerIdsToShow = new Set([ - ...activeConnectionIds, // Only connected providers - ...noAuthIds, // No-auth providers (kind-filtered) - ]); + // No-auth providers: filter by kindFilter as well + const noAuthIds = kindFilter + ? NO_AUTH_PROVIDER_IDS.filter((id) => + (AI_PROVIDERS[id]?.serviceKinds || ["llm"]).includes(kindFilter), + ) + : NO_AUTH_PROVIDER_IDS; - // Sort by PROVIDER_ORDER - const sortedProviderIds = [...providerIdsToShow].sort((a, b) => { - const indexA = PROVIDER_ORDER.indexOf(a); - const indexB = PROVIDER_ORDER.indexOf(b); - return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB); - }); + // Only show connected providers (including both standard and custom) + const providerIdsToShow = new Set([ + ...activeConnectionIds, // Only connected providers + ...noAuthIds, // No-auth providers (kind-filtered) + ]); - sortedProviderIds.forEach((providerId) => { - const alias = getProviderAlias(providerId); - const providerInfo = allProviders[providerId] || { name: providerId, color: "#666" }; - const isCustomProvider = isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId); + // Sort by PROVIDER_ORDER + const sortedProviderIds = [...providerIdsToShow].sort((a, b) => { + const indexA = PROVIDER_ORDER.indexOf(a); + const indexB = PROVIDER_ORDER.indexOf(b); + return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB); + }); - // For provider-as-model kinds (webSearch/webFetch): emit a single entry where value === providerId - if (kindFilter && PROVIDER_AS_MODEL_KINDS.has(kindFilter)) { - groups[providerId] = { - name: providerInfo.name, - alias, - color: providerInfo.color, - models: [{ id: providerId, name: providerInfo.name, value: providerId }], - }; - return; - } + sortedProviderIds.forEach((providerId) => { + const alias = getProviderAlias(providerId); + const providerInfo = allProviders[providerId] || { + name: providerId, + color: "#666", + }; + const isCustomProvider = + isOpenAICompatibleProvider(providerId) || + isAnthropicCompatibleProvider(providerId); - if (providerInfo.passthroughModels) { - const aliasModels = Object.entries(modelAliases) - .filter(([, fullModel]) => fullModel.startsWith(`${alias}/`)) - .map(([aliasName, fullModel]) => ({ - id: fullModel.replace(`${alias}/`, ""), - name: aliasName, - value: fullModel, - })); - const customRegisteredModels = customModels - .filter((m) => m.providerAlias === alias) - .map((m) => ({ - id: m.id, - name: m.name || m.id, - value: `${alias}/${m.id}`, - kind: getModelKind(m), - isCustom: true, - })); + // For provider-as-model kinds (webSearch/webFetch): emit a single entry where value === providerId + if (kindFilter && PROVIDER_AS_MODEL_KINDS.has(kindFilter)) { + groups[providerId] = { + name: providerInfo.name, + alias, + color: providerInfo.color, + models: [ + { id: providerId, name: providerInfo.name, value: providerId }, + ], + }; + return; + } - // For typed kinds, only include hardcoded typed models (aliases are typically LLM-only and lack type info) - let combined = aliasModels; - if (kindFilter && TYPED_KINDS.has(kindFilter)) { - const registeredTyped = customRegisteredModels.filter((m) => getModelKind(m) === kindFilter); - combined = [ - ...registeredTyped, - ...getModelsByProviderId(providerId) - .filter((m) => getModelKind(m) === kindFilter) - .map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) })) - .filter((m) => !registeredTyped.some((registered) => registered.value === m.value)), - ]; - // Fallback: provider-as-model when no hardcoded models match (tts/image/webFetch only) - if (combined.length === 0 && ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)) { - const supports = (providerInfo.serviceKinds || ["llm"]).includes(kindFilter); - if (supports) combined = [{ id: providerId, name: providerInfo.name, value: alias }]; - } - } else { - // LLM/null kind: merge hardcoded models (e.g. mimo-free → mimo-auto) with user-added models - const registeredLlms = customRegisteredModels.filter((m) => !getModelKind(m) || getModelKind(m) === "llm"); - const seen = new Set([...aliasModels, ...registeredLlms].map((m) => m.value)); - const hardcoded = getModelsByProviderId(providerId) - .filter((m) => !getModelKind(m) || getModelKind(m) === "llm") - .map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) })) - .filter((m) => !seen.has(m.value)); - combined = [...registeredLlms, ...aliasModels.filter((m) => !registeredLlms.some((registered) => registered.value === m.value)), ...hardcoded]; - } + if (providerInfo.passthroughModels) { + const aliasModels = Object.entries(modelAliases) + .filter(([, fullModel]) => fullModel.startsWith(`${alias}/`)) + .map(([aliasName, fullModel]) => ({ + id: fullModel.replace(`${alias}/`, ""), + name: aliasName, + value: fullModel, + })); + const customRegisteredModels = customModels + .filter((m) => m.providerAlias === alias) + .map((m) => ({ + id: m.id, + name: m.name || m.id, + value: `${alias}/${m.id}`, + kind: getModelKind(m), + isCustom: true, + })); - if (combined.length > 0) { - // Check for custom name from providerNodes (for compatible providers) - const matchedNode = providerNodes.find(node => node.id === providerId); - const displayName = matchedNode?.name || providerInfo.name; + // For typed kinds, only include hardcoded typed models (aliases are typically LLM-only and lack type info) + let combined = aliasModels; + if (kindFilter && TYPED_KINDS.has(kindFilter)) { + const registeredTyped = customRegisteredModels.filter( + (m) => getModelKind(m) === kindFilter, + ); + combined = [ + ...registeredTyped, + ...getModelsByProviderId(providerId) + .filter((m) => getModelKind(m) === kindFilter) + .map((m) => ({ + id: m.id, + name: m.name, + value: `${alias}/${m.id}`, + kind: getModelKind(m), + })) + .filter( + (m) => + !registeredTyped.some( + (registered) => registered.value === m.value, + ), + ), + ]; + // Fallback: provider-as-model when no hardcoded models match (tts/image/webFetch only) + if ( + combined.length === 0 && + ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter) + ) { + const supports = (providerInfo.serviceKinds || ["llm"]).includes( + kindFilter, + ); + if (supports) + combined = [ + { id: providerId, name: providerInfo.name, value: alias }, + ]; + } + } else { + // LLM/null kind: merge hardcoded models (e.g. mimo-free → mimo-auto) with user-added models + const registeredLlms = customRegisteredModels.filter( + (m) => !getModelKind(m) || getModelKind(m) === "llm", + ); + const seen = new Set( + [...aliasModels, ...registeredLlms].map((m) => m.value), + ); + const hardcoded = getModelsByProviderId(providerId) + .filter((m) => !getModelKind(m) || getModelKind(m) === "llm") + .map((m) => ({ + id: m.id, + name: m.name, + value: `${alias}/${m.id}`, + kind: getModelKind(m), + })) + .filter((m) => !seen.has(m.value)); + combined = [ + ...registeredLlms, + ...aliasModels.filter( + (m) => + !registeredLlms.some( + (registered) => registered.value === m.value, + ), + ), + ...hardcoded, + ]; + } - groups[providerId] = { - name: displayName, - alias: alias, - color: providerInfo.color, - models: combined, - }; - } - } else if (isCustomProvider) { - // Custom (openai/anthropic-compatible) providers are LLM-only — skip for typed media kinds - if (kindFilter && TYPED_KINDS.has(kindFilter)) return; - // Find connection object to get prefix synchronously without waiting for providerNodes fetch - const connection = activeProviders.find(p => p.provider === providerId); - const matchedNode = providerNodes.find(node => node.id === providerId); - const displayName = matchedNode?.name || connection?.name || providerInfo.name; - const nodePrefix = connection?.providerSpecificData?.prefix || matchedNode?.prefix || providerId; + if (combined.length > 0) { + // Check for custom name from providerNodes (for compatible providers) + const matchedNode = providerNodes.find( + (node) => node.id === providerId, + ); + const displayName = matchedNode?.name || providerInfo.name; - // Aliases are stored using the raw providerId as key (e.g. "openai-compatible-chat-/glm-4.7"), - // so we must filter by providerId, not by the display prefix. - const nodeModels = Object.entries(modelAliases) - .filter(([, fullModel]) => fullModel.startsWith(`${providerId}/`)) - .map(([aliasName, fullModel]) => ({ - id: fullModel.replace(`${providerId}/`, ""), - name: aliasName, - value: `${nodePrefix}/${fullModel.replace(`${providerId}/`, "")}`, - })); + groups[providerId] = { + name: displayName, + alias: alias, + color: providerInfo.color, + models: combined, + }; + } + } else if (isCustomProvider) { + // Custom (openai/anthropic-compatible) providers are LLM-only — skip for typed media kinds + if (kindFilter && TYPED_KINDS.has(kindFilter)) return; + // Find connection object to get prefix synchronously without waiting for providerNodes fetch + const connection = activeProviders.find( + (p) => p.provider === providerId, + ); + const matchedNode = providerNodes.find( + (node) => node.id === providerId, + ); + const displayName = + matchedNode?.name || connection?.name || providerInfo.name; + const nodePrefix = + connection?.providerSpecificData?.prefix || + matchedNode?.prefix || + providerId; - // Merge custom models registered via /api/models/custom for this provider - // providerAlias in DB uses the raw providerId, not the display prefix - const registeredCustom = customModels - .filter((m) => m.providerAlias === providerId) - .map((m) => ({ - id: m.id, - name: m.name || m.id, - value: `${nodePrefix}/${m.id}`, - isCustom: true, - })); - const seen = new Set(nodeModels.map((m) => m.value)); - const mergedModels = [...nodeModels, ...registeredCustom.filter((m) => !seen.has(m.value))]; + // Aliases are stored using the raw providerId as key (e.g. "openai-compatible-chat-/glm-4.7"), + // so we must filter by providerId, not by the display prefix. + const nodeModels = Object.entries(modelAliases) + .filter(([, fullModel]) => fullModel.startsWith(`${providerId}/`)) + .map(([aliasName, fullModel]) => ({ + id: fullModel.replace(`${providerId}/`, ""), + name: aliasName, + value: `${nodePrefix}/${fullModel.replace(`${providerId}/`, "")}`, + })); - // Always show compatible providers that are connected, even with no aliases. - // When no aliases exist, show a placeholder so users know it's available. - const modelsToShow = mergedModels.length > 0 ? mergedModels : [{ - id: `__placeholder__${providerId}`, - name: `${nodePrefix}/model-id`, - value: `${nodePrefix}/model-id`, - isPlaceholder: true, - }]; + // Merge custom models registered via /api/models/custom for this provider + // providerAlias in DB uses the raw providerId, not the display prefix + const registeredCustom = customModels + .filter((m) => m.providerAlias === providerId) + .map((m) => ({ + id: m.id, + name: m.name || m.id, + value: `${nodePrefix}/${m.id}`, + isCustom: true, + })); + const seen = new Set(nodeModels.map((m) => m.value)); + const mergedModels = [ + ...nodeModels, + ...registeredCustom.filter((m) => !seen.has(m.value)), + ]; - groups[providerId] = { - name: displayName, - alias: nodePrefix, - color: providerInfo.color, - models: modelsToShow, - isCustom: true, - hasModels: mergedModels.length > 0, - }; - } else { - const hardcodedModels = providerId === "cursor" && cursorModels.length > 0 - ? cursorModels - : getModelsByProviderId(providerId); - const hardcodedIds = new Set(hardcodedModels.map((m) => m.id)); + // Always show compatible providers that are connected, even with no aliases. + // When no aliases exist, show a placeholder so users know it's available. + const modelsToShow = + mergedModels.length > 0 + ? mergedModels + : [ + { + id: `__placeholder__${providerId}`, + name: `${nodePrefix}/model-id`, + value: `${nodePrefix}/model-id`, + isPlaceholder: true, + }, + ]; - // Custom models: if no hardcoded models (e.g. openrouter), show all aliases for this provider - // Otherwise only show aliases where aliasName === modelId ("Add Model" button pattern) - const hasHardcoded = hardcodedModels.length > 0; - const customAliasModels = Object.entries(modelAliases) - .filter(([aliasName, fullModel]) => - fullModel.startsWith(`${alias}/`) && - (hasHardcoded ? aliasName === fullModel.replace(`${alias}/`, "") : true) && - !hardcodedIds.has(fullModel.replace(`${alias}/`, "")) - ) - .map(([aliasName, fullModel]) => { - const modelId = fullModel.replace(`${alias}/`, ""); - return { id: modelId, name: aliasName, value: fullModel, isCustom: true }; - }); + groups[providerId] = { + name: displayName, + alias: nodePrefix, + color: providerInfo.color, + models: modelsToShow, + isCustom: true, + hasModels: mergedModels.length > 0, + }; + } else { + const hardcodedModels = + providerId === "cursor" && cursorModels.length > 0 + ? cursorModels + : getModelsByProviderId(providerId); + const hardcodedIds = new Set(hardcodedModels.map((m) => m.id)); - // Custom models registered via /api/models/custom (provider "Add Model" button) - const customAliasIds = new Set(customAliasModels.map((m) => m.id)); - const customRegisteredModels = customModels - .filter((m) => m.providerAlias === alias && !hardcodedIds.has(m.id) && !customAliasIds.has(m.id)) - .map((m) => ({ id: m.id, name: m.name || m.id, value: `${alias}/${m.id}`, isCustom: true })); + // Custom models: if no hardcoded models (e.g. openrouter), show all aliases for this provider + // Otherwise only show aliases where aliasName === modelId ("Add Model" button pattern) + const hasHardcoded = hardcodedModels.length > 0; + const customAliasModels = Object.entries(modelAliases) + .filter( + ([aliasName, fullModel]) => + fullModel.startsWith(`${alias}/`) && + (hasHardcoded + ? aliasName === fullModel.replace(`${alias}/`, "") + : true) && + !hardcodedIds.has(fullModel.replace(`${alias}/`, "")), + ) + .map(([aliasName, fullModel]) => { + const modelId = fullModel.replace(`${alias}/`, ""); + return { + id: modelId, + name: aliasName, + value: fullModel, + isCustom: true, + }; + }); - const merged = [ - ...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) })), - ...customAliasModels, - ...customRegisteredModels, - ]; - // Dedupe by value (alias may equal hardcoded id, causing React key collision) - const seen = new Set(); - let allModels = filterByKind(merged.filter((m) => { - if (seen.has(m.value)) return false; - seen.add(m.value); - return true; - })); + // Custom models registered via /api/models/custom (provider "Add Model" button) + const customAliasIds = new Set(customAliasModels.map((m) => m.id)); + const customRegisteredModels = customModels + .filter( + (m) => + m.providerAlias === alias && + !hardcodedIds.has(m.id) && + !customAliasIds.has(m.id), + ) + .map((m) => ({ + id: m.id, + name: m.name || m.id, + value: `${alias}/${m.id}`, + isCustom: true, + })); - // Provider-as-model fallback: providers that support the kind but have no hardcoded models - // can still be picked (value = providerAlias). Skips embedding (always needs model). - if (allModels.length === 0 && kindFilter && ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)) { - const supports = (providerInfo.serviceKinds || ["llm"]).includes(kindFilter); - if (supports) { - allModels = [{ id: providerId, name: providerInfo.name, value: alias }]; - } - } + const merged = [ + ...hardcodedModels.map((m) => ({ + id: m.id, + name: m.name, + value: `${alias}/${m.id}`, + kind: getModelKind(m), + })), + ...customAliasModels, + ...customRegisteredModels, + ]; + // Dedupe by value (alias may equal hardcoded id, causing React key collision) + const seen = new Set(); + let allModels = filterByKind( + merged.filter((m) => { + if (seen.has(m.value)) return false; + seen.add(m.value); + return true; + }), + ); - if (allModels.length > 0) { - groups[providerId] = { - name: providerInfo.name, - alias: alias, - color: providerInfo.color, - models: allModels, - }; - } - } - }); + // Provider-as-model fallback: providers that support the kind but have no hardcoded models + // can still be picked (value = providerAlias). Skips embedding (always needs model). + if ( + allModels.length === 0 && + kindFilter && + ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter) + ) { + const supports = (providerInfo.serviceKinds || ["llm"]).includes( + kindFilter, + ); + if (supports) { + allModels = [ + { id: providerId, name: providerInfo.name, value: alias }, + ]; + } + } - // Filter out disabled models per provider (disabled keyed by storage alias OR providerId) - Object.entries(groups).forEach(([providerId, group]) => { - const aliasKey = getProviderAlias(providerId); - const disabledIds = new Set([ - ...(disabledModels[aliasKey] || []), - ...(disabledModels[providerId] || []), - ]); - if (disabledIds.size === 0) return; - group.models = group.models.filter((m) => !disabledIds.has(m.id)); - if (group.models.length === 0) delete groups[providerId]; - }); + if (allModels.length > 0) { + groups[providerId] = { + name: providerInfo.name, + alias: alias, + color: providerInfo.color, + models: allModels, + }; + } + } + }); - return groups; - }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders, cursorModels]); + // Filter out disabled models per provider (disabled keyed by storage alias OR providerId) + Object.entries(groups).forEach(([providerId, group]) => { + const aliasKey = getProviderAlias(providerId); + const disabledIds = new Set([ + ...(disabledModels[aliasKey] || []), + ...(disabledModels[providerId] || []), + ]); + if (disabledIds.size === 0) return; + group.models = group.models.filter((m) => !disabledIds.has(m.id)); + if (group.models.length === 0) delete groups[providerId]; + }); - // Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design) - const filteredCombos = useMemo(() => { - if (kindFilter) return []; - if (!searchQuery.trim()) return combos; - const query = searchQuery.toLowerCase(); - return combos.filter(c => c.name.toLowerCase().includes(query)); - }, [combos, searchQuery, kindFilter]); + return groups; + }, [ + filteredActiveProviders, + modelAliases, + allProviders, + providerNodes, + customModels, + disabledModels, + kindFilter, + activeProviders, + cursorModels, + ]); - // Sort models alphabetically, with added models floated to top - const sortModels = (models) => { - const added = models.filter(m => addedModelValues.includes(m.value)).sort((a, b) => a.name.localeCompare(b.name)); - const rest = models.filter(m => !addedModelValues.includes(m.value)).sort((a, b) => a.name.localeCompare(b.name)); - return [...added, ...rest]; - }; + // Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design) + const filteredCombos = useMemo(() => { + if (kindFilter) return []; + if (!searchQuery.trim()) return combos; + const query = searchQuery.toLowerCase(); + return combos.filter((c) => c.name.toLowerCase().includes(query)); + }, [combos, searchQuery, kindFilter]); - // Filter models by search query - const filteredGroups = useMemo(() => { - const query = searchQuery.trim().toLowerCase(); + // Sort models alphabetically, with added models floated to top + const sortModels = (models) => { + const added = models + .filter((m) => addedModelValues.includes(m.value)) + .sort((a, b) => a.name.localeCompare(b.name)); + const rest = models + .filter((m) => !addedModelValues.includes(m.value)) + .sort((a, b) => a.name.localeCompare(b.name)); + return [...added, ...rest]; + }; - const filtered = {}; - Object.entries(groupedModels).forEach(([providerId, group]) => { - let models = group.models; - if (query) { - const providerNameMatches = group.name.toLowerCase().includes(query); - models = models.filter( - (m) => - m.name.toLowerCase().includes(query) || - m.id.toLowerCase().includes(query) - ); - if (models.length === 0 && !providerNameMatches) return; - } - filtered[providerId] = { - ...group, - models: sortModels(models), - }; - }); + // Filter models by search query + const filteredGroups = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); - return filtered; - }, [groupedModels, searchQuery, addedModelValues]); + const filtered = {}; + Object.entries(groupedModels).forEach(([providerId, group]) => { + let models = group.models; + if (query) { + const providerNameMatches = group.name.toLowerCase().includes(query); + models = models.filter( + (m) => + m.name.toLowerCase().includes(query) || + m.id.toLowerCase().includes(query), + ); + if (models.length === 0 && !providerNameMatches) return; + } + filtered[providerId] = { + ...group, + models: sortModels(models), + }; + }); - const handleSelect = (model) => { - const value = model?.value || model?.name || model; - const isAdded = addedModelValues.includes(value); + return filtered; + }, [groupedModels, searchQuery, addedModelValues]); - if (isAdded && onDeselect) { - onDeselect(model); - } else { - onSelect(model); - } + // Filter to only show models present in combos when showOnlyComboModels is ON + const finalGroups = useMemo(() => { + if (!showOnlyComboModels) return filteredGroups; + const comboModelValues = new Set(); + for (const c of combos) { + if (c.enabled === false) continue; + for (const m of c.models || []) { + comboModelValues.add(m); + } + } + const result = {}; + for (const [providerId, group] of Object.entries(filteredGroups)) { + const filtered = group.models.filter((m) => + comboModelValues.has(m.value), + ); + if (filtered.length > 0) { + result[providerId] = { ...group, models: filtered }; + } + } + return result; + }, [showOnlyComboModels, filteredGroups, combos]); - if (closeOnSelect) { - onClose(); - setSearchQuery(""); - } - }; + const handleSelect = (model) => { + const value = model?.value || model?.name || model; + const isAdded = addedModelValues.includes(value); - return ( - { - onClose(); - setSearchQuery(""); - }} - title={title} - size="md" - className="p-4!" - footer={null} - > - {/* Info bar */} -
- info - Click to add, click again to remove. Changes are saved automatically. -
+ if (isAdded && onDeselect) { + onDeselect(model); + } else { + onSelect(model); + } - {/* Search - compact */} -
-
- - search - - setSearchQuery(e.target.value)} - className="w-full pl-8 pr-3 py-1.5 bg-surface border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" - /> -
-
+ if (closeOnSelect) { + onClose(); + setSearchQuery(""); + } + }; - {/* Models grouped by provider - compact */} -
- {/* Combos section - always first */} - {filteredCombos.length > 0 && ( -
-
- layers - Combos - ({filteredCombos.length}) -
-
- {filteredCombos.map((combo) => { - const isSelected = selectedModel === combo.name; - return ( - - ); - })} -
-
- )} + > + {addedModelValues.includes(combo.name) && ( + + check + + )} + {combo.name} + + ); + })} +
+ + )} - {/* Provider models */} - {Object.entries(filteredGroups).map(([providerId, group]) => ( -
- {/* Provider header */} -
- - - {group.name} - - - ({group.models.length}) - -
+ {/* Provider models */} + {showOnlyComboModels && + Object.keys(finalGroups).length === 0 && + filteredCombos.length === 0 && ( +
+ + visibility_off + +

+ No models found in active combos. Add models to combos or + disable "Only show combo models" in Settings. +

+
+ )} + {Object.entries(showOnlyComboModels ? finalGroups : filteredGroups).map( + ([providerId, group]) => ( +
+ {/* Provider header */} +
+ + + {group.name} + + + ({group.models.length}) + +
-
- {group.models.map((model) => { - const isSelected = selectedModel === model.value; - const isPlaceholder = model.isPlaceholder; - return ( - - ); - })} -
-
- ))} + > + + {addedModelValues.includes(model.value) && + !isPlaceholder && ( + + check + + )} + {isPlaceholder ? ( + <> + + edit + + {model.name} + + ) : model.isCustom ? ( + <> + {model.name} + + custom + + + + ) : ( + <> + {model.name} + + + )} + + + ); + })} +
+ + ), + )} - {Object.keys(filteredGroups).length === 0 && filteredCombos.length === 0 && ( -
- - search_off - -

No models found

-
- )} - -
- ); + {Object.keys(filteredGroups).length === 0 && + filteredCombos.length === 0 && ( +
+ + search_off + +

No models found

+
+ )} + + + ); } ModelSelectModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onSelect: PropTypes.func.isRequired, - onDeselect: PropTypes.func, - selectedModel: PropTypes.string, - activeProviders: PropTypes.arrayOf( - PropTypes.shape({ - provider: PropTypes.string.isRequired, - }) - ), - title: PropTypes.string, - modelAliases: PropTypes.object, - kindFilter: PropTypes.string, - addedModelValues: PropTypes.arrayOf(PropTypes.string), - closeOnSelect: PropTypes.bool, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSelect: PropTypes.func.isRequired, + onDeselect: PropTypes.func, + selectedModel: PropTypes.string, + activeProviders: PropTypes.arrayOf( + PropTypes.shape({ + provider: PropTypes.string.isRequired, + }), + ), + title: PropTypes.string, + modelAliases: PropTypes.object, + kindFilter: PropTypes.string, + addedModelValues: PropTypes.arrayOf(PropTypes.string), + closeOnSelect: PropTypes.bool, }; diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index ecab40b1..ff5286a3 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -6,528 +6,755 @@ import { FREE_PROVIDERS, AI_PROVIDERS } from "@/shared/constants/providers"; // Keep providers without serviceKinds (default LLM) or with "llm" in serviceKinds function isLLMProvider(id) { - const p = AI_PROVIDERS[id]; - if (!p?.serviceKinds) return true; - return p.serviceKinds.includes("llm"); + const p = AI_PROVIDERS[id]; + if (!p?.serviceKinds) return true; + return p.serviceKinds.includes("llm"); } import Badge from "./Badge"; import Card from "./Card"; import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards"; -import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/components/UsageTable"; +import UsageTable, { + fmt, + fmtTime, +} from "@/app/(dashboard)/dashboard/usage/components/UsageTable"; import dynamic from "next/dynamic"; // Lazy-load: keeps @xyflow/react out of the shared bundle until topology renders -const ProviderTopology = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/ProviderTopology"), { ssr: false }); +const ProviderTopology = dynamic( + () => import("@/app/(dashboard)/dashboard/usage/components/ProviderTopology"), + { ssr: false }, +); import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart"; function timeAgo(timestamp) { - const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000); - if (diff < 60) return `${diff}s ago`; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - return `${Math.floor(diff / 86400)}d ago`; + const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; } // Auto-update time display every second without re-rendering parent function TimeAgo({ timestamp }) { - const [, setTick] = useState(0); - - useEffect(() => { - const timer = setInterval(() => setTick(t => t + 1), 1000); - return () => clearInterval(timer); - }, []); - - return <>{timeAgo(timestamp)}; + const [, setTick] = useState(0); + + useEffect(() => { + const timer = setInterval(() => setTick((t) => t + 1), 1000); + return () => clearInterval(timer); + }, []); + + return <>{timeAgo(timestamp)}; } function RecentRequests({ requests = [] }) { - return ( - - {/* Header */} -
- Recent Requests -
+ return ( + + {/* Header */} +
+ + Recent Requests + +
- {!requests.length ? ( -
No requests yet.
- ) : ( -
- - - - - - - - - - - {requests.map((r, i) => { - const ok = !r.status || r.status === "ok" || r.status === "success"; - return ( - - - - - - - ); - })} - -
ModelIn / OutWhen
- - {r.model} - {fmt(r.promptTokens)}↑ - {" "} - {fmt(r.completionTokens)}↓ -
-
- )} -
- ); + {!requests.length ? ( +
+ No requests yet. +
+ ) : ( +
+ + + + + + + + + + + + {requests.map((r, i) => { + const ok = + !r.status || r.status === "ok" || r.status === "success"; + return ( + + + + + + + + ); + })} + +
+ Model + + Provider + + In / Out + + When +
+ + + {r.model} + + {r.provider || "—"} + + + {fmt(r.promptTokens)}↑ + {" "} + + {fmt(r.completionTokens)}↓ + + + +
+
+ )} +
+ ); } function sortData(dataMap, pendingMap = {}, sortBy, sortOrder) { - return Object.entries(dataMap || {}) - .map(([key, data]) => { - const totalTokens = (data.promptTokens || 0) + (data.completionTokens || 0); - const totalCost = data.cost || 0; - // ponytail: cost split is a token-share allocation of the (rate-accurate) - // server total, not a per-rate recompute. cached is a subset of prompt, so - // peel it out of the input share. Upgrade to a stored per-component cost - // breakdown if exact cached-rate cost display is needed. - const cachedTokens = data.cachedTokens || 0; - const nonCachedInput = Math.max(0, (data.promptTokens || 0) - cachedTokens); - const inputCost = totalTokens > 0 ? nonCachedInput * (totalCost / totalTokens) : 0; - const cachedCost = totalTokens > 0 ? cachedTokens * (totalCost / totalTokens) : 0; - const outputCost = totalTokens > 0 ? (data.completionTokens || 0) * (totalCost / totalTokens) : 0; - return { ...data, key, totalTokens, totalCost, inputCost, cachedCost, outputCost, pending: pendingMap[key] || 0 }; - }) - .sort((a, b) => { - let valA = a[sortBy]; - let valB = b[sortBy]; - if (typeof valA === "string") valA = valA.toLowerCase(); - if (typeof valB === "string") valB = valB.toLowerCase(); - if (valA < valB) return sortOrder === "asc" ? -1 : 1; - if (valA > valB) return sortOrder === "asc" ? 1 : -1; - return 0; - }); + return Object.entries(dataMap || {}) + .map(([key, data]) => { + const totalTokens = + (data.promptTokens || 0) + (data.completionTokens || 0); + const totalCost = data.cost || 0; + // ponytail: cost split is a token-share allocation of the (rate-accurate) + // server total, not a per-rate recompute. cached is a subset of prompt, so + // peel it out of the input share. Upgrade to a stored per-component cost + // breakdown if exact cached-rate cost display is needed. + const cachedTokens = data.cachedTokens || 0; + const nonCachedInput = Math.max( + 0, + (data.promptTokens || 0) - cachedTokens, + ); + const inputCost = + totalTokens > 0 ? nonCachedInput * (totalCost / totalTokens) : 0; + const cachedCost = + totalTokens > 0 ? cachedTokens * (totalCost / totalTokens) : 0; + const outputCost = + totalTokens > 0 + ? (data.completionTokens || 0) * (totalCost / totalTokens) + : 0; + return { + ...data, + key, + totalTokens, + totalCost, + inputCost, + cachedCost, + outputCost, + pending: pendingMap[key] || 0, + }; + }) + .sort((a, b) => { + let valA = a[sortBy]; + let valB = b[sortBy]; + if (typeof valA === "string") valA = valA.toLowerCase(); + if (typeof valB === "string") valB = valB.toLowerCase(); + if (valA < valB) return sortOrder === "asc" ? -1 : 1; + if (valA > valB) return sortOrder === "asc" ? 1 : -1; + return 0; + }); } function getGroupKey(item, keyField) { - switch (keyField) { - case "rawModel": return item.rawModel || "Unknown Model"; - case "accountName": return item.accountName || `Account ${item.connectionId?.slice(0, 8)}...` || "Unknown Account"; - case "keyName": return item.keyName || "Unknown Key"; - case "endpoint": return item.endpoint || "Unknown Endpoint"; - default: return item[keyField] || "Unknown"; - } + switch (keyField) { + case "provider": + return item.provider || item.key || "Unknown Provider"; + case "rawModel": + return item.rawModel || "Unknown Model"; + case "accountName": + return ( + item.accountName || + `Account ${item.connectionId?.slice(0, 8)}...` || + "Unknown Account" + ); + case "keyName": + return item.keyName || "Unknown Key"; + case "endpoint": + return item.endpoint || "Unknown Endpoint"; + default: + return item[keyField] || "Unknown"; + } } function groupDataByKey(data, keyField) { - if (!Array.isArray(data)) return []; - const groups = {}; - data.forEach((item) => { - const gk = getGroupKey(item, keyField); - if (!groups[gk]) { - groups[gk] = { - groupKey: gk, - summary: { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, totalTokens: 0, cost: 0, inputCost: 0, cachedCost: 0, outputCost: 0, lastUsed: null, pending: 0 }, - items: [], - }; - } - const s = groups[gk].summary; - s.requests += item.requests || 0; - s.promptTokens += item.promptTokens || 0; - s.completionTokens += item.completionTokens || 0; - s.cachedTokens += item.cachedTokens || 0; - s.totalTokens += item.totalTokens || 0; - s.cost += item.cost || 0; - s.inputCost += item.inputCost || 0; - s.cachedCost += item.cachedCost || 0; - s.outputCost += item.outputCost || 0; - s.pending += item.pending || 0; - if (item.lastUsed && (!s.lastUsed || new Date(item.lastUsed) > new Date(s.lastUsed))) { - s.lastUsed = item.lastUsed; - } - groups[gk].items.push(item); - }); - return Object.values(groups); + if (!Array.isArray(data)) return []; + const groups = {}; + data.forEach((item) => { + const gk = getGroupKey(item, keyField); + if (!groups[gk]) { + groups[gk] = { + groupKey: gk, + summary: { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: 0, + totalTokens: 0, + cost: 0, + inputCost: 0, + cachedCost: 0, + outputCost: 0, + lastUsed: null, + pending: 0, + }, + items: [], + }; + } + const s = groups[gk].summary; + s.requests += item.requests || 0; + s.promptTokens += item.promptTokens || 0; + s.completionTokens += item.completionTokens || 0; + s.cachedTokens += item.cachedTokens || 0; + s.totalTokens += item.totalTokens || 0; + s.cost += item.cost || 0; + s.inputCost += item.inputCost || 0; + s.cachedCost += item.cachedCost || 0; + s.outputCost += item.outputCost || 0; + s.pending += item.pending || 0; + if ( + item.lastUsed && + (!s.lastUsed || new Date(item.lastUsed) > new Date(s.lastUsed)) + ) { + s.lastUsed = item.lastUsed; + } + groups[gk].items.push(item); + }); + return Object.values(groups); } const MODEL_COLUMNS = [ - { field: "rawModel", label: "Model" }, - { field: "provider", label: "Provider" }, - { field: "requests", label: "Requests", align: "right" }, - { field: "lastUsed", label: "Last Used", align: "right" }, + { field: "rawModel", label: "Model" }, + { field: "provider", label: "Provider" }, + { field: "requests", label: "Requests", align: "right" }, + { field: "lastUsed", label: "Last Used", align: "right" }, ]; const ACCOUNT_COLUMNS = [ - { field: "rawModel", label: "Model" }, - { field: "provider", label: "Provider" }, - { field: "accountName", label: "Account" }, - { field: "requests", label: "Requests", align: "right" }, - { field: "lastUsed", label: "Last Used", align: "right" }, + { field: "rawModel", label: "Model" }, + { field: "provider", label: "Provider" }, + { field: "accountName", label: "Account" }, + { field: "requests", label: "Requests", align: "right" }, + { field: "lastUsed", label: "Last Used", align: "right" }, ]; const API_KEY_COLUMNS = [ - { field: "keyName", label: "API Key Name" }, - { field: "rawModel", label: "Model" }, - { field: "provider", label: "Provider" }, - { field: "requests", label: "Requests", align: "right" }, - { field: "lastUsed", label: "Last Used", align: "right" }, + { field: "keyName", label: "API Key Name" }, + { field: "rawModel", label: "Model" }, + { field: "provider", label: "Provider" }, + { field: "requests", label: "Requests", align: "right" }, + { field: "lastUsed", label: "Last Used", align: "right" }, ]; const ENDPOINT_COLUMNS = [ - { field: "endpoint", label: "Endpoint" }, - { field: "rawModel", label: "Model" }, - { field: "provider", label: "Provider" }, - { field: "requests", label: "Requests", align: "right" }, - { field: "lastUsed", label: "Last Used", align: "right" }, + { field: "endpoint", label: "Endpoint" }, + { field: "rawModel", label: "Model" }, + { field: "provider", label: "Provider" }, + { field: "requests", label: "Requests", align: "right" }, + { field: "lastUsed", label: "Last Used", align: "right" }, +]; + +const PROVIDER_COLUMNS = [ + { field: "provider", label: "Provider" }, + { field: "requests", label: "Requests", align: "right" }, + { field: "lastUsed", label: "Last Used", align: "right" }, ]; const TABLE_OPTIONS = [ - { value: "model", label: "Usage by Model" }, - { value: "account", label: "Usage by Account" }, - { value: "apiKey", label: "Usage by API Key" }, - { value: "endpoint", label: "Usage by Endpoint" }, + { value: "model", label: "Usage by Model" }, + { value: "account", label: "Usage by Account" }, + { value: "apiKey", label: "Usage by API Key" }, + { value: "endpoint", label: "Usage by Endpoint" }, + { value: "provider", label: "Usage by Provider" }, ]; const PERIODS = [ - { value: "today", label: "Today" }, - { value: "24h", label: "24h" }, - { value: "7d", label: "7D" }, - { value: "30d", label: "30D" }, - { value: "60d", label: "60D" }, + { value: "today", label: "Today" }, + { value: "24h", label: "24h" }, + { value: "7d", label: "7D" }, + { value: "30d", label: "30D" }, + { value: "60d", label: "60D" }, ]; -export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false } = {}) { - const router = useRouter(); - const searchParams = useSearchParams(); +export default function UsageStats({ + period: periodProp, + setPeriod: setPeriodProp, + hidePeriodSelector = false, +} = {}) { + const router = useRouter(); + const searchParams = useSearchParams(); - const sortBy = searchParams.get("sortBy") || "rawModel"; - const sortOrder = searchParams.get("sortOrder") || "asc"; + const sortBy = searchParams.get("sortBy") || "rawModel"; + const sortOrder = searchParams.get("sortOrder") || "asc"; - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [fetching, setFetching] = useState(false); - const [tableView, setTableView] = useState("model"); - const [viewMode, setViewMode] = useState("costs"); - const [providers, setProviders] = useState([]); - const [periodLocal, setPeriodLocal] = useState("today"); - const isInitialLoad = useRef(true); - const hasLoadedStats = useRef(false); - const period = periodProp ?? periodLocal; - const setPeriod = setPeriodProp ?? setPeriodLocal; + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [fetching, setFetching] = useState(false); + const [tableView, setTableView] = useState("provider"); + const [viewMode, setViewMode] = useState("costs"); + const [providers, setProviders] = useState([]); + const [periodLocal, setPeriodLocal] = useState("today"); + const isInitialLoad = useRef(true); + const hasLoadedStats = useRef(false); + const period = periodProp ?? periodLocal; + const setPeriod = setPeriodProp ?? setPeriodLocal; - // Fetch connected providers once, deduplicate by provider type - // Always include noAuth free providers (e.g. opencode) regardless of connections - useEffect(() => { - Promise.all([ - fetch("/api/providers").then((r) => r.ok ? r.json() : null), - fetch("/api/provider-nodes").then((r) => r.ok ? r.json() : null), - ]) - .then(([d, nodesData]) => { - // Build node name lookup for custom providers - const nodeNameMap = {}; - for (const node of (nodesData?.nodes || [])) { - nodeNameMap[node.id] = node.name; - } - const seen = new Set(); - const unique = (d?.connections || []).filter((c) => { - if (c.isActive === false) return false; - if (!isLLMProvider(c.provider)) return false; - if (seen.has(c.provider)) return false; - seen.add(c.provider); - return true; - }).map((c) => ({ - ...c, - nodeName: nodeNameMap[c.provider] || null, - })); - const noAuthProviders = Object.values(FREE_PROVIDERS) - .filter((p) => p.noAuth && !seen.has(p.id) && isLLMProvider(p.id)) - .map((p) => ({ provider: p.id, name: p.name })); - setProviders([...unique, ...noAuthProviders]); - }) - .catch(() => {}); - }, []); + // Fetch connected providers once, deduplicate by provider type + // Always include noAuth free providers (e.g. opencode) regardless of connections + useEffect(() => { + Promise.all([ + fetch("/api/providers").then((r) => (r.ok ? r.json() : null)), + fetch("/api/provider-nodes").then((r) => (r.ok ? r.json() : null)), + ]) + .then(([d, nodesData]) => { + // Build node name lookup for custom providers + const nodeNameMap = {}; + for (const node of nodesData?.nodes || []) { + nodeNameMap[node.id] = node.name; + } + const seen = new Set(); + const unique = (d?.connections || []) + .filter((c) => { + if (c.isActive === false) return false; + if (!isLLMProvider(c.provider)) return false; + if (seen.has(c.provider)) return false; + seen.add(c.provider); + return true; + }) + .map((c) => ({ + ...c, + nodeName: nodeNameMap[c.provider] || null, + })); + const noAuthProviders = Object.values(FREE_PROVIDERS) + .filter((p) => p.noAuth && !seen.has(p.id) && isLLMProvider(p.id)) + .map((p) => ({ provider: p.id, name: p.name })); + setProviders([...unique, ...noAuthProviders]); + }) + .catch(() => {}); + }, []); - // Fetch filtered stats via REST when period changes - useEffect(() => { - // First load: show full spinner; subsequent: show subtle fetching indicator - if (isInitialLoad.current) { - isInitialLoad.current = false; - setLoading(true); - } else { - setFetching(true); - } + // Fetch filtered stats via REST when period changes + useEffect(() => { + // First load: show full spinner; subsequent: show subtle fetching indicator + if (isInitialLoad.current) { + isInitialLoad.current = false; + setLoading(true); + } else { + setFetching(true); + } - fetch(`/api/usage/stats?period=${period}`) - .then((r) => r.ok ? r.json() : null) - .then((data) => { - if (data) { - hasLoadedStats.current = true; - setStats((prev) => ({ ...prev, ...data })); - } - }) - .catch(() => {}) - .finally(() => { - setLoading(false); - setFetching(false); - }); - }, [period]); + fetch(`/api/usage/stats?period=${period}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data) { + hasLoadedStats.current = true; + setStats((prev) => ({ ...prev, ...data })); + } + }) + .catch(() => {}) + .finally(() => { + setLoading(false); + setFetching(false); + }); + }, [period]); - // SSE connection - real-time updates for activeRequests + recentRequests only - useEffect(() => { - const es = new EventSource("/api/usage/stream"); + // SSE connection - real-time updates for activeRequests + recentRequests only + useEffect(() => { + const es = new EventSource("/api/usage/stream"); - es.onmessage = (e) => { - try { - const data = JSON.parse(e.data); - // Always merge only real-time fields, never overwrite full stats from REST - setStats((prev) => { - if (!prev) return prev; - return { - ...prev, - activeRequests: data.activeRequests, - recentRequests: data.recentRequests, - errorProvider: data.errorProvider, - pending: data.pending, - }; - }); - if (hasLoadedStats.current) setLoading(false); - } catch (err) { - console.error("[SSE CLIENT] parse error:", err); - } - }; + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + // Always merge only real-time fields, never overwrite full stats from REST + setStats((prev) => { + if (!prev) return prev; + return { + ...prev, + activeRequests: data.activeRequests, + recentRequests: data.recentRequests, + errorProvider: data.errorProvider, + pending: data.pending, + }; + }); + if (hasLoadedStats.current) setLoading(false); + } catch (err) { + console.error("[SSE CLIENT] parse error:", err); + } + }; - es.onerror = () => setLoading(false); + es.onerror = () => setLoading(false); - return () => es.close(); - }, []); + return () => es.close(); + }, []); - const toggleSort = useCallback((tableType, field) => { - const params = new URLSearchParams(searchParams.toString()); - if (params.get("sortBy") === field) { - params.set("sortOrder", params.get("sortOrder") === "asc" ? "desc" : "asc"); - } else { - params.set("sortBy", field); - params.set("sortOrder", "asc"); - } - router.replace(`?${params.toString()}`, { scroll: false }); - }, [searchParams, router]); + const toggleSort = useCallback( + (tableType, field) => { + const params = new URLSearchParams(searchParams.toString()); + if (params.get("sortBy") === field) { + params.set( + "sortOrder", + params.get("sortOrder") === "asc" ? "desc" : "asc", + ); + } else { + params.set("sortBy", field); + params.set("sortOrder", "asc"); + } + router.replace(`?${params.toString()}`, { scroll: false }); + }, + [searchParams, router], + ); - // Compute active table data - const activeTableConfig = useMemo(() => { - if (!stats) return null; - switch (tableView) { - case "model": { - const pendingMap = stats.pending?.byModel || {}; - return { - columns: MODEL_COLUMNS, - groupedData: groupDataByKey(sortData(stats.byModel, pendingMap, sortBy, sortOrder), "rawModel"), - storageKey: "usage-stats:expanded-models", - emptyMessage: "No usage recorded yet.", - renderSummaryCells: (group) => ( - <> - — - {fmt(group.summary.requests)} - {fmtTime(group.summary.lastUsed)} - - ), - renderDetailCells: (item) => ( - <> - 0 ? "text-primary" : ""}`}>{item.rawModel} - 0 ? "primary" : "neutral"} size="sm">{item.provider} - {fmt(item.requests)} - {fmtTime(item.lastUsed)} - - ), - }; - } - case "account": { - const pendingMap = {}; - if (stats?.pending?.byAccount) { - Object.entries(stats.byAccount || {}).forEach(([accountKey, data]) => { - const connPending = stats.pending.byAccount[data.connectionId]; - if (connPending) { - const modelKey = data.provider ? `${data.rawModel} (${data.provider})` : data.rawModel; - pendingMap[accountKey] = connPending[modelKey] || 0; - } - }); - } - return { - columns: ACCOUNT_COLUMNS, - groupedData: groupDataByKey(sortData(stats.byAccount, pendingMap, sortBy, sortOrder), "accountName"), - storageKey: "usage-stats:expanded-accounts", - emptyMessage: "No account-specific usage recorded yet.", - renderSummaryCells: (group) => ( - <> - — - — - {fmt(group.summary.requests)} - {fmtTime(group.summary.lastUsed)} - - ), - renderDetailCells: (item) => ( - <> - 0 ? "text-primary" : ""}`}>{item.accountName || `Account ${item.connectionId?.slice(0, 8)}...`} - 0 ? "text-primary" : ""}`}>{item.rawModel} - 0 ? "primary" : "neutral"} size="sm">{item.provider} - {fmt(item.requests)} - {fmtTime(item.lastUsed)} - - ), - }; - } - case "apiKey": { - return { - columns: API_KEY_COLUMNS, - groupedData: groupDataByKey(sortData(stats.byApiKey, {}, sortBy, sortOrder), "keyName"), - storageKey: "usage-stats:expanded-apikeys", - emptyMessage: "No API key usage recorded yet.", - renderSummaryCells: (group) => ( - <> - — - — - {fmt(group.summary.requests)} - {fmtTime(group.summary.lastUsed)} - - ), - renderDetailCells: (item) => ( - <> - {item.keyName} - {item.rawModel} - {item.provider} - {fmt(item.requests)} - {fmtTime(item.lastUsed)} - - ), - }; - } - case "endpoint": - default: { - return { - columns: ENDPOINT_COLUMNS, - groupedData: groupDataByKey(sortData(stats.byEndpoint, {}, sortBy, sortOrder), "endpoint"), - storageKey: "usage-stats:expanded-endpoints", - emptyMessage: "No endpoint usage recorded yet.", - renderSummaryCells: (group) => ( - <> - — - — - {fmt(group.summary.requests)} - {fmtTime(group.summary.lastUsed)} - - ), - renderDetailCells: (item) => ( - <> - {item.endpoint} - {item.rawModel} - {item.provider} - {fmt(item.requests)} - {fmtTime(item.lastUsed)} - - ), - }; - } - } - }, [stats, tableView, sortBy, sortOrder]); + // Compute active table data + const activeTableConfig = useMemo(() => { + if (!stats) return null; + switch (tableView) { + case "model": { + const pendingMap = stats.pending?.byModel || {}; + return { + columns: MODEL_COLUMNS, + groupedData: groupDataByKey( + sortData(stats.byModel, pendingMap, sortBy, sortOrder), + "rawModel", + ), + storageKey: "usage-stats:expanded-models", + emptyMessage: "No usage recorded yet.", + renderSummaryCells: (group) => ( + <> + — + + {fmt(group.summary.requests)} + + + {fmtTime(group.summary.lastUsed)} + + + ), + renderDetailCells: (item) => ( + <> + 0 ? "text-primary" : ""}`} + > + {item.rawModel} + + + 0 ? "primary" : "neutral"} + size="sm" + > + {item.provider} + + + {fmt(item.requests)} + + {fmtTime(item.lastUsed)} + + + ), + }; + } + case "account": { + const pendingMap = {}; + if (stats?.pending?.byAccount) { + Object.entries(stats.byAccount || {}).forEach( + ([accountKey, data]) => { + const connPending = stats.pending.byAccount[data.connectionId]; + if (connPending) { + const modelKey = data.provider + ? `${data.rawModel} (${data.provider})` + : data.rawModel; + pendingMap[accountKey] = connPending[modelKey] || 0; + } + }, + ); + } + return { + columns: ACCOUNT_COLUMNS, + groupedData: groupDataByKey( + sortData(stats.byAccount, pendingMap, sortBy, sortOrder), + "accountName", + ), + storageKey: "usage-stats:expanded-accounts", + emptyMessage: "No account-specific usage recorded yet.", + renderSummaryCells: (group) => ( + <> + — + — + + {fmt(group.summary.requests)} + + + {fmtTime(group.summary.lastUsed)} + + + ), + renderDetailCells: (item) => ( + <> + 0 ? "text-primary" : ""}`} + > + {item.accountName || + `Account ${item.connectionId?.slice(0, 8)}...`} + + 0 ? "text-primary" : ""}`} + > + {item.rawModel} + + + 0 ? "primary" : "neutral"} + size="sm" + > + {item.provider} + + + {fmt(item.requests)} + + {fmtTime(item.lastUsed)} + + + ), + }; + } + case "apiKey": { + return { + columns: API_KEY_COLUMNS, + groupedData: groupDataByKey( + sortData(stats.byApiKey, {}, sortBy, sortOrder), + "keyName", + ), + storageKey: "usage-stats:expanded-apikeys", + emptyMessage: "No API key usage recorded yet.", + renderSummaryCells: (group) => ( + <> + — + — + + {fmt(group.summary.requests)} + + + {fmtTime(group.summary.lastUsed)} + + + ), + renderDetailCells: (item) => ( + <> + {item.keyName} + {item.rawModel} + + + {item.provider} + + + {fmt(item.requests)} + + {fmtTime(item.lastUsed)} + + + ), + }; + } + case "provider": { + return { + columns: PROVIDER_COLUMNS, + groupedData: groupDataByKey( + sortData(stats.byProvider, {}, sortBy, sortOrder), + "provider", + ), + storageKey: "usage-stats:expanded-providers", + emptyMessage: "No usage recorded yet.", + renderSummaryCells: (group) => ( + <> + + {fmt(group.summary.requests)} + + + {fmtTime(group.summary.lastUsed)} + + + ), + renderDetailCells: (item) => ( + <> + + {item.provider || item.key} + + {fmt(item.requests)} + + {fmtTime(item.lastUsed)} + + + ), + }; + } + case "endpoint": + default: { + return { + columns: ENDPOINT_COLUMNS, + groupedData: groupDataByKey( + sortData(stats.byEndpoint, {}, sortBy, sortOrder), + "endpoint", + ), + storageKey: "usage-stats:expanded-endpoints", + emptyMessage: "No endpoint usage recorded yet.", + renderSummaryCells: (group) => ( + <> + — + — + + {fmt(group.summary.requests)} + + + {fmtTime(group.summary.lastUsed)} + + + ), + renderDetailCells: (item) => ( + <> + + {item.endpoint} + + {item.rawModel} + + + {item.provider} + + + {fmt(item.requests)} + + {fmtTime(item.lastUsed)} + + + ), + }; + } + } + }, [stats, tableView, sortBy, sortOrder]); - if (!stats && !loading) return
Failed to load usage statistics.
; + if (!stats && !loading) + return ( +
Failed to load usage statistics.
+ ); - const spinner = ( -
- progress_activity -
- ); + const spinner = ( +
+ + progress_activity + +
+ ); - return ( -
- {/* Period selector (hidden when controlled by parent) */} - {!hidePeriodSelector && ( -
-
- {PERIODS.map((p) => ( - - ))} -
- {fetching && ( - progress_activity - )} -
- )} + return ( +
+ {/* Period selector (hidden when controlled by parent) */} + {!hidePeriodSelector && ( +
+
+ {PERIODS.map((p) => ( + + ))} +
+ {fetching && ( + + progress_activity + + )} +
+ )} - {/* Overview cards */} - {loading ? spinner : } + {/* Overview cards */} + {loading ? spinner : } - {/* Provider topology + Recent Requests */} - {loading ? spinner : ( -
- - -
- )} + {/* Provider topology + Recent Requests */} + {loading ? ( + spinner + ) : ( +
+ + +
+ )} - {/* Token / Cost chart - sync period */} - {loading ? spinner : } + {/* Token / Cost chart - sync period */} + {loading ? spinner : } - {/* Table with dropdown selector */} -
-
- -
- - -
-
- {loading ? spinner : activeTableConfig && ( - - )} -
-
- ); + {/* Table with dropdown selector */} +
+
+ +
+ + +
+
+ {loading + ? spinner + : activeTableConfig && ( + + )} +
+
+ ); } diff --git a/src/sse/services/model.js b/src/sse/services/model.js index ba4cc6c2..80207903 100644 --- a/src/sse/services/model.js +++ b/src/sse/services/model.js @@ -1,81 +1,103 @@ // Re-export from open-sse with localDb integration -import { getModelAliases, getComboByName, getProviderNodes } from "@/lib/localDb"; -import { parseModel as parseModelCore, resolveModelAliasFromMap, getModelInfoCore } from "open-sse/services/model.js"; +import { + getModelAliases, + getComboByName, + getProviderNodes, +} from "@/lib/localDb"; +import { + parseModel as parseModelCore, + resolveModelAliasFromMap, + getModelInfoCore, +} from "open-sse/services/model.js"; import REGISTRY from "open-sse/providers/registry/index.js"; // Local provider alias overrides (HMR-friendly, applied on top of open-sse map) const LOCAL_PROVIDER_ALIASES = { - xmtp: "xiaomi-tokenplan", - "xiaomi-tokenplan": "xiaomi-tokenplan", + xmtp: "xiaomi-tokenplan", + "xiaomi-tokenplan": "xiaomi-tokenplan", }; const RESERVED_PROVIDER_PREFIXES = new Set(Object.keys(LOCAL_PROVIDER_ALIASES)); for (const entry of REGISTRY) { - RESERVED_PROVIDER_PREFIXES.add(entry.id); - if (entry.alias) RESERVED_PROVIDER_PREFIXES.add(entry.alias); - for (const alias of entry.aliases || []) RESERVED_PROVIDER_PREFIXES.add(alias); + RESERVED_PROVIDER_PREFIXES.add(entry.id); + if (entry.alias) RESERVED_PROVIDER_PREFIXES.add(entry.alias); + for (const alias of entry.aliases || []) + RESERVED_PROVIDER_PREFIXES.add(alias); } export function parseModel(modelStr) { - const parsed = parseModelCore(modelStr); - if (parsed?.providerAlias && LOCAL_PROVIDER_ALIASES[parsed.providerAlias]) { - return { ...parsed, provider: LOCAL_PROVIDER_ALIASES[parsed.providerAlias] }; - } - return parsed; + const parsed = parseModelCore(modelStr); + if (parsed?.providerAlias && LOCAL_PROVIDER_ALIASES[parsed.providerAlias]) { + return { + ...parsed, + provider: LOCAL_PROVIDER_ALIASES[parsed.providerAlias], + }; + } + return parsed; } /** * Resolve model alias from localDb */ export async function resolveModelAlias(alias) { - const aliases = await getModelAliases(); - return resolveModelAliasFromMap(alias, aliases); + const aliases = await getModelAliases(); + return resolveModelAliasFromMap(alias, aliases); } /** * Get full model info (parse or resolve) */ export async function getModelInfo(modelStr) { - const parsed = parseModel(modelStr); + const parsed = parseModel(modelStr); - if (!parsed.isAlias) { - // Provider-node prefixes are user-defined. They must not override built-in - // provider ids/aliases such as `cf`, `cloudflare-ai`, `openai`, or `hf`. - if (!RESERVED_PROVIDER_PREFIXES.has(parsed.providerAlias)) { - const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); - const matchedOpenAI = openaiNodes.find((node) => node.prefix === parsed.providerAlias); - if (matchedOpenAI) { - return { provider: matchedOpenAI.id, model: parsed.model }; - } + if (!parsed.isAlias) { + // Provider-node prefixes are user-defined. They must not override built-in + // provider ids/aliases such as `cf`, `cloudflare-ai`, `openai`, or `hf`. + if (!RESERVED_PROVIDER_PREFIXES.has(parsed.providerAlias)) { + const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); + const matchedOpenAI = openaiNodes.find( + (node) => node.prefix === parsed.providerAlias, + ); + if (matchedOpenAI) { + return { provider: matchedOpenAI.id, model: parsed.model }; + } - const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" }); - const matchedAnthropic = anthropicNodes.find((node) => node.prefix === parsed.providerAlias); - if (matchedAnthropic) { - return { provider: matchedAnthropic.id, model: parsed.model }; - } + const anthropicNodes = await getProviderNodes({ + type: "anthropic-compatible", + }); + const matchedAnthropic = anthropicNodes.find( + (node) => node.prefix === parsed.providerAlias, + ); + if (matchedAnthropic) { + return { provider: matchedAnthropic.id, model: parsed.model }; + } - const embeddingNodes = await getProviderNodes({ type: "custom-embedding" }); - const matchedEmbedding = embeddingNodes.find((node) => node.prefix === parsed.providerAlias); - if (matchedEmbedding) { - return { provider: matchedEmbedding.id, model: parsed.model }; - } - } - return { - provider: parsed.provider, - model: parsed.model - }; - } + const embeddingNodes = await getProviderNodes({ + type: "custom-embedding", + }); + const matchedEmbedding = embeddingNodes.find( + (node) => node.prefix === parsed.providerAlias, + ); + if (matchedEmbedding) { + return { provider: matchedEmbedding.id, model: parsed.model }; + } + } + return { + provider: parsed.provider, + model: parsed.model, + }; + } - // Check if this is a combo name before resolving as alias - // This prevents combo names from being incorrectly routed to providers - const combo = await getComboByName(parsed.model); - if (combo) { - // Return null provider to signal this should be handled as combo - // The caller (handleChat) will detect this and handle it as combo - return { provider: null, model: parsed.model }; - } + // Check if this is a combo name before resolving as alias + // This prevents combo names from being incorrectly routed to providers + const combo = await getComboByName(parsed.model); + if (combo && combo.enabled !== false) { + // Return null provider to signal this should be handled as combo + // The caller (handleChat) will detect this and handle it as combo + return { provider: null, model: parsed.model }; + } - return getModelInfoCore(modelStr, getModelAliases); + return getModelInfoCore(modelStr, getModelAliases); } /** @@ -83,12 +105,17 @@ export async function getModelInfo(modelStr) { * @returns {Promise} Array of models or null if not a combo */ export async function getComboModels(modelStr) { - // Only check if it's not in provider/model format - if (modelStr.includes("/")) return null; + // Only check if it's not in provider/model format + if (modelStr.includes("/")) return null; - const combo = await getComboByName(modelStr); - if (combo && combo.models && combo.models.length > 0) { - return combo.models; - } - return null; + const combo = await getComboByName(modelStr); + if ( + combo && + combo.enabled !== false && + combo.models && + combo.models.length > 0 + ) { + return combo.models; + } + return null; }