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:
@@ -19,66 +19,86 @@ const TOOL_RESULT_PREFIX = "[Tool result: ";
|
||||
// on tools: drop the request's tools, turn tool/function results into assistant
|
||||
// text, and inline assistant tool_calls names instead of the structured field.
|
||||
function flattenToolHistory(messages) {
|
||||
return messages
|
||||
.filter((msg) => msg)
|
||||
.map((msg) => {
|
||||
if (msg.role === "tool" || msg.role === "function") {
|
||||
return { role: "assistant", content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]` };
|
||||
}
|
||||
if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
|
||||
const { tool_calls, ...rest } = msg;
|
||||
const names = tool_calls.map((c) => c?.function?.name || c?.name || "tool").join(", ");
|
||||
const base = extractTextContent(rest.content) || (typeof rest.content === "string" ? rest.content : "");
|
||||
return { ...rest, content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]` };
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
const hasToolUse = msg.content.some((c) => c.type === "tool_use");
|
||||
const hasToolResult = msg.content.some((c) => c.type === "tool_result");
|
||||
if (hasToolUse || hasToolResult) {
|
||||
const textParts = [];
|
||||
const toolNames = [];
|
||||
const toolResults = [];
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text" && block.text) textParts.push(block.text);
|
||||
if (block.type === "tool_use") toolNames.push(block.name || "tool");
|
||||
if (block.type === "tool_result") toolResults.push(extractTextContent(block.content) || String(block.content ?? ""));
|
||||
}
|
||||
const { ...rest } = msg;
|
||||
let newContent = textParts.join("\n");
|
||||
if (toolNames.length > 0) {
|
||||
newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_CALL_PREFIX}${toolNames.join(", ")}]`;
|
||||
}
|
||||
if (toolResults.length > 0) {
|
||||
newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_RESULT_PREFIX}${toolResults.join("\n")}]`;
|
||||
}
|
||||
return { ...rest, content: newContent };
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
return messages
|
||||
.filter((msg) => msg)
|
||||
.map((msg) => {
|
||||
if (msg.role === "tool" || msg.role === "function") {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]`,
|
||||
};
|
||||
}
|
||||
if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
|
||||
const { tool_calls, ...rest } = msg;
|
||||
const names = tool_calls
|
||||
.map((c) => c?.function?.name || c?.name || "tool")
|
||||
.join(", ");
|
||||
const base =
|
||||
extractTextContent(rest.content) ||
|
||||
(typeof rest.content === "string" ? rest.content : "");
|
||||
return {
|
||||
...rest,
|
||||
content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]`,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
const hasToolUse = msg.content.some((c) => c.type === "tool_use");
|
||||
const hasToolResult = msg.content.some((c) => c.type === "tool_result");
|
||||
if (hasToolUse || hasToolResult) {
|
||||
const textParts = [];
|
||||
const toolNames = [];
|
||||
const toolResults = [];
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text" && block.text) textParts.push(block.text);
|
||||
if (block.type === "tool_use") toolNames.push(block.name || "tool");
|
||||
if (block.type === "tool_result")
|
||||
toolResults.push(
|
||||
extractTextContent(block.content) ||
|
||||
String(block.content ?? ""),
|
||||
);
|
||||
}
|
||||
const { ...rest } = msg;
|
||||
let newContent = textParts.join("\n");
|
||||
if (toolNames.length > 0) {
|
||||
newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_CALL_PREFIX}${toolNames.join(", ")}]`;
|
||||
}
|
||||
if (toolResults.length > 0) {
|
||||
newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_RESULT_PREFIX}${toolResults.join("\n")}]`;
|
||||
}
|
||||
return { ...rest, content: newContent };
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
|
||||
// Reorder combo models by capability fit. Stable; never drops a model (fallback intact).
|
||||
// Tier 0: satisfies all hard + all soft. Tier 1: all hard only. Tier 2: rest.
|
||||
export function reorderByCapabilities(models, required) {
|
||||
if (!required || required.size === 0 || !Array.isArray(models) || models.length <= 1) return models;
|
||||
const hard = [...required].filter((c) => HARD_CAPS.has(c));
|
||||
const soft = [...required].filter((c) => !HARD_CAPS.has(c));
|
||||
if (
|
||||
!required ||
|
||||
required.size === 0 ||
|
||||
!Array.isArray(models) ||
|
||||
models.length <= 1
|
||||
)
|
||||
return models;
|
||||
const hard = [...required].filter((c) => HARD_CAPS.has(c));
|
||||
const soft = [...required].filter((c) => !HARD_CAPS.has(c));
|
||||
|
||||
const tierOf = (m) => {
|
||||
const slash = typeof m === "string" ? m.indexOf("/") : -1;
|
||||
const provider = slash > 0 ? m.slice(0, slash) : "";
|
||||
const model = slash > 0 ? m.slice(slash + 1) : m;
|
||||
const caps = getCapabilitiesForModel(provider, model);
|
||||
if (!hard.every((c) => caps[c] === true)) return 2;
|
||||
return soft.every((c) => caps[c] === true) ? 0 : 1;
|
||||
};
|
||||
const tierOf = (m) => {
|
||||
const slash = typeof m === "string" ? m.indexOf("/") : -1;
|
||||
const provider = slash > 0 ? m.slice(0, slash) : "";
|
||||
const model = slash > 0 ? m.slice(slash + 1) : m;
|
||||
const caps = getCapabilitiesForModel(provider, model);
|
||||
if (!hard.every((c) => caps[c] === true)) return 2;
|
||||
return soft.every((c) => caps[c] === true) ? 0 : 1;
|
||||
};
|
||||
|
||||
// Stable sort by tier (Array.prototype.sort is stable in modern engines).
|
||||
return models
|
||||
.map((m, i) => ({ m, i, t: tierOf(m) }))
|
||||
.sort((a, b) => a.t - b.t || a.i - b.i)
|
||||
.map((x) => x.m);
|
||||
// Stable sort by tier (Array.prototype.sort is stable in modern engines).
|
||||
return models
|
||||
.map((m, i) => ({ m, i, t: tierOf(m) }))
|
||||
.sort((a, b) => a.t - b.t || a.i - b.i)
|
||||
.map((x) => x.m);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,58 +112,61 @@ const comboRotationState = new Map();
|
||||
// so we return all of them. History media (older turns) must not pin the combo
|
||||
// to a vision model — those get stripped + placeholdered downstream instead.
|
||||
function trailingUserItems(arr) {
|
||||
if (!Array.isArray(arr) || arr.length === 0) return [];
|
||||
const isAssistant = (r) => r === "assistant" || r === "model";
|
||||
let i = arr.length - 1;
|
||||
while (i >= 0 && !isAssistant(arr[i]?.role)) i--;
|
||||
return arr.slice(i + 1);
|
||||
if (!Array.isArray(arr) || arr.length === 0) return [];
|
||||
const isAssistant = (r) => r === "assistant" || r === "model";
|
||||
let i = arr.length - 1;
|
||||
while (i >= 0 && !isAssistant(arr[i]?.role)) i--;
|
||||
return arr.slice(i + 1);
|
||||
}
|
||||
|
||||
// Detect which capabilities a request needs. Modalities (vision/pdf) are scanned
|
||||
// only on the current user turn; "search" is request-wide (lives in tools).
|
||||
// Returns a Set of: "vision" | "pdf" | "search".
|
||||
export function detectRequiredCapabilities(body) {
|
||||
const required = new Set();
|
||||
if (!body || typeof body !== "object") return required;
|
||||
const required = new Set();
|
||||
if (!body || typeof body !== "object") return required;
|
||||
|
||||
const scanBlock = (b) => {
|
||||
if (!b || typeof b !== "object") return;
|
||||
const t = b.type;
|
||||
if (t === "image_url" || t === "image" || t === "input_image") required.add("vision");
|
||||
if (t === "file" || t === "document" || t === "input_file") required.add("pdf");
|
||||
// gemini parts: inlineData/fileData carry a mime
|
||||
const mime = b.inlineData?.mimeType || b.fileData?.mimeType;
|
||||
if (typeof mime === "string" && mime.startsWith("image/")) required.add("vision");
|
||||
if (mime === "application/pdf") required.add("pdf");
|
||||
};
|
||||
const scanBlock = (b) => {
|
||||
if (!b || typeof b !== "object") return;
|
||||
const t = b.type;
|
||||
if (t === "image_url" || t === "image" || t === "input_image")
|
||||
required.add("vision");
|
||||
if (t === "file" || t === "document" || t === "input_file")
|
||||
required.add("pdf");
|
||||
// gemini parts: inlineData/fileData carry a mime
|
||||
const mime = b.inlineData?.mimeType || b.fileData?.mimeType;
|
||||
if (typeof mime === "string" && mime.startsWith("image/"))
|
||||
required.add("vision");
|
||||
if (mime === "application/pdf") required.add("pdf");
|
||||
};
|
||||
|
||||
const scanContent = (content) => {
|
||||
if (Array.isArray(content)) for (const b of content) scanBlock(b);
|
||||
};
|
||||
const scanContent = (content) => {
|
||||
if (Array.isArray(content)) for (const b of content) scanBlock(b);
|
||||
};
|
||||
|
||||
// Modalities: current user turn only (trailing user run across each known shape).
|
||||
for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude
|
||||
for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses
|
||||
const contents = body.contents || body.request?.contents; // gemini / antigravity
|
||||
for (const c of trailingUserItems(contents)) scanContent(c.parts);
|
||||
// Modalities: current user turn only (trailing user run across each known shape).
|
||||
for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude
|
||||
for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses
|
||||
const contents = body.contents || body.request?.contents; // gemini / antigravity
|
||||
for (const c of trailingUserItems(contents)) scanContent(c.parts);
|
||||
|
||||
// search: temporarily disabled in auto-switch (feature not wired yet).
|
||||
// search: temporarily disabled in auto-switch (feature not wired yet).
|
||||
|
||||
return required;
|
||||
return required;
|
||||
}
|
||||
|
||||
function normalizeStickyLimit(stickyLimit) {
|
||||
const parsed = Number.parseInt(stickyLimit, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
||||
const parsed = Number.parseInt(stickyLimit, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
||||
}
|
||||
|
||||
function rotateModelsFromIndex(models, currentIndex) {
|
||||
const rotatedModels = [...models];
|
||||
for (let i = 0; i < currentIndex; i++) {
|
||||
const moved = rotatedModels.shift();
|
||||
rotatedModels.push(moved);
|
||||
}
|
||||
return rotatedModels;
|
||||
const rotatedModels = [...models];
|
||||
for (let i = 0; i < currentIndex; i++) {
|
||||
const moved = rotatedModels.shift();
|
||||
rotatedModels.push(moved);
|
||||
}
|
||||
return rotatedModels;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,34 +178,35 @@ function rotateModelsFromIndex(models, currentIndex) {
|
||||
* @returns {string[]} Rotated models array
|
||||
*/
|
||||
export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) {
|
||||
if (!models || models.length <= 1 || strategy !== "round-robin") {
|
||||
return models;
|
||||
}
|
||||
if (!models || models.length <= 1 || strategy !== "round-robin") {
|
||||
return models;
|
||||
}
|
||||
|
||||
const rotationKey = comboName || "__default__";
|
||||
const normalizedStickyLimit = normalizeStickyLimit(stickyLimit);
|
||||
const existingState = comboRotationState.get(rotationKey);
|
||||
const state = typeof existingState === "number"
|
||||
? { index: existingState, consecutiveUseCount: 0 }
|
||||
: (existingState || { index: 0, consecutiveUseCount: 0 });
|
||||
const rotationKey = comboName || "__default__";
|
||||
const normalizedStickyLimit = normalizeStickyLimit(stickyLimit);
|
||||
const existingState = comboRotationState.get(rotationKey);
|
||||
const state =
|
||||
typeof existingState === "number"
|
||||
? { index: existingState, consecutiveUseCount: 0 }
|
||||
: existingState || { index: 0, consecutiveUseCount: 0 };
|
||||
|
||||
const currentIndex = state.index % models.length;
|
||||
const rotatedModels = rotateModelsFromIndex(models, currentIndex);
|
||||
const nextUseCount = state.consecutiveUseCount + 1;
|
||||
const currentIndex = state.index % models.length;
|
||||
const rotatedModels = rotateModelsFromIndex(models, currentIndex);
|
||||
const nextUseCount = state.consecutiveUseCount + 1;
|
||||
|
||||
if (nextUseCount >= normalizedStickyLimit) {
|
||||
comboRotationState.set(rotationKey, {
|
||||
index: (currentIndex + 1) % models.length,
|
||||
consecutiveUseCount: 0,
|
||||
});
|
||||
} else {
|
||||
comboRotationState.set(rotationKey, {
|
||||
index: currentIndex,
|
||||
consecutiveUseCount: nextUseCount,
|
||||
});
|
||||
}
|
||||
if (nextUseCount >= normalizedStickyLimit) {
|
||||
comboRotationState.set(rotationKey, {
|
||||
index: (currentIndex + 1) % models.length,
|
||||
consecutiveUseCount: 0,
|
||||
});
|
||||
} else {
|
||||
comboRotationState.set(rotationKey, {
|
||||
index: currentIndex,
|
||||
consecutiveUseCount: nextUseCount,
|
||||
});
|
||||
}
|
||||
|
||||
return rotatedModels;
|
||||
return rotatedModels;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,8 +214,8 @@ export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) {
|
||||
* @param {string} [comboName] - Combo name to reset; omit to clear all
|
||||
*/
|
||||
export function resetComboRotation(comboName) {
|
||||
if (comboName) comboRotationState.delete(comboName);
|
||||
else comboRotationState.clear();
|
||||
if (comboName) comboRotationState.delete(comboName);
|
||||
else comboRotationState.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,17 +225,24 @@ export function resetComboRotation(comboName) {
|
||||
* @returns {string[]|null} Array of models or null if not a combo
|
||||
*/
|
||||
export function getComboModelsFromData(modelStr, combosData) {
|
||||
// Don't check if it's in provider/model format
|
||||
if (modelStr.includes("/")) return null;
|
||||
|
||||
// Handle both array and object formats
|
||||
const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []);
|
||||
|
||||
const combo = combos.find(c => c.name === modelStr);
|
||||
if (combo && combo.models && combo.models.length > 0) {
|
||||
return combo.models;
|
||||
}
|
||||
return null;
|
||||
// Don't check if it's in provider/model format
|
||||
if (modelStr.includes("/")) return null;
|
||||
|
||||
// Handle both array and object formats
|
||||
const combos = Array.isArray(combosData)
|
||||
? combosData
|
||||
: combosData?.combos || [];
|
||||
|
||||
const combo = combos.find((c) => c.name === modelStr);
|
||||
if (
|
||||
combo &&
|
||||
combo.enabled !== false &&
|
||||
combo.models &&
|
||||
combo.models.length > 0
|
||||
) {
|
||||
return combo.models;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,108 +257,159 @@ export function getComboModelsFromData(modelStr, combosData) {
|
||||
* @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) {
|
||||
// Apply rotation strategy if enabled
|
||||
let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit);
|
||||
export async function handleComboChat({
|
||||
body,
|
||||
models,
|
||||
handleSingleModel,
|
||||
log,
|
||||
comboName,
|
||||
comboStrategy,
|
||||
comboStickyLimit = 1,
|
||||
autoSwitch = true,
|
||||
}) {
|
||||
// Apply rotation strategy if enabled
|
||||
let rotatedModels = getRotatedModels(
|
||||
models,
|
||||
comboName,
|
||||
comboStrategy,
|
||||
comboStickyLimit,
|
||||
);
|
||||
|
||||
// Auto-switch: float models that satisfy the request's required capabilities to the front.
|
||||
if (autoSwitch) {
|
||||
const required = detectRequiredCapabilities(body);
|
||||
if (required.size > 0) {
|
||||
const reordered = reorderByCapabilities(rotatedModels, required);
|
||||
if (reordered[0] !== rotatedModels[0]) {
|
||||
log.info("COMBO", `auto-switch for [${[...required].join(",")}] → ${reordered[0]}`);
|
||||
}
|
||||
rotatedModels = reordered;
|
||||
}
|
||||
}
|
||||
|
||||
let lastError = null;
|
||||
let earliestRetryAfter = null;
|
||||
let lastStatus = null;
|
||||
// Auto-switch: float models that satisfy the request's required capabilities to the front.
|
||||
if (autoSwitch) {
|
||||
const required = detectRequiredCapabilities(body);
|
||||
if (required.size > 0) {
|
||||
const reordered = reorderByCapabilities(rotatedModels, required);
|
||||
if (reordered[0] !== rotatedModels[0]) {
|
||||
log.info(
|
||||
"COMBO",
|
||||
`auto-switch for [${[...required].join(",")}] → ${reordered[0]}`,
|
||||
);
|
||||
}
|
||||
rotatedModels = reordered;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < rotatedModels.length; i++) {
|
||||
const modelStr = rotatedModels[i];
|
||||
log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`);
|
||||
let lastError = null;
|
||||
let earliestRetryAfter = null;
|
||||
let lastStatus = null;
|
||||
|
||||
try {
|
||||
const result = await handleSingleModel(body, modelStr);
|
||||
|
||||
// Success (2xx) - return response
|
||||
if (result.ok) {
|
||||
log.info("COMBO", `Model ${modelStr} succeeded`);
|
||||
return result;
|
||||
}
|
||||
for (let i = 0; i < rotatedModels.length; i++) {
|
||||
const modelStr = rotatedModels[i];
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`,
|
||||
);
|
||||
|
||||
// Extract error info from response
|
||||
let errorText = result.statusText || "";
|
||||
let retryAfter = null;
|
||||
try {
|
||||
const errorBody = await result.clone().json();
|
||||
errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
|
||||
retryAfter = errorBody?.retryAfter || null;
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
try {
|
||||
const result = await handleSingleModel(body, modelStr);
|
||||
|
||||
// Track earliest retryAfter across all combo models
|
||||
if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) {
|
||||
earliestRetryAfter = retryAfter;
|
||||
}
|
||||
// Success (2xx) - return response
|
||||
if (result.ok) {
|
||||
log.info("COMBO", `Model ${modelStr} succeeded`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Normalize error text to string (Worker-safe)
|
||||
if (typeof errorText !== "string") {
|
||||
try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); }
|
||||
}
|
||||
// Extract error info from response
|
||||
let errorText = result.statusText || "";
|
||||
let retryAfter = null;
|
||||
try {
|
||||
const errorBody = await result.clone().json();
|
||||
errorText =
|
||||
errorBody?.error?.message ||
|
||||
errorBody?.error ||
|
||||
errorBody?.message ||
|
||||
errorText;
|
||||
retryAfter = errorBody?.retryAfter || null;
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
|
||||
// Check if should fallback to next model
|
||||
const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText);
|
||||
// Track earliest retryAfter across all combo models
|
||||
if (
|
||||
retryAfter &&
|
||||
(!earliestRetryAfter ||
|
||||
new Date(retryAfter) < new Date(earliestRetryAfter))
|
||||
) {
|
||||
earliestRetryAfter = retryAfter;
|
||||
}
|
||||
|
||||
if (!shouldFallback) {
|
||||
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status });
|
||||
return result;
|
||||
}
|
||||
// Normalize error text to string (Worker-safe)
|
||||
if (typeof errorText !== "string") {
|
||||
try {
|
||||
errorText = JSON.stringify(errorText);
|
||||
} catch {
|
||||
errorText = String(errorText);
|
||||
}
|
||||
}
|
||||
|
||||
// For transient errors (503/502/504), wait for cooldown before falling through
|
||||
// so a briefly-overloaded provider gets a chance to recover rather than being
|
||||
// skipped immediately (fixes: combo falls through on transient 503)
|
||||
if (cooldownMs && cooldownMs > 0 && cooldownMs <= 5000 &&
|
||||
(result.status === 503 || result.status === 502 || result.status === 504)) {
|
||||
log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`);
|
||||
await new Promise(r => setTimeout(r, cooldownMs));
|
||||
}
|
||||
// Check if should fallback to next model
|
||||
const { shouldFallback, cooldownMs } = checkFallbackError(
|
||||
result.status,
|
||||
errorText,
|
||||
);
|
||||
|
||||
// Fallback to next model
|
||||
lastError = errorText || String(result.status);
|
||||
if (!lastStatus) lastStatus = result.status;
|
||||
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
|
||||
} catch (error) {
|
||||
// Catch unexpected exceptions to ensure fallback continues
|
||||
lastError = error.message || String(error);
|
||||
if (!lastStatus) lastStatus = 500;
|
||||
log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError });
|
||||
}
|
||||
}
|
||||
if (!shouldFallback) {
|
||||
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, {
|
||||
status: result.status,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// All models failed
|
||||
// Use 503 (Service Unavailable) rather than 406 (Not Acceptable) — 406 implies
|
||||
// the request itself is invalid, but here the providers are simply unavailable
|
||||
// or have no active credentials. 503 is more accurate and retryable by clients.
|
||||
const allDisabled = lastError && lastError.toLowerCase().includes("no credentials");
|
||||
const status = allDisabled ? 503 : (lastStatus || 503);
|
||||
const msg = lastError || "All combo models unavailable";
|
||||
// For transient errors (503/502/504), wait for cooldown before falling through
|
||||
// so a briefly-overloaded provider gets a chance to recover rather than being
|
||||
// skipped immediately (fixes: combo falls through on transient 503)
|
||||
if (
|
||||
cooldownMs &&
|
||||
cooldownMs > 0 &&
|
||||
cooldownMs <= 5000 &&
|
||||
(result.status === 503 ||
|
||||
result.status === 502 ||
|
||||
result.status === 504)
|
||||
) {
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, cooldownMs));
|
||||
}
|
||||
|
||||
if (earliestRetryAfter) {
|
||||
const retryHuman = formatRetryAfter(earliestRetryAfter);
|
||||
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
|
||||
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
|
||||
}
|
||||
// Fallback to next model
|
||||
lastError = errorText || String(result.status);
|
||||
if (!lastStatus) lastStatus = result.status;
|
||||
log.warn("COMBO", `Model ${modelStr} failed, trying next`, {
|
||||
status: result.status,
|
||||
});
|
||||
} catch (error) {
|
||||
// Catch unexpected exceptions to ensure fallback continues
|
||||
lastError = error.message || String(error);
|
||||
if (!lastStatus) lastStatus = 500;
|
||||
log.warn("COMBO", `Model ${modelStr} threw error, trying next`, {
|
||||
error: lastError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("COMBO", `All models failed | ${msg}`);
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: msg } }),
|
||||
{ status, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
// All models failed
|
||||
// Use 503 (Service Unavailable) rather than 406 (Not Acceptable) — 406 implies
|
||||
// the request itself is invalid, but here the providers are simply unavailable
|
||||
// or have no active credentials. 503 is more accurate and retryable by clients.
|
||||
const allDisabled =
|
||||
lastError && lastError.toLowerCase().includes("no credentials");
|
||||
const status = allDisabled ? 503 : lastStatus || 503;
|
||||
const msg = lastError || "All combo models unavailable";
|
||||
|
||||
if (earliestRetryAfter) {
|
||||
const retryHuman = formatRetryAfter(earliestRetryAfter);
|
||||
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
|
||||
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
|
||||
}
|
||||
|
||||
log.warn("COMBO", `All models failed | ${msg}`);
|
||||
return new Response(JSON.stringify({ error: { message: msg } }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,37 +419,40 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co
|
||||
* leaf content→string step reuses the translator's own extractTextContent.
|
||||
*/
|
||||
function extractPanelText(json) {
|
||||
if (!json || typeof json !== "object") return "";
|
||||
if (!json || typeof json !== "object") return "";
|
||||
|
||||
// OpenAI chat completion
|
||||
const choice = json.choices?.[0];
|
||||
if (choice) {
|
||||
const msg = choice.message ?? choice.delta ?? {};
|
||||
const t = extractTextContent(msg.content);
|
||||
if (t.trim()) return t;
|
||||
if (typeof choice.text === "string" && choice.text.trim()) return choice.text;
|
||||
}
|
||||
// OpenAI chat completion
|
||||
const choice = json.choices?.[0];
|
||||
if (choice) {
|
||||
const msg = choice.message ?? choice.delta ?? {};
|
||||
const t = extractTextContent(msg.content);
|
||||
if (t.trim()) return t;
|
||||
if (typeof choice.text === "string" && choice.text.trim())
|
||||
return choice.text;
|
||||
}
|
||||
|
||||
// Claude messages (text blocks share OpenAI's {type:"text"} shape)
|
||||
const claudeText = extractTextContent(json.content);
|
||||
if (claudeText.trim()) return claudeText;
|
||||
// Claude messages (text blocks share OpenAI's {type:"text"} shape)
|
||||
const claudeText = extractTextContent(json.content);
|
||||
if (claudeText.trim()) return claudeText;
|
||||
|
||||
// Gemini (parts carry .text without a type discriminator)
|
||||
const parts = json.candidates?.[0]?.content?.parts;
|
||||
if (Array.isArray(parts)) {
|
||||
const t = parts.map((p) => p?.text || "").join("");
|
||||
if (t.trim()) return t;
|
||||
}
|
||||
// Gemini (parts carry .text without a type discriminator)
|
||||
const parts = json.candidates?.[0]?.content?.parts;
|
||||
if (Array.isArray(parts)) {
|
||||
const t = parts.map((p) => p?.text || "").join("");
|
||||
if (t.trim()) return t;
|
||||
}
|
||||
|
||||
// OpenAI Responses API
|
||||
if (Array.isArray(json.output)) {
|
||||
const t = json.output
|
||||
.flatMap((o) => (Array.isArray(o.content) ? o.content.map((c) => c?.text || "") : []))
|
||||
.join("");
|
||||
if (t.trim()) return t;
|
||||
}
|
||||
// OpenAI Responses API
|
||||
if (Array.isArray(json.output)) {
|
||||
const t = json.output
|
||||
.flatMap((o) =>
|
||||
Array.isArray(o.content) ? o.content.map((c) => c?.text || "") : [],
|
||||
)
|
||||
.join("");
|
||||
if (t.trim()) return t;
|
||||
}
|
||||
|
||||
return "";
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,17 +460,17 @@ function extractPanelText(json) {
|
||||
* Preserves the original conversation + system prompt so the judge has full context.
|
||||
*/
|
||||
function appendUserTurn(body, text) {
|
||||
const next = { ...body };
|
||||
if (Array.isArray(body.messages)) {
|
||||
next.messages = [...body.messages, { role: "user", content: text }];
|
||||
} else if (Array.isArray(body.input)) {
|
||||
next.input = [...body.input, { role: "user", content: text }];
|
||||
} else if (Array.isArray(body.contents)) {
|
||||
next.contents = [...body.contents, { role: "user", parts: [{ text }] }];
|
||||
} else {
|
||||
next.messages = [{ role: "user", content: text }];
|
||||
}
|
||||
return next;
|
||||
const next = { ...body };
|
||||
if (Array.isArray(body.messages)) {
|
||||
next.messages = [...body.messages, { role: "user", content: text }];
|
||||
} else if (Array.isArray(body.input)) {
|
||||
next.input = [...body.input, { role: "user", content: text }];
|
||||
} else if (Array.isArray(body.contents)) {
|
||||
next.contents = [...body.contents, { role: "user", parts: [{ text }] }];
|
||||
} else {
|
||||
next.messages = [{ role: "user", content: text }];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,40 +483,46 @@ function appendUserTurn(body, text) {
|
||||
* reputation of a model brand.
|
||||
*/
|
||||
function buildJudgePrompt(answers) {
|
||||
const panel = answers
|
||||
.map((a, i) => `[Source ${i + 1}]\n${a.text}`)
|
||||
.join("\n\n");
|
||||
const panel = answers
|
||||
.map((a, i) => `[Source ${i + 1}]\n${a.text}`)
|
||||
.join("\n\n");
|
||||
|
||||
return [
|
||||
`You are the JUDGE in a model-fusion panel. ${answers.length} expert models independently answered the user's most recent request. Their responses are below, anonymized by source.`,
|
||||
"",
|
||||
"Do NOT mention that multiple models were used, and do NOT refer to the sources. Produce ONE authoritative final answer addressed directly to the user.",
|
||||
"",
|
||||
"First, internally analyze the panel along these dimensions: consensus (points most sources agree on — treat as higher-confidence), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed. Then write the best possible final answer grounded in that analysis — more complete and correct than any single response, with no filler.",
|
||||
"",
|
||||
"=== PANEL RESPONSES ===",
|
||||
panel,
|
||||
"=== END PANEL RESPONSES ===",
|
||||
"",
|
||||
"Now write the final answer to the user's original request.",
|
||||
].join("\n");
|
||||
return [
|
||||
`You are the JUDGE in a model-fusion panel. ${answers.length} expert models independently answered the user's most recent request. Their responses are below, anonymized by source.`,
|
||||
"",
|
||||
"Do NOT mention that multiple models were used, and do NOT refer to the sources. Produce ONE authoritative final answer addressed directly to the user.",
|
||||
"",
|
||||
"First, internally analyze the panel along these dimensions: consensus (points most sources agree on — treat as higher-confidence), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed. Then write the best possible final answer grounded in that analysis — more complete and correct than any single response, with no filler.",
|
||||
"",
|
||||
"=== PANEL RESPONSES ===",
|
||||
panel,
|
||||
"=== END PANEL RESPONSES ===",
|
||||
"",
|
||||
"Now write the final answer to the user's original request.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Fusion tuning. Overridable per-combo via settings.comboStrategies[name].
|
||||
const FUSION_DEFAULTS = {
|
||||
minPanel: 2, // answers needed before stragglers get a grace window
|
||||
stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached
|
||||
panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever
|
||||
minPanel: 2, // answers needed before stragglers get a grace window
|
||||
stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached
|
||||
panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever
|
||||
};
|
||||
|
||||
// Resolve a Response (or {__error}) within ms; the loser keeps running but is ignored.
|
||||
function withTimeout(promise, ms) {
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => resolve({ __timeout: true }), ms);
|
||||
Promise.resolve(promise)
|
||||
.then((v) => { clearTimeout(t); resolve(v); })
|
||||
.catch((e) => { clearTimeout(t); resolve({ __error: e }); });
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => resolve({ __timeout: true }), ms);
|
||||
Promise.resolve(promise)
|
||||
.then((v) => {
|
||||
clearTimeout(t);
|
||||
resolve(v);
|
||||
})
|
||||
.catch((e) => {
|
||||
clearTimeout(t);
|
||||
resolve({ __error: e });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -441,33 +532,41 @@ function withTimeout(promise, ms) {
|
||||
* still preferring a full panel when everyone is fast. Bounded by a hard timeout.
|
||||
* Returns a sparse array aligned to `calls` (undefined = not yet / dropped).
|
||||
*/
|
||||
function collectPanel(calls, { minPanel, stragglerGraceMs, panelHardTimeoutMs }) {
|
||||
return new Promise((resolve) => {
|
||||
const out = new Array(calls.length);
|
||||
let settled = 0;
|
||||
let ok = 0;
|
||||
let finished = false;
|
||||
let graceTimer = null;
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(hardTimer);
|
||||
if (graceTimer) clearTimeout(graceTimer);
|
||||
resolve(out);
|
||||
};
|
||||
const hardTimer = setTimeout(finish, panelHardTimeoutMs);
|
||||
calls.forEach((p, i) => {
|
||||
Promise.resolve(p)
|
||||
.then((v) => { out[i] = v; })
|
||||
.catch((e) => { out[i] = { __error: e }; })
|
||||
.finally(() => {
|
||||
settled++;
|
||||
if (out[i] && out[i].ok) ok++;
|
||||
if (settled === calls.length) return finish();
|
||||
if (ok >= minPanel && !graceTimer) graceTimer = setTimeout(finish, stragglerGraceMs);
|
||||
});
|
||||
});
|
||||
});
|
||||
function collectPanel(
|
||||
calls,
|
||||
{ minPanel, stragglerGraceMs, panelHardTimeoutMs },
|
||||
) {
|
||||
return new Promise((resolve) => {
|
||||
const out = new Array(calls.length);
|
||||
let settled = 0;
|
||||
let ok = 0;
|
||||
let finished = false;
|
||||
let graceTimer = null;
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(hardTimer);
|
||||
if (graceTimer) clearTimeout(graceTimer);
|
||||
resolve(out);
|
||||
};
|
||||
const hardTimer = setTimeout(finish, panelHardTimeoutMs);
|
||||
calls.forEach((p, i) => {
|
||||
Promise.resolve(p)
|
||||
.then((v) => {
|
||||
out[i] = v;
|
||||
})
|
||||
.catch((e) => {
|
||||
out[i] = { __error: e };
|
||||
})
|
||||
.finally(() => {
|
||||
settled++;
|
||||
if (out[i] && out[i].ok) ok++;
|
||||
if (settled === calls.length) return finish();
|
||||
if (ok >= minPanel && !graceTimer)
|
||||
graceTimer = setTimeout(finish, stragglerGraceMs);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,79 +592,111 @@ function collectPanel(calls, { minPanel, stragglerGraceMs, panelHardTimeoutMs })
|
||||
* @param {Object} [options.tuning] - Override FUSION_DEFAULTS (minPanel, grace, timeout)
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function handleFusionChat({ body, models, handleSingleModel, log, comboName, judgeModel, tuning }) {
|
||||
const panel = Array.isArray(models) ? models.filter(Boolean) : [];
|
||||
if (panel.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "Fusion combo has no models" } }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
export async function handleFusionChat({
|
||||
body,
|
||||
models,
|
||||
handleSingleModel,
|
||||
log,
|
||||
comboName,
|
||||
judgeModel,
|
||||
tuning,
|
||||
}) {
|
||||
const panel = Array.isArray(models) ? models.filter(Boolean) : [];
|
||||
if (panel.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "Fusion combo has no models" } }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// A single-model fusion has nothing to fuse — just answer directly.
|
||||
if (panel.length === 1) {
|
||||
return handleSingleModel(body, panel[0]);
|
||||
}
|
||||
// A single-model fusion has nothing to fuse — just answer directly.
|
||||
if (panel.length === 1) {
|
||||
return handleSingleModel(body, panel[0]);
|
||||
}
|
||||
|
||||
const cfg = { ...FUSION_DEFAULTS, ...(tuning || {}) };
|
||||
const minPanel = Math.min(Math.max(2, cfg.minPanel), panel.length);
|
||||
const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0];
|
||||
log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`);
|
||||
const cfg = { ...FUSION_DEFAULTS, ...(tuning || {}) };
|
||||
const minPanel = Math.min(Math.max(2, cfg.minPanel), panel.length);
|
||||
const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0];
|
||||
log.info(
|
||||
"FUSION",
|
||||
`Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`,
|
||||
);
|
||||
|
||||
// 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose).
|
||||
const { tools, tool_choice, ...rest } = body;
|
||||
const panelBody = { ...rest, stream: false };
|
||||
// 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose).
|
||||
const { tools, tool_choice, ...rest } = body;
|
||||
const panelBody = { ...rest, stream: false };
|
||||
|
||||
// Flatten tool turns to prose so panel models keep context without emitting tool_calls.
|
||||
if (Array.isArray(panelBody.messages)) {
|
||||
panelBody.messages = flattenToolHistory(panelBody.messages);
|
||||
} else if (Array.isArray(panelBody.input)) {
|
||||
panelBody.input = flattenToolHistory(panelBody.input);
|
||||
}
|
||||
// Flatten tool turns to prose so panel models keep context without emitting tool_calls.
|
||||
if (Array.isArray(panelBody.messages)) {
|
||||
panelBody.messages = flattenToolHistory(panelBody.messages);
|
||||
} else if (Array.isArray(panelBody.input)) {
|
||||
panelBody.input = flattenToolHistory(panelBody.input);
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const calls = panel.map((m) => withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs));
|
||||
const settled = await collectPanel(calls, { ...cfg, minPanel });
|
||||
log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`);
|
||||
const t0 = Date.now();
|
||||
const calls = panel.map((m) =>
|
||||
withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs),
|
||||
);
|
||||
const settled = await collectPanel(calls, { ...cfg, minPanel });
|
||||
log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`);
|
||||
|
||||
// 2. Collect successful answers.
|
||||
const answers = [];
|
||||
for (let i = 0; i < settled.length; i++) {
|
||||
const res = settled[i];
|
||||
const model = panel[i];
|
||||
if (!res) { log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`); continue; }
|
||||
if (res.__timeout) { log.warn("FUSION", `Panel ${model} timed out`); continue; }
|
||||
if (res.__error) { log.warn("FUSION", `Panel ${model} threw`, { error: res.__error?.message || String(res.__error) }); continue; }
|
||||
if (!res.ok) { log.warn("FUSION", `Panel ${model} failed`, { status: res.status }); continue; }
|
||||
try {
|
||||
const json = await res.clone().json();
|
||||
const text = extractPanelText(json);
|
||||
if (text) {
|
||||
answers.push({ model, text });
|
||||
log.info("FUSION", `Panel ${model} ok (${text.length} chars)`);
|
||||
} else {
|
||||
log.warn("FUSION", `Panel ${model} returned empty content`);
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn("FUSION", `Panel ${model} unparseable`, { error: e.message || String(e) });
|
||||
}
|
||||
}
|
||||
// 2. Collect successful answers.
|
||||
const answers = [];
|
||||
for (let i = 0; i < settled.length; i++) {
|
||||
const res = settled[i];
|
||||
const model = panel[i];
|
||||
if (!res) {
|
||||
log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`);
|
||||
continue;
|
||||
}
|
||||
if (res.__timeout) {
|
||||
log.warn("FUSION", `Panel ${model} timed out`);
|
||||
continue;
|
||||
}
|
||||
if (res.__error) {
|
||||
log.warn("FUSION", `Panel ${model} threw`, {
|
||||
error: res.__error?.message || String(res.__error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) {
|
||||
log.warn("FUSION", `Panel ${model} failed`, { status: res.status });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const json = await res.clone().json();
|
||||
const text = extractPanelText(json);
|
||||
if (text) {
|
||||
answers.push({ model, text });
|
||||
log.info("FUSION", `Panel ${model} ok (${text.length} chars)`);
|
||||
} else {
|
||||
log.warn("FUSION", `Panel ${model} returned empty content`);
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn("FUSION", `Panel ${model} unparseable`, {
|
||||
error: e.message || String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Degrade gracefully when the panel is too thin to fuse.
|
||||
if (answers.length === 0) {
|
||||
log.warn("FUSION", "All panel models failed");
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "All fusion panel models failed" } }),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
if (answers.length === 1) {
|
||||
log.info("FUSION", `Only ${answers[0].model} succeeded — answering directly (no fusion)`);
|
||||
return handleSingleModel(body, answers[0].model);
|
||||
}
|
||||
// 3. Degrade gracefully when the panel is too thin to fuse.
|
||||
if (answers.length === 0) {
|
||||
log.warn("FUSION", "All panel models failed");
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "All fusion panel models failed" } }),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (answers.length === 1) {
|
||||
log.info(
|
||||
"FUSION",
|
||||
`Only ${answers[0].model} succeeded — answering directly (no fusion)`,
|
||||
);
|
||||
return handleSingleModel(body, answers[0].model);
|
||||
}
|
||||
|
||||
// 4. Judge analyzes + writes one final answer (streams to client if requested).
|
||||
const judgeBody = appendUserTurn(body, buildJudgePrompt(answers));
|
||||
log.info("FUSION", `Judging ${answers.length} answers with ${judge}`);
|
||||
return handleSingleModel(judgeBody, judge);
|
||||
// 4. Judge analyzes + writes one final answer (streams to client if requested).
|
||||
const judgeBody = appendUserTurn(body, buildJudgePrompt(answers));
|
||||
log.info("FUSION", `Judging ${answers.length} answers with ${judge}`);
|
||||
return handleSingleModel(judgeBody, judge);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,71 +3,92 @@ import { getAdapter } from "../driver.js";
|
||||
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
||||
|
||||
function rowToCombo(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
models: parseJson(row.models, []),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
models: parseJson(row.models, []),
|
||||
enabled: row.enabled === 1 || row.enabled === true,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCombos() {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT * FROM combos ORDER BY createdAt ASC`);
|
||||
return rows.map(rowToCombo);
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT * FROM combos ORDER BY createdAt ASC`);
|
||||
return rows.map(rowToCombo);
|
||||
}
|
||||
|
||||
export async function getComboById(id) {
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]);
|
||||
return rowToCombo(row);
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]);
|
||||
return rowToCombo(row);
|
||||
}
|
||||
|
||||
export async function getComboByName(name) {
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT * FROM combos WHERE name = ?`, [name]);
|
||||
return rowToCombo(row);
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT * FROM combos WHERE name = ?`, [name]);
|
||||
return rowToCombo(row);
|
||||
}
|
||||
|
||||
export async function createCombo(data) {
|
||||
const db = await getAdapter();
|
||||
const now = new Date().toISOString();
|
||||
const combo = {
|
||||
id: uuidv4(),
|
||||
name: data.name,
|
||||
kind: data.kind || null,
|
||||
models: data.models || [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
db.run(
|
||||
`INSERT INTO combos(id, name, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`,
|
||||
[combo.id, combo.name, combo.kind, stringifyJson(combo.models), combo.createdAt, combo.updatedAt]
|
||||
);
|
||||
return combo;
|
||||
const db = await getAdapter();
|
||||
const now = new Date().toISOString();
|
||||
const combo = {
|
||||
id: uuidv4(),
|
||||
name: data.name,
|
||||
kind: data.kind || null,
|
||||
models: data.models || [],
|
||||
enabled: data.enabled !== false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
db.run(
|
||||
`INSERT INTO combos(id, name, kind, models, enabled, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
combo.id,
|
||||
combo.name,
|
||||
combo.kind,
|
||||
stringifyJson(combo.models),
|
||||
combo.enabled !== false ? 1 : 0,
|
||||
combo.createdAt,
|
||||
combo.updatedAt,
|
||||
],
|
||||
);
|
||||
return combo;
|
||||
}
|
||||
|
||||
export async function updateCombo(id, data) {
|
||||
const db = await getAdapter();
|
||||
let result = null;
|
||||
db.transaction(() => {
|
||||
const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]);
|
||||
if (!row) return;
|
||||
const merged = { ...rowToCombo(row), ...data, updatedAt: new Date().toISOString() };
|
||||
db.run(
|
||||
`UPDATE combos SET name = ?, kind = ?, models = ?, updatedAt = ? WHERE id = ?`,
|
||||
[merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id]
|
||||
);
|
||||
result = merged;
|
||||
});
|
||||
return result;
|
||||
const db = await getAdapter();
|
||||
let result = null;
|
||||
db.transaction(() => {
|
||||
const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]);
|
||||
if (!row) return;
|
||||
const merged = {
|
||||
...rowToCombo(row),
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
db.run(
|
||||
`UPDATE combos SET name = ?, kind = ?, models = ?, enabled = ?, updatedAt = ? WHERE id = ?`,
|
||||
[
|
||||
merged.name,
|
||||
merged.kind,
|
||||
stringifyJson(merged.models || []),
|
||||
merged.enabled ? 1 : 0,
|
||||
merged.updatedAt,
|
||||
id,
|
||||
],
|
||||
);
|
||||
result = merged;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteCombo(id) {
|
||||
const db = await getAdapter();
|
||||
const res = db.run(`DELETE FROM combos WHERE id = ?`, [id]);
|
||||
return (res?.changes ?? 0) > 0;
|
||||
const db = await getAdapter();
|
||||
const res = db.run(`DELETE FROM combos WHERE id = ?`, [id]);
|
||||
return (res?.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,143 +19,144 @@ PRAGMA busy_timeout = 5000;
|
||||
// auto-add missing tables/columns/indexes after versioned migrations.
|
||||
// For destructive changes (drop/rename/type-change), write a migration file.
|
||||
export const TABLES = {
|
||||
_meta: {
|
||||
columns: {
|
||||
key: "TEXT PRIMARY KEY",
|
||||
value: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
columns: {
|
||||
id: "INTEGER PRIMARY KEY CHECK (id = 1)",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
providerConnections: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
provider: "TEXT NOT NULL",
|
||||
authType: "TEXT NOT NULL",
|
||||
name: "TEXT",
|
||||
email: "TEXT",
|
||||
priority: "INTEGER",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_provider ON providerConnections(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_provider_active ON providerConnections(provider, isActive)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_priority ON providerConnections(provider, priority)",
|
||||
],
|
||||
},
|
||||
providerNodes: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
type: "TEXT",
|
||||
name: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_pn_type ON providerNodes(type)"],
|
||||
},
|
||||
proxyPools: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
testStatus: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_pp_active ON proxyPools(isActive)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pp_status ON proxyPools(testStatus)",
|
||||
],
|
||||
},
|
||||
apiKeys: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
key: "TEXT UNIQUE NOT NULL",
|
||||
name: "TEXT",
|
||||
machineId: "TEXT",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_ak_key ON apiKeys(key)"],
|
||||
},
|
||||
combos: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
name: "TEXT UNIQUE NOT NULL",
|
||||
kind: "TEXT",
|
||||
models: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_combo_name ON combos(name)"],
|
||||
},
|
||||
kv: {
|
||||
columns: {
|
||||
scope: "TEXT NOT NULL",
|
||||
key: "TEXT NOT NULL",
|
||||
value: "TEXT NOT NULL",
|
||||
},
|
||||
primaryKey: "PRIMARY KEY (scope, key)",
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_kv_scope ON kv(scope)"],
|
||||
},
|
||||
usageHistory: {
|
||||
columns: {
|
||||
id: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||
timestamp: "TEXT NOT NULL",
|
||||
provider: "TEXT",
|
||||
model: "TEXT",
|
||||
connectionId: "TEXT",
|
||||
apiKey: "TEXT",
|
||||
endpoint: "TEXT",
|
||||
promptTokens: "INTEGER DEFAULT 0",
|
||||
completionTokens: "INTEGER DEFAULT 0",
|
||||
cost: "REAL DEFAULT 0",
|
||||
status: "TEXT",
|
||||
tokens: "TEXT",
|
||||
meta: "TEXT",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_ts ON usageHistory(timestamp DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_provider ON usageHistory(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_model ON usageHistory(model)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)",
|
||||
],
|
||||
},
|
||||
usageDaily: {
|
||||
columns: {
|
||||
dateKey: "TEXT PRIMARY KEY",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
requestDetails: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
timestamp: "TEXT NOT NULL",
|
||||
provider: "TEXT",
|
||||
model: "TEXT",
|
||||
connectionId: "TEXT",
|
||||
status: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_ts ON requestDetails(timestamp DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_provider ON requestDetails(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_model ON requestDetails(model)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_conn ON requestDetails(connectionId)",
|
||||
],
|
||||
},
|
||||
_meta: {
|
||||
columns: {
|
||||
key: "TEXT PRIMARY KEY",
|
||||
value: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
columns: {
|
||||
id: "INTEGER PRIMARY KEY CHECK (id = 1)",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
providerConnections: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
provider: "TEXT NOT NULL",
|
||||
authType: "TEXT NOT NULL",
|
||||
name: "TEXT",
|
||||
email: "TEXT",
|
||||
priority: "INTEGER",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_provider ON providerConnections(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_provider_active ON providerConnections(provider, isActive)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_priority ON providerConnections(provider, priority)",
|
||||
],
|
||||
},
|
||||
providerNodes: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
type: "TEXT",
|
||||
name: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_pn_type ON providerNodes(type)"],
|
||||
},
|
||||
proxyPools: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
testStatus: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_pp_active ON proxyPools(isActive)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pp_status ON proxyPools(testStatus)",
|
||||
],
|
||||
},
|
||||
apiKeys: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
key: "TEXT UNIQUE NOT NULL",
|
||||
name: "TEXT",
|
||||
machineId: "TEXT",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_ak_key ON apiKeys(key)"],
|
||||
},
|
||||
combos: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
name: "TEXT UNIQUE NOT NULL",
|
||||
kind: "TEXT",
|
||||
models: "TEXT NOT NULL",
|
||||
enabled: "INTEGER NOT NULL DEFAULT 1",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_combo_name ON combos(name)"],
|
||||
},
|
||||
kv: {
|
||||
columns: {
|
||||
scope: "TEXT NOT NULL",
|
||||
key: "TEXT NOT NULL",
|
||||
value: "TEXT NOT NULL",
|
||||
},
|
||||
primaryKey: "PRIMARY KEY (scope, key)",
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_kv_scope ON kv(scope)"],
|
||||
},
|
||||
usageHistory: {
|
||||
columns: {
|
||||
id: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||
timestamp: "TEXT NOT NULL",
|
||||
provider: "TEXT",
|
||||
model: "TEXT",
|
||||
connectionId: "TEXT",
|
||||
apiKey: "TEXT",
|
||||
endpoint: "TEXT",
|
||||
promptTokens: "INTEGER DEFAULT 0",
|
||||
completionTokens: "INTEGER DEFAULT 0",
|
||||
cost: "REAL DEFAULT 0",
|
||||
status: "TEXT",
|
||||
tokens: "TEXT",
|
||||
meta: "TEXT",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_ts ON usageHistory(timestamp DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_provider ON usageHistory(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_model ON usageHistory(model)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)",
|
||||
],
|
||||
},
|
||||
usageDaily: {
|
||||
columns: {
|
||||
dateKey: "TEXT PRIMARY KEY",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
requestDetails: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
timestamp: "TEXT NOT NULL",
|
||||
provider: "TEXT",
|
||||
model: "TEXT",
|
||||
connectionId: "TEXT",
|
||||
status: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_ts ON requestDetails(timestamp DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_provider ON requestDetails(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_model ON requestDetails(model)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_conn ON requestDetails(connectionId)",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function buildCreateTableSql(name, def) {
|
||||
const cols = Object.entries(def.columns).map(([k, v]) => `${k} ${v}`);
|
||||
if (def.primaryKey) cols.push(def.primaryKey);
|
||||
return `CREATE TABLE IF NOT EXISTS ${name} (${cols.join(", ")})`;
|
||||
const cols = Object.entries(def.columns).map(([k, v]) => `${k} ${v}`);
|
||||
if (def.primaryKey) cols.push(def.primaryKey);
|
||||
return `CREATE TABLE IF NOT EXISTS ${name} (${cols.join(", ")})`;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,81 +1,103 @@
|
||||
// Re-export from open-sse with localDb integration
|
||||
import { getModelAliases, getComboByName, getProviderNodes } from "@/lib/localDb";
|
||||
import { parseModel as parseModelCore, resolveModelAliasFromMap, getModelInfoCore } from "open-sse/services/model.js";
|
||||
import {
|
||||
getModelAliases,
|
||||
getComboByName,
|
||||
getProviderNodes,
|
||||
} from "@/lib/localDb";
|
||||
import {
|
||||
parseModel as parseModelCore,
|
||||
resolveModelAliasFromMap,
|
||||
getModelInfoCore,
|
||||
} from "open-sse/services/model.js";
|
||||
import REGISTRY from "open-sse/providers/registry/index.js";
|
||||
|
||||
// Local provider alias overrides (HMR-friendly, applied on top of open-sse map)
|
||||
const LOCAL_PROVIDER_ALIASES = {
|
||||
xmtp: "xiaomi-tokenplan",
|
||||
"xiaomi-tokenplan": "xiaomi-tokenplan",
|
||||
xmtp: "xiaomi-tokenplan",
|
||||
"xiaomi-tokenplan": "xiaomi-tokenplan",
|
||||
};
|
||||
|
||||
const RESERVED_PROVIDER_PREFIXES = new Set(Object.keys(LOCAL_PROVIDER_ALIASES));
|
||||
for (const entry of REGISTRY) {
|
||||
RESERVED_PROVIDER_PREFIXES.add(entry.id);
|
||||
if (entry.alias) RESERVED_PROVIDER_PREFIXES.add(entry.alias);
|
||||
for (const alias of entry.aliases || []) RESERVED_PROVIDER_PREFIXES.add(alias);
|
||||
RESERVED_PROVIDER_PREFIXES.add(entry.id);
|
||||
if (entry.alias) RESERVED_PROVIDER_PREFIXES.add(entry.alias);
|
||||
for (const alias of entry.aliases || [])
|
||||
RESERVED_PROVIDER_PREFIXES.add(alias);
|
||||
}
|
||||
|
||||
export function parseModel(modelStr) {
|
||||
const parsed = parseModelCore(modelStr);
|
||||
if (parsed?.providerAlias && LOCAL_PROVIDER_ALIASES[parsed.providerAlias]) {
|
||||
return { ...parsed, provider: LOCAL_PROVIDER_ALIASES[parsed.providerAlias] };
|
||||
}
|
||||
return parsed;
|
||||
const parsed = parseModelCore(modelStr);
|
||||
if (parsed?.providerAlias && LOCAL_PROVIDER_ALIASES[parsed.providerAlias]) {
|
||||
return {
|
||||
...parsed,
|
||||
provider: LOCAL_PROVIDER_ALIASES[parsed.providerAlias],
|
||||
};
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve model alias from localDb
|
||||
*/
|
||||
export async function resolveModelAlias(alias) {
|
||||
const aliases = await getModelAliases();
|
||||
return resolveModelAliasFromMap(alias, aliases);
|
||||
const aliases = await getModelAliases();
|
||||
return resolveModelAliasFromMap(alias, aliases);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full model info (parse or resolve)
|
||||
*/
|
||||
export async function getModelInfo(modelStr) {
|
||||
const parsed = parseModel(modelStr);
|
||||
const parsed = parseModel(modelStr);
|
||||
|
||||
if (!parsed.isAlias) {
|
||||
// Provider-node prefixes are user-defined. They must not override built-in
|
||||
// provider ids/aliases such as `cf`, `cloudflare-ai`, `openai`, or `hf`.
|
||||
if (!RESERVED_PROVIDER_PREFIXES.has(parsed.providerAlias)) {
|
||||
const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
|
||||
const matchedOpenAI = openaiNodes.find((node) => node.prefix === parsed.providerAlias);
|
||||
if (matchedOpenAI) {
|
||||
return { provider: matchedOpenAI.id, model: parsed.model };
|
||||
}
|
||||
if (!parsed.isAlias) {
|
||||
// Provider-node prefixes are user-defined. They must not override built-in
|
||||
// provider ids/aliases such as `cf`, `cloudflare-ai`, `openai`, or `hf`.
|
||||
if (!RESERVED_PROVIDER_PREFIXES.has(parsed.providerAlias)) {
|
||||
const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
|
||||
const matchedOpenAI = openaiNodes.find(
|
||||
(node) => node.prefix === parsed.providerAlias,
|
||||
);
|
||||
if (matchedOpenAI) {
|
||||
return { provider: matchedOpenAI.id, model: parsed.model };
|
||||
}
|
||||
|
||||
const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" });
|
||||
const matchedAnthropic = anthropicNodes.find((node) => node.prefix === parsed.providerAlias);
|
||||
if (matchedAnthropic) {
|
||||
return { provider: matchedAnthropic.id, model: parsed.model };
|
||||
}
|
||||
const anthropicNodes = await getProviderNodes({
|
||||
type: "anthropic-compatible",
|
||||
});
|
||||
const matchedAnthropic = anthropicNodes.find(
|
||||
(node) => node.prefix === parsed.providerAlias,
|
||||
);
|
||||
if (matchedAnthropic) {
|
||||
return { provider: matchedAnthropic.id, model: parsed.model };
|
||||
}
|
||||
|
||||
const embeddingNodes = await getProviderNodes({ type: "custom-embedding" });
|
||||
const matchedEmbedding = embeddingNodes.find((node) => node.prefix === parsed.providerAlias);
|
||||
if (matchedEmbedding) {
|
||||
return { provider: matchedEmbedding.id, model: parsed.model };
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: parsed.provider,
|
||||
model: parsed.model
|
||||
};
|
||||
}
|
||||
const embeddingNodes = await getProviderNodes({
|
||||
type: "custom-embedding",
|
||||
});
|
||||
const matchedEmbedding = embeddingNodes.find(
|
||||
(node) => node.prefix === parsed.providerAlias,
|
||||
);
|
||||
if (matchedEmbedding) {
|
||||
return { provider: matchedEmbedding.id, model: parsed.model };
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: parsed.provider,
|
||||
model: parsed.model,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if this is a combo name before resolving as alias
|
||||
// This prevents combo names from being incorrectly routed to providers
|
||||
const combo = await getComboByName(parsed.model);
|
||||
if (combo) {
|
||||
// Return null provider to signal this should be handled as combo
|
||||
// The caller (handleChat) will detect this and handle it as combo
|
||||
return { provider: null, model: parsed.model };
|
||||
}
|
||||
// Check if this is a combo name before resolving as alias
|
||||
// This prevents combo names from being incorrectly routed to providers
|
||||
const combo = await getComboByName(parsed.model);
|
||||
if (combo && combo.enabled !== false) {
|
||||
// Return null provider to signal this should be handled as combo
|
||||
// The caller (handleChat) will detect this and handle it as combo
|
||||
return { provider: null, model: parsed.model };
|
||||
}
|
||||
|
||||
return getModelInfoCore(modelStr, getModelAliases);
|
||||
return getModelInfoCore(modelStr, getModelAliases);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,12 +105,17 @@ export async function getModelInfo(modelStr) {
|
||||
* @returns {Promise<string[]|null>} Array of models or null if not a combo
|
||||
*/
|
||||
export async function getComboModels(modelStr) {
|
||||
// Only check if it's not in provider/model format
|
||||
if (modelStr.includes("/")) return null;
|
||||
// Only check if it's not in provider/model format
|
||||
if (modelStr.includes("/")) return null;
|
||||
|
||||
const combo = await getComboByName(modelStr);
|
||||
if (combo && combo.models && combo.models.length > 0) {
|
||||
return combo.models;
|
||||
}
|
||||
return null;
|
||||
const combo = await getComboByName(modelStr);
|
||||
if (
|
||||
combo &&
|
||||
combo.enabled !== false &&
|
||||
combo.models &&
|
||||
combo.models.length > 0
|
||||
) {
|
||||
return combo.models;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user