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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 "Only show combo models" 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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user