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
|
// 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.
|
// text, and inline assistant tool_calls names instead of the structured field.
|
||||||
function flattenToolHistory(messages) {
|
function flattenToolHistory(messages) {
|
||||||
return messages
|
return 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",
|
||||||
if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
|
content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]`,
|
||||||
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 : "");
|
if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
|
||||||
return { ...rest, content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]` };
|
const { tool_calls, ...rest } = msg;
|
||||||
}
|
const names = tool_calls
|
||||||
if (Array.isArray(msg.content)) {
|
.map((c) => c?.function?.name || c?.name || "tool")
|
||||||
const hasToolUse = msg.content.some((c) => c.type === "tool_use");
|
.join(", ");
|
||||||
const hasToolResult = msg.content.some((c) => c.type === "tool_result");
|
const base =
|
||||||
if (hasToolUse || hasToolResult) {
|
extractTextContent(rest.content) ||
|
||||||
const textParts = [];
|
(typeof rest.content === "string" ? rest.content : "");
|
||||||
const toolNames = [];
|
return {
|
||||||
const toolResults = [];
|
...rest,
|
||||||
for (const block of msg.content) {
|
content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]`,
|
||||||
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 ?? ""));
|
if (Array.isArray(msg.content)) {
|
||||||
}
|
const hasToolUse = msg.content.some((c) => c.type === "tool_use");
|
||||||
const { ...rest } = msg;
|
const hasToolResult = msg.content.some((c) => c.type === "tool_result");
|
||||||
let newContent = textParts.join("\n");
|
if (hasToolUse || hasToolResult) {
|
||||||
if (toolNames.length > 0) {
|
const textParts = [];
|
||||||
newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_CALL_PREFIX}${toolNames.join(", ")}]`;
|
const toolNames = [];
|
||||||
}
|
const toolResults = [];
|
||||||
if (toolResults.length > 0) {
|
for (const block of msg.content) {
|
||||||
newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_RESULT_PREFIX}${toolResults.join("\n")}]`;
|
if (block.type === "text" && block.text) textParts.push(block.text);
|
||||||
}
|
if (block.type === "tool_use") toolNames.push(block.name || "tool");
|
||||||
return { ...rest, content: newContent };
|
if (block.type === "tool_result")
|
||||||
}
|
toolResults.push(
|
||||||
}
|
extractTextContent(block.content) ||
|
||||||
return msg;
|
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).
|
// 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 (
|
||||||
const hard = [...required].filter((c) => HARD_CAPS.has(c));
|
!required ||
|
||||||
const soft = [...required].filter((c) => !HARD_CAPS.has(c));
|
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 tierOf = (m) => {
|
||||||
const slash = typeof m === "string" ? m.indexOf("/") : -1;
|
const slash = typeof m === "string" ? m.indexOf("/") : -1;
|
||||||
const provider = slash > 0 ? m.slice(0, slash) : "";
|
const provider = slash > 0 ? m.slice(0, slash) : "";
|
||||||
const model = slash > 0 ? m.slice(slash + 1) : m;
|
const model = slash > 0 ? m.slice(slash + 1) : m;
|
||||||
const caps = getCapabilitiesForModel(provider, model);
|
const caps = getCapabilitiesForModel(provider, model);
|
||||||
if (!hard.every((c) => caps[c] === true)) return 2;
|
if (!hard.every((c) => caps[c] === true)) return 2;
|
||||||
return soft.every((c) => caps[c] === true) ? 0 : 1;
|
return soft.every((c) => caps[c] === true) ? 0 : 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stable sort by tier (Array.prototype.sort is stable in modern engines).
|
// Stable sort by tier (Array.prototype.sort is stable in modern engines).
|
||||||
return models
|
return models
|
||||||
.map((m, i) => ({ m, i, t: tierOf(m) }))
|
.map((m, i) => ({ m, i, t: tierOf(m) }))
|
||||||
.sort((a, b) => a.t - b.t || a.i - b.i)
|
.sort((a, b) => a.t - b.t || a.i - b.i)
|
||||||
.map((x) => x.m);
|
.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
|
// 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.
|
// to a vision model — those get stripped + placeholdered downstream instead.
|
||||||
function trailingUserItems(arr) {
|
function trailingUserItems(arr) {
|
||||||
if (!Array.isArray(arr) || arr.length === 0) return [];
|
if (!Array.isArray(arr) || arr.length === 0) return [];
|
||||||
const isAssistant = (r) => r === "assistant" || r === "model";
|
const isAssistant = (r) => r === "assistant" || r === "model";
|
||||||
let i = arr.length - 1;
|
let i = arr.length - 1;
|
||||||
while (i >= 0 && !isAssistant(arr[i]?.role)) i--;
|
while (i >= 0 && !isAssistant(arr[i]?.role)) i--;
|
||||||
return arr.slice(i + 1);
|
return arr.slice(i + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect which capabilities a request needs. Modalities (vision/pdf) are scanned
|
// Detect which capabilities a request needs. Modalities (vision/pdf) are scanned
|
||||||
// only on the current user turn; "search" is request-wide (lives in tools).
|
// only on the current user turn; "search" is request-wide (lives in tools).
|
||||||
// Returns a Set of: "vision" | "pdf" | "search".
|
// Returns a Set of: "vision" | "pdf" | "search".
|
||||||
export function detectRequiredCapabilities(body) {
|
export function detectRequiredCapabilities(body) {
|
||||||
const required = new Set();
|
const required = new Set();
|
||||||
if (!body || typeof body !== "object") return required;
|
if (!body || typeof body !== "object") return required;
|
||||||
|
|
||||||
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");
|
||||||
// gemini parts: inlineData/fileData carry a mime
|
if (t === "file" || t === "document" || t === "input_file")
|
||||||
const mime = b.inlineData?.mimeType || b.fileData?.mimeType;
|
required.add("pdf");
|
||||||
if (typeof mime === "string" && mime.startsWith("image/")) required.add("vision");
|
// gemini parts: inlineData/fileData carry a mime
|
||||||
if (mime === "application/pdf") required.add("pdf");
|
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) => {
|
const scanContent = (content) => {
|
||||||
if (Array.isArray(content)) for (const b of content) scanBlock(b);
|
if (Array.isArray(content)) for (const b of content) scanBlock(b);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Modalities: current user turn only (trailing user run across each known shape).
|
// 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 m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude
|
||||||
for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses
|
for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses
|
||||||
const contents = body.contents || body.request?.contents; // gemini / antigravity
|
const contents = body.contents || body.request?.contents; // gemini / antigravity
|
||||||
for (const c of trailingUserItems(contents)) scanContent(c.parts);
|
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) {
|
function normalizeStickyLimit(stickyLimit) {
|
||||||
const parsed = Number.parseInt(stickyLimit, 10);
|
const parsed = Number.parseInt(stickyLimit, 10);
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateModelsFromIndex(models, currentIndex) {
|
function rotateModelsFromIndex(models, currentIndex) {
|
||||||
const rotatedModels = [...models];
|
const rotatedModels = [...models];
|
||||||
for (let i = 0; i < currentIndex; i++) {
|
for (let i = 0; i < currentIndex; i++) {
|
||||||
const moved = rotatedModels.shift();
|
const moved = rotatedModels.shift();
|
||||||
rotatedModels.push(moved);
|
rotatedModels.push(moved);
|
||||||
}
|
}
|
||||||
return rotatedModels;
|
return rotatedModels;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,34 +178,35 @@ function rotateModelsFromIndex(models, currentIndex) {
|
|||||||
* @returns {string[]} Rotated models array
|
* @returns {string[]} Rotated models array
|
||||||
*/
|
*/
|
||||||
export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) {
|
export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) {
|
||||||
if (!models || models.length <= 1 || strategy !== "round-robin") {
|
if (!models || models.length <= 1 || strategy !== "round-robin") {
|
||||||
return models;
|
return models;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 =
|
||||||
? { index: existingState, consecutiveUseCount: 0 }
|
typeof existingState === "number"
|
||||||
: (existingState || { index: 0, consecutiveUseCount: 0 });
|
? { index: existingState, 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);
|
||||||
const nextUseCount = state.consecutiveUseCount + 1;
|
const nextUseCount = state.consecutiveUseCount + 1;
|
||||||
|
|
||||||
if (nextUseCount >= normalizedStickyLimit) {
|
if (nextUseCount >= normalizedStickyLimit) {
|
||||||
comboRotationState.set(rotationKey, {
|
comboRotationState.set(rotationKey, {
|
||||||
index: (currentIndex + 1) % models.length,
|
index: (currentIndex + 1) % models.length,
|
||||||
consecutiveUseCount: 0,
|
consecutiveUseCount: 0,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
comboRotationState.set(rotationKey, {
|
comboRotationState.set(rotationKey, {
|
||||||
index: currentIndex,
|
index: currentIndex,
|
||||||
consecutiveUseCount: nextUseCount,
|
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
|
* @param {string} [comboName] - Combo name to reset; omit to clear all
|
||||||
*/
|
*/
|
||||||
export function resetComboRotation(comboName) {
|
export function resetComboRotation(comboName) {
|
||||||
if (comboName) comboRotationState.delete(comboName);
|
if (comboName) comboRotationState.delete(comboName);
|
||||||
else comboRotationState.clear();
|
else comboRotationState.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,17 +225,24 @@ export function resetComboRotation(comboName) {
|
|||||||
* @returns {string[]|null} Array of models or null if not a combo
|
* @returns {string[]|null} Array of models or null if not a combo
|
||||||
*/
|
*/
|
||||||
export function getComboModelsFromData(modelStr, combosData) {
|
export function getComboModelsFromData(modelStr, combosData) {
|
||||||
// Don't check if it's in provider/model format
|
// Don't check if it's in provider/model format
|
||||||
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 (
|
||||||
return combo.models;
|
combo &&
|
||||||
}
|
combo.enabled !== false &&
|
||||||
return null;
|
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
|
* @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({
|
||||||
// Apply rotation strategy if enabled
|
body,
|
||||||
let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit);
|
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.
|
// Auto-switch: float models that satisfy the request's required capabilities to the front.
|
||||||
if (autoSwitch) {
|
if (autoSwitch) {
|
||||||
const required = detectRequiredCapabilities(body);
|
const required = detectRequiredCapabilities(body);
|
||||||
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",
|
||||||
rotatedModels = reordered;
|
`auto-switch for [${[...required].join(",")}] → ${reordered[0]}`,
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
rotatedModels = reordered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
let earliestRetryAfter = null;
|
let earliestRetryAfter = null;
|
||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
// Success (2xx) - return response
|
// Success (2xx) - return response
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
log.info("COMBO", `Model ${modelStr} succeeded`);
|
log.info("COMBO", `Model ${modelStr} succeeded`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract error info from response
|
// Extract error info from response
|
||||||
let errorText = result.statusText || "";
|
let errorText = result.statusText || "";
|
||||||
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 =
|
||||||
retryAfter = errorBody?.retryAfter || null;
|
errorBody?.error?.message ||
|
||||||
} catch {
|
errorBody?.error ||
|
||||||
// Ignore JSON parse errors
|
errorBody?.message ||
|
||||||
}
|
errorText;
|
||||||
|
retryAfter = errorBody?.retryAfter || null;
|
||||||
|
} catch {
|
||||||
|
// 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 (
|
||||||
earliestRetryAfter = retryAfter;
|
retryAfter &&
|
||||||
}
|
(!earliestRetryAfter ||
|
||||||
|
new Date(retryAfter) < new Date(earliestRetryAfter))
|
||||||
|
) {
|
||||||
|
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)`, {
|
||||||
return result;
|
status: result.status,
|
||||||
}
|
});
|
||||||
|
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`, {
|
||||||
} catch (error) {
|
status: result.status,
|
||||||
// Catch unexpected exceptions to ensure fallback continues
|
});
|
||||||
lastError = error.message || String(error);
|
} catch (error) {
|
||||||
if (!lastStatus) lastStatus = 500;
|
// Catch unexpected exceptions to ensure fallback continues
|
||||||
log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError });
|
lastError = error.message || String(error);
|
||||||
}
|
if (!lastStatus) lastStatus = 500;
|
||||||
}
|
log.warn("COMBO", `Model ${modelStr} threw error, trying next`, {
|
||||||
|
error: lastError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// All models failed
|
// All models failed
|
||||||
// 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 msg = lastError || "All combo models unavailable";
|
const status = allDisabled ? 503 : lastStatus || 503;
|
||||||
|
const msg = lastError || "All combo models unavailable";
|
||||||
|
|
||||||
if (earliestRetryAfter) {
|
if (earliestRetryAfter) {
|
||||||
const retryHuman = formatRetryAfter(earliestRetryAfter);
|
const retryHuman = formatRetryAfter(earliestRetryAfter);
|
||||||
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
|
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
|
||||||
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
|
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
|
||||||
}
|
}
|
||||||
|
|
||||||
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" },
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -337,37 +419,40 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co
|
|||||||
* leaf content→string step reuses the translator's own extractTextContent.
|
* leaf content→string step reuses the translator's own extractTextContent.
|
||||||
*/
|
*/
|
||||||
function extractPanelText(json) {
|
function extractPanelText(json) {
|
||||||
if (!json || typeof json !== "object") return "";
|
if (!json || typeof json !== "object") return "";
|
||||||
|
|
||||||
// OpenAI chat completion
|
// OpenAI chat completion
|
||||||
const choice = json.choices?.[0];
|
const choice = json.choices?.[0];
|
||||||
if (choice) {
|
if (choice) {
|
||||||
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)
|
||||||
const claudeText = extractTextContent(json.content);
|
const claudeText = extractTextContent(json.content);
|
||||||
if (claudeText.trim()) return claudeText;
|
if (claudeText.trim()) return claudeText;
|
||||||
|
|
||||||
// Gemini (parts carry .text without a type discriminator)
|
// Gemini (parts carry .text without a type discriminator)
|
||||||
const parts = json.candidates?.[0]?.content?.parts;
|
const parts = json.candidates?.[0]?.content?.parts;
|
||||||
if (Array.isArray(parts)) {
|
if (Array.isArray(parts)) {
|
||||||
const t = parts.map((p) => p?.text || "").join("");
|
const t = parts.map((p) => p?.text || "").join("");
|
||||||
if (t.trim()) return t;
|
if (t.trim()) return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) =>
|
||||||
.join("");
|
Array.isArray(o.content) ? o.content.map((c) => c?.text || "") : [],
|
||||||
if (t.trim()) return t;
|
)
|
||||||
}
|
.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.
|
* Preserves the original conversation + system prompt so the judge has full context.
|
||||||
*/
|
*/
|
||||||
function appendUserTurn(body, text) {
|
function appendUserTurn(body, text) {
|
||||||
const next = { ...body };
|
const next = { ...body };
|
||||||
if (Array.isArray(body.messages)) {
|
if (Array.isArray(body.messages)) {
|
||||||
next.messages = [...body.messages, { role: "user", content: text }];
|
next.messages = [...body.messages, { role: "user", content: text }];
|
||||||
} else if (Array.isArray(body.input)) {
|
} else if (Array.isArray(body.input)) {
|
||||||
next.input = [...body.input, { role: "user", content: text }];
|
next.input = [...body.input, { role: "user", content: text }];
|
||||||
} else if (Array.isArray(body.contents)) {
|
} else if (Array.isArray(body.contents)) {
|
||||||
next.contents = [...body.contents, { role: "user", parts: [{ text }] }];
|
next.contents = [...body.contents, { role: "user", parts: [{ text }] }];
|
||||||
} else {
|
} else {
|
||||||
next.messages = [{ role: "user", content: text }];
|
next.messages = [{ role: "user", content: text }];
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -398,40 +483,46 @@ function appendUserTurn(body, text) {
|
|||||||
* reputation of a model brand.
|
* reputation of a model brand.
|
||||||
*/
|
*/
|
||||||
function buildJudgePrompt(answers) {
|
function buildJudgePrompt(answers) {
|
||||||
const panel = answers
|
const panel = answers
|
||||||
.map((a, i) => `[Source ${i + 1}]\n${a.text}`)
|
.map((a, i) => `[Source ${i + 1}]\n${a.text}`)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
return [
|
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.`,
|
`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.",
|
"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.",
|
"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 RESPONSES ===",
|
||||||
panel,
|
panel,
|
||||||
"=== END PANEL RESPONSES ===",
|
"=== END PANEL RESPONSES ===",
|
||||||
"",
|
"",
|
||||||
"Now write the final answer to the user's original request.",
|
"Now write the final answer to the user's original request.",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fusion tuning. Overridable per-combo via settings.comboStrategies[name].
|
// Fusion tuning. Overridable per-combo via settings.comboStrategies[name].
|
||||||
const FUSION_DEFAULTS = {
|
const FUSION_DEFAULTS = {
|
||||||
minPanel: 2, // answers needed before stragglers get a grace window
|
minPanel: 2, // answers needed before stragglers get a grace window
|
||||||
stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached
|
stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached
|
||||||
panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever
|
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.
|
// Resolve a Response (or {__error}) within ms; the loser keeps running but is ignored.
|
||||||
function withTimeout(promise, ms) {
|
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,33 +532,41 @@ 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(
|
||||||
return new Promise((resolve) => {
|
calls,
|
||||||
const out = new Array(calls.length);
|
{ minPanel, stragglerGraceMs, panelHardTimeoutMs },
|
||||||
let settled = 0;
|
) {
|
||||||
let ok = 0;
|
return new Promise((resolve) => {
|
||||||
let finished = false;
|
const out = new Array(calls.length);
|
||||||
let graceTimer = null;
|
let settled = 0;
|
||||||
const finish = () => {
|
let ok = 0;
|
||||||
if (finished) return;
|
let finished = false;
|
||||||
finished = true;
|
let graceTimer = null;
|
||||||
clearTimeout(hardTimer);
|
const finish = () => {
|
||||||
if (graceTimer) clearTimeout(graceTimer);
|
if (finished) return;
|
||||||
resolve(out);
|
finished = true;
|
||||||
};
|
clearTimeout(hardTimer);
|
||||||
const hardTimer = setTimeout(finish, panelHardTimeoutMs);
|
if (graceTimer) clearTimeout(graceTimer);
|
||||||
calls.forEach((p, i) => {
|
resolve(out);
|
||||||
Promise.resolve(p)
|
};
|
||||||
.then((v) => { out[i] = v; })
|
const hardTimer = setTimeout(finish, panelHardTimeoutMs);
|
||||||
.catch((e) => { out[i] = { __error: e }; })
|
calls.forEach((p, i) => {
|
||||||
.finally(() => {
|
Promise.resolve(p)
|
||||||
settled++;
|
.then((v) => {
|
||||||
if (out[i] && out[i].ok) ok++;
|
out[i] = v;
|
||||||
if (settled === calls.length) return finish();
|
})
|
||||||
if (ok >= minPanel && !graceTimer) graceTimer = setTimeout(finish, stragglerGraceMs);
|
.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)
|
* @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({
|
||||||
const panel = Array.isArray(models) ? models.filter(Boolean) : [];
|
body,
|
||||||
if (panel.length === 0) {
|
models,
|
||||||
return new Response(
|
handleSingleModel,
|
||||||
JSON.stringify({ error: { message: "Fusion combo has no models" } }),
|
log,
|
||||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
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.
|
// A single-model fusion has nothing to fuse — just answer directly.
|
||||||
if (panel.length === 1) {
|
if (panel.length === 1) {
|
||||||
return handleSingleModel(body, panel[0]);
|
return handleSingleModel(body, panel[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
const panelBody = { ...rest, stream: false };
|
const panelBody = { ...rest, stream: false };
|
||||||
|
|
||||||
// Flatten tool turns to prose so panel models keep context without emitting tool_calls.
|
// Flatten tool turns to prose so panel models keep context without emitting tool_calls.
|
||||||
if (Array.isArray(panelBody.messages)) {
|
if (Array.isArray(panelBody.messages)) {
|
||||||
panelBody.messages = flattenToolHistory(panelBody.messages);
|
panelBody.messages = flattenToolHistory(panelBody.messages);
|
||||||
} else if (Array.isArray(panelBody.input)) {
|
} else if (Array.isArray(panelBody.input)) {
|
||||||
panelBody.input = flattenToolHistory(panelBody.input);
|
panelBody.input = flattenToolHistory(panelBody.input);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) =>
|
||||||
const settled = await collectPanel(calls, { ...cfg, minPanel });
|
withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs),
|
||||||
log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`);
|
);
|
||||||
|
const settled = await collectPanel(calls, { ...cfg, minPanel });
|
||||||
|
log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`);
|
||||||
|
|
||||||
// 2. Collect successful answers.
|
// 2. Collect successful answers.
|
||||||
const answers = [];
|
const answers = [];
|
||||||
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; }
|
}
|
||||||
try {
|
if (res.__timeout) {
|
||||||
const json = await res.clone().json();
|
log.warn("FUSION", `Panel ${model} timed out`);
|
||||||
const text = extractPanelText(json);
|
continue;
|
||||||
if (text) {
|
}
|
||||||
answers.push({ model, text });
|
if (res.__error) {
|
||||||
log.info("FUSION", `Panel ${model} ok (${text.length} chars)`);
|
log.warn("FUSION", `Panel ${model} threw`, {
|
||||||
} else {
|
error: res.__error?.message || String(res.__error),
|
||||||
log.warn("FUSION", `Panel ${model} returned empty content`);
|
});
|
||||||
}
|
continue;
|
||||||
} catch (e) {
|
}
|
||||||
log.warn("FUSION", `Panel ${model} unparseable`, { error: e.message || String(e) });
|
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.
|
// 3. Degrade gracefully when the panel is too thin to fuse.
|
||||||
if (answers.length === 0) {
|
if (answers.length === 0) {
|
||||||
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(
|
||||||
return handleSingleModel(body, answers[0].model);
|
"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).
|
// 4. Judge analyzes + writes one final answer (streams to client if requested).
|
||||||
const judgeBody = appendUserTurn(body, buildJudgePrompt(answers));
|
const judgeBody = appendUserTurn(body, buildJudgePrompt(answers));
|
||||||
log.info("FUSION", `Judging ${answers.length} answers with ${judge}`);
|
log.info("FUSION", `Judging ${answers.length} answers with ${judge}`);
|
||||||
return handleSingleModel(judgeBody, 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";
|
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
||||||
|
|
||||||
function rowToCombo(row) {
|
function rowToCombo(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
kind: row.kind,
|
kind: row.kind,
|
||||||
models: parseJson(row.models, []),
|
models: parseJson(row.models, []),
|
||||||
createdAt: row.createdAt,
|
enabled: row.enabled === 1 || row.enabled === true,
|
||||||
updatedAt: row.updatedAt,
|
createdAt: row.createdAt,
|
||||||
};
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCombos() {
|
export async function getCombos() {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const rows = db.all(`SELECT * FROM combos ORDER BY createdAt ASC`);
|
const rows = db.all(`SELECT * FROM combos ORDER BY createdAt ASC`);
|
||||||
return rows.map(rowToCombo);
|
return rows.map(rowToCombo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getComboById(id) {
|
export async function getComboById(id) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]);
|
const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]);
|
||||||
return rowToCombo(row);
|
return rowToCombo(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getComboByName(name) {
|
export async function getComboByName(name) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const row = db.get(`SELECT * FROM combos WHERE name = ?`, [name]);
|
const row = db.get(`SELECT * FROM combos WHERE name = ?`, [name]);
|
||||||
return rowToCombo(row);
|
return rowToCombo(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCombo(data) {
|
export async function createCombo(data) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const combo = {
|
const combo = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: data.name,
|
name: data.name,
|
||||||
kind: data.kind || null,
|
kind: data.kind || null,
|
||||||
models: data.models || [],
|
models: data.models || [],
|
||||||
createdAt: now,
|
enabled: data.enabled !== false,
|
||||||
updatedAt: now,
|
createdAt: now,
|
||||||
};
|
updatedAt: now,
|
||||||
db.run(
|
};
|
||||||
`INSERT INTO combos(id, name, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`,
|
db.run(
|
||||||
[combo.id, combo.name, combo.kind, stringifyJson(combo.models), combo.createdAt, combo.updatedAt]
|
`INSERT INTO combos(id, name, kind, models, enabled, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||||
);
|
[
|
||||||
return combo;
|
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) {
|
export async function updateCombo(id, data) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
let result = null;
|
let result = null;
|
||||||
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 = {
|
||||||
db.run(
|
...rowToCombo(row),
|
||||||
`UPDATE combos SET name = ?, kind = ?, models = ?, updatedAt = ? WHERE id = ?`,
|
...data,
|
||||||
[merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id]
|
updatedAt: new Date().toISOString(),
|
||||||
);
|
};
|
||||||
result = merged;
|
db.run(
|
||||||
});
|
`UPDATE combos SET name = ?, kind = ?, models = ?, enabled = ?, updatedAt = ? WHERE id = ?`,
|
||||||
return result;
|
[
|
||||||
|
merged.name,
|
||||||
|
merged.kind,
|
||||||
|
stringifyJson(merged.models || []),
|
||||||
|
merged.enabled ? 1 : 0,
|
||||||
|
merged.updatedAt,
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
result = merged;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCombo(id) {
|
export async function deleteCombo(id) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const res = db.run(`DELETE FROM combos WHERE id = ?`, [id]);
|
const res = db.run(`DELETE FROM combos WHERE id = ?`, [id]);
|
||||||
return (res?.changes ?? 0) > 0;
|
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.
|
// auto-add missing tables/columns/indexes after versioned migrations.
|
||||||
// For destructive changes (drop/rename/type-change), write a migration file.
|
// For destructive changes (drop/rename/type-change), write a migration file.
|
||||||
export const TABLES = {
|
export const TABLES = {
|
||||||
_meta: {
|
_meta: {
|
||||||
columns: {
|
columns: {
|
||||||
key: "TEXT PRIMARY KEY",
|
key: "TEXT PRIMARY KEY",
|
||||||
value: "TEXT NOT NULL",
|
value: "TEXT NOT NULL",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
columns: {
|
columns: {
|
||||||
id: "INTEGER PRIMARY KEY CHECK (id = 1)",
|
id: "INTEGER PRIMARY KEY CHECK (id = 1)",
|
||||||
data: "TEXT NOT NULL",
|
data: "TEXT NOT NULL",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
providerConnections: {
|
providerConnections: {
|
||||||
columns: {
|
columns: {
|
||||||
id: "TEXT PRIMARY KEY",
|
id: "TEXT PRIMARY KEY",
|
||||||
provider: "TEXT NOT NULL",
|
provider: "TEXT NOT NULL",
|
||||||
authType: "TEXT NOT NULL",
|
authType: "TEXT NOT NULL",
|
||||||
name: "TEXT",
|
name: "TEXT",
|
||||||
email: "TEXT",
|
email: "TEXT",
|
||||||
priority: "INTEGER",
|
priority: "INTEGER",
|
||||||
isActive: "INTEGER DEFAULT 1",
|
isActive: "INTEGER DEFAULT 1",
|
||||||
data: "TEXT NOT NULL",
|
data: "TEXT NOT NULL",
|
||||||
createdAt: "TEXT NOT NULL",
|
createdAt: "TEXT NOT NULL",
|
||||||
updatedAt: "TEXT NOT NULL",
|
updatedAt: "TEXT NOT NULL",
|
||||||
},
|
},
|
||||||
indexes: [
|
indexes: [
|
||||||
"CREATE INDEX IF NOT EXISTS idx_pc_provider ON providerConnections(provider)",
|
"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_provider_active ON providerConnections(provider, isActive)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_pc_priority ON providerConnections(provider, priority)",
|
"CREATE INDEX IF NOT EXISTS idx_pc_priority ON providerConnections(provider, priority)",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
providerNodes: {
|
providerNodes: {
|
||||||
columns: {
|
columns: {
|
||||||
id: "TEXT PRIMARY KEY",
|
id: "TEXT PRIMARY KEY",
|
||||||
type: "TEXT",
|
type: "TEXT",
|
||||||
name: "TEXT",
|
name: "TEXT",
|
||||||
data: "TEXT NOT NULL",
|
data: "TEXT NOT NULL",
|
||||||
createdAt: "TEXT NOT NULL",
|
createdAt: "TEXT NOT NULL",
|
||||||
updatedAt: "TEXT NOT NULL",
|
updatedAt: "TEXT NOT NULL",
|
||||||
},
|
},
|
||||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_pn_type ON providerNodes(type)"],
|
indexes: ["CREATE INDEX IF NOT EXISTS idx_pn_type ON providerNodes(type)"],
|
||||||
},
|
},
|
||||||
proxyPools: {
|
proxyPools: {
|
||||||
columns: {
|
columns: {
|
||||||
id: "TEXT PRIMARY KEY",
|
id: "TEXT PRIMARY KEY",
|
||||||
isActive: "INTEGER DEFAULT 1",
|
isActive: "INTEGER DEFAULT 1",
|
||||||
testStatus: "TEXT",
|
testStatus: "TEXT",
|
||||||
data: "TEXT NOT NULL",
|
data: "TEXT NOT NULL",
|
||||||
createdAt: "TEXT NOT NULL",
|
createdAt: "TEXT NOT NULL",
|
||||||
updatedAt: "TEXT NOT NULL",
|
updatedAt: "TEXT NOT NULL",
|
||||||
},
|
},
|
||||||
indexes: [
|
indexes: [
|
||||||
"CREATE INDEX IF NOT EXISTS idx_pp_active ON proxyPools(isActive)",
|
"CREATE INDEX IF NOT EXISTS idx_pp_active ON proxyPools(isActive)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_pp_status ON proxyPools(testStatus)",
|
"CREATE INDEX IF NOT EXISTS idx_pp_status ON proxyPools(testStatus)",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
apiKeys: {
|
apiKeys: {
|
||||||
columns: {
|
columns: {
|
||||||
id: "TEXT PRIMARY KEY",
|
id: "TEXT PRIMARY KEY",
|
||||||
key: "TEXT UNIQUE NOT NULL",
|
key: "TEXT UNIQUE NOT NULL",
|
||||||
name: "TEXT",
|
name: "TEXT",
|
||||||
machineId: "TEXT",
|
machineId: "TEXT",
|
||||||
isActive: "INTEGER DEFAULT 1",
|
isActive: "INTEGER DEFAULT 1",
|
||||||
createdAt: "TEXT NOT NULL",
|
createdAt: "TEXT NOT NULL",
|
||||||
},
|
},
|
||||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_ak_key ON apiKeys(key)"],
|
indexes: ["CREATE INDEX IF NOT EXISTS idx_ak_key ON apiKeys(key)"],
|
||||||
},
|
},
|
||||||
combos: {
|
combos: {
|
||||||
columns: {
|
columns: {
|
||||||
id: "TEXT PRIMARY KEY",
|
id: "TEXT PRIMARY KEY",
|
||||||
name: "TEXT UNIQUE NOT NULL",
|
name: "TEXT UNIQUE NOT NULL",
|
||||||
kind: "TEXT",
|
kind: "TEXT",
|
||||||
models: "TEXT NOT NULL",
|
models: "TEXT NOT NULL",
|
||||||
createdAt: "TEXT NOT NULL",
|
enabled: "INTEGER NOT NULL DEFAULT 1",
|
||||||
updatedAt: "TEXT NOT NULL",
|
createdAt: "TEXT NOT NULL",
|
||||||
},
|
updatedAt: "TEXT NOT NULL",
|
||||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_combo_name ON combos(name)"],
|
},
|
||||||
},
|
indexes: ["CREATE INDEX IF NOT EXISTS idx_combo_name ON combos(name)"],
|
||||||
kv: {
|
},
|
||||||
columns: {
|
kv: {
|
||||||
scope: "TEXT NOT NULL",
|
columns: {
|
||||||
key: "TEXT NOT NULL",
|
scope: "TEXT NOT NULL",
|
||||||
value: "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)"],
|
primaryKey: "PRIMARY KEY (scope, key)",
|
||||||
},
|
indexes: ["CREATE INDEX IF NOT EXISTS idx_kv_scope ON kv(scope)"],
|
||||||
usageHistory: {
|
},
|
||||||
columns: {
|
usageHistory: {
|
||||||
id: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
columns: {
|
||||||
timestamp: "TEXT NOT NULL",
|
id: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||||
provider: "TEXT",
|
timestamp: "TEXT NOT NULL",
|
||||||
model: "TEXT",
|
provider: "TEXT",
|
||||||
connectionId: "TEXT",
|
model: "TEXT",
|
||||||
apiKey: "TEXT",
|
connectionId: "TEXT",
|
||||||
endpoint: "TEXT",
|
apiKey: "TEXT",
|
||||||
promptTokens: "INTEGER DEFAULT 0",
|
endpoint: "TEXT",
|
||||||
completionTokens: "INTEGER DEFAULT 0",
|
promptTokens: "INTEGER DEFAULT 0",
|
||||||
cost: "REAL DEFAULT 0",
|
completionTokens: "INTEGER DEFAULT 0",
|
||||||
status: "TEXT",
|
cost: "REAL DEFAULT 0",
|
||||||
tokens: "TEXT",
|
status: "TEXT",
|
||||||
meta: "TEXT",
|
tokens: "TEXT",
|
||||||
},
|
meta: "TEXT",
|
||||||
indexes: [
|
},
|
||||||
"CREATE INDEX IF NOT EXISTS idx_uh_ts ON usageHistory(timestamp DESC)",
|
indexes: [
|
||||||
"CREATE INDEX IF NOT EXISTS idx_uh_provider ON usageHistory(provider)",
|
"CREATE INDEX IF NOT EXISTS idx_uh_ts ON usageHistory(timestamp DESC)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_uh_model ON usageHistory(model)",
|
"CREATE INDEX IF NOT EXISTS idx_uh_provider ON usageHistory(provider)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)",
|
"CREATE INDEX IF NOT EXISTS idx_uh_model ON usageHistory(model)",
|
||||||
],
|
"CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)",
|
||||||
},
|
],
|
||||||
usageDaily: {
|
},
|
||||||
columns: {
|
usageDaily: {
|
||||||
dateKey: "TEXT PRIMARY KEY",
|
columns: {
|
||||||
data: "TEXT NOT NULL",
|
dateKey: "TEXT PRIMARY KEY",
|
||||||
},
|
data: "TEXT NOT NULL",
|
||||||
},
|
},
|
||||||
requestDetails: {
|
},
|
||||||
columns: {
|
requestDetails: {
|
||||||
id: "TEXT PRIMARY KEY",
|
columns: {
|
||||||
timestamp: "TEXT NOT NULL",
|
id: "TEXT PRIMARY KEY",
|
||||||
provider: "TEXT",
|
timestamp: "TEXT NOT NULL",
|
||||||
model: "TEXT",
|
provider: "TEXT",
|
||||||
connectionId: "TEXT",
|
model: "TEXT",
|
||||||
status: "TEXT",
|
connectionId: "TEXT",
|
||||||
data: "TEXT NOT NULL",
|
status: "TEXT",
|
||||||
},
|
data: "TEXT NOT NULL",
|
||||||
indexes: [
|
},
|
||||||
"CREATE INDEX IF NOT EXISTS idx_rd_ts ON requestDetails(timestamp DESC)",
|
indexes: [
|
||||||
"CREATE INDEX IF NOT EXISTS idx_rd_provider ON requestDetails(provider)",
|
"CREATE INDEX IF NOT EXISTS idx_rd_ts ON requestDetails(timestamp DESC)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_rd_model ON requestDetails(model)",
|
"CREATE INDEX IF NOT EXISTS idx_rd_provider ON requestDetails(provider)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_rd_conn ON requestDetails(connectionId)",
|
"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) {
|
export function buildCreateTableSql(name, def) {
|
||||||
const cols = Object.entries(def.columns).map(([k, v]) => `${k} ${v}`);
|
const cols = Object.entries(def.columns).map(([k, v]) => `${k} ${v}`);
|
||||||
if (def.primaryKey) cols.push(def.primaryKey);
|
if (def.primaryKey) cols.push(def.primaryKey);
|
||||||
return `CREATE TABLE IF NOT EXISTS ${name} (${cols.join(", ")})`;
|
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
|
// 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)
|
||||||
const LOCAL_PROVIDER_ALIASES = {
|
const LOCAL_PROVIDER_ALIASES = {
|
||||||
xmtp: "xiaomi-tokenplan",
|
xmtp: "xiaomi-tokenplan",
|
||||||
"xiaomi-tokenplan": "xiaomi-tokenplan",
|
"xiaomi-tokenplan": "xiaomi-tokenplan",
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESERVED_PROVIDER_PREFIXES = new Set(Object.keys(LOCAL_PROVIDER_ALIASES));
|
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,
|
||||||
return parsed;
|
provider: LOCAL_PROVIDER_ALIASES[parsed.providerAlias],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve model alias from localDb
|
* Resolve model alias from localDb
|
||||||
*/
|
*/
|
||||||
export async function resolveModelAlias(alias) {
|
export async function resolveModelAlias(alias) {
|
||||||
const aliases = await getModelAliases();
|
const aliases = await getModelAliases();
|
||||||
return resolveModelAliasFromMap(alias, aliases);
|
return resolveModelAliasFromMap(alias, aliases);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full model info (parse or resolve)
|
* Get full model info (parse or resolve)
|
||||||
*/
|
*/
|
||||||
export async function getModelInfo(modelStr) {
|
export async function getModelInfo(modelStr) {
|
||||||
const parsed = parseModel(modelStr);
|
const parsed = parseModel(modelStr);
|
||||||
|
|
||||||
if (!parsed.isAlias) {
|
if (!parsed.isAlias) {
|
||||||
// Provider-node prefixes are user-defined. They must not override built-in
|
// Provider-node prefixes are user-defined. They must not override built-in
|
||||||
// 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(
|
||||||
if (matchedOpenAI) {
|
(node) => node.prefix === parsed.providerAlias,
|
||||||
return { provider: matchedOpenAI.id, model: parsed.model };
|
);
|
||||||
}
|
if (matchedOpenAI) {
|
||||||
|
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",
|
||||||
if (matchedAnthropic) {
|
});
|
||||||
return { provider: matchedAnthropic.id, model: parsed.model };
|
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 embeddingNodes = await getProviderNodes({
|
||||||
const matchedEmbedding = embeddingNodes.find((node) => node.prefix === parsed.providerAlias);
|
type: "custom-embedding",
|
||||||
if (matchedEmbedding) {
|
});
|
||||||
return { provider: matchedEmbedding.id, model: parsed.model };
|
const matchedEmbedding = embeddingNodes.find(
|
||||||
}
|
(node) => node.prefix === parsed.providerAlias,
|
||||||
}
|
);
|
||||||
return {
|
if (matchedEmbedding) {
|
||||||
provider: parsed.provider,
|
return { provider: matchedEmbedding.id, model: parsed.model };
|
||||||
model: parsed.model
|
}
|
||||||
};
|
}
|
||||||
}
|
return {
|
||||||
|
provider: parsed.provider,
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
* @returns {Promise<string[]|null>} Array of models or null if not a combo
|
||||||
*/
|
*/
|
||||||
export async function getComboModels(modelStr) {
|
export async function getComboModels(modelStr) {
|
||||||
// Only check if it's not in provider/model format
|
// Only check if it's not in provider/model format
|
||||||
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 (
|
||||||
return combo.models;
|
combo &&
|
||||||
}
|
combo.enabled !== false &&
|
||||||
return null;
|
combo.models &&
|
||||||
|
combo.models.length > 0
|
||||||
|
) {
|
||||||
|
return combo.models;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user