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
This commit is contained in:
2026-07-31 10:37:45 +07:00
parent 0dbae80930
commit 88faba150a
11 changed files with 8689 additions and 6505 deletions

View File

@@ -23,13 +23,23 @@ function flattenToolHistory(messages) {
.filter((msg) => msg) .filter((msg) => msg)
.map((msg) => { .map((msg) => {
if (msg.role === "tool" || msg.role === "function") { if (msg.role === "tool" || msg.role === "function") {
return { role: "assistant", content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]` }; return {
role: "assistant",
content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]`,
};
} }
if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) { if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
const { tool_calls, ...rest } = msg; const { tool_calls, ...rest } = msg;
const names = tool_calls.map((c) => c?.function?.name || c?.name || "tool").join(", "); const names = tool_calls
const base = extractTextContent(rest.content) || (typeof rest.content === "string" ? rest.content : ""); .map((c) => c?.function?.name || c?.name || "tool")
return { ...rest, content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]` }; .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)) { if (Array.isArray(msg.content)) {
const hasToolUse = msg.content.some((c) => c.type === "tool_use"); const hasToolUse = msg.content.some((c) => c.type === "tool_use");
@@ -41,7 +51,11 @@ function flattenToolHistory(messages) {
for (const block of msg.content) { for (const block of msg.content) {
if (block.type === "text" && block.text) textParts.push(block.text); if (block.type === "text" && block.text) textParts.push(block.text);
if (block.type === "tool_use") toolNames.push(block.name || "tool"); if (block.type === "tool_use") toolNames.push(block.name || "tool");
if (block.type === "tool_result") toolResults.push(extractTextContent(block.content) || String(block.content ?? "")); if (block.type === "tool_result")
toolResults.push(
extractTextContent(block.content) ||
String(block.content ?? ""),
);
} }
const { ...rest } = msg; const { ...rest } = msg;
let newContent = textParts.join("\n"); let newContent = textParts.join("\n");
@@ -61,7 +75,13 @@ function flattenToolHistory(messages) {
// Reorder combo models by capability fit. Stable; never drops a model (fallback intact). // 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. // Tier 0: satisfies all hard + all soft. Tier 1: all hard only. Tier 2: rest.
export function reorderByCapabilities(models, required) { export function reorderByCapabilities(models, required) {
if (!required || required.size === 0 || !Array.isArray(models) || models.length <= 1) return models; if (
!required ||
required.size === 0 ||
!Array.isArray(models) ||
models.length <= 1
)
return models;
const hard = [...required].filter((c) => HARD_CAPS.has(c)); const hard = [...required].filter((c) => HARD_CAPS.has(c));
const soft = [...required].filter((c) => !HARD_CAPS.has(c)); const soft = [...required].filter((c) => !HARD_CAPS.has(c));
@@ -109,11 +129,14 @@ export function detectRequiredCapabilities(body) {
const scanBlock = (b) => { const scanBlock = (b) => {
if (!b || typeof b !== "object") return; if (!b || typeof b !== "object") return;
const t = b.type; const t = b.type;
if (t === "image_url" || t === "image" || t === "input_image") required.add("vision"); if (t === "image_url" || t === "image" || t === "input_image")
if (t === "file" || t === "document" || t === "input_file") required.add("pdf"); required.add("vision");
if (t === "file" || t === "document" || t === "input_file")
required.add("pdf");
// gemini parts: inlineData/fileData carry a mime // gemini parts: inlineData/fileData carry a mime
const mime = b.inlineData?.mimeType || b.fileData?.mimeType; const mime = b.inlineData?.mimeType || b.fileData?.mimeType;
if (typeof mime === "string" && mime.startsWith("image/")) required.add("vision"); if (typeof mime === "string" && mime.startsWith("image/"))
required.add("vision");
if (mime === "application/pdf") required.add("pdf"); if (mime === "application/pdf") required.add("pdf");
}; };
@@ -162,9 +185,10 @@ export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) {
const rotationKey = comboName || "__default__"; const rotationKey = comboName || "__default__";
const normalizedStickyLimit = normalizeStickyLimit(stickyLimit); const normalizedStickyLimit = normalizeStickyLimit(stickyLimit);
const existingState = comboRotationState.get(rotationKey); const existingState = comboRotationState.get(rotationKey);
const state = typeof existingState === "number" const state =
typeof existingState === "number"
? { index: existingState, consecutiveUseCount: 0 } ? { index: existingState, consecutiveUseCount: 0 }
: (existingState || { index: 0, consecutiveUseCount: 0 }); : existingState || { index: 0, consecutiveUseCount: 0 };
const currentIndex = state.index % models.length; const currentIndex = state.index % models.length;
const rotatedModels = rotateModelsFromIndex(models, currentIndex); const rotatedModels = rotateModelsFromIndex(models, currentIndex);
@@ -205,10 +229,17 @@ export function getComboModelsFromData(modelStr, combosData) {
if (modelStr.includes("/")) return null; if (modelStr.includes("/")) return null;
// Handle both array and object formats // Handle both array and object formats
const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []); const combos = Array.isArray(combosData)
? combosData
: combosData?.combos || [];
const combo = combos.find(c => c.name === modelStr); const combo = combos.find((c) => c.name === modelStr);
if (combo && combo.models && combo.models.length > 0) { if (
combo &&
combo.enabled !== false &&
combo.models &&
combo.models.length > 0
) {
return combo.models; return combo.models;
} }
return null; return null;
@@ -226,9 +257,23 @@ export function getComboModelsFromData(modelStr, combosData) {
* @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching * @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching
* @returns {Promise<Response>} * @returns {Promise<Response>}
*/ */
export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) { export async function handleComboChat({
body,
models,
handleSingleModel,
log,
comboName,
comboStrategy,
comboStickyLimit = 1,
autoSwitch = true,
}) {
// Apply rotation strategy if enabled // Apply rotation strategy if enabled
let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); let rotatedModels = getRotatedModels(
models,
comboName,
comboStrategy,
comboStickyLimit,
);
// Auto-switch: float models that satisfy the request's required capabilities to the front. // Auto-switch: float models that satisfy the request's required capabilities to the front.
if (autoSwitch) { if (autoSwitch) {
@@ -236,7 +281,10 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co
if (required.size > 0) { if (required.size > 0) {
const reordered = reorderByCapabilities(rotatedModels, required); const reordered = reorderByCapabilities(rotatedModels, required);
if (reordered[0] !== rotatedModels[0]) { if (reordered[0] !== rotatedModels[0]) {
log.info("COMBO", `auto-switch for [${[...required].join(",")}] → ${reordered[0]}`); log.info(
"COMBO",
`auto-switch for [${[...required].join(",")}] → ${reordered[0]}`,
);
} }
rotatedModels = reordered; rotatedModels = reordered;
} }
@@ -248,7 +296,10 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co
for (let i = 0; i < rotatedModels.length; i++) { for (let i = 0; i < rotatedModels.length; i++) {
const modelStr = rotatedModels[i]; const modelStr = rotatedModels[i];
log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`); log.info(
"COMBO",
`Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`,
);
try { try {
const result = await handleSingleModel(body, modelStr); const result = await handleSingleModel(body, modelStr);
@@ -264,48 +315,78 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co
let retryAfter = null; let retryAfter = null;
try { try {
const errorBody = await result.clone().json(); const errorBody = await result.clone().json();
errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; errorText =
errorBody?.error?.message ||
errorBody?.error ||
errorBody?.message ||
errorText;
retryAfter = errorBody?.retryAfter || null; retryAfter = errorBody?.retryAfter || null;
} catch { } catch {
// Ignore JSON parse errors // Ignore JSON parse errors
} }
// Track earliest retryAfter across all combo models // Track earliest retryAfter across all combo models
if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) { if (
retryAfter &&
(!earliestRetryAfter ||
new Date(retryAfter) < new Date(earliestRetryAfter))
) {
earliestRetryAfter = retryAfter; earliestRetryAfter = retryAfter;
} }
// Normalize error text to string (Worker-safe) // Normalize error text to string (Worker-safe)
if (typeof errorText !== "string") { if (typeof errorText !== "string") {
try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); } try {
errorText = JSON.stringify(errorText);
} catch {
errorText = String(errorText);
}
} }
// Check if should fallback to next model // Check if should fallback to next model
const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText); const { shouldFallback, cooldownMs } = checkFallbackError(
result.status,
errorText,
);
if (!shouldFallback) { if (!shouldFallback) {
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status }); log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, {
status: result.status,
});
return result; return result;
} }
// For transient errors (503/502/504), wait for cooldown before falling through // 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 // so a briefly-overloaded provider gets a chance to recover rather than being
// skipped immediately (fixes: combo falls through on transient 503) // skipped immediately (fixes: combo falls through on transient 503)
if (cooldownMs && cooldownMs > 0 && cooldownMs <= 5000 && if (
(result.status === 503 || result.status === 502 || result.status === 504)) { cooldownMs &&
log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`); cooldownMs > 0 &&
await new Promise(r => setTimeout(r, cooldownMs)); 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));
} }
// Fallback to next model // Fallback to next model
lastError = errorText || String(result.status); lastError = errorText || String(result.status);
if (!lastStatus) lastStatus = result.status; if (!lastStatus) lastStatus = result.status;
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); log.warn("COMBO", `Model ${modelStr} failed, trying next`, {
status: result.status,
});
} catch (error) { } catch (error) {
// Catch unexpected exceptions to ensure fallback continues // Catch unexpected exceptions to ensure fallback continues
lastError = error.message || String(error); lastError = error.message || String(error);
if (!lastStatus) lastStatus = 500; if (!lastStatus) lastStatus = 500;
log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError }); log.warn("COMBO", `Model ${modelStr} threw error, trying next`, {
error: lastError,
});
} }
} }
@@ -313,8 +394,9 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co
// Use 503 (Service Unavailable) rather than 406 (Not Acceptable) — 406 implies // Use 503 (Service Unavailable) rather than 406 (Not Acceptable) — 406 implies
// the request itself is invalid, but here the providers are simply unavailable // 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. // or have no active credentials. 503 is more accurate and retryable by clients.
const allDisabled = lastError && lastError.toLowerCase().includes("no credentials"); const allDisabled =
const status = allDisabled ? 503 : (lastStatus || 503); lastError && lastError.toLowerCase().includes("no credentials");
const status = allDisabled ? 503 : lastStatus || 503;
const msg = lastError || "All combo models unavailable"; const msg = lastError || "All combo models unavailable";
if (earliestRetryAfter) { if (earliestRetryAfter) {
@@ -324,10 +406,10 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co
} }
log.warn("COMBO", `All models failed | ${msg}`); log.warn("COMBO", `All models failed | ${msg}`);
return new Response( return new Response(JSON.stringify({ error: { message: msg } }), {
JSON.stringify({ error: { message: msg } }), status,
{ status, headers: { "Content-Type": "application/json" } } headers: { "Content-Type": "application/json" },
); });
} }
/** /**
@@ -345,7 +427,8 @@ function extractPanelText(json) {
const msg = choice.message ?? choice.delta ?? {}; const msg = choice.message ?? choice.delta ?? {};
const t = extractTextContent(msg.content); const t = extractTextContent(msg.content);
if (t.trim()) return t; if (t.trim()) return t;
if (typeof choice.text === "string" && choice.text.trim()) return choice.text; if (typeof choice.text === "string" && choice.text.trim())
return choice.text;
} }
// Claude messages (text blocks share OpenAI's {type:"text"} shape) // Claude messages (text blocks share OpenAI's {type:"text"} shape)
@@ -362,7 +445,9 @@ function extractPanelText(json) {
// OpenAI Responses API // OpenAI Responses API
if (Array.isArray(json.output)) { if (Array.isArray(json.output)) {
const t = json.output const t = json.output
.flatMap((o) => (Array.isArray(o.content) ? o.content.map((c) => c?.text || "") : [])) .flatMap((o) =>
Array.isArray(o.content) ? o.content.map((c) => c?.text || "") : [],
)
.join(""); .join("");
if (t.trim()) return t; if (t.trim()) return t;
} }
@@ -429,8 +514,14 @@ function withTimeout(promise, ms) {
return new Promise((resolve) => { return new Promise((resolve) => {
const t = setTimeout(() => resolve({ __timeout: true }), ms); const t = setTimeout(() => resolve({ __timeout: true }), ms);
Promise.resolve(promise) Promise.resolve(promise)
.then((v) => { clearTimeout(t); resolve(v); }) .then((v) => {
.catch((e) => { clearTimeout(t); resolve({ __error: e }); }); clearTimeout(t);
resolve(v);
})
.catch((e) => {
clearTimeout(t);
resolve({ __error: e });
});
}); });
} }
@@ -441,7 +532,10 @@ function withTimeout(promise, ms) {
* still preferring a full panel when everyone is fast. Bounded by a hard timeout. * 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). * Returns a sparse array aligned to `calls` (undefined = not yet / dropped).
*/ */
function collectPanel(calls, { minPanel, stragglerGraceMs, panelHardTimeoutMs }) { function collectPanel(
calls,
{ minPanel, stragglerGraceMs, panelHardTimeoutMs },
) {
return new Promise((resolve) => { return new Promise((resolve) => {
const out = new Array(calls.length); const out = new Array(calls.length);
let settled = 0; let settled = 0;
@@ -458,13 +552,18 @@ function collectPanel(calls, { minPanel, stragglerGraceMs, panelHardTimeoutMs })
const hardTimer = setTimeout(finish, panelHardTimeoutMs); const hardTimer = setTimeout(finish, panelHardTimeoutMs);
calls.forEach((p, i) => { calls.forEach((p, i) => {
Promise.resolve(p) Promise.resolve(p)
.then((v) => { out[i] = v; }) .then((v) => {
.catch((e) => { out[i] = { __error: e }; }) out[i] = v;
})
.catch((e) => {
out[i] = { __error: e };
})
.finally(() => { .finally(() => {
settled++; settled++;
if (out[i] && out[i].ok) ok++; if (out[i] && out[i].ok) ok++;
if (settled === calls.length) return finish(); if (settled === calls.length) return finish();
if (ok >= minPanel && !graceTimer) graceTimer = setTimeout(finish, stragglerGraceMs); if (ok >= minPanel && !graceTimer)
graceTimer = setTimeout(finish, stragglerGraceMs);
}); });
}); });
}); });
@@ -493,12 +592,20 @@ function collectPanel(calls, { minPanel, stragglerGraceMs, panelHardTimeoutMs })
* @param {Object} [options.tuning] - Override FUSION_DEFAULTS (minPanel, grace, timeout) * @param {Object} [options.tuning] - Override FUSION_DEFAULTS (minPanel, grace, timeout)
* @returns {Promise<Response>} * @returns {Promise<Response>}
*/ */
export async function handleFusionChat({ body, models, handleSingleModel, log, comboName, judgeModel, tuning }) { export async function handleFusionChat({
body,
models,
handleSingleModel,
log,
comboName,
judgeModel,
tuning,
}) {
const panel = Array.isArray(models) ? models.filter(Boolean) : []; const panel = Array.isArray(models) ? models.filter(Boolean) : [];
if (panel.length === 0) { if (panel.length === 0) {
return new Response( return new Response(
JSON.stringify({ error: { message: "Fusion combo has no models" } }), JSON.stringify({ error: { message: "Fusion combo has no models" } }),
{ status: 400, headers: { "Content-Type": "application/json" } } { status: 400, headers: { "Content-Type": "application/json" } },
); );
} }
@@ -510,7 +617,10 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
const cfg = { ...FUSION_DEFAULTS, ...(tuning || {}) }; const cfg = { ...FUSION_DEFAULTS, ...(tuning || {}) };
const minPanel = Math.min(Math.max(2, cfg.minPanel), panel.length); const minPanel = Math.min(Math.max(2, cfg.minPanel), panel.length);
const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0]; const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0];
log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`); 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). // 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose).
const { tools, tool_choice, ...rest } = body; const { tools, tool_choice, ...rest } = body;
@@ -524,7 +634,9 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
} }
const t0 = Date.now(); const t0 = Date.now();
const calls = panel.map((m) => withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs)); const calls = panel.map((m) =>
withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs),
);
const settled = await collectPanel(calls, { ...cfg, minPanel }); const settled = await collectPanel(calls, { ...cfg, minPanel });
log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`); log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`);
@@ -533,10 +645,24 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
for (let i = 0; i < settled.length; i++) { for (let i = 0; i < settled.length; i++) {
const res = settled[i]; const res = settled[i];
const model = panel[i]; const model = panel[i];
if (!res) { log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`); continue; } if (!res) {
if (res.__timeout) { log.warn("FUSION", `Panel ${model} timed out`); continue; } log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`);
if (res.__error) { log.warn("FUSION", `Panel ${model} threw`, { error: res.__error?.message || String(res.__error) }); continue; } continue;
if (!res.ok) { log.warn("FUSION", `Panel ${model} failed`, { status: res.status }); 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 { try {
const json = await res.clone().json(); const json = await res.clone().json();
const text = extractPanelText(json); const text = extractPanelText(json);
@@ -547,7 +673,9 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
log.warn("FUSION", `Panel ${model} returned empty content`); log.warn("FUSION", `Panel ${model} returned empty content`);
} }
} catch (e) { } catch (e) {
log.warn("FUSION", `Panel ${model} unparseable`, { error: e.message || String(e) }); log.warn("FUSION", `Panel ${model} unparseable`, {
error: e.message || String(e),
});
} }
} }
@@ -556,11 +684,14 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
log.warn("FUSION", "All panel models failed"); log.warn("FUSION", "All panel models failed");
return new Response( return new Response(
JSON.stringify({ error: { message: "All fusion panel models failed" } }), JSON.stringify({ error: { message: "All fusion panel models failed" } }),
{ status: 503, headers: { "Content-Type": "application/json" } } { status: 503, headers: { "Content-Type": "application/json" } },
); );
} }
if (answers.length === 1) { if (answers.length === 1) {
log.info("FUSION", `Only ${answers[0].model} succeeded — answering directly (no fusion)`); log.info(
"FUSION",
`Only ${answers[0].model} succeeded — answering directly (no fusion)`,
);
return handleSingleModel(body, answers[0].model); return handleSingleModel(body, answers[0].model);
} }

View File

