Merge remote-tracking branch 'origin/master' into gitea/feature/end
Resolved conflicts taking origin/master (v0.5.55) as canonical, with local features re-applied: - runtime log level (LOG_LEVEL env + dashboard Settings → Logging, applied immediately and persisted across restarts) - free/noAuth provider enable/disable toggle via providerStrategies.enabled - parallel model testing (Test All Models / Test Selected Keys)
This commit is contained in:
@@ -106,23 +106,74 @@ export function detectRequiredCapabilities(body) {
|
||||
const required = new Set();
|
||||
if (!body || typeof body !== "object") return required;
|
||||
|
||||
const addByMime = (mime) => {
|
||||
if (typeof mime !== "string") return;
|
||||
if (mime.startsWith("image/")) required.add("vision");
|
||||
else if (mime === "application/pdf") required.add("pdf");
|
||||
else if (mime.startsWith("audio/")) required.add("audioInput");
|
||||
else if (mime.startsWith("video/")) required.add("videoInput");
|
||||
};
|
||||
|
||||
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");
|
||||
if (t === "input_audio" || t === "audio_url" || t === "audio") required.add("audioInput");
|
||||
if (t === "input_video" || t === "video_url" || t === "video") required.add("videoInput");
|
||||
if (t === "file" || t === "document" || t === "input_file") {
|
||||
// Infer modality from embedded mime when available; fall back to pdf for generic files.
|
||||
let fmime = null;
|
||||
if (b.input_audio?.format) fmime = `audio/${b.input_audio.format}`;
|
||||
else if (b.file?.file_data) fmime = String(b.file.file_data).match(/^data:([^;,]+)/)?.[1];
|
||||
else if (b.source?.media_type) fmime = b.source.media_type;
|
||||
else if (b.source?.data) fmime = String(b.source.data).match(/^data:([^;,]+)/)?.[1];
|
||||
if (fmime) addByMime(fmime);
|
||||
else 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");
|
||||
addByMime(b.inlineData?.mimeType || b.fileData?.mimeType);
|
||||
};
|
||||
|
||||
const scanContent = (content) => {
|
||||
if (Array.isArray(content)) for (const b of content) scanBlock(b);
|
||||
};
|
||||
|
||||
const scanMessage = (m) => {
|
||||
if (!m || typeof m !== "object") return;
|
||||
|
||||
// Ollama / Hermes images array (strings or objects)
|
||||
if (Array.isArray(m.images) && m.images.length > 0) {
|
||||
required.add("vision");
|
||||
}
|
||||
|
||||
// Vercel AI SDK / Hermes attachments / experimental_attachments
|
||||
const attachments = m.experimental_attachments || m.attachments;
|
||||
if (Array.isArray(attachments)) {
|
||||
for (const att of attachments) {
|
||||
if (!att) continue;
|
||||
const mime = att.contentType || att.mediaType || (typeof att.url === "string" && att.url.match(/^data:([^;,]+)/)?.[1]);
|
||||
if (mime) addByMime(mime);
|
||||
else if (att.url || att.data) required.add("vision");
|
||||
}
|
||||
}
|
||||
|
||||
// Direct message-level modality properties
|
||||
if (m.image_url || m.image) required.add("vision");
|
||||
if (m.audio_url || m.audio) required.add("audioInput");
|
||||
|
||||
// Scan array content blocks
|
||||
scanContent(m.content);
|
||||
|
||||
// Scan string content for embedded data URIs
|
||||
if (typeof m.content === "string") {
|
||||
if (m.content.includes("data:image/")) required.add("vision");
|
||||
else if (m.content.includes("data:audio/")) required.add("audioInput");
|
||||
else if (m.content.includes("data:application/pdf")) required.add("pdf");
|
||||
}
|
||||
};
|
||||
|
||||
// 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)) scanMessage(m); // openai / claude / hermes / ollama
|
||||
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);
|
||||
@@ -513,7 +564,10 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
|
||||
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 { tools, tool_choice, stream_options, ...rest } = body;
|
||||
// Fusion runs panel models non-streaming; drop stream_options too, or providers
|
||||
// like DeepSeek reject it with "stream_options should be set along with stream = true".
|
||||
// See issue #3024.
|
||||
const panelBody = { ...rest, stream: false };
|
||||
|
||||
// Flatten tool turns to prose so panel models keep context without emitting tool_calls.
|
||||
|
||||
Reference in New Issue
Block a user