From 87e5c1c6dd68d223eb4d04c19c8a60017849ad2f Mon Sep 17 00:00:00 2001 From: Daniil Schovkunov Date: Wed, 17 Jun 2026 10:34:27 +0700 Subject: [PATCH] =?UTF-8?q?feat(combo):=20add=20Fusion=20strategy=20?= =?UTF-8?q?=E2=80=94=20parallel=20panel=20+=20judge=20synthesis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Fusion as a third combo strategy alongside fallback/round-robin. A fusion combo fans the prompt out to all member models in parallel, then a configurable judge model synthesizes one final answer from the panel. - handleFusionChat in open-sse/services/combo.js: quorum-grace collection caps the straggler penalty, anonymized sources prevent judge brand-bias, degrades to a direct answer on single survivor and 503 on total failure. - chat.js dispatches strategy==="fusion" at both combo entry points. - Combos dashboard: per-combo strategy Select replaces the round-robin toggle, fusion reveals a judge picker, plus a strategy/capacity explainer. - tests/unit/combo-fusion.test.js covers fan-out, judge routing/default, quorum-grace straggler drop, single-survivor and total-failure degradation. Co-authored-by: Cursor --- images/fusion-combo-ui.png | Bin 0 -> 38539 bytes open-sse/services/combo.js | 233 +++++++++++++++++++ src/app/(dashboard)/dashboard/combos/page.js | 93 ++++++-- src/sse/handlers/chat.js | 32 ++- tests/unit/combo-fusion.test.js | 142 +++++++++++ 5 files changed, 478 insertions(+), 22 deletions(-) create mode 100644 images/fusion-combo-ui.png create mode 100644 tests/unit/combo-fusion.test.js diff --git a/images/fusion-combo-ui.png b/images/fusion-combo-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..8799d72082b2cdc1234bd8389f4e50e45d64a7a7 GIT binary patch literal 38539 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%0DjvJzF4`n~JBzxMr~x5W#F z7%UYD3%`c5C4vFy=SkU^Oqz5qk2GGRoGYBVgnzxVSkHHnyc^Y7y~Gml!=%FDHOLTh zshp(|o{HA|Rp03q(UIlmyp0q-7A5|-Q-i-ToQI_HLY>!0F?NMLvwGuGa0YE`UvQr@ zHF!H-WPISpU2e!-p?AdQO#mlRBWQybi-s<0Y+Q6kQK~LkIXN7B^6w*o9JxX$2%{+P zCG`kZ@$^j6>SZr?1f|+N$u&0q?r?W&>y~qfQo{y18kz_&sz}Jluq#nP*9ga%R$#s<>L7LDQ9%an1F0Y)hb~lC72F$3Bbh&iilwLCfE4X z2yBT_*2VqH5i>r$_S82RQbW?Jsj3>^wTss`*mH?;9+Uv;gI1M4oCR}v1`r{12f_5b z(b36KTXIP*s&vat#XKi&ABFK%OnQuX;3Y;@F}mhDhY#a6%$lGgiI`EV;EG{Yw#_oZp53*5|K;KW~!XLKogdcYH`k0{Kg>u^x}|KS_cI2Zo7%& zuJIh^66Fp`bkNS_+-B?bF4;MJY~sQ7=1xEHsMIFL`;Rk6eIs!~8RrVHQ$JHo&j^M!q#eN{aNb2}8bb0?4m}CEXTF9hWdPyd`3Vx#j30VzVqkfV zidH}W%L^o0pZ)A@%W{*e!guG#!MMIbHn{Ow3}5>Rrab&_dg5%(l2q{pE7*9i>% zCh!e#s_aevix2-g9e!@SNR!xYcjf+RTK~hw$?$p-8O5d;;*tJ)n?30P<3Fs{5c@A9 z8nDa+qA=1VVbLf<-6a3sKbEB2QIlWc6QY0f_kZb@KmNiG3!F;EI?E~XzqEN6@K>xh zYqI}p8wtGL6LLo}h)Oab#r%7lJHCPm$Qsb7{TJ(x{0=ykU|-O-)_-X;%TKpA=qL7H xZbX(Zn85h^Ph;%j|K8^RH+cU4HF!FnU+&aHgP+>c=Yj!WA_9{9WxQX!{|EdczQX_j literal 0 HcmV?d00001 diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 2719d7c2..56271795 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -5,6 +5,7 @@ import { checkFallbackError, formatRetryAfter } from "./accountFallback.js"; import { unavailableResponse } from "../utils/error.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; +import { extractTextContent } from "../translator/formats/gemini.js"; // Hard capabilities = input modalities; missing one drops request data (e.g. image // stripped). Must be prioritized. Soft (e.g. search) only degrades a feature. @@ -281,3 +282,235 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co { status, headers: { "Content-Type": "application/json" } } ); } + +/** + * Extract assistant text from a non-stream completion across formats + * (OpenAI chat, Claude messages, Gemini, OpenAI Responses). Returns "" if none. + * Panel responses are already translated to the client format by chatCore, so the + * leaf content→string step reuses the translator's own extractTextContent. + */ +function extractPanelText(json) { + 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; + } + + // 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; + } + + // 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 ""; +} + +/** + * Append a synthesized user turn to whichever message array the request format uses. + * 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; +} + +/** + * Build the judge directive. Per OpenRouter's Fusion design, the judge does NOT + * merge — it analyzes (consensus / contradictions / partial coverage / unique + * insights / blind spots) then writes one answer grounded in that analysis. + * ~3/4 of fusion's quality lift comes from this synthesis step. + * + * Sources are anonymized ("Source N") so the judge weighs substance, not the + * reputation of a model brand. + */ +function buildJudgePrompt(answers) { + 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"); +} + +// 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 +}; + +// 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 }); }); + }); +} + +/** + * Collect panel responses with quorum-grace: as soon as `minPanel` calls succeed, + * start a short grace timer for the rest, then proceed with whatever arrived. This + * caps the straggler penalty (the slowest model otherwise dominates wall time) while + * 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); + }); + }); + }); +} + +/** + * Handle a fusion combo: fan the prompt out to every panel model in parallel, + * then a judge model synthesizes one final answer from all panel responses. + * + * Panel calls are forced non-streaming with tools stripped (the judge needs + * complete prose to synthesize). The judge call keeps the client's original + * stream flag + tools, so streaming and downstream tool use still work. + * + * Speed: quorum-grace collection caps the straggler penalty. Quality: the judge + * runs the consensus/contradiction/blind-spot analysis before writing. + * + * Degrades gracefully: 0 panel answers -> 503, exactly 1 -> return it directly. + * + * @param {Object} options + * @param {Object} options.body - Request body (client format) + * @param {string[]} options.models - Panel model strings + * @param {Function} options.handleSingleModel - (body, modelStr) => Promise + * @param {Object} options.log - Logger + * @param {string} [options.comboName] - Combo name (logging) + * @param {string} [options.judgeModel] - Judge model; falls back to panel[0] + * @param {Object} [options.tuning] - Override FUSION_DEFAULTS (minPanel, grace, timeout) + * @returns {Promise} + */ +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]); + } + + 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 }; + const t0 = Date.now(); + const calls = panel.map((m) => withTimeout(handleSingleModel(panelBody, m), 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) }); + } + } + + // 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); +} diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 8b694d31..8902db4e 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -5,7 +5,7 @@ import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, us import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; -import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, Toggle, ConfirmModal, CapacityBadges } from "@/shared/components"; +import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; @@ -115,21 +115,25 @@ export default function CombosPage() { }); }; - const handleToggleRoundRobin = async (comboName, enabled) => { + // Merge a per-combo strategy patch into settings.comboStrategies. Passing an empty + // patch (strategy back to default "fallback") drops the entry entirely. + const handleSetComboStrategy = async (comboName, patch) => { try { const updated = { ...comboStrategies }; - if (enabled) { - updated[comboName] = { fallbackStrategy: "round-robin" }; - } else { + const next = { ...(updated[comboName] || {}), ...patch }; + // Prune to keep settings clean: default fallback with no extras = no entry. + if (!next.fallbackStrategy || next.fallbackStrategy === "fallback") { delete updated[comboName]; + } else { + updated[comboName] = next; } - + await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ comboStrategies: updated }), }); - + setComboStrategies(updated); } catch (error) { console.log("Error updating combo strategy:", error); @@ -150,10 +154,15 @@ export default function CombosPage() { {/* Header */}
-

Combos

- Create model combos with fallback support — auto-adapts per request: routes images to vision models and web search to search-capable models. + Group models under one name, then pick a strategy per combo:

+
    +
  • Fallback — tries models in order (next on failure)
  • +
  • Round Robin — rotates models across requests to spread load
  • +
  • Fusion — queries all models in parallel, then a judge synthesizes one answer
  • +
  • Capacity auto-switch — reorders models per request so images/PDFs route to capable models first
  • +
@@ -224,7 +234,18 @@ export default function CombosPage() { ); } -function ComboCard({ combo, modelCaps = {}, copied, onCopy, onEdit, onDelete, roundRobinEnabled, onToggleRoundRobin }) { +const STRATEGY_OPTIONS = [ + { value: "fallback", label: "Fallback — try in order" }, + { value: "round-robin", label: "Round Robin — rotate" }, + { value: "fusion", label: "Fusion — panel + judge" }, +]; + +function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) { + const [showJudgeSelect, setShowJudgeSelect] = useState(false); + const current = strategy.fallbackStrategy || "fallback"; + const judge = strategy.judgeModel || ""; + const isFusion = current === "fusion"; + return (
@@ -249,18 +270,41 @@ function ComboCard({ combo, modelCaps = {}, copied, onCopy, onEdit, onDelete, ro +{combo.models.length - 3} more )}
+ {/* Fusion: judge picker (Auto = first model) */} + {isFusion && ( +
+ Judge + + {judge && ( + + )} +
+ )} {/* Actions */}
- {/* Round Robin Toggle — always visible */} -
- Round Robin - +