feat(combo): add Fusion strategy — parallel panel + judge synthesis

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 <cursoragent@cursor.com>
This commit is contained in:
Daniil Schovkunov
2026-06-17 10:34:27 +07:00
committed by decolua
parent 37bfcc3719
commit 87e5c1c6dd
5 changed files with 478 additions and 22 deletions

View File

@@ -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 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h1 className="text-2xl font-semibold">Combos</h1>
<p className="text-sm text-text-muted mt-1">
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:
</p>
<ul className="text-sm text-text-muted mt-2 flex flex-col gap-1">
<li><span className="font-medium text-text-main">Fallback</span> tries models in order (next on failure)</li>
<li><span className="font-medium text-text-main">Round Robin</span> rotates models across requests to spread load</li>
<li><span className="font-medium text-text-main">Fusion</span> queries all models in parallel, then a judge synthesizes one answer</li>
<li><span className="font-medium text-text-main">Capacity auto-switch</span> reorders models per request so images/PDFs route to capable models first</li>
</ul>
</div>
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto">
Create Combo
@@ -181,12 +190,13 @@ export default function CombosPage() {
key={combo.id}
combo={combo}
modelCaps={modelCaps}
activeProviders={activeProviders}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
roundRobinEnabled={comboStrategies[combo.name]?.fallbackStrategy === "round-robin"}
onToggleRoundRobin={(enabled) => handleToggleRoundRobin(combo.name, enabled)}
strategy={comboStrategies[combo.name] || {}}
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
/>
))}
</div>
@@ -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 (
<Card padding="sm" className="group">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -249,18 +270,41 @@ function ComboCard({ combo, modelCaps = {}, copied, onCopy, onEdit, onDelete, ro
<span className="text-[10px] text-text-muted">+{combo.models.length - 3} more</span>
)}
</div>
{/* Fusion: judge picker (Auto = first model) */}
{isFusion && (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
<span className="text-[11px] font-medium text-text-muted">Judge</span>
<button
onClick={() => setShowJudgeSelect(true)}
className="inline-flex max-w-full items-center gap-1 rounded border border-dashed border-primary/40 px-1.5 py-0.5 font-mono text-[11px] text-primary hover:border-primary hover:bg-primary/5 transition-colors"
title="Pick the model that fuses panel answers"
>
<span className="material-symbols-outlined text-[13px]">gavel</span>
<span className="truncate">{judge || `Auto — ${combo.models[0] || "first model"}`}</span>
</button>
{judge && (
<button
onClick={() => onSetStrategy({ judgeModel: "" })}
className="p-0.5 rounded text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-colors"
title="Reset judge to Auto"
>
<span className="material-symbols-outlined text-[13px]">close</span>
</button>
)}
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:gap-3 sm:shrink-0">
{/* Round Robin Toggle — always visible */}
<div className="flex items-center justify-between gap-1.5 rounded-lg bg-black/[0.02] px-2 py-1.5 dark:bg-white/[0.02] sm:justify-start sm:bg-transparent sm:px-0 sm:py-0 sm:dark:bg-transparent">
<span className="text-xs text-text-muted font-medium">Round Robin</span>
<Toggle
size="sm"
checked={roundRobinEnabled}
onChange={onToggleRoundRobin}
{/* Strategy selector — always visible */}
<div className="w-full sm:w-[200px]">
<Select
options={STRATEGY_OPTIONS}
value={current}
onChange={(e) => onSetStrategy({ fallbackStrategy: e.target.value })}
selectClassName="py-1.5 text-xs"
/>
</div>
@@ -294,6 +338,17 @@ function ComboCard({ combo, modelCaps = {}, copied, onCopy, onEdit, onDelete, ro
</div>
</div>
</div>
{/* Judge model picker (single-select; combo members make natural judges too) */}
<ModelSelectModal
isOpen={showJudgeSelect}
onClose={() => setShowJudgeSelect(false)}
onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }}
activeProviders={activeProviders}
title="Select Judge Model"
addedModelValues={judge ? [judge] : []}
closeOnSelect={true}
/>
</Card>
);
}

View File

@@ -12,7 +12,7 @@ import { getSettings } from "@/lib/localDb";
import { getModelInfo, getComboModels } from "../services/model.js";
import { handleChatCore } from "open-sse/handlers/chatCore.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { handleComboChat } from "open-sse/services/combo.js";
import { handleComboChat, handleFusionChat } from "open-sse/services/combo.js";
import { handleBypassRequest } from "open-sse/utils/bypassHandler.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import { detectFormatByEndpoint } from "open-sse/translator/formats.js";
@@ -96,7 +96,20 @@ export async function handleChat(request, clientRawRequest = null) {
const comboStrategies = settings.comboStrategies || {};
const comboSpecificStrategy = comboStrategies[modelStr]?.fallbackStrategy;
const comboStrategy = comboSpecificStrategy || settings.comboStrategy || "fallback";
if (comboStrategy === "fusion") {
log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models (strategy: fusion)`);
return handleFusionChat({
body,
models: comboModels,
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
log,
comboName: modelStr,
judgeModel: comboStrategies[modelStr]?.judgeModel,
tuning: comboStrategies[modelStr]?.fusionTuning,
});
}
const comboStickyLimit = settings.comboStickyRoundRobinLimit;
log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
return handleComboChat({
@@ -129,7 +142,20 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
const comboStrategies = chatSettings.comboStrategies || {};
const comboSpecificStrategy = comboStrategies[modelStr]?.fallbackStrategy;
const comboStrategy = comboSpecificStrategy || chatSettings.comboStrategy || "fallback";
if (comboStrategy === "fusion") {
log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models (strategy: fusion)`);
return handleFusionChat({
body,
models: comboModels,
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
log,
comboName: modelStr,
judgeModel: comboStrategies[modelStr]?.judgeModel,
tuning: comboStrategies[modelStr]?.fusionTuning,
});
}
const comboStickyLimit = chatSettings.comboStickyRoundRobinLimit;
log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
return handleComboChat({