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

@@ -13,6 +13,8 @@ import {
extractRequestConfig,
saveUsageStats,
formatDoneLine,
tokensForDetail,
shouldPersistRequestDetail,
} from "./requestDetail.js";
import { streamStatusForContent } from "../../utils/streamErrorPatterns.js";
import { saveRequestDetail } from "@/lib/usageDb.js";
@@ -60,7 +62,7 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
/**
* Handle streaming response — pipe provider SSE through transform stream to client.
*/
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId, pxpipe, reqTag, log, credentials }) {
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, pxpipe, reqTag, log, credentials }) {
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
@@ -145,35 +147,6 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
stallTimeoutMs,
);
saveRequestDetail(
buildRequestDetail(
{
provider,
model,
connectionId,
apiKey,
latency: { ttft: 0, total: Date.now() - requestStartTime },
tokens: { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
providerResponse: "[Streaming - raw response not captured]",
response: {
content: "[Streaming in progress...]",
thinking: null,
type: "streaming",
},
pxpipe,
status: "success",
},
{ id: streamDetailId },
),
).catch((err) => {
console.error(
"[RequestDetail] Failed to save streaming request:",
err.message,
);
});
return {
success: true,
response: new Response(transformedBody, { headers: SSE_HEADERS }),
@@ -198,6 +171,7 @@ export function buildOnStreamComplete({
reqTag,
log,
streamErrorPatterns,
persistUsage = "all",
}) {
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
@@ -210,37 +184,39 @@ export function buildOnStreamComplete({
const safeThinking = contentObj?.thinking || null;
const rawProviderText = typeof contentObj?.rawProviderText === "string" ? contentObj.rawProviderText : "";
saveRequestDetail(
buildRequestDetail(
{
provider,
model,
connectionId,
apiKey,
latency,
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
providerResponse: rawProviderText || safeContent,
response: {
content: safeContent,
thinking: safeThinking,
type: "streaming",
if (shouldPersistRequestDetail(persistUsage, "success")) {
saveRequestDetail(
buildRequestDetail(
{
provider,
model,
connectionId,
apiKey,
latency,
tokens: tokensForDetail(usage),
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
providerResponse: rawProviderText || safeContent,
response: {
content: safeContent,
thinking: safeThinking,
type: "streaming",
},
pxpipe,
status: streamStatusForContent(
streamErrorPatterns?.[provider],
safeContent,
),
},
pxpipe,
status: streamStatusForContent(
streamErrorPatterns?.[provider],
safeContent,
),
},
{ id: streamDetailId },
),
).catch((err) => {
console.error(
"[RequestDetail] Failed to update streaming content:",
err.message,
);
});
{ id: streamDetailId },
),
).catch((err) => {
console.error(
"[RequestDetail] Failed to update streaming content:",
err.message,
);
});
}
// Persist stream usage to DB (no console line; the "📊 done" line below is authoritative)
saveUsageStats({