Files
OmniRoute/open-sse/services/fusion.ts
Brandon Bennett 6615a5445b feat: combo-lane awareness + activation UX + MCP visibility (Wave 2 of #9654) (#10039)
* feat(admission): per-target lane-aware probes for combo/fusion fan-out (#9654 Wave 2)

Combo and fusion fan out N targets without ever consulting the adaptive-admission
layer: the parent request holds one lease, but each fan-out target is dispatched
unconditionally. With virtual lanes enabled (OMNIROUTE_CHAT_VIRTUAL_LANES=1), a
connection whose lane queue is full now SKIPS additional fan-out targets instead
of piling more queued work onto an already-congested session.

Adds PerTargetAdmissionHook (admission/types.ts) + createPerTargetAdmissionHook
factory (chatAdmission.ts): strictly non-blocking (maxWaitMs 0 - skip, never
queue), a no-op when virtual lanes are off, keyed to the parent tenantKey, and
release-on-admit so the probe is a capacity gate, not a hold.

Threaded through every parallel fan-out path:
- priority/weighted executeTarget + round-robin skip chains (combo.ts)
- fusion panel before fan-out (fusion.ts), judge fallback prefers survivors
- chaos parallel panel (autoCombo/chaosEngine.ts)
- tryFusionDispatch / tryRuntimeUnitDispatch / buildBaseOptions (dispatchPrelude.ts)
- chat.ts primary + safety-net redirect call sites

Snapshot exposes virtualLanes so the no-op gate is cheap and honest.

Tests: tests/unit/combo-lane-awareness-9654.test.ts (10 tests) - factory
semantics, priority/RR skip, fusion panel drop + all-skipped 503, no-hook
backward-compat baseline.

* feat(flags): activation UX - env-wins adaptive virtual-lanes flag + env docs (#9654 Wave 2)

U7: make adaptive virtual admission lanes discoverable + activatable.
- New OMNIROUTE_CHAT_VIRTUAL_LANES feature flag (boolean/runtime/requiresRestart) in featureFlagDefinitions + en.json i18n key.
- lib/admissionVirtualLanes.ts: env-wins resolver (env > DB > default) + boot warm folding a DB-sourced override into the process-global runtime env via reloadAdaptiveAdmissionRuntime(options.env) - no process.env mutation, no open-sse changes. Env still wins; DB toggle gates at next boot.
- GET /api/settings/feature-flags special-cases the flag to report the gate true source (ccDiscoveryAliases precedent); flagPayload helper dedupes the payload shape.
- Wire the warm into instrumentation-node registerNodejs (non-fatal, DB-ready).
- Document the master switch in .env.example + ENVIRONMENT.md with the system-1/system-2 distinction; zero new env-doc-sync drift.
- 11 new tests (resolver precedence + warm); 60/60 across feature-flag suites; typecheck core clean; ESLint + doc gates green.

* feat(mcp): surface adaptive admission lane data in omniroute_get_health (#9654 Wave 2)

U8: make adaptive virtual-lane admission visible to agents via the MCP health tool. handleGetHealth now surfaces a curated adaptiveAdmission block from the health payload (which already carried the runtime snapshot but was dropping it): virtualLanes/pressure/utilization/laneCount/laneQueuedCount/laneQueuedCost, laneTenants capped at top-10 by queued cost, admitted/rejected/wouldReject counts, shutdown. Block omitted entirely when the health endpoint reports none.

isLaneFlagOn mirrors the runtime 1|true convention so a string serialization can never invert a boolean lane report. getHealthOutput schema extended with the matching optional shape; tool description updated.

4 new dispatch tests (full block, top-10 cap/order, omission, defensive coercion of string flags + malformed lane entries) - 22/22 in essentialTools.test.ts. README: Adaptive Admission Lane Data table + Skills & Tool Navigability audit (29/43 schema entries covered, 14 undocumented, tool_search keyword runtime discovery, full catalog in docs/frameworks/MCP-SERVER.md).

No new lint errors (4 pre-existing in server.ts), typecheck core clean, doc counts + fabricated-docs gates green.

* docs: add changelog entry for #9654 Wave 2 (#10039)

* fix(codeql): suppress js/insufficient-password-hash false positive in lane-key fingerprinting (#10039)

resolveSessionId sha256-hashes bearer/x-api-key/x-goog-api-key to derive a deterministic, non-reversible per-key lane-bucket ID for virtual admission lanes (#9654). This is not password storage or verification, so the rule is a false positive; suppress it inline (same house style as src/lib/sync/tokens.ts) to clear the codeqlAlerts ratchet (2 > baseline 1) that blocks #10039 and every PR against release/v3.8.50.

* docs(mcp): complete MCP server README tool reference (#10039)

The MCP server README covered only 29 of the 43 schema entries, listing the
remaining tools solely as a gap note with omniroute_tool_search as the runtime
fallback. Add tool-reference tables for the agent-skills trio, oneproxy trio,
web_fetch/web_search, tool_search, create_combo, set_routing_strategy,
pick_fastest_model, sync_pricing, and db_health_check so the README covers the
full schemas catalog, and fold the coverage note into the tool_search discovery
paragraph.

* fix(chat): drop unused correlationId from safety-net combo redirect (#10039)

handleComboChat's HandleComboChatOptions has no correlationId member and
the combo pipeline never consumes it; the property was copied from the
handleSingleModelChat options shape by accident and introduced a new
TS2353 under the open-sse workspace typecheck gate.

* fix(i18n): translate featureFlagChatVirtualLanesEnabledDescription into 42 locales (#10039)

en.json gained the flag description in this PR but the locale catalogs
were never mirrored, failing the pt-BR key-parity (#6695) and vi
completeness gates. Adds a real translation to every locale, keeping the
zh-CN/zh-TW glossary canonical terms (提供者/儀表板) and no ICU drift.

* chore(quality): ratchet open-sse-typecheck baseline down (#10039)

The Wave 2 admission refactor removed 66 baselined open-sse type errors;
re-freeze the baseline so the gate pins the new, tighter state.

* docs: resync provider reference to 341 and CLI tools to 34

The release branch gained an 11th no-auth provider (freeaiapikey registry
resync, #10233) and a 26th CLI Code tool without regenerating the
auto-generated docs, leaving every PR against release/v3.8.50 failing the
Docs Gates strict validator (code 341 vs doc 340, CLI 34 vs "33 tools").

Regenerate docs/reference/PROVIDER_REFERENCE.md and sync the provider/tool
counts across README.md, AGENTS.md, llm.txt plus 42 i18n mirrors,
package.json description, and the four diagram SVGs.

* fix(tests): align count expectations with live catalogs (pre-existing release drift)

Release/v3.8.50 currently fails five gates on its own tree; this PR inherits
them. Fix the stale expectations to match live code:

- feature-flags-settings: 48 -> 49 flags (Wave 2 adds OMNIROUTE_CHAT_VIRTUAL_LANES)
- cli-tools-schema / cli-catalog-counts: 33 -> 34 tools (zcode added; 26 code = 21 visible + 5 none)
- optional-transformers-dependency: onnxruntime-node ~1.24.3 -> ~1.27.0 (bump #10382)
- stryker.conf.json: register chatcore-header-drop-warn-dedupe-10315 test
- check-public-creds: freeze zcodeProtocol clientId false positive (client identifier, not a credential)

* fix(tests): follow release's onnxruntime-node revert to ~1.24.3

release/v3.8.50's #10543 pinned onnxruntime-node back to ~1.24.3 after
#10403's ~1.27.0 bump caused npm to nest a second native copy under
@huggingface/transformers and broke the Docker SONAME contract. This
PR's own drift-alignment commit (57b9c033) predates that revert and
still expected ~1.27.0; the 3-way merge did not flag it as a textual
conflict since only one side touched this exact line, but the merged
tree became internally inconsistent (package.json ~1.24.3 vs test
expecting ~1.27.0). Align the test with the now-canonical release
value.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(quality): dedupe stryker.conf.json chatcore-header-drop-warn-dedupe entry

The 3-way merge applied both sides' insertion of the same test-file entry
at different positions, producing a duplicate with broken indentation.
Adopted release's clean version of the file.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Brandon Bennett <brandonbennett@macbookair.myfiosgateway.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Brandon Bennett <branben@users.noreply.github.com>
2026-08-18 11:31:46 -03:00

492 lines
20 KiB
TypeScript

/**
* Fusion combo strategy — parallel panel + judge synthesis.
*
* A fusion combo fans the prompt out to every panel model in parallel, then a
* configurable judge model synthesizes one final answer from all panel responses.
*
* - quorum-grace collection caps the straggler penalty (the slowest model
* otherwise dominates wall time);
* - anonymized sources prevent judge brand-bias ("Source N" rather than model name);
* - degrades to a direct answer on a single survivor, 503 on total failure.
*
* 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. Most of fusion's quality lift
* comes from this synthesis step.
*
* Ported from upstream decolua/9router (Daniil Schovkunov), adapted JS → TS and
* wired through OmniRoute's existing combo schema (combo.config.judgeModel /
* combo.config.fusionTuning).
*/
import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts";
import { extractTextContent } from "../translator/helpers/geminiHelper.ts";
import type { PerTargetAdmissionHook } from "./admission/types.ts";
import type { ComboLogger, HandleSingleModel, ResolvedComboTarget } from "./combo/types.ts";
// Fusion tuning. Overridable per-combo via combo.config.fusionTuning.
export 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
// Hard cap on panel size (issue #1905). Every panel member is fanned out in
// parallel and its full response text buffered in memory simultaneously —
// with the runtime heap capped (Dockerfile OMNIROUTE_MEMORY_MB, default
// 1024MB), a large panel (reported: ~73 models) with sizable concurrent
// responses can exceed the heap ceiling and OOM-crash the whole process.
// Reject oversized panels up front with a clean 400 instead.
maxPanel: 40,
} as const;
export type FusionTuning = {
minPanel?: number;
stragglerGraceMs?: number;
panelHardTimeoutMs?: number;
maxPanel?: number;
};
type Body = Record<string, unknown>;
/**
* 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.
*/
export function extractPanelText(json: unknown): string {
if (!json || typeof json !== "object") return "";
const j = json as Record<string, unknown>;
// OpenAI chat completion
const choices = j.choices as Array<Record<string, unknown>> | undefined;
const choice = choices?.[0];
if (choice) {
const msg = (choice.message ?? choice.delta ?? {}) as Record<string, unknown>;
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(j.content);
if (claudeText.trim()) return claudeText;
// Gemini (parts carry .text without a type discriminator)
const candidates = j.candidates as Array<Record<string, unknown>> | undefined;
const parts = (candidates?.[0]?.content as Record<string, unknown> | undefined)?.parts as
Array<{ text?: unknown }> | undefined;
if (Array.isArray(parts)) {
const t = parts.map((p) => (typeof p?.text === "string" ? p.text : "")).join("");
if (t.trim()) return t;
}
// OpenAI Responses API
const output = j.output as Array<Record<string, unknown>> | undefined;
if (Array.isArray(output)) {
const t = output
.flatMap((o) =>
Array.isArray(o.content)
? (o.content as Array<{ text?: unknown }>).map((c) =>
typeof c?.text === "string" ? 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.
*/
export function appendUserTurn(body: Body, text: string): Body {
const next: Body = { ...body };
if (Array.isArray(body.messages)) {
next.messages = [...(body.messages as unknown[]), { role: "user", content: text }];
} else if (Array.isArray(body.input)) {
next.input = [...(body.input as unknown[]), { role: "user", content: text }];
} else if (Array.isArray(body.contents)) {
next.contents = [...(body.contents as unknown[]), { role: "user", parts: [{ text }] }];
} else {
next.messages = [{ role: "user", content: text }];
}
return next;
}
/**
* Build the judge directive. Sources are anonymized ("Source N") so the judge
* weighs substance, not the reputation of a model brand.
*/
export function buildJudgePrompt(answers: Array<{ text: string }>): string {
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 — usually higher-confidence, but NOT automatically correct), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed.",
"",
"You are not a vote-counter, and the panel is not a ceiling — treat it as strong evidence, not as the limit of what you may say. Apply your OWN reasoning and knowledge as a full participant: if the consensus is wrong, incomplete, or outdated, override it and state what is correct; if every source missed something you know, add it; if a lone source is right against the majority, side with it. Do not water down a correct answer to match panel agreement. The only hard limit is honesty — do not assert facts you are not confident about.",
"",
"Then write the best possible final answer — more complete and correct than any single response, and than the panel as a whole — with no filler.",
"",
"=== PANEL RESPONSES ===",
panel,
"=== END PANEL RESPONSES ===",
"",
"Now write the final answer to the user's original request.",
].join("\n");
}
/**
* A request is "tool-bearing" when the client supplied tools AND did not
* explicitly opt out of tool use this turn (tool_choice: "none" is a valid
* way to declare available tools while opting out — that must NOT trigger
* the bypass, see issue #6771).
*/
export function isToolBearingRequest(body: Body): boolean {
const hasTools = Array.isArray(body.tools) && body.tools.length > 0;
if (!hasTools) return false;
return body.tool_choice !== "none";
}
type Sentinel = { __timeout?: true; __error?: unknown };
// Resolve a Response (or sentinel) within ms; the loser keeps running but is ignored.
function withTimeout(promise: Promise<Response>, ms: number): Promise<Response | Sentinel> {
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 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).
*/
export function collectPanel(
calls: Array<Promise<Response | Sentinel>>,
cfg: { minPanel: number; stragglerGraceMs: number; panelHardTimeoutMs: number }
): Promise<Array<Response | Sentinel | undefined>> {
return new Promise((resolve) => {
const out: Array<Response | Sentinel | undefined> = new Array(calls.length);
let settled = 0;
let ok = 0;
let finished = false;
let graceTimer: ReturnType<typeof setTimeout> | null = null;
const finish = () => {
if (finished) return;
finished = true;
clearTimeout(hardTimer);
if (graceTimer) clearTimeout(graceTimer);
resolve(out);
};
const hardTimer = setTimeout(finish, cfg.panelHardTimeoutMs);
calls.forEach((p, i) => {
Promise.resolve(p)
.then((v) => {
out[i] = v;
})
.catch((e) => {
out[i] = { __error: e };
})
.finally(() => {
settled++;
const slot = out[i] as Response | undefined;
if (slot && (slot as Response).ok) ok++;
if (settled === calls.length) return finish();
if (ok >= cfg.minPanel && !graceTimer) {
graceTimer = setTimeout(finish, cfg.stragglerGraceMs);
}
});
});
});
}
export type FusionModel = ResolvedComboTarget | string;
export type HandleFusionChatOptions = {
body: Body;
models: FusionModel[];
handleSingleModel: HandleSingleModel;
log: ComboLogger;
comboName?: string;
judgeModel?: string | null;
judgeTarget?: ResolvedComboTarget | null;
tuning?: FusionTuning | null;
/** #9654 Wave 2: per-target lane-aware admission probe (see HandleComboChatOptions). */
perTargetAdmission?: PerTargetAdmissionHook | null;
};
function getFusionModelString(model: FusionModel): string {
return typeof model === "string" ? model : model.modelStr;
}
function dispatchFusionModel(
handleSingleModel: HandleSingleModel,
body: Body,
model: FusionModel
): Promise<Response> {
return typeof model === "string"
? handleSingleModel(body, model)
: handleSingleModel(body, model.modelStr, model);
}
/**
* 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.
*
* Tool-bearing requests (non-empty `tools` with `tool_choice` not "none")
* skip panel synthesis entirely and route straight to a single model (the
* configured judge, or panel[0]) with tools/tool_choice intact — panel
* members have no tool access and the judge's synthesis directive steers
* even a tools-capable judge away from emitting a tool call (#6771).
*
* 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.
*/
export async function handleFusionChat({
body,
models,
handleSingleModel,
log,
comboName,
judgeModel,
judgeTarget,
tuning,
perTargetAdmission,
}: HandleFusionChatOptions): Promise<Response> {
const panel = Array.isArray(models) ? models.filter(Boolean) : [];
if (panel.length === 0) {
return errorResponse(400, "Fusion combo has no models");
}
// A single-model fusion has nothing to fuse — just answer directly.
if (panel.length === 1) {
return dispatchFusionModel(handleSingleModel, body, panel[0]);
}
// Reject an oversized panel BEFORE fan-out (issue #1905): fanning out N
// parallel calls and buffering N full response bodies at once is what
// drives the process into an OOM crash, not any one call in isolation.
const maxPanel = tuning?.maxPanel ?? FUSION_DEFAULTS.maxPanel;
if (panel.length > maxPanel) {
log.warn(
"FUSION",
`Combo "${comboName ?? ""}" panel=${panel.length} exceeds maxPanel=${maxPanel} — rejecting before fan-out (#1905)`
);
return errorResponse(
400,
`Fusion panel too large (${panel.length} models, max ${maxPanel}) — reduce the combo's target count or raise fusionTuning.maxPanel`
);
}
const cfg = {
minPanel: tuning?.minPanel ?? FUSION_DEFAULTS.minPanel,
stragglerGraceMs: tuning?.stragglerGraceMs ?? FUSION_DEFAULTS.stragglerGraceMs,
panelHardTimeoutMs: tuning?.panelHardTimeoutMs ?? FUSION_DEFAULTS.panelHardTimeoutMs,
};
// Tools-stripped panel body (we want prose from panel members) — computed
// early so the per-target probe can estimate cost from the real fan-out body.
const { tools: _tools, tool_choice: _tc, ...rest } = body;
void _tools;
void _tc;
const panelBody: Body = { ...rest, stream: false };
// #9654 Wave 2: per-target lane-aware admission probe — drop lane-full panel
// members before fan-out (strictly non-blocking; no-op when lanes off). See
// createPerTargetAdmissionHook for the full contract. Runs BEFORE minPanel /
// judge selection so quorum and the judge fallback only consider survivors.
let panelToDispatch = panel;
if (perTargetAdmission) {
const gates = await Promise.all(
panel.map(async (target) => ({
target,
ok: await perTargetAdmission({
modelStr: getFusionModelString(target),
executionKey: typeof target === "string" ? target : target.executionKey,
body: panelBody,
}),
}))
);
const dropped = gates.filter((g) => !g.ok);
if (dropped.length > 0) {
log.info(
"FUSION",
`Skipping ${dropped.length} panel member(s) — admission lane full: ${dropped
.map((g) => getFusionModelString(g.target))
.join(", ")}`
);
}
panelToDispatch = gates.filter((g) => g.ok).map((g) => g.target);
if (panelToDispatch.length === 0) {
log.warn("FUSION", "All panel members skipped by admission lanes — nothing to fan out");
return errorResponse(503, "All fusion panel members were skipped by admission lanes");
}
}
// Honor user-supplied minPanel down to 1: with 1 survivor we still degrade
// gracefully via the answers.length===1 branch below (issue #6454).
const minPanel = Math.min(Math.max(1, cfg.minPanel), panelToDispatch.length);
const hasExplicitJudge = Boolean(judgeModel && judgeModel.trim());
// Judge fallback prefers the first SURVIVING panel member — a lane-full
// member dropped by the probe is never selected as the synthesis judge.
const judge = hasExplicitJudge
? (judgeModel as string).trim()
: getFusionModelString(panelToDispatch[0]);
log.info(
"FUSION",
`Combo "${comboName ?? ""}" | panel=${panelToDispatch.length} [${panelToDispatch
.map(getFusionModelString)
.join(", ")}] | judge=${judge} | quorum=${minPanel}`
);
// Tool-bearing requests get no value from panel synthesis — panel members
// would answer with no tool access (degraded prose), and the judge's
// synthesis directive steers it away from emitting a tool call even though
// it technically still receives `tools`. Skip straight to a single model
// with the full, unmodified body (tools/tool_choice intact) so agentic
// clients get a real tool-call decision (#6771).
if (isToolBearingRequest(body)) {
log.info(
"FUSION",
`Combo "${comboName ?? ""}" received a tool-bearing request — bypassing panel synthesis, routing directly to ${judge} with tools intact`
);
return handleSingleModel(body, judge);
}
const t0 = Date.now();
const calls = panelToDispatch.map((target) =>
withTimeout(dispatchFusionModel(handleSingleModel, panelBody, target), cfg.panelHardTimeoutMs)
);
const settled = await collectPanel(calls, { ...cfg, minPanel });
log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`);
// 2. Collect successful answers + per-member failure reasons (issue #6454).
const answers: Array<{ model: string; text: string }> = [];
const failures: Array<{ model: string; reason: string }> = [];
for (let i = 0; i < settled.length; i++) {
const res = settled[i];
const model = getFusionModelString(panelToDispatch[i]);
if (!res) {
log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`);
failures.push({ model, reason: "straggler_dropped" });
continue;
}
const sentinel = res as Sentinel;
if (sentinel.__timeout) {
log.warn("FUSION", `Panel ${model} timed out`);
failures.push({ model, reason: "timeout" });
continue;
}
if (sentinel.__error) {
log.warn("FUSION", `Panel ${model} threw`, {
error: sanitizeErrorMessage(sentinel.__error as Error),
});
failures.push({ model, reason: "threw" });
continue;
}
const resp = res as Response;
if (!resp.ok) {
// Per-member reason keeps the exact status code (e.g. status_429 for a
// rate-limit fan-fail, status_503 for an outage) — strictly more
// informative than the earlier aggregate rate-limit count (#6454).
failures.push({ model, reason: `status_${resp.status}` });
log.warn("FUSION", `Panel ${model} ${resp.status === 429 ? "rate-limited" : "failed"}`, {
status: resp.status,
});
continue;
}
try {
const json = await resp.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`);
failures.push({ model, reason: "empty_content" });
}
} catch (e) {
log.warn("FUSION", `Panel ${model} unparseable`, {
error: sanitizeErrorMessage(e as Error),
});
failures.push({ model, reason: "unparseable" });
}
}
// 3. Degrade gracefully when the panel is too thin to fuse.
if (answers.length === 0) {
// Surface per-member reasons so operators can distinguish a rate-limit
// fan-fail (reason=rate_limited) from an outage (issue #6454). This supersedes
// the earlier aggregate "N rate-limited, M failed" summary — per-member is
// strictly more informative. Still routed through errorResponse for sanitization.
const detail = failures.map((f) => `${f.model}=${f.reason}`).join(", ");
log.warn("FUSION", `No live models: ${detail}`);
return errorResponse(
503,
detail ? `All fusion panel models failed: ${detail}` : "All fusion panel models failed"
);
}
if (answers.length === 1) {
// No explicit judgeModel configured: the "judge" is just panel[0], so
// synthesizing from a single source through itself would be redundant —
// answer directly with the lone survivor (issue #6454).
if (!hasExplicitJudge) {
log.info("FUSION", `Only ${answers[0].model} succeeded — answering directly (no fusion)`);
return handleSingleModel(body, answers[0].model);
}
// An explicit judgeModel IS configured: honor it even with a single
// surviving panel answer, rather than silently substituting the panel
// member for the configured judge (issue #6455). The judge still adds
// value reviewing/polishing a lone source per its documented contract.
}
// Resolve the judge that ACTUALLY runs synthesis. An explicit judgeModel is
// honored as configured (operator intent — kept even if it was down during
// fan-out; that's the operator's choice). With NO explicit judge the judge
// defaulted to panel[0] — but panel[0] may have FAILED fan-out (timeout /
// rate-limit / dropped straggler → it lands in `failures`, not `answers`).
// Handing synthesis to a dead panel[0] sinks the whole request despite a
// healthy quorum — exactly the case fusion exists to tolerate. So pick a
// SURVIVOR: prefer panel[0] when it survived, otherwise the first survivor.
const effectiveJudge = hasExplicitJudge
? judge
: answers.some((a) => a.model === getFusionModelString(panel[0]))
? getFusionModelString(panel[0])
: answers[0].model;
if (answers.length === 1) {
log.info(
"FUSION",
`Only ${answers[0].model} succeeded — judging single answer with ${effectiveJudge}`
);
}
// 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 ${effectiveJudge}`);
return judgeTarget
? handleSingleModel(judgeBody, judgeTarget.modelStr, judgeTarget)
: handleSingleModel(judgeBody, effectiveJudge);
}