fix(combo): keep nested combos as single units and stop 0-token detail rows

Nested combos (comboA lists comboB, comboC, …) now stay one slot each:
the inner combo always runs as fallback to produce a single answer.
Failed hops are no longer written to Details/usage, and streaming no
longer inserts a 0-token placeholder row.

- chat.js: comboStack cycle detection; nested combos forced to fallback;
  persistUsage="success-only" for combo hops
- combo.js: discardResponse() cancels unused bodies (fusion timeout /
  fallback) so dropped streams fire onStreamComplete; getComboModelsFromData
  keeps nested names and honors enabled=false
- requestDetail.js: tokensForDetail() canonicalizes Claude/Gemini usage;
  shouldPersistRequestDetail() skips streaming-start and non-success hops
- streamingHandler.js: drop the 0-token streaming placeholder write
- RequestDetailsTab.js: read Gemini/Claude token names; show "streaming"
  status in amber
- tests: add combo-nested.test.js (13 cases)
- gitignore: ignore local .vitest/ artifacts

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
2026-09-17 14:03:59 +07:00
parent a84ba559f3
commit 81a47f36c6
11 changed files with 332 additions and 144 deletions

View File

@@ -246,23 +246,39 @@ export function resetComboRotation(comboName) {
}
/**
* Get combo models from combos data
* Cancel an unused Response body so its SSE transform (and usage callback)
* is not left hanging. Fusion timeouts and combo fallback both drop Response
* objects without reading them; an unread stream never fires onStreamComplete,
* which is how request-details rows get stuck at input/output tokens = 0.
*/
export function discardResponse(res) {
if (!res || res.__timeout || res.__error) return;
const cancel = res.body?.cancel;
if (typeof cancel === "function") {
try { cancel.call(res.body); } catch { /* already closed / consumed */ }
}
}
/**
* Get combo models from combos data.
* Nested combo names are kept as-is (comboA listing comboB yields ["comboB", ...]).
* Flattening them into leaves would explode fusion/round-robin: every nested
* leaf becomes its own panel/rotation slot. Nested combos run as one unit
* (see handleSingleModelChat: inner strategy forced to fallback).
*
* @param {string} modelStr - Model string to check
* @param {Array|Object} combosData - Array of combos or object with combos
* @returns {string[]|null} Array of models or null if not a combo
* @returns {string[]|null} Direct members, or null if not a combo
*/
export function getComboModelsFromData(modelStr, combosData) {
// Don't check if it's in provider/model format
if (modelStr.includes("/")) return null;
// Handle both array and object formats
if (typeof modelStr !== "string" || modelStr.includes("/")) return null;
const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []);
const combo = combos.find(c => c.name === modelStr);
if (combo && combo.models && combo.models.length > 0) {
return combo.models;
const combo = combos.find((c) => c.name === modelStr);
if (!combo || combo.enabled === false || !Array.isArray(combo.models) || combo.models.length === 0) {
return null;
}
return null;
return combo.models;
}
/**
@@ -339,6 +355,9 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co
return result;
}
// Falling through: this body will never be read by the client.
discardResponse(result);
// For transient errors (503/502/504), wait for cooldown before falling through
// so a briefly-overloaded provider gets a chance to recover rather than being
// skipped immediately (fixes: combo falls through on transient 503)
@@ -509,8 +528,13 @@ function collectPanel(calls, { minPanel, stragglerGraceMs, panelHardTimeoutMs })
const hardTimer = setTimeout(finish, panelHardTimeoutMs);
calls.forEach((p, i) => {
Promise.resolve(p)
.then((v) => { out[i] = v; })
.catch((e) => { out[i] = { __error: e }; })
.then((v) => {
// Quorum/timeout already moved on: cancel the late body so its
// streaming usage callback is not left at tokens=0 forever.
if (finished) discardResponse(v);
else out[i] = v;
})
.catch((e) => { if (!finished) out[i] = { __error: e }; })
.finally(() => {
settled++;
if (out[i] && out[i].ok) ok++;
@@ -590,7 +614,7 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
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; }
if (!res.ok) { log.warn("FUSION", `Panel ${model} failed`, { status: res.status }); discardResponse(res); continue; }
try {
const json = await res.clone().json();
const text = extractPanelText(json);
@@ -602,6 +626,11 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
}
} catch (e) {
log.warn("FUSION", `Panel ${model} unparseable`, { error: e.message || String(e) });
} finally {
// Panel responses are never returned to the client; cancel the original
// body (clone().json() already captured the payload) so streaming
// placeholders are not left as success/0-token rows.
discardResponse(res);
}
}