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:
committed by
decolua
parent
37bfcc3719
commit
87e5c1c6dd
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user