@@ -1,14 +1,44 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import {
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; 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 { CSS } from "@dnd-kit/utilities";
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; import {
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components"; 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 { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useModelCaps } from "@/shared/hooks/useModelCaps"; 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, -, _ // Validate combo name: only a-z, A-Z, 0-9, -, _
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
@@ -26,7 +56,7 @@ export default function CombosPage() {
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []);
const fetchData = async () => { const fetchData = async () => {
try { try {
@@ -40,7 +70,10 @@ export default function CombosPage() {
const settingsData = settingsRes.ok ? await settingsRes.json() : {}; const settingsData = settingsRes.ok ? await settingsRes.json() : {};
// Only LLM combos here - webSearch/webFetch combos belong to media-providers/web // 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 (combosRes.ok)
setCombos(
(combosData.combos || []).filter((c) => !c.kind || c.kind === "llm"),
);
if (providersRes.ok) { if (providersRes.ok) {
setActiveProviders(providersData.connections || []); setActiveProviders(providersData.connections || []);
} }
@@ -99,12 +132,12 @@ export default function CombosPage() {
try { try {
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" }); const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) { if (res.ok) {
setCombos(combos.filter(c => c.id !== id)); setCombos(combos.filter((c) => c.id !== id));
} }
} catch (error) { } catch (error) {
console.log("Error deleting combo:", error); console.log("Error deleting combo:", error);
} }
} },
}); });
}; };
@@ -151,13 +184,34 @@ export default function CombosPage() {
Group models under one name, then pick a strategy per combo: Group models under one name, then pick a strategy per combo:
</p> </p>
<ul className="text-sm text-text-muted mt-2 flex flex-col gap-1"> <ul className="text-sm text-text-muted mt-2 flex flex-col gap-1">
<li><span className="font-medium text-text-main">Fallback</span> — tries models in order (next on failure)</li> <li>
<li><span className="font-medium text-text-main">Round Robin</span> — rotates models across requests to spread load</li> <span className="font-medium text-text-main">Fallback</span> —
<li><span className="font-medium text-text-main">Fusion</span> — 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)</li> tries models in order (next on failure)
<li><span className="font-medium text-text-main">Capacity auto-switch</span> — sends image/PDF/audio requests to a model that supports them first</li> </li>
<li>
<span className="font-medium text-text-main">Round Robin</span> —
rotates models across requests to spread load
</li>
<li>
<span className="font-medium text-text-main">Fusion</span> —
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)
</li>
<li>
<span className="font-medium text-text-main">
Capacity auto-switch
</span>{" "}
— sends image/PDF/audio requests to a model that supports them
first
</li>
</ul> </ul>
</div> </div>
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto whitespace-nowrap"> <Button
icon="add"
onClick={() => setShowCreateModal(true)}
className="w-full sm:w-auto whitespace-nowrap"
>
Create Combo Create Combo
</Button> </Button>
</div> </div>
@@ -167,11 +221,19 @@ export default function CombosPage() {
<Card> <Card>
<div className="text-center py-12"> <div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
<span className="material-symbols-outlined text-[32px]">layers</span> <span className="material-symbols-outlined text-[32px]">
layers
</span>
</div> </div>
<p className="text-text-main font-medium mb-1">No combos yet</p> <p className="text-text-main font-medium mb-1">No combos yet</p>
<p className="text-sm text-text-muted mb-4">Create model combos with fallback support</p> <p className="text-sm text-text-muted mb-4">
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto"> Create model combos with fallback support
</p>
<Button
icon="add"
onClick={() => setShowCreateModal(true)}
className="w-full sm:w-auto"
>
Create Combo Create Combo
</Button> </Button>
</div> </div>
@@ -188,8 +250,11 @@ export default function CombosPage() {
onCopy={copy} onCopy={copy}
onEdit={() => setEditingCombo(combo)} onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)} onDelete={() => handleDelete(combo.id)}
onToggleEnabled={(enabled) => handleUpdate(combo.id, { enabled })}
strategy={comboStrategies[combo.name] || {}} strategy={comboStrategies[combo.name] || {}}
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)} onSetStrategy={(patch) =>
handleSetComboStrategy(combo.name, patch)
}
/> />
))} ))}
</div> </div>
@@ -236,47 +301,78 @@ const STRATEGY_OPTIONS = [
{ value: "fusion", label: "Fusion — panel + judge" }, { value: "fusion", label: "Fusion — panel + judge" },
]; ];
function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) { function ComboCard({
combo,
getCaps,
activeProviders = [],
copied,
onCopy,
onEdit,
onDelete,
strategy = {},
onSetStrategy,
onToggleEnabled,
}) {
const [showJudgeSelect, setShowJudgeSelect] = useState(false); const [showJudgeSelect, setShowJudgeSelect] = useState(false);
const current = strategy.fallbackStrategy || "fallback"; const current = strategy.fallbackStrategy || "fallback";
const judge = strategy.judgeModel || ""; const judge = strategy.judgeModel || "";
const isFusion = current === "fusion"; const isFusion = current === "fusion";
return ( return (
<Card padding="sm" className="group"> <Card
padding="sm"
className={combo.enabled === false ? "opacity-50" : "group"}
>
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center"> <div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0"> <div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span> <span className="material-symbols-outlined text-primary text-[18px]">
layers
</span>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<code className="block truncate font-mono text-sm font-medium">{combo.name}</code> <code className="block truncate font-mono text-sm font-medium">
{combo.name}
</code>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1"> <div className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
{combo.models.length === 0 ? ( {combo.models.length === 0 ? (
<span className="text-xs text-text-muted italic">No models</span> <span className="text-xs text-text-muted italic">
No models
</span>
) : ( ) : (
combo.models.slice(0, 3).map((model, index) => ( combo.models.slice(0, 3).map((model, index) => (
<code key={index} className="inline-flex items-center gap-1 rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-text-muted dark:bg-white/5"> <code
key={index}
className="inline-flex items-center gap-1 rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-text-muted dark:bg-white/5"
>
<span>{model}</span> <span>{model}</span>
<CapacityBadges caps={getCaps?.(model)} /> <CapacityBadges caps={getCaps?.(model)} />
</code> </code>
)) ))
)} )}
{combo.models.length > 3 && ( {combo.models.length > 3 && (
<span className="text-[10px] text-text-muted">+{combo.models.length - 3} more</span> <span className="text-[10px] text-text-muted">
+{combo.models.length - 3} more
</span>
)} )}
</div> </div>
{/* Fusion: judge picker (Auto = first model) */} {/* Fusion: judge picker (Auto = first model) */}
{isFusion && ( {isFusion && (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5"> <div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
<span className="text-[11px] font-medium text-text-muted">Judge</span> <span className="text-[11px] font-medium text-text-muted">
Judge
</span>
<button <button
onClick={() => setShowJudgeSelect(true)} onClick={() => setShowJudgeSelect(true)}
className="inline-flex max-w-full items-center gap-1 rounded border border-dashed border-primary/40 px-1.5 py-0.5 font-mono text-[11px] text-primary hover:border-primary hover:bg-primary/5 transition-colors" className="inline-flex max-w-full items-center gap-1 rounded border border-dashed border-primary/40 px-1.5 py-0.5 font-mono text-[11px] text-primary hover:border-primary hover:bg-primary/5 transition-colors"
title="Pick the model that fuses panel answers" title="Pick the model that fuses panel answers"
> >
<span className="material-symbols-outlined text-[13px]">gavel</span> <span className="material-symbols-outlined text-[13px]">
<span className="truncate">{judge || `Auto — ${combo.models[0] || "first model"}`}</span> gavel
</span>
<span className="truncate">
{judge || `Auto — ${combo.models[0] || "first model"}`}
</span>
</button> </button>
{judge && ( {judge && (
<button <button
@@ -284,7 +380,9 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
className="p-0.5 rounded text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-colors" className="p-0.5 rounded text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-colors"
title="Reset judge to Auto" title="Reset judge to Auto"
> >
<span className="material-symbols-outlined text-[13px]">close</span> <span className="material-symbols-outlined text-[13px]">
close
</span>
</button> </button>
)} )}
</div> </div>
@@ -299,14 +397,25 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
<Select <Select
options={STRATEGY_OPTIONS} options={STRATEGY_OPTIONS}
value={current} value={current}
onChange={(e) => onSetStrategy({ fallbackStrategy: e.target.value })} onChange={(e) =>
onSetStrategy({ fallbackStrategy: e.target.value })
}
selectClassName="py-1.5 text-xs" selectClassName="py-1.5 text-xs"
/> />
</div> </div>
{onToggleEnabled && (
<Toggle
checked={combo.enabled !== false}
onChange={(checked) => onToggleEnabled(checked)}
/>
)}
<div className="grid grid-cols-3 gap-1 sm:flex"> <div className="grid grid-cols-3 gap-1 sm:flex">
<button <button
onClick={(e) => { e.stopPropagation(); onCopy(combo.name, `combo-${combo.id}`); }} onClick={(e) => {
e.stopPropagation();
onCopy(combo.name, `combo-${combo.id}`);
}}
className="flex flex-col items-center rounded px-2 py-1 text-text-muted transition-colors hover:bg-black/5 hover:text-primary dark:hover:bg-white/5" className="flex flex-col items-center rounded px-2 py-1 text-text-muted transition-colors hover:bg-black/5 hover:text-primary dark:hover:bg-white/5"
title="Copy combo name" title="Copy combo name"
> >
@@ -320,7 +429,9 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
className="flex flex-col items-center rounded px-2 py-1 text-text-muted transition-colors hover:bg-black/5 hover:text-primary dark:hover:bg-white/5" className="flex flex-col items-center rounded px-2 py-1 text-text-muted transition-colors hover:bg-black/5 hover:text-primary dark:hover:bg-white/5"
title="Edit" title="Edit"
> >
<span className="material-symbols-outlined text-[18px]">edit</span> <span className="material-symbols-outlined text-[18px]">
edit
</span>
<span className="text-[10px] leading-tight">Edit</span> <span className="text-[10px] leading-tight">Edit</span>
</button> </button>
<button <button
@@ -328,7 +439,9 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
className="flex flex-col items-center rounded px-2 py-1 text-red-500 transition-colors hover:bg-red-500/10" className="flex flex-col items-center rounded px-2 py-1 text-red-500 transition-colors hover:bg-red-500/10"
title="Delete" title="Delete"
> >
<span className="material-symbols-outlined text-[18px]">delete</span> <span className="material-symbols-outlined text-[18px]">
delete
</span>
<span className="text-[10px] leading-tight">Delete</span> <span className="text-[10px] leading-tight">Delete</span>
</button> </button>
</div> </div>
@@ -340,7 +453,10 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
<ModelSelectModal <ModelSelectModal
isOpen={showJudgeSelect} isOpen={showJudgeSelect}
onClose={() => setShowJudgeSelect(false)} onClose={() => setShowJudgeSelect(false)}
onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} onSelect={(m) => {
onSetStrategy({ judgeModel: m?.value || "" });
setShowJudgeSelect(false);
}}
activeProviders={activeProviders} activeProviders={activeProviders}
title="Select Judge Model" title="Select Judge Model"
addedModelValues={judge ? [judge] : []} addedModelValues={judge ? [judge] : []}
@@ -351,8 +467,19 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
); );
} }
function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) { function ModelItem({
const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({ id }); id,
index,
model,
isFirst,
isLast,
onEdit,
onMoveUp,
onMoveDown,
onRemove,
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useSortable({ id });
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
// no transition — prevents the CSS settle animation fighting React's re-render on drop // no transition — prevents the CSS settle animation fighting React's re-render on drop
@@ -370,7 +497,10 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.key === "Enter") commit(); if (e.key === "Enter") commit();
if (e.key === "Escape") { setDraft(model); setEditing(false); } if (e.key === "Escape") {
setDraft(model);
setEditing(false);
}
}; };
return ( return (
@@ -388,14 +518,19 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove
title="Drag to reorder" title="Drag to reorder"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="4" r="2"/><circle cx="15" cy="4" r="2"/> <circle cx="9" cy="4" r="2" />
<circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/> <circle cx="15" cy="4" r="2" />
<circle cx="9" cy="20" r="2"/><circle cx="15" cy="20" r="2"/> <circle cx="9" cy="12" r="2" />
<circle cx="15" cy="12" r="2" />
<circle cx="9" cy="20" r="2" />
<circle cx="15" cy="20" r="2" />
</svg> </svg>
</button> </button>
{/* Index badge */} {/* Index badge */}
<span className="text-[10px] font-medium text-text-muted w-3 text-center shrink-0">{index + 1}</span> <span className="text-[10px] font-medium text-text-muted w-3 text-center shrink-0">
{index + 1}
</span>
{/* Inline editable model value */} {/* Inline editable model value */}
{editing ? ( {editing ? (
@@ -425,7 +560,9 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove
className={`p-0.5 rounded ${isFirst ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`} className={`p-0.5 rounded ${isFirst ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move up" title="Move up"
> >
<span className="material-symbols-outlined text-[12px]">arrow_upward</span> <span className="material-symbols-outlined text-[12px]">
arrow_upward
</span>
</button> </button>
<button <button
onClick={onMoveDown} onClick={onMoveDown}
@@ -433,7 +570,9 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove
className={`p-0.5 rounded ${isLast ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`} className={`p-0.5 rounded ${isLast ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move down" title="Move down"
> >
<span className="material-symbols-outlined text-[12px]">arrow_downward</span> <span className="material-symbols-outlined text-[12px]">
arrow_downward
</span>
</button> </button>
</div> </div>
@@ -449,7 +588,14 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove
); );
} }
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null }) { function ComboFormModal({
isOpen,
combo,
onClose,
onSave,
activeProviders,
kindFilter = null,
}) {
// Initialize state with combo values - key prop on parent handles reset on remount // Initialize state with combo values - key prop on parent handles reset on remount
const [name, setName] = useState(combo?.name || ""); const [name, setName] = useState(combo?.name || "");
const [models, setModels] = useState(combo?.models || []); const [models, setModels] = useState(combo?.models || []);
@@ -460,7 +606,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
); );
// Use stable index-based IDs so duplicates and similar names are handled correctly // Use stable index-based IDs so duplicates and similar names are handled correctly
@@ -529,14 +677,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
const handleMoveUp = (index) => { const handleMoveUp = (index) => {
if (index === 0) return; if (index === 0) return;
const newModels = [...models]; const newModels = [...models];
[newModels[index - 1], newModels[index]] = [newModels[index], newModels[index - 1]]; [newModels[index - 1], newModels[index]] = [
newModels[index],
newModels[index - 1],
];
setModels(newModels); setModels(newModels);
}; };
const handleMoveDown = (index) => { const handleMoveDown = (index) => {
if (index === models.length - 1) return; if (index === models.length - 1) return;
const newModels = [...models]; const newModels = [...models];
[newModels[index], newModels[index + 1]] = [newModels[index + 1], newModels[index]]; [newModels[index], newModels[index + 1]] = [
newModels[index + 1],
newModels[index],
];
setModels(newModels); setModels(newModels);
}; };
@@ -577,12 +731,22 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
{models.length === 0 ? ( {models.length === 0 ? (
<div className="text-center py-4 border border-dashed border-black/10 dark:border-white/10 rounded-lg bg-black/[0.01] dark:bg-white/[0.01]"> <div className="text-center py-4 border border-dashed border-black/10 dark:border-white/10 rounded-lg bg-black/[0.01] dark:bg-white/[0.01]">
<span className="material-symbols-outlined text-text-muted text-xl mb-1">layers</span> <span className="material-symbols-outlined text-text-muted text-xl mb-1">
layers
</span>
<p className="text-xs text-text-muted">No models added yet</p> <p className="text-xs text-text-muted">No models added yet</p>
</div> </div>
) : ( ) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis, restrictToParentElement]}> <DndContext
<SortableContext items={modelItems.map((m) => m.uid)} strategy={verticalListSortingStrategy}> sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={modelItems.map((m) => m.uid)}
strategy={verticalListSortingStrategy}
>
<div className="flex max-h-[55vh] min-w-0 flex-col gap-1 overflow-y-auto sm:max-h-[350px]"> <div className="flex max-h-[55vh] min-w-0 flex-col gap-1 overflow-y-auto sm:max-h-[350px]">
{modelItems.map(({ uid, model }, index) => ( {modelItems.map(({ uid, model }, index) => (
<ModelItem <ModelItem

View File

@@ -27,7 +27,11 @@ export default function ProfilePage() {
const [isShuttingDown, setIsShuttingDown] = useState(false); const [isShuttingDown, setIsShuttingDown] = useState(false);
const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" }); const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" }); const [passwords, setPasswords] = useState({
current: "",
new: "",
confirm: "",
});
const [passStatus, setPassStatus] = useState({ type: "", message: "" }); const [passStatus, setPassStatus] = useState({ type: "", message: "" });
const [passLoading, setPassLoading] = useState(false); const [passLoading, setPassLoading] = useState(false);
const [dbLoading, setDbLoading] = useState(false); const [dbLoading, setDbLoading] = useState(false);
@@ -45,8 +49,13 @@ export default function ProfilePage() {
const [oidcStatus, setOidcStatus] = useState({ type: "", message: "" }); const [oidcStatus, setOidcStatus] = useState({ type: "", message: "" });
const [oidcLoading, setOidcLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false);
const [oidcTestLoading, setOidcTestLoading] = useState(false); const [oidcTestLoading, setOidcTestLoading] = useState(false);
const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" }); const [oidcTestStatus, setOidcTestStatus] = useState({
const [oidcRedirectUri, setOidcRedirectUri] = useState("/api/auth/oidc/callback"); type: "",
message: "",
});
const [oidcRedirectUri, setOidcRedirectUri] = useState(
"/api/auth/oidc/callback",
);
const [oidcExpanded, setOidcExpanded] = useState(false); const [oidcExpanded, setOidcExpanded] = useState(false);
const importFileRef = useRef(null); const importFileRef = useRef(null);
const [proxyForm, setProxyForm] = useState({ const [proxyForm, setProxyForm] = useState({
@@ -75,7 +84,8 @@ export default function ProfilePage() {
oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC", oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC",
}); });
setOidcClientSecret(""); setOidcClientSecret("");
if (data?.authMode === "oidc" || data?.authMode === "both") setOidcExpanded(true); if (data?.authMode === "oidc" || data?.authMode === "both")
setOidcExpanded(true);
setProxyForm({ setProxyForm({
outboundProxyEnabled: data?.outboundProxyEnabled === true, outboundProxyEnabled: data?.outboundProxyEnabled === true,
outboundProxyUrl: data?.outboundProxyUrl || "", outboundProxyUrl: data?.outboundProxyUrl || "",
@@ -116,7 +126,10 @@ export default function ProfilePage() {
setSettings((prev) => ({ ...prev, ...data })); setSettings((prev) => ({ ...prev, ...data }));
setProxyStatus({ type: "success", message: "Proxy settings applied" }); setProxyStatus({ type: "success", message: "Proxy settings applied" });
} else { } else {
setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" }); setProxyStatus({
type: "error",
message: data.error || "Failed to update proxy settings",
});
} }
} catch (err) { } catch (err) {
setProxyStatus({ type: "error", message: "An error occurred" }); setProxyStatus({ type: "error", message: "An error occurred" });
@@ -130,7 +143,10 @@ export default function ProfilePage() {
const proxyUrl = (proxyForm.outboundProxyUrl || "").trim(); const proxyUrl = (proxyForm.outboundProxyUrl || "").trim();
if (!proxyUrl) { if (!proxyUrl) {
setProxyStatus({ type: "error", message: "Please enter a Proxy URL to test" }); setProxyStatus({
type: "error",
message: "Please enter a Proxy URL to test",
});
return; return;
} }
@@ -177,13 +193,19 @@ export default function ProfilePage() {
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
setSettings((prev) => ({ ...prev, ...data })); setSettings((prev) => ({ ...prev, ...data }));
setProxyForm((prev) => ({ ...prev, outboundProxyEnabled: data?.outboundProxyEnabled === true })); setProxyForm((prev) => ({
...prev,
outboundProxyEnabled: data?.outboundProxyEnabled === true,
}));
setProxyStatus({ setProxyStatus({
type: "success", type: "success",
message: outboundProxyEnabled ? "Proxy enabled" : "Proxy disabled", message: outboundProxyEnabled ? "Proxy enabled" : "Proxy disabled",
}); });
} else { } else {
setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" }); setProxyStatus({
type: "error",
message: data.error || "Failed to update proxy settings",
});
} }
} catch (err) { } catch (err) {
setProxyStatus({ type: "error", message: "An error occurred" }); setProxyStatus({ type: "error", message: "An error occurred" });
@@ -215,10 +237,16 @@ export default function ProfilePage() {
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
setPassStatus({ type: "success", message: "Password updated successfully" }); setPassStatus({
type: "success",
message: "Password updated successfully",
});
setPasswords({ current: "", new: "", confirm: "" }); setPasswords({ current: "", new: "", confirm: "" });
} else { } else {
setPassStatus({ type: "error", message: data.error || "Failed to update password" }); setPassStatus({
type: "error",
message: data.error || "Failed to update password",
});
} }
} catch (err) { } catch (err) {
setPassStatus({ type: "error", message: "An error occurred" }); setPassStatus({ type: "error", message: "An error occurred" });
@@ -235,7 +263,7 @@ export default function ProfilePage() {
body: JSON.stringify({ fallbackStrategy: strategy }), body: JSON.stringify({ fallbackStrategy: strategy }),
}); });
if (res.ok) { if (res.ok) {
setSettings(prev => ({ ...prev, fallbackStrategy: strategy })); setSettings((prev) => ({ ...prev, fallbackStrategy: strategy }));
} }
} catch (err) { } catch (err) {
console.error("Failed to update settings:", err); console.error("Failed to update settings:", err);
@@ -250,7 +278,7 @@ export default function ProfilePage() {
body: JSON.stringify({ comboStrategy: strategy }), body: JSON.stringify({ comboStrategy: strategy }),
}); });
if (res.ok) { if (res.ok) {
setSettings(prev => ({ ...prev, comboStrategy: strategy })); setSettings((prev) => ({ ...prev, comboStrategy: strategy }));
} }
} catch (err) { } catch (err) {
console.error("Failed to update combo strategy:", err); console.error("Failed to update combo strategy:", err);
@@ -260,7 +288,10 @@ export default function ProfilePage() {
const handleGlobalTimeoutChange = async (e) => { const handleGlobalTimeoutChange = async (e) => {
const raw = e.target.value.replace(/[^0-9]/g, ""); const raw = e.target.value.replace(/[^0-9]/g, "");
const numTimeout = parseInt(raw, 10); const numTimeout = parseInt(raw, 10);
const patchValue = (raw !== "" && Number.isFinite(numTimeout) && numTimeout > 0) ? numTimeout : null; const patchValue =
raw !== "" && Number.isFinite(numTimeout) && numTimeout > 0
? numTimeout
: null;
try { try {
const res = await fetch("/api/settings", { const res = await fetch("/api/settings", {
@@ -269,7 +300,7 @@ export default function ProfilePage() {
body: JSON.stringify({ defaultTimeoutMs: patchValue }), body: JSON.stringify({ defaultTimeoutMs: patchValue }),
}); });
if (res.ok) { if (res.ok) {
setSettings(prev => ({ ...prev, defaultTimeoutMs: patchValue })); setSettings((prev) => ({ ...prev, defaultTimeoutMs: patchValue }));
} }
} catch (err) { } catch (err) {
console.error("Failed to update default timeout:", err); console.error("Failed to update default timeout:", err);
@@ -287,7 +318,7 @@ export default function ProfilePage() {
body: JSON.stringify({ stickyRoundRobinLimit: numLimit }), body: JSON.stringify({ stickyRoundRobinLimit: numLimit }),
}); });
if (res.ok) { if (res.ok) {
setSettings(prev => ({ ...prev, stickyRoundRobinLimit: numLimit })); setSettings((prev) => ({ ...prev, stickyRoundRobinLimit: numLimit }));
} }
} catch (err) { } catch (err) {
console.error("Failed to update sticky limit:", err); console.error("Failed to update sticky limit:", err);
@@ -305,7 +336,10 @@ export default function ProfilePage() {
body: JSON.stringify({ comboStickyRoundRobinLimit: numLimit }), body: JSON.stringify({ comboStickyRoundRobinLimit: numLimit }),
}); });
if (res.ok) { if (res.ok) {
setSettings(prev => ({ ...prev, comboStickyRoundRobinLimit: numLimit })); setSettings((prev) => ({
...prev,
comboStickyRoundRobinLimit: numLimit,
}));
} }
} catch (err) { } catch (err) {
console.error("Failed to update combo sticky limit:", err); console.error("Failed to update combo sticky limit:", err);
@@ -320,7 +354,7 @@ export default function ProfilePage() {
body: JSON.stringify({ requireLogin }), body: JSON.stringify({ requireLogin }),
}); });
if (res.ok) { if (res.ok) {
setSettings(prev => ({ ...prev, requireLogin })); setSettings((prev) => ({ ...prev, requireLogin }));
} }
} catch (err) { } catch (err) {
console.error("Failed to update require login:", err); console.error("Failed to update require login:", err);
@@ -331,15 +365,25 @@ export default function ProfilePage() {
setOidcForm((prev) => ({ ...prev, [field]: value })); setOidcForm((prev) => ({ ...prev, [field]: value }));
}; };
const saveOidcSettings = async (authMode = oidcForm.authMode || "password") => { const saveOidcSettings = async (
authMode = oidcForm.authMode || "password",
) => {
const issuerUrl = oidcForm.oidcIssuerUrl.trim(); const issuerUrl = oidcForm.oidcIssuerUrl.trim();
const clientId = oidcForm.oidcClientId.trim(); const clientId = oidcForm.oidcClientId.trim();
const scopes = oidcForm.oidcScopes.trim(); const scopes = oidcForm.oidcScopes.trim();
const loginLabel = oidcForm.oidcLoginLabel.trim(); const loginLabel = oidcForm.oidcLoginLabel.trim();
const secret = oidcClientSecret.trim(); const secret = oidcClientSecret.trim();
if (authMode !== "password" && (!issuerUrl || !clientId || !secret) && !settings.oidcConfigured) { if (
setOidcStatus({ type: "error", message: "Issuer URL, client ID, and client secret are required to enable OIDC." }); authMode !== "password" &&
(!issuerUrl || !clientId || !secret) &&
!settings.oidcConfigured
) {
setOidcStatus({
type: "error",
message:
"Issuer URL, client ID, and client secret are required to enable OIDC.",
});
return; return;
} }
@@ -373,7 +417,8 @@ export default function ProfilePage() {
oidcIssuerUrl: data?.oidcIssuerUrl || issuerUrl, oidcIssuerUrl: data?.oidcIssuerUrl || issuerUrl,
oidcClientId: data?.oidcClientId || clientId, oidcClientId: data?.oidcClientId || clientId,
oidcScopes: data?.oidcScopes || scopes || "openid profile email", oidcScopes: data?.oidcScopes || scopes || "openid profile email",
oidcLoginLabel: data?.oidcLoginLabel || loginLabel || "Sign in with OIDC", oidcLoginLabel:
data?.oidcLoginLabel || loginLabel || "Sign in with OIDC",
}); });
setOidcClientSecret(""); setOidcClientSecret("");
setOidcStatus({ setOidcStatus({
@@ -386,7 +431,10 @@ export default function ProfilePage() {
: "OIDC settings saved", : "OIDC settings saved",
}); });
} else { } else {
setOidcStatus({ type: "error", message: data.error || "Failed to save OIDC settings" }); setOidcStatus({
type: "error",
message: data.error || "Failed to save OIDC settings",
});
} }
} catch (err) { } catch (err) {
setOidcStatus({ type: "error", message: "An error occurred" }); setOidcStatus({ type: "error", message: "An error occurred" });
@@ -402,7 +450,11 @@ export default function ProfilePage() {
const secret = oidcClientSecret.trim(); const secret = oidcClientSecret.trim();
if (!issuerUrl || !clientId) { if (!issuerUrl || !clientId) {
setOidcTestStatus({ type: "error", message: "Issuer URL and client ID are required to test the connection." }); setOidcTestStatus({
type: "error",
message:
"Issuer URL and client ID are required to test the connection.",
});
return; return;
} }
@@ -455,7 +507,10 @@ export default function ProfilePage() {
message: statusMessage, message: statusMessage,
}); });
} else { } else {
setOidcTestStatus({ type: "error", message: data.error || "OIDC connection test failed" }); setOidcTestStatus({
type: "error",
message: data.error || "OIDC connection test failed",
});
} }
} catch (err) { } catch (err) {
setOidcTestStatus({ type: "error", message: "An error occurred" }); setOidcTestStatus({ type: "error", message: "An error occurred" });
@@ -472,13 +527,28 @@ export default function ProfilePage() {
body: JSON.stringify({ enableObservability: enabled }), body: JSON.stringify({ enableObservability: enabled }),
}); });
if (res.ok) { if (res.ok) {
setSettings(prev => ({ ...prev, enableObservability: enabled })); setSettings((prev) => ({ ...prev, enableObservability: enabled }));
} }
} catch (err) { } catch (err) {
console.error("Failed to update enableObservability:", err); console.error("Failed to update enableObservability:", 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 reloadSettings = async () => { const reloadSettings = async () => {
try { try {
const res = await fetch("/api/settings"); const res = await fetch("/api/settings");
@@ -517,7 +587,10 @@ export default function ProfilePage() {
setDbStatus({ type: "success", message: "Database backup downloaded" }); setDbStatus({ type: "success", message: "Database backup downloaded" });
} catch (err) { } catch (err) {
setDbStatus({ type: "error", message: err.message || "Failed to export database" }); setDbStatus({
type: "error",
message: err.message || "Failed to export database",
});
} finally { } finally {
setDbLoading(false); setDbLoading(false);
} }
@@ -552,9 +625,15 @@ export default function ProfilePage() {
} }
await reloadSettings(); await reloadSettings();
setDbStatus({ type: "success", message: "Database imported successfully" }); setDbStatus({
type: "success",
message: "Database imported successfully",
});
} catch (err) { } catch (err) {
setDbStatus({ type: "error", message: err.message || "Invalid backup file" }); setDbStatus({
type: "error",
message: err.message || "Invalid backup file",
});
} finally { } finally {
pendingImportRef.current = null; pendingImportRef.current = null;
setDbLoading(false); setDbLoading(false);
@@ -601,11 +680,15 @@ export default function ProfilePage() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div className="flex items-center gap-3 sm:gap-4"> <div className="flex items-center gap-3 sm:gap-4">
<div className="size-10 sm:size-12 rounded-lg bg-green-500/10 text-green-500 flex items-center justify-center shrink-0"> <div className="size-10 sm:size-12 rounded-lg bg-green-500/10 text-green-500 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-xl sm:text-2xl">computer</span> <span className="material-symbols-outlined text-xl sm:text-2xl">
computer
</span>
</div> </div>
<div> <div>
<h2 className="text-lg sm:text-xl font-semibold">Local Mode</h2> <h2 className="text-lg sm:text-xl font-semibold">Local Mode</h2>
<p className="text-sm text-text-muted">Running on your machine</p> <p className="text-sm text-text-muted">
Running on your machine
</p>
</div> </div>
</div> </div>
<div className="inline-flex p-1 rounded-lg bg-black/5 dark:bg-white/5 w-full sm:w-auto"> <div className="inline-flex p-1 rounded-lg bg-black/5 dark:bg-white/5 w-full sm:w-auto">
@@ -618,13 +701,19 @@ export default function ProfilePage() {
"flex items-center justify-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-md font-medium transition-all flex-1 sm:flex-initial", "flex items-center justify-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-md font-medium transition-all flex-1 sm:flex-initial",
theme === option theme === option
? "bg-white dark:bg-white/10 text-text-main shadow-sm" ? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main" : "text-text-muted hover:text-text-main",
)} )}
> >
<span className="material-symbols-outlined text-[18px]"> <span className="material-symbols-outlined text-[18px]">
{option === "light" ? "light_mode" : option === "dark" ? "dark_mode" : "contrast"} {option === "light"
? "light_mode"
: option === "dark"
? "dark_mode"
: "contrast"}
</span>
<span className="capitalize text-xs sm:text-sm">
{option}
</span> </span>
<span className="capitalize text-xs sm:text-sm">{option}</span>
</button> </button>
))} ))}
</div> </div>
@@ -632,15 +721,21 @@ export default function ProfilePage() {
<div className="flex flex-col gap-3 pt-4 border-t border-border"> <div className="flex flex-col gap-3 pt-4 border-t border-border">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 rounded-lg bg-bg border border-border gap-2"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 rounded-lg bg-bg border border-border gap-2">
<div> <div>
<p className="font-medium text-sm sm:text-base">Database Location</p> <p className="font-medium text-sm sm:text-base">
<p className="text-xs sm:text-sm text-text-muted font-mono break-all">~/.9router/db/data.sqlite</p> Database Location
</p>
<p className="text-xs sm:text-sm text-text-muted font-mono break-all">
~/.9router/db/data.sqlite
</p>
</div> </div>
</div> </div>
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<Button <Button
variant="secondary" variant="secondary"
icon="download" icon="download"
onClick={() => setDbAuth({ open: true, mode: "export", password: "" })} onClick={() =>
setDbAuth({ open: true, mode: "export", password: "" })
}
loading={dbLoading} loading={dbLoading}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
@@ -664,7 +759,9 @@ export default function ProfilePage() {
/> />
</div> </div>
{dbStatus.message && ( {dbStatus.message && (
<p className={`text-sm ${dbStatus.type === "error" ? "text-red-500" : "text-green-600 dark:text-green-400"}`}> <p
className={`text-sm ${dbStatus.type === "error" ? "text-red-500" : "text-green-600 dark:text-green-400"}`}
>
{dbStatus.message} {dbStatus.message}
</p> </p>
)} )}
@@ -675,7 +772,9 @@ export default function ProfilePage() {
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-lg bg-blue-500/10 text-blue-500 flex items-center justify-center shrink-0"> <div className="size-10 rounded-lg bg-blue-500/10 text-blue-500 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-[20px]">language</span> <span className="material-symbols-outlined text-[20px]">
language
</span>
</div> </div>
<h3 className="text-base sm:text-lg font-semibold">Language</h3> <h3 className="text-base sm:text-lg font-semibold">Language</h3>
</div> </div>
@@ -693,16 +792,21 @@ export default function ProfilePage() {
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-primary/10 text-primary shrink-0"> <div className="p-2 rounded-lg bg-primary/10 text-primary shrink-0">
<span className="material-symbols-outlined text-[20px]">shield</span> <span className="material-symbols-outlined text-[20px]">
shield
</span>
</div> </div>
<h3 className="text-base sm:text-lg font-semibold">Security</h3> <h3 className="text-base sm:text-lg font-semibold">Security</h3>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-start sm:items-center justify-between gap-4"> <div className="flex items-start sm:items-center justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base">Require login</p> <p className="font-medium text-sm sm:text-base">
Require login
</p>
<p className="text-xs sm:text-sm text-text-muted"> <p className="text-xs sm:text-sm text-text-muted">
When ON, dashboard requires password. When OFF, access without login. When ON, dashboard requires password. When OFF, access without
login.
</p> </p>
</div> </div>
<Toggle <Toggle
@@ -712,15 +816,22 @@ export default function ProfilePage() {
/> />
</div> </div>
{settings.requireLogin === true && ( {settings.requireLogin === true && (
<form onSubmit={handlePasswordChange} className="flex flex-col gap-4 pt-4 border-t border-border/50"> <form
onSubmit={handlePasswordChange}
className="flex flex-col gap-4 pt-4 border-t border-border/50"
>
{settings.hasPassword && ( {settings.hasPassword && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-xs sm:text-sm font-medium">Current Password</label> <label className="text-xs sm:text-sm font-medium">
Current Password
</label>
<Input <Input
type="password" type="password"
placeholder="Enter current password" placeholder="Enter current password"
value={passwords.current} value={passwords.current}
onChange={(e) => setPasswords({ ...passwords, current: e.target.value })} onChange={(e) =>
setPasswords({ ...passwords, current: e.target.value })
}
required required
/> />
</div> </div>
@@ -734,35 +845,50 @@ export default function ProfilePage() {
)} */} )} */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-xs sm:text-sm font-medium">New Password</label> <label className="text-xs sm:text-sm font-medium">
New Password
</label>
<Input <Input
type="password" type="password"
placeholder="Enter new password" placeholder="Enter new password"
value={passwords.new} value={passwords.new}
onChange={(e) => setPasswords({ ...passwords, new: e.target.value })} onChange={(e) =>
setPasswords({ ...passwords, new: e.target.value })
}
required required
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-xs sm:text-sm font-medium">Confirm New Password</label> <label className="text-xs sm:text-sm font-medium">
Confirm New Password
</label>
<Input <Input
type="password" type="password"
placeholder="Confirm new password" placeholder="Confirm new password"
value={passwords.confirm} value={passwords.confirm}
onChange={(e) => setPasswords({ ...passwords, confirm: e.target.value })} onChange={(e) =>
setPasswords({ ...passwords, confirm: e.target.value })
}
required required
/> />
</div> </div>
</div> </div>
{passStatus.message && ( {passStatus.message && (
<p className={`text-xs sm:text-sm ${passStatus.type === "error" ? "text-red-500" : "text-green-500"}`}> <p
className={`text-xs sm:text-sm ${passStatus.type === "error" ? "text-red-500" : "text-green-500"}`}
>
{passStatus.message} {passStatus.message}
</p> </p>
)} )}
<div className="pt-2"> <div className="pt-2">
<Button type="submit" variant="primary" loading={passLoading} className="w-full sm:w-auto"> <Button
type="submit"
variant="primary"
loading={passLoading}
className="w-full sm:w-auto"
>
{settings.hasPassword ? "Update Password" : "Set Password"} {settings.hasPassword ? "Update Password" : "Set Password"}
</Button> </Button>
</div> </div>
@@ -779,12 +905,20 @@ export default function ProfilePage() {
className="w-full flex items-center gap-3 text-left" className="w-full flex items-center gap-3 text-left"
> >
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500 shrink-0"> <div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500 shrink-0">
<span className="material-symbols-outlined text-[20px]">lock_open</span> <span className="material-symbols-outlined text-[20px]">
lock_open
</span>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold">OIDC Dashboard Login</h3> <h3 className="text-base sm:text-lg font-semibold">
OIDC Dashboard Login
</h3>
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
{settings.authMode === "oidc" ? "OIDC active" : settings.authMode === "both" ? "Password + OIDC active" : "Optional SSO via Authentik/Keycloak/Google"} {settings.authMode === "oidc"
? "OIDC active"
: settings.authMode === "both"
? "Password + OIDC active"
: "Optional SSO via Authentik/Keycloak/Google"}
</p> </p>
</div> </div>
<span className="material-symbols-outlined text-text-muted shrink-0"> <span className="material-symbols-outlined text-text-muted shrink-0">
@@ -794,11 +928,15 @@ export default function ProfilePage() {
{oidcExpanded && ( {oidcExpanded && (
<div className="flex flex-col gap-4 mt-4"> <div className="flex flex-col gap-4 mt-4">
<p className="text-xs sm:text-sm text-text-muted"> <p className="text-xs sm:text-sm text-text-muted">
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. 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.
</p> </p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Auth Mode</label> <label className="font-medium text-sm sm:text-base">
Auth Mode
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{[ {[
{ {
@@ -827,12 +965,16 @@ export default function ProfilePage() {
"text-left rounded-lg border p-3 transition-colors", "text-left rounded-lg border p-3 transition-colors",
active active
? "border-primary bg-primary/5" ? "border-primary bg-primary/5"
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5" : "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5",
)} )}
disabled={loading || oidcLoading} disabled={loading || oidcLoading}
> >
<p className="font-medium text-sm sm:text-base">{option.title}</p> <p className="font-medium text-sm sm:text-base">
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p> {option.title}
</p>
<p className="text-xs sm:text-sm text-text-muted mt-1">
{option.desc}
</p>
</button> </button>
); );
})} })}
@@ -841,27 +983,37 @@ export default function ProfilePage() {
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Issuer URL</label> <label className="font-medium text-sm sm:text-base">
Issuer URL
</label>
<Input <Input
placeholder="https://auth.example.com/application/o/9router/" placeholder="https://auth.example.com/application/o/9router/"
value={oidcForm.oidcIssuerUrl} value={oidcForm.oidcIssuerUrl}
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)} onChange={(e) =>
updateOidcForm("oidcIssuerUrl", e.target.value)
}
disabled={loading || oidcLoading} disabled={loading || oidcLoading}
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client ID</label> <label className="font-medium text-sm sm:text-base">
Client ID
</label>
<Input <Input
placeholder="9router-dashboard" placeholder="9router-dashboard"
value={oidcForm.oidcClientId} value={oidcForm.oidcClientId}
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)} onChange={(e) =>
updateOidcForm("oidcClientId", e.target.value)
}
disabled={loading || oidcLoading} disabled={loading || oidcLoading}
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client Secret</label> <label className="font-medium text-sm sm:text-base">
Client Secret
</label>
<Input <Input
type="password" type="password"
placeholder="Leave blank to keep existing secret" placeholder="Leave blank to keep existing secret"
@@ -869,25 +1021,35 @@ export default function ProfilePage() {
onChange={(e) => setOidcClientSecret(e.target.value)} onChange={(e) => setOidcClientSecret(e.target.value)}
disabled={loading || oidcLoading} disabled={loading || oidcLoading}
/> />
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p> <p className="text-xs sm:text-sm text-text-muted">
This value is write-only after saving.
</p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Scopes</label> <label className="font-medium text-sm sm:text-base">
Scopes
</label>
<Input <Input
placeholder="openid profile email" placeholder="openid profile email"
value={oidcForm.oidcScopes} value={oidcForm.oidcScopes}
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)} onChange={(e) =>
updateOidcForm("oidcScopes", e.target.value)
}
disabled={loading || oidcLoading} disabled={loading || oidcLoading}
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Login Button Label</label> <label className="font-medium text-sm sm:text-base">
Login Button Label
</label>
<Input <Input
placeholder="Sign in with OIDC" placeholder="Sign in with OIDC"
value={oidcForm.oidcLoginLabel} value={oidcForm.oidcLoginLabel}
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)} onChange={(e) =>
updateOidcForm("oidcLoginLabel", e.target.value)
}
disabled={loading || oidcLoading} disabled={loading || oidcLoading}
/> />
</div> </div>
@@ -895,33 +1057,52 @@ export default function ProfilePage() {
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted"> <div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
<p className="font-medium text-text-main mb-1">Redirect URI</p> <p className="font-medium text-text-main mb-1">Redirect URI</p>
<code className="block break-all font-mono">{oidcRedirectUri}</code> <code className="block break-all font-mono">
{oidcRedirectUri}
</code>
</div> </div>
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50"> <div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto"> <Button
type="button"
variant="primary"
loading={oidcLoading}
onClick={() => saveOidcSettings()}
className="w-full sm:w-auto"
>
Save auth mode Save auth mode
</Button> </Button>
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto"> <Button
type="button"
variant="outline"
loading={oidcTestLoading}
onClick={testOidcConnection}
className="w-full sm:w-auto"
>
Test connection Test connection
</Button> </Button>
</div> </div>
{oidcTestStatus.message && ( {oidcTestStatus.message && (
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}> <p
className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}
>
{oidcTestStatus.message} {oidcTestStatus.message}
</p> </p>
)} )}
{oidcStatus.message && ( {oidcStatus.message && (
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}> <p
className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}
>
{oidcStatus.message} {oidcStatus.message}
</p> </p>
)} )}
{settings.authMode === "oidc" && ( {settings.authMode === "oidc" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400"> <p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
OIDC login is currently active. Password login is disabled until you switch back. OIDC login is currently active. Password login is disabled
until you switch back.
</p> </p>
)} )}
@@ -938,9 +1119,13 @@ export default function ProfilePage() {
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500 shrink-0"> <div className="p-2 rounded-lg bg-blue-500/10 text-blue-500 shrink-0">
<span className="material-symbols-outlined text-[20px]">route</span> <span className="material-symbols-outlined text-[20px]">
route
</span>
</div> </div>
<h3 className="text-base sm:text-lg font-semibold">Routing Strategy</h3> <h3 className="text-base sm:text-lg font-semibold">
Routing Strategy
</h3>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-start sm:items-center justify-between gap-4"> <div className="flex items-start sm:items-center justify-between gap-4">
@@ -952,7 +1137,13 @@ export default function ProfilePage() {
</div> </div>
<Toggle <Toggle
checked={settings.fallbackStrategy === "round-robin"} checked={settings.fallbackStrategy === "round-robin"}
onChange={() => updateFallbackStrategy(settings.fallbackStrategy === "round-robin" ? "fill-first" : "round-robin")} onChange={() =>
updateFallbackStrategy(
settings.fallbackStrategy === "round-robin"
? "fill-first"
: "round-robin",
)
}
disabled={loading} disabled={loading}
/> />
</div> </div>
@@ -961,7 +1152,9 @@ export default function ProfilePage() {
{settings.fallbackStrategy === "round-robin" && ( {settings.fallbackStrategy === "round-robin" && (
<div className="flex items-start sm:items-center justify-between gap-4 pt-2 border-t border-border/50"> <div className="flex items-start sm:items-center justify-between gap-4 pt-2 border-t border-border/50">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base">Sticky Limit</p> <p className="font-medium text-sm sm:text-base">
Sticky Limit
</p>
<p className="text-xs sm:text-sm text-text-muted"> <p className="text-xs sm:text-sm text-text-muted">
Calls per account before switching Calls per account before switching
</p> </p>
@@ -981,14 +1174,23 @@ export default function ProfilePage() {
{/* Combo Round Robin */} {/* Combo Round Robin */}
<div className="flex items-start sm:items-center justify-between gap-4 pt-4 border-t border-border/50"> <div className="flex items-start sm:items-center justify-between gap-4 pt-4 border-t border-border/50">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base">Combo Round Robin</p> <p className="font-medium text-sm sm:text-base">
Combo Round Robin
</p>
<p className="text-xs sm:text-sm text-text-muted"> <p className="text-xs sm:text-sm text-text-muted">
Cycle through providers in combos instead of always starting with first Cycle through providers in combos instead of always starting
with first
</p> </p>
</div> </div>
<Toggle <Toggle
checked={settings.comboStrategy === "round-robin"} checked={settings.comboStrategy === "round-robin"}
onChange={() => updateComboStrategy(settings.comboStrategy === "round-robin" ? "fallback" : "round-robin")} onChange={() =>
updateComboStrategy(
settings.comboStrategy === "round-robin"
? "fallback"
: "round-robin",
)
}
disabled={loading} disabled={loading}
/> />
</div> </div>
@@ -1029,16 +1231,24 @@ export default function ProfilePage() {
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500 shrink-0"> <div className="p-2 rounded-lg bg-amber-500/10 text-amber-500 shrink-0">
<span className="material-symbols-outlined text-[20px]">timer</span> <span className="material-symbols-outlined text-[20px]">
timer
</span>
</div> </div>
<h3 className="text-base sm:text-lg font-semibold">Default Connect Timeout</h3> <h3 className="text-base sm:text-lg font-semibold">
Default Connect Timeout
</h3>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-start sm:items-center justify-between gap-4"> <div className="flex items-start sm:items-center justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base">All Providers</p> <p className="font-medium text-sm sm:text-base">
All Providers
</p>
<p className="text-xs sm:text-sm text-text-muted"> <p className="text-xs sm:text-sm text-text-muted">
Timeout for upstream connect (applies globally unless overridden per provider). Set to 0 or leave empty for system default (60s). Timeout for upstream connect (applies globally unless
overridden per provider). Set to 0 or leave empty for system
default (60s).
</p> </p>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -1046,7 +1256,11 @@ export default function ProfilePage() {
type="text" type="text"
inputMode="numeric" inputMode="numeric"
placeholder="60000" placeholder="60000"
value={settings.defaultTimeoutMs != null ? String(settings.defaultTimeoutMs) : ""} value={
settings.defaultTimeoutMs != null
? String(settings.defaultTimeoutMs)
: ""
}
onChange={handleGlobalTimeoutChange} onChange={handleGlobalTimeoutChange}
disabled={loading} disabled={loading}
className="w-20 text-center" className="w-20 text-center"
@@ -1066,7 +1280,9 @@ export default function ProfilePage() {
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500 shrink-0"> <div className="p-2 rounded-lg bg-purple-500/10 text-purple-500 shrink-0">
<span className="material-symbols-outlined text-[20px]">wifi</span> <span className="material-symbols-outlined text-[20px]">
wifi
</span>
</div> </div>
<h3 className="text-base sm:text-lg font-semibold">Network</h3> <h3 className="text-base sm:text-lg font-semibold">Network</h3>
</div> </div>
@@ -1074,38 +1290,67 @@ export default function ProfilePage() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-start sm:items-center justify-between gap-4"> <div className="flex items-start sm:items-center justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base">Outbound Proxy</p> <p className="font-medium text-sm sm:text-base">
<p className="text-xs sm:text-sm text-text-muted">Enable proxy for OAuth + provider outbound requests.</p> Outbound Proxy
</p>
<p className="text-xs sm:text-sm text-text-muted">
Enable proxy for OAuth + provider outbound requests.
</p>
</div> </div>
<Toggle <Toggle
checked={settings.outboundProxyEnabled === true} checked={settings.outboundProxyEnabled === true}
onChange={() => updateOutboundProxyEnabled(!(settings.outboundProxyEnabled === true))} onChange={() =>
updateOutboundProxyEnabled(
!(settings.outboundProxyEnabled === true),
)
}
disabled={loading || proxyLoading} disabled={loading || proxyLoading}
/> />
</div> </div>
{settings.outboundProxyEnabled === true && ( {settings.outboundProxyEnabled === true && (
<form onSubmit={updateOutboundProxy} className="flex flex-col gap-4 pt-2 border-t border-border/50"> <form
onSubmit={updateOutboundProxy}
className="flex flex-col gap-4 pt-2 border-t border-border/50"
>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Proxy URL</label> <label className="font-medium text-sm sm:text-base">
Proxy URL
</label>
<Input <Input
placeholder="http://127.0.0.1:7897" placeholder="http://127.0.0.1:7897"
value={proxyForm.outboundProxyUrl} value={proxyForm.outboundProxyUrl}
onChange={(e) => setProxyForm((prev) => ({ ...prev, outboundProxyUrl: e.target.value }))} onChange={(e) =>
setProxyForm((prev) => ({
...prev,
outboundProxyUrl: e.target.value,
}))
}
disabled={loading || proxyLoading} disabled={loading || proxyLoading}
/> />
<p className="text-xs sm:text-sm text-text-muted">Leave empty to inherit existing env proxy (if any).</p> <p className="text-xs sm:text-sm text-text-muted">
Leave empty to inherit existing env proxy (if any).
</p>
</div> </div>
<div className="flex flex-col gap-2 pt-2 border-t border-border/50"> <div className="flex flex-col gap-2 pt-2 border-t border-border/50">
<label className="font-medium text-sm sm:text-base">No Proxy</label> <label className="font-medium text-sm sm:text-base">
No Proxy
</label>
<Input <Input
placeholder="localhost,127.0.0.1" placeholder="localhost,127.0.0.1"
value={proxyForm.outboundNoProxy} value={proxyForm.outboundNoProxy}
onChange={(e) => setProxyForm((prev) => ({ ...prev, outboundNoProxy: e.target.value }))} onChange={(e) =>
setProxyForm((prev) => ({
...prev,
outboundNoProxy: e.target.value,
}))
}
disabled={loading || proxyLoading} disabled={loading || proxyLoading}
/> />
<p className="text-xs sm:text-sm text-text-muted">Comma-separated hostnames/domains to bypass the proxy.</p> <p className="text-xs sm:text-sm text-text-muted">
Comma-separated hostnames/domains to bypass the proxy.
</p>
</div> </div>
<div className="pt-2 border-t border-border/50 flex flex-col sm:flex-row items-stretch sm:items-center gap-2"> <div className="pt-2 border-t border-border/50 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
@@ -1119,7 +1364,12 @@ export default function ProfilePage() {
> >
Test proxy URL Test proxy URL
</Button> </Button>
<Button type="submit" variant="primary" loading={proxyLoading} className="w-full sm:w-auto"> <Button
type="submit"
variant="primary"
loading={proxyLoading}
className="w-full sm:w-auto"
>
Apply Apply
</Button> </Button>
</div> </div>
@@ -1127,24 +1377,63 @@ export default function ProfilePage() {
)} )}
{proxyStatus.message && ( {proxyStatus.message && (
<p className={`text-xs sm:text-sm ${proxyStatus.type === "error" ? "text-red-500" : "text-green-500"} pt-2 border-t border-border/50`}> <p
className={`text-xs sm:text-sm ${proxyStatus.type === "error" ? "text-red-500" : "text-green-500"} pt-2 border-t border-border/50`}
>
{proxyStatus.message} {proxyStatus.message}
</p> </p>
)} )}
</div> </div>
</Card> </Card>
{/* Model Display Settings */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-teal-500/10 text-teal-500 shrink-0">
<span className="material-symbols-outlined text-[20px]">
visibility
</span>
</div>
<h3 className="text-base sm:text-lg font-semibold">
Model Display
</h3>
</div>
<div className="flex items-start sm:items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base">
Only show combo models
</p>
<p className="text-xs sm:text-sm text-text-muted">
When ON, the model selector only shows models that have been
added to at least one active combo. Individual provider models
are hidden.
</p>
</div>
<Toggle
checked={settings.showOnlyComboModels === true}
onChange={updateShowOnlyComboModels}
disabled={loading}
/>
</div>
</Card>
{/* Observability Settings */} {/* Observability Settings */}
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-orange-500/10 text-orange-500 shrink-0"> <div className="p-2 rounded-lg bg-orange-500/10 text-orange-500 shrink-0">
<span className="material-symbols-outlined text-[20px]">monitoring</span> <span className="material-symbols-outlined text-[20px]">
monitoring
</span>
</div> </div>
<h3 className="text-base sm:text-lg font-semibold">Observability</h3> <h3 className="text-base sm:text-lg font-semibold">
Observability
</h3>
</div> </div>
<div className="flex items-start sm:items-center justify-between gap-4"> <div className="flex items-start sm:items-center justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base">Enable Observability</p> <p className="font-medium text-sm sm:text-base">
Enable Observability
</p>
<p className="text-xs sm:text-sm text-text-muted"> <p className="text-xs sm:text-sm text-text-muted">
Record request details for inspection in the logs view Record request details for inspection in the logs view
</p> </p>
@@ -1180,7 +1469,9 @@ export default function ProfilePage() {
{/* App Info */} {/* App Info */}
<div className="text-center text-xs sm:text-sm text-text-muted py-4"> <div className="text-center text-xs sm:text-sm text-text-muted py-4">
<p>{APP_CONFIG.name} v{APP_CONFIG.version}</p> <p>
{APP_CONFIG.name} v{APP_CONFIG.version}
</p>
<p className="mt-1">Local Mode - All data stored on your machine</p> <p className="mt-1">Local Mode - All data stored on your machine</p>
</div> </div>
</div> </div>
@@ -1212,23 +1503,37 @@ export default function ProfilePage() {
size="sm" size="sm"
footer={ footer={
<> <>
<Button variant="ghost" onClick={() => setDbAuth({ open: false, mode: "", password: "" })} disabled={dbLoading}> <Button
variant="ghost"
onClick={() => setDbAuth({ open: false, mode: "", password: "" })}
disabled={dbLoading}
>
Cancel Cancel
</Button> </Button>
<Button variant="primary" onClick={handleDbAuthConfirm} loading={dbLoading} disabled={!dbAuth.password}> <Button
variant="primary"
onClick={handleDbAuthConfirm}
loading={dbLoading}
disabled={!dbAuth.password}
>
Confirm Confirm
</Button> </Button>
</> </>
} }
> >
<p className="text-text-muted mb-3 text-sm"> <p className="text-text-muted mb-3 text-sm">
Enter your current password to {dbAuth.mode === "export" ? "export" : "import"} the database. Enter your current password to{" "}
{dbAuth.mode === "export" ? "export" : "import"} the database.
</p> </p>
<Input <Input
type="password" type="password"
value={dbAuth.password} value={dbAuth.password}
onChange={(e) => setDbAuth((s) => ({ ...s, password: e.target.value }))} onChange={(e) =>
onKeyDown={(e) => { if (e.key === "Enter" && dbAuth.password) handleDbAuthConfirm(); }} setDbAuth((s) => ({ ...s, password: e.target.value }))
}
onKeyDown={(e) => {
if (e.key === "Enter" && dbAuth.password) handleDbAuthConfirm();
}}
placeholder="Current password" placeholder="Current password"
autoFocus autoFocus
/> />

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,21 @@
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS, getModelKind } from "@/shared/constants/models"; import {
PROVIDER_MODELS,
PROVIDER_ID_TO_ALIAS,
getModelKind,
} from "@/shared/constants/models";
import { import {
AI_PROVIDERS, AI_PROVIDERS,
getProviderAlias, getProviderAlias,
isAnthropicCompatibleProvider, isAnthropicCompatibleProvider,
isOpenAICompatibleProvider, isOpenAICompatibleProvider,
} from "@/shared/constants/providers"; } 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 { getDisabledModels } from "@/lib/disabledModelsDb";
import { resolveKiroModels } from "open-sse/services/kiroModels.js"; import { resolveKiroModels } from "open-sse/services/kiroModels.js";
import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
@@ -17,18 +27,24 @@ import { resolveCursorModels } from "open-sse/services/cursorModels.js";
import { resolveZedModels } from "open-sse/shared/zedAuth.js"; import { resolveZedModels } from "open-sse/shared/zedAuth.js";
import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; 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 // Per-provider live model resolvers. Each receives a connection record and
// returns { models: [{ id, name? }, ...] } | null on failure. // returns { models: [{ id, name? }, ...] } | null on failure.
// Adding a provider here makes /v1/models prefer the live catalog for it. // Adding a provider here makes /v1/models prefer the live catalog for it.
const LIVE_MODEL_RESOLVERS = { const LIVE_MODEL_RESOLVERS = {
kiro: async (conn) => { kiro: async (conn) => {
const result = await resolveKiroModels({ const result = await resolveKiroModels(
{
accessToken: conn.accessToken, accessToken: conn.accessToken,
refreshToken: conn.refreshToken, refreshToken: conn.refreshToken,
providerSpecificData: conn.providerSpecificData || {} providerSpecificData: conn.providerSpecificData || {},
}, { log: console }); },
{ log: console },
);
return result?.models?.length ? { models: result.models } : null; return result?.models?.length ? { models: result.models } : null;
}, },
qoder: async (conn) => { qoder: async (conn) => {
@@ -37,7 +53,7 @@ const LIVE_MODEL_RESOLVERS = {
refreshToken: conn.refreshToken, refreshToken: conn.refreshToken,
email: conn.email, email: conn.email,
displayName: conn.displayName, displayName: conn.displayName,
providerSpecificData: conn.providerSpecificData || {} providerSpecificData: conn.providerSpecificData || {},
}); });
if (!result?.models?.length) return null; if (!result?.models?.length) return null;
return { return {
@@ -45,19 +61,24 @@ const LIVE_MODEL_RESOLVERS = {
}; };
}, },
kimchi: async (conn) => { kimchi: async (conn) => {
const result = await resolveKimchiModels({ const result = await resolveKimchiModels(
{
accessToken: conn.accessToken, accessToken: conn.accessToken,
apiKey: conn.apiKey, apiKey: conn.apiKey,
providerSpecificData: conn.providerSpecificData || {} providerSpecificData: conn.providerSpecificData || {},
}, { log: console }); },
{ log: console },
);
return result?.models?.length ? { models: result.models } : null; return result?.models?.length ? { models: result.models } : null;
}, },
github: async (conn) => { github: async (conn) => {
const result = await resolveCopilotModels({ const result = await resolveCopilotModels(
{
accessToken: conn.accessToken, accessToken: conn.accessToken,
refreshToken: conn.refreshToken, refreshToken: conn.refreshToken,
providerSpecificData: conn.providerSpecificData || {} providerSpecificData: conn.providerSpecificData || {},
}, { },
{
log: console, log: console,
onCredentialsRefreshed: async (refreshed) => { onCredentialsRefreshed: async (refreshed) => {
await updateProviderCredentials(conn.id, { await updateProviderCredentials(conn.id, {
@@ -66,7 +87,8 @@ const LIVE_MODEL_RESOLVERS = {
existingProviderSpecificData: conn.providerSpecificData || {}, existingProviderSpecificData: conn.providerSpecificData || {},
}); });
}, },
}); },
);
return result?.models?.length ? { models: result.models } : null; return result?.models?.length ? { models: result.models } : null;
}, },
clinepass: async (conn) => { clinepass: async (conn) => {
@@ -77,11 +99,15 @@ const LIVE_MODEL_RESOLVERS = {
return result?.models?.length ? { models: result.models } : null; return result?.models?.length ? { models: result.models } : null;
}, },
"grok-cli": async (conn) => { "grok-cli": async (conn) => {
const proxy = await resolveConnectionProxyConfig(conn.providerSpecificData || {}); const proxy = await resolveConnectionProxyConfig(
const result = await resolveGrokCliModels({ conn.providerSpecificData || {},
);
const result = await resolveGrokCliModels(
{
...conn, ...conn,
connectionId: conn.id, connectionId: conn.id,
}, { },
{
log: console, log: console,
proxyOptions: { proxyOptions: {
connectionProxyEnabled: proxy.connectionProxyEnabled === true, connectionProxyEnabled: proxy.connectionProxyEnabled === true,
@@ -96,14 +122,18 @@ const LIVE_MODEL_RESOLVERS = {
existingProviderSpecificData: conn.providerSpecificData || {}, existingProviderSpecificData: conn.providerSpecificData || {},
}); });
}, },
}); },
);
return result?.models?.length ? { models: result.models } : null; return result?.models?.length ? { models: result.models } : null;
}, },
cursor: async (conn) => { cursor: async (conn) => {
const result = await resolveCursorModels({ const result = await resolveCursorModels(
{
accessToken: conn.accessToken, accessToken: conn.accessToken,
providerSpecificData: conn.providerSpecificData || {}, providerSpecificData: conn.providerSpecificData || {},
}, { log: console }); },
{ log: console },
);
return result?.models?.length ? { models: result.models } : null; return result?.models?.length ? { models: result.models } : null;
}, },
zed: async (conn) => { zed: async (conn) => {
@@ -159,14 +189,16 @@ function inferKindFromUnknownModelId(modelId) {
const lower = String(modelId).toLowerCase(); const lower = String(modelId).toLowerCase();
if (/embed/.test(lower)) return "embedding"; if (/embed/.test(lower)) return "embedding";
if (/tts|speech|audio|voice/.test(lower)) return "tts"; if (/tts|speech|audio|voice/.test(lower)) return "tts";
if (/image|imagen|dall-?e|flux|sdxl|sd-|stable-diffusion/.test(lower)) return "image"; if (/image|imagen|dall-?e|flux|sdxl|sd-|stable-diffusion/.test(lower))
return "image";
return LLM_KIND; return LLM_KIND;
} }
async function fetchCompatibleModelIds(connection) { async function fetchCompatibleModelIds(connection) {
if (!connection?.apiKey) return []; if (!connection?.apiKey) return [];
const baseUrl = typeof connection?.providerSpecificData?.baseUrl === "string" const baseUrl =
typeof connection?.providerSpecificData?.baseUrl === "string"
? connection.providerSpecificData.baseUrl.trim().replace(/\/$/, "") ? connection.providerSpecificData.baseUrl.trim().replace(/\/$/, "")
: ""; : "";
@@ -212,8 +244,10 @@ async function fetchCompatibleModelIds(connection) {
new Set( new Set(
rawModels rawModels
.map((model) => model?.id || model?.name || model?.model) .map((model) => model?.id || model?.name || model?.model)
.filter((modelId) => typeof modelId === "string" && modelId.trim() !== "") .filter(
) (modelId) => typeof modelId === "string" && modelId.trim() !== "",
),
),
); );
} catch { } catch {
return []; return [];
@@ -224,7 +258,8 @@ async function fetchCompatibleModelIds(connection) {
// LLM is the default kind for providers missing serviceKinds. // LLM is the default kind for providers missing serviceKinds.
function providerMatchesKinds(providerId, kindFilter) { function providerMatchesKinds(providerId, kindFilter) {
const provider = AI_PROVIDERS[providerId]; const provider = AI_PROVIDERS[providerId];
const kinds = Array.isArray(provider?.serviceKinds) && provider.serviceKinds.length > 0 const kinds =
Array.isArray(provider?.serviceKinds) && provider.serviceKinds.length > 0
? provider.serviceKinds ? provider.serviceKinds
: [LLM_KIND]; : [LLM_KIND];
return kindFilter.some((k) => kinds.includes(k)); return kindFilter.some((k) => kinds.includes(k));
@@ -249,7 +284,7 @@ export async function buildModelsList(kindFilter, options = {}) {
let connections = []; let connections = [];
try { try {
connections = await getProviderConnections(); connections = await getProviderConnections();
connections = connections.filter(c => c.isActive !== false); connections = connections.filter((c) => c.isActive !== false);
} catch (e) { } catch (e) {
console.log("Could not fetch providers, returning all models"); console.log("Could not fetch providers, returning all models");
} }
@@ -281,7 +316,9 @@ export async function buildModelsList(kindFilter, options = {}) {
} catch (e) { } catch (e) {
console.log("Could not fetch disabled models"); console.log("Could not fetch disabled models");
} }
const isDisabled = (alias, modelId) => Array.isArray(disabledByAlias[alias]) && disabledByAlias[alias].includes(modelId); const isDisabled = (alias, modelId) =>
Array.isArray(disabledByAlias[alias]) &&
disabledByAlias[alias].includes(modelId);
const activeConnectionByProvider = new Map(); const activeConnectionByProvider = new Map();
for (const conn of connections) { for (const conn of connections) {
@@ -309,7 +346,7 @@ export async function buildModelsList(kindFilter, options = {}) {
if (connections.length === 0) { if (connections.length === 0) {
// DB unavailable -> return static models, filtered by per-model kind // DB unavailable -> return static models, filtered by per-model kind
const aliasToProviderId = Object.fromEntries( const aliasToProviderId = Object.fromEntries(
Object.entries(PROVIDER_ID_TO_ALIAS).map(([id, alias]) => [alias, id]) Object.entries(PROVIDER_ID_TO_ALIAS).map(([id, alias]) => [alias, id]),
); );
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) { for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
const providerId = aliasToProviderId[alias] || alias; const providerId = aliasToProviderId[alias] || alias;
@@ -326,7 +363,8 @@ export async function buildModelsList(kindFilter, options = {}) {
} }
for (const customModel of customModels) { for (const customModel of customModels) {
if (!customModel?.id || (customModel.type && customModel.type !== "llm")) continue; if (!customModel?.id || (customModel.type && customModel.type !== "llm"))
continue;
// Custom models without active connection are LLM-only by current schema // Custom models without active connection are LLM-only by current schema
if (!kindFilter.includes(LLM_KIND)) continue; if (!kindFilter.includes(LLM_KIND)) continue;
const providerAlias = customModel.providerAlias; const providerAlias = customModel.providerAlias;
@@ -347,20 +385,21 @@ export async function buildModelsList(kindFilter, options = {}) {
const staticAlias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; const staticAlias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
const outputAlias = ( const outputAlias = (
conn?.providerSpecificData?.prefix conn?.providerSpecificData?.prefix ||
|| getProviderAlias(providerId) getProviderAlias(providerId) ||
|| staticAlias staticAlias
).trim(); ).trim();
const providerModels = PROVIDER_MODELS[staticAlias] || []; const providerModels = PROVIDER_MODELS[staticAlias] || [];
const enabledModels = conn?.providerSpecificData?.enabledModels; const enabledModels = conn?.providerSpecificData?.enabledModels;
const hasExplicitEnabledModels = const hasExplicitEnabledModels =
Array.isArray(enabledModels) && enabledModels.length > 0; Array.isArray(enabledModels) && enabledModels.length > 0;
const isCompatibleProvider = const isCompatibleProvider =
isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId); isOpenAICompatibleProvider(providerId) ||
isAnthropicCompatibleProvider(providerId);
// Build kind lookup for static models so we can filter even when only IDs are exposed // Build kind lookup for static models so we can filter even when only IDs are exposed
const staticModelKindById = new Map( const staticModelKindById = new Map(
providerModels.map((m) => [m.id, modelKind(m)]) providerModels.map((m) => [m.id, modelKind(m)]),
); );
let liveModelKindById = new Map(); let liveModelKindById = new Map();
let liveCapabilitiesById = new Map(); let liveCapabilitiesById = new Map();
@@ -369,13 +408,18 @@ export async function buildModelsList(kindFilter, options = {}) {
? Array.from( ? Array.from(
new Set( new Set(
enabledModels.filter( enabledModels.filter(
(modelId) => typeof modelId === "string" && modelId.trim() !== "", (modelId) =>
typeof modelId === "string" && modelId.trim() !== "",
), ),
), ),
) )
: providerModels.map((model) => model.id); : providerModels.map((model) => model.id);
if (isCompatibleProvider && rawModelIds.length === 0 && !skipDynamicFetch) { if (
isCompatibleProvider &&
rawModelIds.length === 0 &&
!skipDynamicFetch
) {
rawModelIds = await fetchCompatibleModelIds(conn); rawModelIds = await fetchCompatibleModelIds(conn);
} }
@@ -389,18 +433,18 @@ export async function buildModelsList(kindFilter, options = {}) {
if (live?.models?.length) { if (live?.models?.length) {
rawModelIds = live.models.map((m) => m.id); rawModelIds = live.models.map((m) => m.id);
liveModelKindById = new Map( liveModelKindById = new Map(
live.models live.models.filter((m) => m?.id).map((m) => [m.id, modelKind(m)]),
.filter((m) => m?.id)
.map((m) => [m.id, modelKind(m)])
); );
liveCapabilitiesById = new Map( liveCapabilitiesById = new Map(
live.models live.models
.filter((m) => m?.id && m.capabilities) .filter((m) => m?.id && m.capabilities)
.map((m) => [m.id, m.capabilities]) .map((m) => [m.id, m.capabilities]),
); );
} }
} catch (err) { } catch (err) {
console.log(`Live model fetch failed for ${providerId}: ${err?.message || err}`); console.log(
`Live model fetch failed for ${providerId}: ${err?.message || err}`,
);
} }
} }
@@ -417,7 +461,9 @@ export async function buildModelsList(kindFilter, options = {}) {
} }
return modelId; return modelId;
}) })
.filter((modelId) => typeof modelId === "string" && modelId.trim() !== ""); .filter(
(modelId) => typeof modelId === "string" && modelId.trim() !== "",
);
const customModelKindById = new Map(); const customModelKindById = new Map();
const customModelIds = customModels const customModelIds = customModels
@@ -426,20 +472,30 @@ export async function buildModelsList(kindFilter, options = {}) {
const kind = getModelKind(m) || LLM_KIND; const kind = getModelKind(m) || LLM_KIND;
// imageToText custom models are vision-capable chat models: expose them // imageToText custom models are vision-capable chat models: expose them
// both in the default LLM list and in /v1/models/image-to-text. // 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; if (
!kindFilter.includes(kind) &&
!(kind === "imageToText" && kindFilter.includes(LLM_KIND))
)
return false;
const alias = m.providerAlias; const alias = m.providerAlias;
return alias === staticAlias || alias === outputAlias || alias === providerId; return (
alias === staticAlias ||
alias === outputAlias ||
alias === providerId
);
}) })
.map((m) => { .map((m) => {
const modelId = String(m.id).trim(); const modelId = String(m.id).trim();
if (modelId) customModelKindById.set(modelId, getModelKind(m) || LLM_KIND); if (modelId)
customModelKindById.set(modelId, getModelKind(m) || LLM_KIND);
return modelId; return modelId;
}) })
.filter((modelId) => modelId !== ""); .filter((modelId) => modelId !== "");
const aliasModelIds = Object.values(modelAliases || {}) const aliasModelIds = Object.values(modelAliases || {})
.filter((fullModel) => { .filter((fullModel) => {
if (typeof fullModel !== "string" || !fullModel.includes("/")) return false; if (typeof fullModel !== "string" || !fullModel.includes("/"))
return false;
return ( return (
fullModel.startsWith(`${outputAlias}/`) || fullModel.startsWith(`${outputAlias}/`) ||
fullModel.startsWith(`${staticAlias}/`) || fullModel.startsWith(`${staticAlias}/`) ||
@@ -458,19 +514,32 @@ export async function buildModelsList(kindFilter, options = {}) {
} }
return fullModel; return fullModel;
}) })
.filter((modelId) => typeof modelId === "string" && modelId.trim() !== ""); .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) { for (const modelId of mergedModelIds) {
// Resolve kind: prefer custom/live metadata, then static, then ID heuristics. // Resolve kind: prefer custom/live metadata, then static, then ID heuristics.
const customKind = customModelKindById.get(modelId); const customKind = customModelKindById.get(modelId);
const liveKind = liveModelKindById.get(modelId); const liveKind = liveModelKindById.get(modelId);
const kind = customKind || liveKind || staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId); const kind =
customKind ||
liveKind ||
staticModelKindById.get(modelId) ||
inferKindFromUnknownModelId(modelId);
// imageToText custom models stay in the LLM list (vision-capable chat models) // imageToText custom models stay in the LLM list (vision-capable chat models)
const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND); const allowAsLlm =
kind === "imageToText" && kindFilter.includes(LLM_KIND);
if (!kindFilter.includes(kind) && !allowAsLlm) continue; if (!kindFilter.includes(kind) && !allowAsLlm) continue;
if (isDisabled(outputAlias, modelId) || isDisabled(staticAlias, modelId)) continue; if (
isDisabled(outputAlias, modelId) ||
isDisabled(staticAlias, modelId)
)
continue;
const model = { const model = {
id: `${outputAlias}/${modelId}`, id: `${outputAlias}/${modelId}`,
@@ -481,9 +550,12 @@ export async function buildModelsList(kindFilter, options = {}) {
// { id, name } — no per-model capability data. Fall back to the same // { id, name } — no per-model capability data. Fall back to the same
// pattern-matched capabilities the dashboard uses (useModelCaps.js) so // pattern-matched capabilities the dashboard uses (useModelCaps.js) so
// dynamically-discovered LLM models still surface vision/reasoning/search/tools. // dynamically-discovered LLM models still surface vision/reasoning/search/tools.
const caps = liveCapabilitiesById.get(modelId) const caps =
|| capabilitiesFromServiceKind(customKind || liveKind) liveCapabilitiesById.get(modelId) ||
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null); capabilitiesFromServiceKind(customKind || liveKind) ||
(kind === LLM_KIND
? getCapabilitiesForModel(providerId, modelId)
: null);
if (caps) model.capabilities = caps; if (caps) model.capabilities = caps;
models.push(model); models.push(model);
} }
@@ -517,6 +589,25 @@ export async function buildModelsList(kindFilter, options = {}) {
dedupedModels.push(model); dedupedModels.push(model);
} }
// 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; return dedupedModels;
} }
@@ -540,16 +631,20 @@ export async function OPTIONS() {
export async function GET(request) { export async function GET(request) {
try { try {
// Detect cross-instance recursive /models fetch (another 9router fetching our /models) // Detect cross-instance recursive /models fetch (another 9router fetching our /models)
const skipDynamicFetch = request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1"; const skipDynamicFetch =
request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1";
const data = await buildModelsList([LLM_KIND], { skipDynamicFetch }); const data = await buildModelsList([LLM_KIND], { skipDynamicFetch });
return Response.json({ object: "list", data }, { return Response.json(
{ object: "list", data },
{
headers: { "Access-Control-Allow-Origin": "*" }, headers: { "Access-Control-Allow-Origin": "*" },
}); },
);
} catch (error) { } catch (error) {
console.log("Error fetching models:", error); console.log("Error fetching models:", error);
return Response.json( return Response.json(
{ error: { message: error.message, type: "server_error" } }, { error: { message: error.message, type: "server_error" } },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@@ -9,6 +9,7 @@ function rowToCombo(row) {
name: row.name, name: row.name,
kind: row.kind, kind: row.kind,
models: parseJson(row.models, []), models: parseJson(row.models, []),
enabled: row.enabled === 1 || row.enabled === true,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
@@ -40,12 +41,21 @@ export async function createCombo(data) {
name: data.name, name: data.name,
kind: data.kind || null, kind: data.kind || null,
models: data.models || [], models: data.models || [],
enabled: data.enabled !== false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
db.run( db.run(
`INSERT INTO combos(id, name, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`, `INSERT INTO combos(id, name, kind, models, enabled, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`,
[combo.id, combo.name, combo.kind, stringifyJson(combo.models), combo.createdAt, combo.updatedAt] [
combo.id,
combo.name,
combo.kind,
stringifyJson(combo.models),
combo.enabled !== false ? 1 : 0,
combo.createdAt,
combo.updatedAt,
],
); );
return combo; return combo;
} }
@@ -56,10 +66,21 @@ export async function updateCombo(id, data) {
db.transaction(() => { db.transaction(() => {
const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]); const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]);
if (!row) return; if (!row) return;
const merged = { ...rowToCombo(row), ...data, updatedAt: new Date().toISOString() }; const merged = {
...rowToCombo(row),
...data,
updatedAt: new Date().toISOString(),
};
db.run( db.run(
`UPDATE combos SET name = ?, kind = ?, models = ?, updatedAt = ? WHERE id = ?`, `UPDATE combos SET name = ?, kind = ?, models = ?, enabled = ?, updatedAt = ? WHERE id = ?`,
[merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id] [
merged.name,
merged.kind,
stringifyJson(merged.models || []),
merged.enabled ? 1 : 0,
merged.updatedAt,
id,
],
); );
result = merged; result = merged;
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,7 @@ export const TABLES = {
name: "TEXT UNIQUE NOT NULL", name: "TEXT UNIQUE NOT NULL",
kind: "TEXT", kind: "TEXT",
models: "TEXT NOT NULL", models: "TEXT NOT NULL",
enabled: "INTEGER NOT NULL DEFAULT 1",
createdAt: "TEXT NOT NULL", createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL", updatedAt: "TEXT NOT NULL",
}, },

View File

@@ -7,7 +7,16 @@ import ProviderIcon from "./ProviderIcon";
import CapacityBadges from "./CapacityBadges"; import CapacityBadges from "./CapacityBadges";
import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { useModelCaps } from "@/shared/hooks/useModelCaps";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; 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) // Provider order: OAuth first, then Free Tier, then API Key (matches dashboard/providers)
const PROVIDER_ORDER = [ const PROVIDER_ORDER = [
@@ -18,7 +27,9 @@ const PROVIDER_ORDER = [
]; ];
// Providers that need no auth — always show in model selector // 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({ export default function ModelSelectModal({
isOpen, isOpen,
@@ -49,12 +60,14 @@ export default function ModelSelectModal({
const [customModels, setCustomModels] = useState([]); const [customModels, setCustomModels] = useState([]);
const [disabledModels, setDisabledModels] = useState({}); const [disabledModels, setDisabledModels] = useState({});
const [cursorModels, setCursorModels] = useState([]); const [cursorModels, setCursorModels] = useState([]);
const [showOnlyComboModels, setShowOnlyComboModels] = useState(false);
// Cursor exposes the usable catalog per account. Keep the static catalog only // Cursor exposes the usable catalog per account. Keep the static catalog only
// as a fallback, since it quickly becomes stale and different accounts can // as a fallback, since it quickly becomes stale and different accounts can
// have different model entitlements. // have different model entitlements.
const cursorConnectionIds = useMemo( const cursorConnectionIds = useMemo(
() => activeProviders () =>
activeProviders
.filter((provider) => provider.provider === "cursor" && provider.id) .filter((provider) => provider.provider === "cursor" && provider.id)
.map((provider) => provider.id), .map((provider) => provider.id),
[activeProviders], [activeProviders],
@@ -67,20 +80,26 @@ export default function ModelSelectModal({
} }
let cancelled = false; let cancelled = false;
Promise.all(cursorConnectionIds.map(async (connectionId) => { Promise.all(
const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" }); cursorConnectionIds.map(async (connectionId) => {
const response = await fetch(`/api/providers/${connectionId}/models`, {
cache: "no-store",
});
if (!response.ok) return []; if (!response.ok) return [];
const data = await response.json(); const data = await response.json();
return Array.isArray(data.models) ? data.models : []; return Array.isArray(data.models) ? data.models : [];
})) }),
)
.then((modelLists) => { .then((modelLists) => {
if (cancelled) return; if (cancelled) return;
const seen = new Set(); const seen = new Set();
setCursorModels(modelLists.flat().filter((model) => { setCursorModels(
modelLists.flat().filter((model) => {
if (!model?.id || seen.has(model.id)) return false; if (!model?.id || seen.has(model.id)) return false;
seen.add(model.id); seen.add(model.id);
return true; return true;
})); }),
);
}) })
.catch((error) => { .catch((error) => {
// Do not hide the static fallback when the account catalog is unavailable. // Do not hide the static fallback when the account catalog is unavailable.
@@ -88,7 +107,9 @@ export default function ModelSelectModal({
if (!cancelled) setCursorModels([]); if (!cancelled) setCursorModels([]);
}); });
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, [isOpen, cursorConnectionIds]); }, [isOpen, cursorConnectionIds]);
const fetchCombos = async () => { const fetchCombos = async () => {
@@ -110,7 +131,8 @@ export default function ModelSelectModal({
const fetchProviderNodes = async () => { const fetchProviderNodes = async () => {
try { try {
const res = await fetch("/api/provider-nodes"); const res = await fetch("/api/provider-nodes");
if (!res.ok) throw new Error(`Failed to fetch provider nodes: ${res.status}`); if (!res.ok)
throw new Error(`Failed to fetch provider nodes: ${res.status}`);
const data = await res.json(); const data = await res.json();
setProviderNodes(data.nodes || []); setProviderNodes(data.nodes || []);
} catch (error) { } catch (error) {
@@ -126,7 +148,8 @@ export default function ModelSelectModal({
const fetchCustomModels = async () => { const fetchCustomModels = async () => {
try { try {
const res = await fetch("/api/models/custom"); const res = await fetch("/api/models/custom");
if (!res.ok) throw new Error(`Failed to fetch custom models: ${res.status}`); if (!res.ok)
throw new Error(`Failed to fetch custom models: ${res.status}`);
const data = await res.json(); const data = await res.json();
setCustomModels(data.models || []); setCustomModels(data.models || []);
} catch (error) { } catch (error) {
@@ -142,7 +165,8 @@ export default function ModelSelectModal({
const fetchDisabledModels = async () => { const fetchDisabledModels = async () => {
try { try {
const res = await fetch("/api/models/disabled"); const res = await fetch("/api/models/disabled");
if (!res.ok) throw new Error(`Failed to fetch disabled models: ${res.status}`); if (!res.ok)
throw new Error(`Failed to fetch disabled models: ${res.status}`);
const data = await res.json(); const data = await res.json();
setDisabledModels(data.disabled || {}); setDisabledModels(data.disabled || {});
} catch (error) { } catch (error) {
@@ -155,7 +179,26 @@ export default function ModelSelectModal({
if (isOpen) fetchDisabledModels(); if (isOpen) fetchDisabledModels();
}, [isOpen]); }, [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]);
const allProviders = useMemo(
() => ({
...OAUTH_PROVIDERS,
...FREE_PROVIDERS,
...FREE_TIER_PROVIDERS,
...APIKEY_PROVIDERS,
}),
[],
);
// Group models by provider with priority order // Group models by provider with priority order
const groupedModels = useMemo(() => { const groupedModels = useMemo(() => {
@@ -164,7 +207,13 @@ export default function ModelSelectModal({
// Kinds where the provider IS the model (no per-model selection needed) // Kinds where the provider IS the model (no per-model selection needed)
const PROVIDER_AS_MODEL_KINDS = new Set(["webSearch", "webFetch"]); const PROVIDER_AS_MODEL_KINDS = new Set(["webSearch", "webFetch"]);
// Kinds that map directly to model.type field // Kinds that map directly to model.type field
const TYPED_KINDS = new Set(["image", "tts", "stt", "embedding", "imageToText"]); 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) // 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"]); const ALLOW_PROVIDER_FALLBACK_KINDS = new Set(["tts", "image", "webFetch"]);
@@ -173,17 +222,28 @@ export default function ModelSelectModal({
// No kindFilter means the LLM selector. Keep custom models visible because // No kindFilter means the LLM selector. Keep custom models visible because
// user-added models may have typed capabilities (for example imageToText) // user-added models may have typed capabilities (for example imageToText)
// while still being valid chat/combo targets. // while still being valid chat/combo targets.
if (!kindFilter) return models.filter((m) => m.isPlaceholder || m.isCustom || !getModelKind(m) || getModelKind(m) === "llm"); if (!kindFilter)
return models.filter(
(m) =>
m.isPlaceholder ||
m.isCustom ||
!getModelKind(m) ||
getModelKind(m) === "llm",
);
if (!TYPED_KINDS.has(kindFilter)) return models; if (!TYPED_KINDS.has(kindFilter)) return models;
return models.filter((m) => m.isPlaceholder || getModelKind(m) === kindFilter); return models.filter(
(m) => m.isPlaceholder || getModelKind(m) === kindFilter,
);
}; };
// Get all active provider IDs from connections (filtered by kindFilter if set) // Get all active provider IDs from connections (filtered by kindFilter if set)
const activeConnectionIds = filteredActiveProviders.map(p => p.provider); const activeConnectionIds = filteredActiveProviders.map((p) => p.provider);
// No-auth providers: filter by kindFilter as well // No-auth providers: filter by kindFilter as well
const noAuthIds = kindFilter const noAuthIds = kindFilter
? NO_AUTH_PROVIDER_IDS.filter((id) => (AI_PROVIDERS[id]?.serviceKinds || ["llm"]).includes(kindFilter)) ? NO_AUTH_PROVIDER_IDS.filter((id) =>
(AI_PROVIDERS[id]?.serviceKinds || ["llm"]).includes(kindFilter),
)
: NO_AUTH_PROVIDER_IDS; : NO_AUTH_PROVIDER_IDS;
// Only show connected providers (including both standard and custom) // Only show connected providers (including both standard and custom)
@@ -201,8 +261,13 @@ export default function ModelSelectModal({
sortedProviderIds.forEach((providerId) => { sortedProviderIds.forEach((providerId) => {
const alias = getProviderAlias(providerId); const alias = getProviderAlias(providerId);
const providerInfo = allProviders[providerId] || { name: providerId, color: "#666" }; const providerInfo = allProviders[providerId] || {
const isCustomProvider = isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId); name: providerId,
color: "#666",
};
const isCustomProvider =
isOpenAICompatibleProvider(providerId) ||
isAnthropicCompatibleProvider(providerId);
// For provider-as-model kinds (webSearch/webFetch): emit a single entry where value === providerId // For provider-as-model kinds (webSearch/webFetch): emit a single entry where value === providerId
if (kindFilter && PROVIDER_AS_MODEL_KINDS.has(kindFilter)) { if (kindFilter && PROVIDER_AS_MODEL_KINDS.has(kindFilter)) {
@@ -210,7 +275,9 @@ export default function ModelSelectModal({
name: providerInfo.name, name: providerInfo.name,
alias, alias,
color: providerInfo.color, color: providerInfo.color,
models: [{ id: providerId, name: providerInfo.name, value: providerId }], models: [
{ id: providerId, name: providerInfo.name, value: providerId },
],
}; };
return; return;
} }
@@ -236,33 +303,73 @@ export default function ModelSelectModal({
// For typed kinds, only include hardcoded typed models (aliases are typically LLM-only and lack type info) // For typed kinds, only include hardcoded typed models (aliases are typically LLM-only and lack type info)
let combined = aliasModels; let combined = aliasModels;
if (kindFilter && TYPED_KINDS.has(kindFilter)) { if (kindFilter && TYPED_KINDS.has(kindFilter)) {
const registeredTyped = customRegisteredModels.filter((m) => getModelKind(m) === kindFilter); const registeredTyped = customRegisteredModels.filter(
(m) => getModelKind(m) === kindFilter,
);
combined = [ combined = [
...registeredTyped, ...registeredTyped,
...getModelsByProviderId(providerId) ...getModelsByProviderId(providerId)
.filter((m) => getModelKind(m) === kindFilter) .filter((m) => getModelKind(m) === kindFilter)
.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) })) .map((m) => ({
.filter((m) => !registeredTyped.some((registered) => registered.value === m.value)), 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) // Fallback: provider-as-model when no hardcoded models match (tts/image/webFetch only)
if (combined.length === 0 && ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)) { if (
const supports = (providerInfo.serviceKinds || ["llm"]).includes(kindFilter); combined.length === 0 &&
if (supports) combined = [{ id: providerId, name: providerInfo.name, value: alias }]; ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)
) {
const supports = (providerInfo.serviceKinds || ["llm"]).includes(
kindFilter,
);
if (supports)
combined = [
{ id: providerId, name: providerInfo.name, value: alias },
];
} }
} else { } else {
// LLM/null kind: merge hardcoded models (e.g. mimo-free → mimo-auto) with user-added models // 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 registeredLlms = customRegisteredModels.filter(
const seen = new Set([...aliasModels, ...registeredLlms].map((m) => m.value)); (m) => !getModelKind(m) || getModelKind(m) === "llm",
);
const seen = new Set(
[...aliasModels, ...registeredLlms].map((m) => m.value),
);
const hardcoded = getModelsByProviderId(providerId) const hardcoded = getModelsByProviderId(providerId)
.filter((m) => !getModelKind(m) || getModelKind(m) === "llm") .filter((m) => !getModelKind(m) || getModelKind(m) === "llm")
.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) })) .map((m) => ({
id: m.id,
name: m.name,
value: `${alias}/${m.id}`,
kind: getModelKind(m),
}))
.filter((m) => !seen.has(m.value)); .filter((m) => !seen.has(m.value));
combined = [...registeredLlms, ...aliasModels.filter((m) => !registeredLlms.some((registered) => registered.value === m.value)), ...hardcoded]; combined = [
...registeredLlms,
...aliasModels.filter(
(m) =>
!registeredLlms.some(
(registered) => registered.value === m.value,
),
),
...hardcoded,
];
} }
if (combined.length > 0) { if (combined.length > 0) {
// Check for custom name from providerNodes (for compatible providers) // Check for custom name from providerNodes (for compatible providers)
const matchedNode = providerNodes.find(node => node.id === providerId); const matchedNode = providerNodes.find(
(node) => node.id === providerId,
);
const displayName = matchedNode?.name || providerInfo.name; const displayName = matchedNode?.name || providerInfo.name;
groups[providerId] = { groups[providerId] = {
@@ -276,10 +383,18 @@ export default function ModelSelectModal({
// Custom (openai/anthropic-compatible) providers are LLM-only — skip for typed media kinds // Custom (openai/anthropic-compatible) providers are LLM-only — skip for typed media kinds
if (kindFilter && TYPED_KINDS.has(kindFilter)) return; if (kindFilter && TYPED_KINDS.has(kindFilter)) return;
// Find connection object to get prefix synchronously without waiting for providerNodes fetch // Find connection object to get prefix synchronously without waiting for providerNodes fetch
const connection = activeProviders.find(p => p.provider === providerId); const connection = activeProviders.find(
const matchedNode = providerNodes.find(node => node.id === providerId); (p) => p.provider === providerId,
const displayName = matchedNode?.name || connection?.name || providerInfo.name; );
const nodePrefix = connection?.providerSpecificData?.prefix || matchedNode?.prefix || 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;
// Aliases are stored using the raw providerId as key (e.g. "openai-compatible-chat-<uuid>/glm-4.7"), // Aliases are stored using the raw providerId as key (e.g. "openai-compatible-chat-<uuid>/glm-4.7"),
// so we must filter by providerId, not by the display prefix. // so we must filter by providerId, not by the display prefix.
@@ -302,16 +417,24 @@ export default function ModelSelectModal({
isCustom: true, isCustom: true,
})); }));
const seen = new Set(nodeModels.map((m) => m.value)); const seen = new Set(nodeModels.map((m) => m.value));
const mergedModels = [...nodeModels, ...registeredCustom.filter((m) => !seen.has(m.value))]; const mergedModels = [
...nodeModels,
...registeredCustom.filter((m) => !seen.has(m.value)),
];
// Always show compatible providers that are connected, even with no aliases. // Always show compatible providers that are connected, even with no aliases.
// When no aliases exist, show a placeholder so users know it's available. // When no aliases exist, show a placeholder so users know it's available.
const modelsToShow = mergedModels.length > 0 ? mergedModels : [{ const modelsToShow =
mergedModels.length > 0
? mergedModels
: [
{
id: `__placeholder__${providerId}`, id: `__placeholder__${providerId}`,
name: `${nodePrefix}/model-id`, name: `${nodePrefix}/model-id`,
value: `${nodePrefix}/model-id`, value: `${nodePrefix}/model-id`,
isPlaceholder: true, isPlaceholder: true,
}]; },
];
groups[providerId] = { groups[providerId] = {
name: displayName, name: displayName,
@@ -322,7 +445,8 @@ export default function ModelSelectModal({
hasModels: mergedModels.length > 0, hasModels: mergedModels.length > 0,
}; };
} else { } else {
const hardcodedModels = providerId === "cursor" && cursorModels.length > 0 const hardcodedModels =
providerId === "cursor" && cursorModels.length > 0
? cursorModels ? cursorModels
: getModelsByProviderId(providerId); : getModelsByProviderId(providerId);
const hardcodedIds = new Set(hardcodedModels.map((m) => m.id)); const hardcodedIds = new Set(hardcodedModels.map((m) => m.id));
@@ -331,41 +455,74 @@ export default function ModelSelectModal({
// Otherwise only show aliases where aliasName === modelId ("Add Model" button pattern) // Otherwise only show aliases where aliasName === modelId ("Add Model" button pattern)
const hasHardcoded = hardcodedModels.length > 0; const hasHardcoded = hardcodedModels.length > 0;
const customAliasModels = Object.entries(modelAliases) const customAliasModels = Object.entries(modelAliases)
.filter(([aliasName, fullModel]) => .filter(
([aliasName, fullModel]) =>
fullModel.startsWith(`${alias}/`) && fullModel.startsWith(`${alias}/`) &&
(hasHardcoded ? aliasName === fullModel.replace(`${alias}/`, "") : true) && (hasHardcoded
!hardcodedIds.has(fullModel.replace(`${alias}/`, "")) ? aliasName === fullModel.replace(`${alias}/`, "")
: true) &&
!hardcodedIds.has(fullModel.replace(`${alias}/`, "")),
) )
.map(([aliasName, fullModel]) => { .map(([aliasName, fullModel]) => {
const modelId = fullModel.replace(`${alias}/`, ""); const modelId = fullModel.replace(`${alias}/`, "");
return { id: modelId, name: aliasName, value: fullModel, isCustom: true }; return {
id: modelId,
name: aliasName,
value: fullModel,
isCustom: true,
};
}); });
// Custom models registered via /api/models/custom (provider "Add Model" button) // Custom models registered via /api/models/custom (provider "Add Model" button)
const customAliasIds = new Set(customAliasModels.map((m) => m.id)); const customAliasIds = new Set(customAliasModels.map((m) => m.id));
const customRegisteredModels = customModels const customRegisteredModels = customModels
.filter((m) => m.providerAlias === alias && !hardcodedIds.has(m.id) && !customAliasIds.has(m.id)) .filter(
.map((m) => ({ id: m.id, name: m.name || m.id, value: `${alias}/${m.id}`, isCustom: true })); (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,
}));
const merged = [ const merged = [
...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) })), ...hardcodedModels.map((m) => ({
id: m.id,
name: m.name,
value: `${alias}/${m.id}`,
kind: getModelKind(m),
})),
...customAliasModels, ...customAliasModels,
...customRegisteredModels, ...customRegisteredModels,
]; ];
// Dedupe by value (alias may equal hardcoded id, causing React key collision) // Dedupe by value (alias may equal hardcoded id, causing React key collision)
const seen = new Set(); const seen = new Set();
let allModels = filterByKind(merged.filter((m) => { let allModels = filterByKind(
merged.filter((m) => {
if (seen.has(m.value)) return false; if (seen.has(m.value)) return false;
seen.add(m.value); seen.add(m.value);
return true; return true;
})); }),
);
// Provider-as-model fallback: providers that support the kind but have no hardcoded models // 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). // can still be picked (value = providerAlias). Skips embedding (always needs model).
if (allModels.length === 0 && kindFilter && ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)) { if (
const supports = (providerInfo.serviceKinds || ["llm"]).includes(kindFilter); allModels.length === 0 &&
kindFilter &&
ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)
) {
const supports = (providerInfo.serviceKinds || ["llm"]).includes(
kindFilter,
);
if (supports) { if (supports) {
allModels = [{ id: providerId, name: providerInfo.name, value: alias }]; allModels = [
{ id: providerId, name: providerInfo.name, value: alias },
];
} }
} }
@@ -393,20 +550,34 @@ export default function ModelSelectModal({
}); });
return groups; return groups;
}, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders, cursorModels]); }, [
filteredActiveProviders,
modelAliases,
allProviders,
providerNodes,
customModels,
disabledModels,
kindFilter,
activeProviders,
cursorModels,
]);
// Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design) // Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design)
const filteredCombos = useMemo(() => { const filteredCombos = useMemo(() => {
if (kindFilter) return []; if (kindFilter) return [];
if (!searchQuery.trim()) return combos; if (!searchQuery.trim()) return combos;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return combos.filter(c => c.name.toLowerCase().includes(query)); return combos.filter((c) => c.name.toLowerCase().includes(query));
}, [combos, searchQuery, kindFilter]); }, [combos, searchQuery, kindFilter]);
// Sort models alphabetically, with added models floated to top // Sort models alphabetically, with added models floated to top
const sortModels = (models) => { const sortModels = (models) => {
const added = models.filter(m => addedModelValues.includes(m.value)).sort((a, b) => a.name.localeCompare(b.name)); const added = models
const rest = models.filter(m => !addedModelValues.includes(m.value)).sort((a, b) => a.name.localeCompare(b.name)); .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]; return [...added, ...rest];
}; };
@@ -422,7 +593,7 @@ export default function ModelSelectModal({
models = models.filter( models = models.filter(
(m) => (m) =>
m.name.toLowerCase().includes(query) || m.name.toLowerCase().includes(query) ||
m.id.toLowerCase().includes(query) m.id.toLowerCase().includes(query),
); );
if (models.length === 0 && !providerNameMatches) return; if (models.length === 0 && !providerNameMatches) return;
} }
@@ -435,6 +606,28 @@ export default function ModelSelectModal({
return filtered; return filtered;
}, [groupedModels, searchQuery, addedModelValues]); }, [groupedModels, searchQuery, addedModelValues]);
// 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]);
const handleSelect = (model) => { const handleSelect = (model) => {
const value = model?.value || model?.name || model; const value = model?.value || model?.name || model;
const isAdded = addedModelValues.includes(value); const isAdded = addedModelValues.includes(value);
@@ -465,8 +658,15 @@ export default function ModelSelectModal({
> >
{/* Info bar */} {/* Info bar */}
<div className="flex items-center gap-2 mb-3 px-2.5 py-2 bg-primary/8 border border-primary/20 rounded-lg text-xs text-text-muted"> <div className="flex items-center gap-2 mb-3 px-2.5 py-2 bg-primary/8 border border-primary/20 rounded-lg text-xs text-text-muted">
<span className="material-symbols-outlined text-primary shrink-0" style={{ fontSize: "14px" }}>info</span> <span
<span>Click to add, click again to remove. Changes are saved automatically.</span> className="material-symbols-outlined text-primary shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span>
Click to add, click again to remove. Changes are saved automatically.
</span>
</div> </div>
{/* Search - compact */} {/* Search - compact */}
@@ -491,9 +691,13 @@ export default function ModelSelectModal({
{filteredCombos.length > 0 && ( {filteredCombos.length > 0 && (
<div> <div>
<div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5"> <div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5">
<span className="material-symbols-outlined text-primary text-[14px]">layers</span> <span className="material-symbols-outlined text-primary text-[14px]">
layers
</span>
<span className="text-xs font-medium text-primary">Combos</span> <span className="text-xs font-medium text-primary">Combos</span>
<span className="text-[10px] text-text-muted">({filteredCombos.length})</span> <span className="text-[10px] text-text-muted">
({filteredCombos.length})
</span>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{filteredCombos.map((combo) => { {filteredCombos.map((combo) => {
@@ -501,10 +705,17 @@ export default function ModelSelectModal({
return ( return (
<button <button
key={combo.id} key={combo.id}
onClick={() => handleSelect({ id: combo.name, name: combo.name, value: combo.name })} onClick={() =>
handleSelect({
id: combo.name,
name: combo.name,
value: combo.name,
})
}
className={` className={`
px-2 py-1 rounded-xl text-xs font-medium transition-all border hover:cursor-pointer flex items-center gap-1 px-2 py-1 rounded-xl text-xs font-medium transition-all border hover:cursor-pointer flex items-center gap-1
${isSelected ${
isSelected
? "bg-primary text-white border-primary" ? "bg-primary text-white border-primary"
: addedModelValues.includes(combo.name) : addedModelValues.includes(combo.name)
? "bg-primary border-primary text-white hover:bg-primary-hover" ? "bg-primary border-primary text-white hover:bg-primary-hover"
@@ -513,7 +724,12 @@ export default function ModelSelectModal({
`} `}
> >
{addedModelValues.includes(combo.name) && ( {addedModelValues.includes(combo.name) && (
<span className="material-symbols-outlined leading-none" style={{ fontSize: "10px" }}>check</span> <span
className="material-symbols-outlined leading-none"
style={{ fontSize: "10px" }}
>
check
</span>
)} )}
{combo.name} {combo.name}
</button> </button>
@@ -524,7 +740,21 @@ export default function ModelSelectModal({
)} )}
{/* Provider models */} {/* Provider models */}
{Object.entries(filteredGroups).map(([providerId, group]) => ( {showOnlyComboModels &&
Object.keys(finalGroups).length === 0 &&
filteredCombos.length === 0 && (
<div className="text-center py-4 text-text-muted">
<span className="material-symbols-outlined text-2xl mb-1 block">
visibility_off
</span>
<p className="text-xs">
No models found in active combos. Add models to combos or
disable &quot;Only show combo models&quot; in Settings.
</p>
</div>
)}
{Object.entries(showOnlyComboModels ? finalGroups : filteredGroups).map(
([providerId, group]) => (
<div key={providerId}> <div key={providerId}>
{/* Provider header */} {/* Provider header */}
<div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5"> <div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5">
@@ -532,7 +762,9 @@ export default function ModelSelectModal({
src={`/providers/${providerId}.png`} src={`/providers/${providerId}.png`}
alt={group.name} alt={group.name}
size={14} size={14}
fallbackText={(group.name || providerId).slice(0, 2).toUpperCase()} fallbackText={(group.name || providerId)
.slice(0, 2)
.toUpperCase()}
fallbackColor={group.color} fallbackColor={group.color}
/> />
<span className="text-xs font-medium text-primary"> <span className="text-xs font-medium text-primary">
@@ -551,10 +783,15 @@ export default function ModelSelectModal({
<button <button
key={model.value} key={model.value}
onClick={() => handleSelect(model)} onClick={() => handleSelect(model)}
title={isPlaceholder ? "Select to pre-fill, then edit model ID in the input" : undefined} title={
isPlaceholder
? "Select to pre-fill, then edit model ID in the input"
: undefined
}
className={` className={`
px-2 py-1 rounded-xl text-xs font-medium transition-all border hover:cursor-pointer px-2 py-1 rounded-xl text-xs font-medium transition-all border hover:cursor-pointer
${isPlaceholder ${
isPlaceholder
? "border-dashed border-border text-text-muted hover:border-primary/50 hover:text-primary bg-surface italic" ? "border-dashed border-border text-text-muted hover:border-primary/50 hover:text-primary bg-surface italic"
: isSelected : isSelected
? "bg-primary text-white border-primary" ? "bg-primary text-white border-primary"
@@ -565,18 +802,28 @@ export default function ModelSelectModal({
`} `}
> >
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
{addedModelValues.includes(model.value) && !isPlaceholder && ( {addedModelValues.includes(model.value) &&
<span className="material-symbols-outlined leading-none" style={{ fontSize: "10px" }}>check</span> !isPlaceholder && (
<span
className="material-symbols-outlined leading-none"
style={{ fontSize: "10px" }}
>
check
</span>
)} )}
{isPlaceholder ? ( {isPlaceholder ? (
<> <>
<span className="material-symbols-outlined text-[11px]">edit</span> <span className="material-symbols-outlined text-[11px]">
edit
</span>
{model.name} {model.name}
</> </>
) : model.isCustom ? ( ) : model.isCustom ? (
<> <>
{model.name} {model.name}
<span className="text-[9px] opacity-60 font-normal">custom</span> <span className="text-[9px] opacity-60 font-normal">
custom
</span>
<CapacityBadges caps={getCaps(model.value)} /> <CapacityBadges caps={getCaps(model.value)} />
</> </>
) : ( ) : (
@@ -591,9 +838,11 @@ export default function ModelSelectModal({
})} })}
</div> </div>
</div> </div>
))} ),
)}
{Object.keys(filteredGroups).length === 0 && filteredCombos.length === 0 && ( {Object.keys(filteredGroups).length === 0 &&
filteredCombos.length === 0 && (
<div className="text-center py-4 text-text-muted"> <div className="text-center py-4 text-text-muted">
<span className="material-symbols-outlined text-2xl mb-1 block"> <span className="material-symbols-outlined text-2xl mb-1 block">
search_off search_off
@@ -615,7 +864,7 @@ ModelSelectModal.propTypes = {
activeProviders: PropTypes.arrayOf( activeProviders: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
provider: PropTypes.string.isRequired, provider: PropTypes.string.isRequired,
}) }),
), ),
title: PropTypes.string, title: PropTypes.string,
modelAliases: PropTypes.object, modelAliases: PropTypes.object,

View File

@@ -13,10 +13,16 @@ function isLLMProvider(id) {
import Badge from "./Badge"; import Badge from "./Badge";
import Card from "./Card"; import Card from "./Card";
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards"; 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"; import dynamic from "next/dynamic";
// Lazy-load: keeps @xyflow/react out of the shared bundle until topology renders // 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"; import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart";
function timeAgo(timestamp) { function timeAgo(timestamp) {
@@ -32,7 +38,7 @@ function TimeAgo({ timestamp }) {
const [, setTick] = useState(0); const [, setTick] = useState(0);
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setTick(t => t + 1), 1000); const timer = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
@@ -41,40 +47,76 @@ function TimeAgo({ timestamp }) {
function RecentRequests({ requests = [] }) { function RecentRequests({ requests = [] }) {
return ( return (
<Card className="flex min-w-0 flex-col overflow-hidden" padding="sm" style={{ height: 480 }}> <Card
className="flex min-w-0 flex-col overflow-hidden"
padding="sm"
style={{ height: 480 }}
>
{/* Header */} {/* Header */}
<div className="px-1 py-2 border-b border-border shrink-0"> <div className="px-1 py-2 border-b border-border shrink-0">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wide">Recent Requests</span> <span className="text-xs font-semibold text-text-muted uppercase tracking-wide">
Recent Requests
</span>
</div> </div>
{!requests.length ? ( {!requests.length ? (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">No requests yet.</div> <div className="flex-1 flex items-center justify-center text-text-muted text-sm">
No requests yet.
</div>
) : ( ) : (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<table className="w-full min-w-[300px] border-collapse text-xs"> <table className="w-full min-w-[300px] border-collapse text-xs">
<thead className="sticky top-0 bg-bg z-10"> <thead className="sticky top-0 bg-bg z-10">
<tr className="border-b border-border"> <tr className="border-b border-border">
<th className="py-1.5 text-left font-semibold text-text-muted w-2"></th> <th className="py-1.5 text-left font-semibold text-text-muted w-2"></th>
<th className="py-1.5 text-left font-semibold text-text-muted">Model</th> <th className="py-1.5 text-left font-semibold text-text-muted">
<th className="py-1.5 text-right font-semibold text-text-muted whitespace-nowrap">In / Out</th> Model
<th className="py-1.5 text-right font-semibold text-text-muted">When</th> </th>
<th className="py-1.5 text-left font-semibold text-text-muted">
Provider
</th>
<th className="py-1.5 text-right font-semibold text-text-muted whitespace-nowrap">
In / Out
</th>
<th className="py-1.5 text-right font-semibold text-text-muted">
When
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border/50"> <tbody className="divide-y divide-border/50">
{requests.map((r, i) => { {requests.map((r, i) => {
const ok = !r.status || r.status === "ok" || r.status === "success"; const ok =
!r.status || r.status === "ok" || r.status === "success";
return ( return (
<tr key={i} className="hover:bg-bg-subtle transition-colors"> <tr key={i} className="hover:bg-bg-subtle transition-colors">
<td className="py-1.5"> <td className="py-1.5">
<span className={`block w-1.5 h-1.5 rounded-full ${ok ? "bg-success" : "bg-error"}`} /> <span
className={`block w-1.5 h-1.5 rounded-full ${ok ? "bg-success" : "bg-error"}`}
/>
</td>
<td
className="py-1.5 font-mono truncate max-w-[120px]"
title={r.model}
>
{r.model}
</td>
<td
className="py-1.5 font-mono truncate max-w-[80px] text-text-muted"
title={r.provider}
>
{r.provider || "—"}
</td> </td>
<td className="py-1.5 font-mono truncate max-w-[120px]" title={r.model}>{r.model}</td>
<td className="py-1.5 text-right whitespace-nowrap"> <td className="py-1.5 text-right whitespace-nowrap">
<span className="text-primary">{fmt(r.promptTokens)}↑</span> <span className="text-primary">
{" "} {fmt(r.promptTokens)}↑
<span className="text-success">{fmt(r.completionTokens)}↓</span> </span>{" "}
<span className="text-success">
{fmt(r.completionTokens)}↓
</span>
</td>
<td className="py-1.5 text-right text-text-muted whitespace-nowrap">
<TimeAgo timestamp={r.timestamp} />
</td> </td>
<td className="py-1.5 text-right text-text-muted whitespace-nowrap"><TimeAgo timestamp={r.timestamp} /></td>
</tr> </tr>
); );
})} })}
@@ -89,18 +131,36 @@ function RecentRequests({ requests = [] }) {
function sortData(dataMap, pendingMap = {}, sortBy, sortOrder) { function sortData(dataMap, pendingMap = {}, sortBy, sortOrder) {
return Object.entries(dataMap || {}) return Object.entries(dataMap || {})
.map(([key, data]) => { .map(([key, data]) => {
const totalTokens = (data.promptTokens || 0) + (data.completionTokens || 0); const totalTokens =
(data.promptTokens || 0) + (data.completionTokens || 0);
const totalCost = data.cost || 0; const totalCost = data.cost || 0;
// ponytail: cost split is a token-share allocation of the (rate-accurate) // 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 // 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 // peel it out of the input share. Upgrade to a stored per-component cost
// breakdown if exact cached-rate cost display is needed. // breakdown if exact cached-rate cost display is needed.
const cachedTokens = data.cachedTokens || 0; const cachedTokens = data.cachedTokens || 0;
const nonCachedInput = Math.max(0, (data.promptTokens || 0) - cachedTokens); const nonCachedInput = Math.max(
const inputCost = totalTokens > 0 ? nonCachedInput * (totalCost / totalTokens) : 0; 0,
const cachedCost = totalTokens > 0 ? cachedTokens * (totalCost / totalTokens) : 0; (data.promptTokens || 0) - cachedTokens,
const outputCost = totalTokens > 0 ? (data.completionTokens || 0) * (totalCost / totalTokens) : 0; );
return { ...data, key, totalTokens, totalCost, inputCost, cachedCost, outputCost, pending: pendingMap[key] || 0 }; 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) => { .sort((a, b) => {
let valA = a[sortBy]; let valA = a[sortBy];
@@ -115,11 +175,22 @@ function sortData(dataMap, pendingMap = {}, sortBy, sortOrder) {
function getGroupKey(item, keyField) { function getGroupKey(item, keyField) {
switch (keyField) { switch (keyField) {
case "rawModel": return item.rawModel || "Unknown Model"; case "provider":
case "accountName": return item.accountName || `Account ${item.connectionId?.slice(0, 8)}...` || "Unknown Account"; return item.provider || item.key || "Unknown Provider";
case "keyName": return item.keyName || "Unknown Key"; case "rawModel":
case "endpoint": return item.endpoint || "Unknown Endpoint"; return item.rawModel || "Unknown Model";
default: return item[keyField] || "Unknown"; 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";
} }
} }
@@ -131,7 +202,19 @@ function groupDataByKey(data, keyField) {
if (!groups[gk]) { if (!groups[gk]) {
groups[gk] = { groups[gk] = {
groupKey: 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 }, summary: {
requests: 0,
promptTokens: 0,
completionTokens: 0,
cachedTokens: 0,
totalTokens: 0,
cost: 0,
inputCost: 0,
cachedCost: 0,
outputCost: 0,
lastUsed: null,
pending: 0,
},
items: [], items: [],
}; };
} }
@@ -146,7 +229,10 @@ function groupDataByKey(data, keyField) {
s.cachedCost += item.cachedCost || 0; s.cachedCost += item.cachedCost || 0;
s.outputCost += item.outputCost || 0; s.outputCost += item.outputCost || 0;
s.pending += item.pending || 0; s.pending += item.pending || 0;
if (item.lastUsed && (!s.lastUsed || new Date(item.lastUsed) > new Date(s.lastUsed))) { if (
item.lastUsed &&
(!s.lastUsed || new Date(item.lastUsed) > new Date(s.lastUsed))
) {
s.lastUsed = item.lastUsed; s.lastUsed = item.lastUsed;
} }
groups[gk].items.push(item); groups[gk].items.push(item);
@@ -185,11 +271,18 @@ const ENDPOINT_COLUMNS = [
{ field: "lastUsed", label: "Last Used", 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 = [ const TABLE_OPTIONS = [
{ value: "model", label: "Usage by Model" }, { value: "model", label: "Usage by Model" },
{ value: "account", label: "Usage by Account" }, { value: "account", label: "Usage by Account" },
{ value: "apiKey", label: "Usage by API Key" }, { value: "apiKey", label: "Usage by API Key" },
{ value: "endpoint", label: "Usage by Endpoint" }, { value: "endpoint", label: "Usage by Endpoint" },
{ value: "provider", label: "Usage by Provider" },
]; ];
const PERIODS = [ const PERIODS = [
@@ -200,7 +293,11 @@ const PERIODS = [
{ value: "60d", label: "60D" }, { value: "60d", label: "60D" },
]; ];
export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false } = {}) { export default function UsageStats({
period: periodProp,
setPeriod: setPeriodProp,
hidePeriodSelector = false,
} = {}) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -210,7 +307,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [tableView, setTableView] = useState("model"); const [tableView, setTableView] = useState("provider");
const [viewMode, setViewMode] = useState("costs"); const [viewMode, setViewMode] = useState("costs");
const [providers, setProviders] = useState([]); const [providers, setProviders] = useState([]);
const [periodLocal, setPeriodLocal] = useState("today"); const [periodLocal, setPeriodLocal] = useState("today");
@@ -223,23 +320,25 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
// Always include noAuth free providers (e.g. opencode) regardless of connections // Always include noAuth free providers (e.g. opencode) regardless of connections
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
fetch("/api/providers").then((r) => r.ok ? r.json() : null), fetch("/api/providers").then((r) => (r.ok ? r.json() : null)),
fetch("/api/provider-nodes").then((r) => r.ok ? r.json() : null), fetch("/api/provider-nodes").then((r) => (r.ok ? r.json() : null)),
]) ])
.then(([d, nodesData]) => { .then(([d, nodesData]) => {
// Build node name lookup for custom providers // Build node name lookup for custom providers
const nodeNameMap = {}; const nodeNameMap = {};
for (const node of (nodesData?.nodes || [])) { for (const node of nodesData?.nodes || []) {
nodeNameMap[node.id] = node.name; nodeNameMap[node.id] = node.name;
} }
const seen = new Set(); const seen = new Set();
const unique = (d?.connections || []).filter((c) => { const unique = (d?.connections || [])
.filter((c) => {
if (c.isActive === false) return false; if (c.isActive === false) return false;
if (!isLLMProvider(c.provider)) return false; if (!isLLMProvider(c.provider)) return false;
if (seen.has(c.provider)) return false; if (seen.has(c.provider)) return false;
seen.add(c.provider); seen.add(c.provider);
return true; return true;
}).map((c) => ({ })
.map((c) => ({
...c, ...c,
nodeName: nodeNameMap[c.provider] || null, nodeName: nodeNameMap[c.provider] || null,
})); }));
@@ -262,7 +361,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
} }
fetch(`/api/usage/stats?period=${period}`) fetch(`/api/usage/stats?period=${period}`)
.then((r) => r.ok ? r.json() : null) .then((r) => (r.ok ? r.json() : null))
.then((data) => { .then((data) => {
if (data) { if (data) {
hasLoadedStats.current = true; hasLoadedStats.current = true;
@@ -305,16 +404,22 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
return () => es.close(); return () => es.close();
}, []); }, []);
const toggleSort = useCallback((tableType, field) => { const toggleSort = useCallback(
(tableType, field) => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
if (params.get("sortBy") === field) { if (params.get("sortBy") === field) {
params.set("sortOrder", params.get("sortOrder") === "asc" ? "desc" : "asc"); params.set(
"sortOrder",
params.get("sortOrder") === "asc" ? "desc" : "asc",
);
} else { } else {
params.set("sortBy", field); params.set("sortBy", field);
params.set("sortOrder", "asc"); params.set("sortOrder", "asc");
} }
router.replace(`?${params.toString()}`, { scroll: false }); router.replace(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]); },
[searchParams, router],
);
// Compute active table data // Compute active table data
const activeTableConfig = useMemo(() => { const activeTableConfig = useMemo(() => {
@@ -324,22 +429,42 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
const pendingMap = stats.pending?.byModel || {}; const pendingMap = stats.pending?.byModel || {};
return { return {
columns: MODEL_COLUMNS, columns: MODEL_COLUMNS,
groupedData: groupDataByKey(sortData(stats.byModel, pendingMap, sortBy, sortOrder), "rawModel"), groupedData: groupDataByKey(
sortData(stats.byModel, pendingMap, sortBy, sortOrder),
"rawModel",
),
storageKey: "usage-stats:expanded-models", storageKey: "usage-stats:expanded-models",
emptyMessage: "No usage recorded yet.", emptyMessage: "No usage recorded yet.",
renderSummaryCells: (group) => ( renderSummaryCells: (group) => (
<> <>
<td className="px-6 py-3 text-text-muted">—</td> <td className="px-6 py-3 text-text-muted">—</td>
<td className="px-6 py-3 text-right">{fmt(group.summary.requests)}</td> <td className="px-6 py-3 text-right">
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">{fmtTime(group.summary.lastUsed)}</td> {fmt(group.summary.requests)}
</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(group.summary.lastUsed)}
</td>
</> </>
), ),
renderDetailCells: (item) => ( renderDetailCells: (item) => (
<> <>
<td className={`px-6 py-3 font-medium transition-colors ${item.pending > 0 ? "text-primary" : ""}`}>{item.rawModel}</td> <td
<td className="px-6 py-3"><Badge variant={item.pending > 0 ? "primary" : "neutral"} size="sm">{item.provider}</Badge></td> className={`px-6 py-3 font-medium transition-colors ${item.pending > 0 ? "text-primary" : ""}`}
>
{item.rawModel}
</td>
<td className="px-6 py-3">
<Badge
variant={item.pending > 0 ? "primary" : "neutral"}
size="sm"
>
{item.provider}
</Badge>
</td>
<td className="px-6 py-3 text-right">{fmt(item.requests)}</td> <td className="px-6 py-3 text-right">{fmt(item.requests)}</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">{fmtTime(item.lastUsed)}</td> <td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(item.lastUsed)}
</td>
</> </>
), ),
}; };
@@ -347,34 +472,63 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
case "account": { case "account": {
const pendingMap = {}; const pendingMap = {};
if (stats?.pending?.byAccount) { if (stats?.pending?.byAccount) {
Object.entries(stats.byAccount || {}).forEach(([accountKey, data]) => { Object.entries(stats.byAccount || {}).forEach(
([accountKey, data]) => {
const connPending = stats.pending.byAccount[data.connectionId]; const connPending = stats.pending.byAccount[data.connectionId];
if (connPending) { if (connPending) {
const modelKey = data.provider ? `${data.rawModel} (${data.provider})` : data.rawModel; const modelKey = data.provider
? `${data.rawModel} (${data.provider})`
: data.rawModel;
pendingMap[accountKey] = connPending[modelKey] || 0; pendingMap[accountKey] = connPending[modelKey] || 0;
} }
}); },
);
} }
return { return {
columns: ACCOUNT_COLUMNS, columns: ACCOUNT_COLUMNS,
groupedData: groupDataByKey(sortData(stats.byAccount, pendingMap, sortBy, sortOrder), "accountName"), groupedData: groupDataByKey(
sortData(stats.byAccount, pendingMap, sortBy, sortOrder),
"accountName",
),
storageKey: "usage-stats:expanded-accounts", storageKey: "usage-stats:expanded-accounts",
emptyMessage: "No account-specific usage recorded yet.", emptyMessage: "No account-specific usage recorded yet.",
renderSummaryCells: (group) => ( renderSummaryCells: (group) => (
<> <>
<td className="px-6 py-3 text-text-muted">—</td> <td className="px-6 py-3 text-text-muted">—</td>
<td className="px-6 py-3 text-text-muted">—</td> <td className="px-6 py-3 text-text-muted">—</td>
<td className="px-6 py-3 text-right">{fmt(group.summary.requests)}</td> <td className="px-6 py-3 text-right">
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">{fmtTime(group.summary.lastUsed)}</td> {fmt(group.summary.requests)}
</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(group.summary.lastUsed)}
</td>
</> </>
), ),
renderDetailCells: (item) => ( renderDetailCells: (item) => (
<> <>
<td className={`px-6 py-3 font-medium transition-colors ${item.pending > 0 ? "text-primary" : ""}`}>{item.accountName || `Account ${item.connectionId?.slice(0, 8)}...`}</td> <td
<td className={`px-6 py-3 font-medium transition-colors ${item.pending > 0 ? "text-primary" : ""}`}>{item.rawModel}</td> className={`px-6 py-3 font-medium transition-colors ${item.pending > 0 ? "text-primary" : ""}`}
<td className="px-6 py-3"><Badge variant={item.pending > 0 ? "primary" : "neutral"} size="sm">{item.provider}</Badge></td> >
{item.accountName ||
`Account ${item.connectionId?.slice(0, 8)}...`}
</td>
<td
className={`px-6 py-3 font-medium transition-colors ${item.pending > 0 ? "text-primary" : ""}`}
>
{item.rawModel}
</td>
<td className="px-6 py-3">
<Badge
variant={item.pending > 0 ? "primary" : "neutral"}
size="sm"
>
{item.provider}
</Badge>
</td>
<td className="px-6 py-3 text-right">{fmt(item.requests)}</td> <td className="px-6 py-3 text-right">{fmt(item.requests)}</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">{fmtTime(item.lastUsed)}</td> <td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(item.lastUsed)}
</td>
</> </>
), ),
}; };
@@ -382,24 +536,69 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
case "apiKey": { case "apiKey": {
return { return {
columns: API_KEY_COLUMNS, columns: API_KEY_COLUMNS,
groupedData: groupDataByKey(sortData(stats.byApiKey, {}, sortBy, sortOrder), "keyName"), groupedData: groupDataByKey(
sortData(stats.byApiKey, {}, sortBy, sortOrder),
"keyName",
),
storageKey: "usage-stats:expanded-apikeys", storageKey: "usage-stats:expanded-apikeys",
emptyMessage: "No API key usage recorded yet.", emptyMessage: "No API key usage recorded yet.",
renderSummaryCells: (group) => ( renderSummaryCells: (group) => (
<> <>
<td className="px-6 py-3 text-text-muted">—</td> <td className="px-6 py-3 text-text-muted">—</td>
<td className="px-6 py-3 text-text-muted">—</td> <td className="px-6 py-3 text-text-muted">—</td>
<td className="px-6 py-3 text-right">{fmt(group.summary.requests)}</td> <td className="px-6 py-3 text-right">
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">{fmtTime(group.summary.lastUsed)}</td> {fmt(group.summary.requests)}
</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(group.summary.lastUsed)}
</td>
</> </>
), ),
renderDetailCells: (item) => ( renderDetailCells: (item) => (
<> <>
<td className="px-6 py-3 font-medium">{item.keyName}</td> <td className="px-6 py-3 font-medium">{item.keyName}</td>
<td className="px-6 py-3">{item.rawModel}</td> <td className="px-6 py-3">{item.rawModel}</td>
<td className="px-6 py-3"><Badge variant="neutral" size="sm">{item.provider}</Badge></td> <td className="px-6 py-3">
<Badge variant="neutral" size="sm">
{item.provider}
</Badge>
</td>
<td className="px-6 py-3 text-right">{fmt(item.requests)}</td> <td className="px-6 py-3 text-right">{fmt(item.requests)}</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">{fmtTime(item.lastUsed)}</td> <td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(item.lastUsed)}
</td>
</>
),
};
}
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) => (
<>
<td className="px-6 py-3 text-right">
{fmt(group.summary.requests)}
</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(group.summary.lastUsed)}
</td>
</>
),
renderDetailCells: (item) => (
<>
<td className="px-6 py-3 font-medium">
{item.provider || item.key}
</td>
<td className="px-6 py-3 text-right">{fmt(item.requests)}</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(item.lastUsed)}
</td>
</> </>
), ),
}; };
@@ -408,24 +607,39 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
default: { default: {
return { return {
columns: ENDPOINT_COLUMNS, columns: ENDPOINT_COLUMNS,
groupedData: groupDataByKey(sortData(stats.byEndpoint, {}, sortBy, sortOrder), "endpoint"), groupedData: groupDataByKey(
sortData(stats.byEndpoint, {}, sortBy, sortOrder),
"endpoint",
),
storageKey: "usage-stats:expanded-endpoints", storageKey: "usage-stats:expanded-endpoints",
emptyMessage: "No endpoint usage recorded yet.", emptyMessage: "No endpoint usage recorded yet.",
renderSummaryCells: (group) => ( renderSummaryCells: (group) => (
<> <>
<td className="px-6 py-3 text-text-muted">—</td> <td className="px-6 py-3 text-text-muted">—</td>
<td className="px-6 py-3 text-text-muted">—</td> <td className="px-6 py-3 text-text-muted">—</td>
<td className="px-6 py-3 text-right">{fmt(group.summary.requests)}</td> <td className="px-6 py-3 text-right">
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">{fmtTime(group.summary.lastUsed)}</td> {fmt(group.summary.requests)}
</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(group.summary.lastUsed)}
</td>
</> </>
), ),
renderDetailCells: (item) => ( renderDetailCells: (item) => (
<> <>
<td className="px-6 py-3 font-medium font-mono text-sm">{item.endpoint}</td> <td className="px-6 py-3 font-medium font-mono text-sm">
{item.endpoint}
</td>
<td className="px-6 py-3">{item.rawModel}</td> <td className="px-6 py-3">{item.rawModel}</td>
<td className="px-6 py-3"><Badge variant="neutral" size="sm">{item.provider}</Badge></td> <td className="px-6 py-3">
<Badge variant="neutral" size="sm">
{item.provider}
</Badge>
</td>
<td className="px-6 py-3 text-right">{fmt(item.requests)}</td> <td className="px-6 py-3 text-right">{fmt(item.requests)}</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">{fmtTime(item.lastUsed)}</td> <td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(item.lastUsed)}
</td>
</> </>
), ),
}; };
@@ -433,11 +647,16 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
} }
}, [stats, tableView, sortBy, sortOrder]); }, [stats, tableView, sortBy, sortOrder]);
if (!stats && !loading) return <div className="text-text-muted">Failed to load usage statistics.</div>; if (!stats && !loading)
return (
<div className="text-text-muted">Failed to load usage statistics.</div>
);
const spinner = ( const spinner = (
<div className="flex items-center justify-center py-12 text-text-muted"> <div className="flex items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined text-[32px] animate-spin">progress_activity</span> <span className="material-symbols-outlined text-[32px] animate-spin">
progress_activity
</span>
</div> </div>
); );
@@ -459,7 +678,9 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
))} ))}
</div> </div>
{fetching && ( {fetching && (
<span className="material-symbols-outlined text-[16px] text-text-muted animate-spin">progress_activity</span> <span className="material-symbols-outlined text-[16px] text-text-muted animate-spin">
progress_activity
</span>
)} )}
</div> </div>
)} )}
@@ -468,7 +689,9 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
{loading ? spinner : <OverviewCards stats={stats} />} {loading ? spinner : <OverviewCards stats={stats} />}
{/* Provider topology + Recent Requests */} {/* Provider topology + Recent Requests */}
{loading ? spinner : ( {loading ? (
spinner
) : (
<div className="grid min-w-0 grid-cols-1 items-stretch gap-2 lg:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)]"> <div className="grid min-w-0 grid-cols-1 items-stretch gap-2 lg:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)]">
<ProviderTopology <ProviderTopology
providers={providers} providers={providers}
@@ -490,10 +713,12 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
value={tableView} value={tableView}
onChange={(e) => setTableView(e.target.value)} onChange={(e) => setTableView(e.target.value)}
className="w-full rounded-lg border border-border bg-surface px-3 py-1.5 text-sm font-medium text-text-main focus:outline-none focus:ring-2 focus:ring-primary/50 sm:w-auto" className="w-full rounded-lg border border-border bg-surface px-3 py-1.5 text-sm font-medium text-text-main focus:outline-none focus:ring-2 focus:ring-primary/50 sm:w-auto"
style={{ colorScheme: 'auto' }} style={{ colorScheme: "auto" }}
> >
{TABLE_OPTIONS.map((opt) => ( {TABLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>
{opt.label}
</option>
))} ))}
</select> </select>
<div className="grid grid-cols-2 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:flex"> <div className="grid grid-cols-2 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:flex">
@@ -511,7 +736,9 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
</button> </button>
</div> </div>
</div> </div>
{loading ? spinner : activeTableConfig && ( {loading
? spinner
: activeTableConfig && (
<UsageTable <UsageTable
title="" title=""
columns={activeTableConfig.columns} columns={activeTableConfig.columns}

View File

@@ -1,6 +1,14 @@
// Re-export from open-sse with localDb integration // Re-export from open-sse with localDb integration
import { getModelAliases, getComboByName, getProviderNodes } from "@/lib/localDb"; import {
import { parseModel as parseModelCore, resolveModelAliasFromMap, getModelInfoCore } from "open-sse/services/model.js"; 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"; import REGISTRY from "open-sse/providers/registry/index.js";
// Local provider alias overrides (HMR-friendly, applied on top of open-sse map) // Local provider alias overrides (HMR-friendly, applied on top of open-sse map)
@@ -13,13 +21,17 @@ const RESERVED_PROVIDER_PREFIXES = new Set(Object.keys(LOCAL_PROVIDER_ALIASES));
for (const entry of REGISTRY) { for (const entry of REGISTRY) {
RESERVED_PROVIDER_PREFIXES.add(entry.id); RESERVED_PROVIDER_PREFIXES.add(entry.id);
if (entry.alias) RESERVED_PROVIDER_PREFIXES.add(entry.alias); if (entry.alias) RESERVED_PROVIDER_PREFIXES.add(entry.alias);
for (const alias of entry.aliases || []) RESERVED_PROVIDER_PREFIXES.add(alias); for (const alias of entry.aliases || [])
RESERVED_PROVIDER_PREFIXES.add(alias);
} }
export function parseModel(modelStr) { export function parseModel(modelStr) {
const parsed = parseModelCore(modelStr); const parsed = parseModelCore(modelStr);
if (parsed?.providerAlias && LOCAL_PROVIDER_ALIASES[parsed.providerAlias]) { if (parsed?.providerAlias && LOCAL_PROVIDER_ALIASES[parsed.providerAlias]) {
return { ...parsed, provider: LOCAL_PROVIDER_ALIASES[parsed.providerAlias] }; return {
...parsed,
provider: LOCAL_PROVIDER_ALIASES[parsed.providerAlias],
};
} }
return parsed; return parsed;
} }
@@ -43,33 +55,43 @@ export async function getModelInfo(modelStr) {
// provider ids/aliases such as `cf`, `cloudflare-ai`, `openai`, or `hf`. // provider ids/aliases such as `cf`, `cloudflare-ai`, `openai`, or `hf`.
if (!RESERVED_PROVIDER_PREFIXES.has(parsed.providerAlias)) { if (!RESERVED_PROVIDER_PREFIXES.has(parsed.providerAlias)) {
const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
const matchedOpenAI = openaiNodes.find((node) => node.prefix === parsed.providerAlias); const matchedOpenAI = openaiNodes.find(
(node) => node.prefix === parsed.providerAlias,
);
if (matchedOpenAI) { if (matchedOpenAI) {
return { provider: matchedOpenAI.id, model: parsed.model }; return { provider: matchedOpenAI.id, model: parsed.model };
} }
const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" }); const anthropicNodes = await getProviderNodes({
const matchedAnthropic = anthropicNodes.find((node) => node.prefix === parsed.providerAlias); type: "anthropic-compatible",
});
const matchedAnthropic = anthropicNodes.find(
(node) => node.prefix === parsed.providerAlias,
);
if (matchedAnthropic) { if (matchedAnthropic) {
return { provider: matchedAnthropic.id, model: parsed.model }; return { provider: matchedAnthropic.id, model: parsed.model };
} }
const embeddingNodes = await getProviderNodes({ type: "custom-embedding" }); const embeddingNodes = await getProviderNodes({
const matchedEmbedding = embeddingNodes.find((node) => node.prefix === parsed.providerAlias); type: "custom-embedding",
});
const matchedEmbedding = embeddingNodes.find(
(node) => node.prefix === parsed.providerAlias,
);
if (matchedEmbedding) { if (matchedEmbedding) {
return { provider: matchedEmbedding.id, model: parsed.model }; return { provider: matchedEmbedding.id, model: parsed.model };
} }
} }
return { return {
provider: parsed.provider, provider: parsed.provider,
model: parsed.model model: parsed.model,
}; };
} }
// Check if this is a combo name before resolving as alias // Check if this is a combo name before resolving as alias
// This prevents combo names from being incorrectly routed to providers // This prevents combo names from being incorrectly routed to providers
const combo = await getComboByName(parsed.model); const combo = await getComboByName(parsed.model);
if (combo) { if (combo && combo.enabled !== false) {
// Return null provider to signal this should be handled as combo // Return null provider to signal this should be handled as combo
// The caller (handleChat) will detect this and handle it as combo // The caller (handleChat) will detect this and handle it as combo
return { provider: null, model: parsed.model }; return { provider: null, model: parsed.model };
@@ -87,7 +109,12 @@ export async function getComboModels(modelStr) {
if (modelStr.includes("/")) return null; if (modelStr.includes("/")) return null;
const combo = await getComboByName(modelStr); const combo = await getComboByName(modelStr);
if (combo && combo.models && combo.models.length > 0) { if (
combo &&
combo.enabled !== false &&
combo.models &&
combo.models.length > 0
) {
return combo.models; return combo.models;
} }
return null; return null;