From 09d8a4aa8cafbf6153c1ebf43bc68f2caf987b69 Mon Sep 17 00:00:00 2001 From: Abhishek Divekar Date: Mon, 22 Jun 2026 04:21:22 +0530 Subject: [PATCH] =?UTF-8?q?feat(combo):=20add=20nestedComboMode=20execute?= =?UTF-8?q?=20=E2=80=94=20black-box=20combo-ref=20execution=20for=20select?= =?UTF-8?q?ion=20strategies=20(#4537)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.33 --- config/quality/complexity-baseline.json | 3 +- config/quality/file-size-baseline.json | 7 +- open-sse/services/combo.ts | 139 +++++++++- open-sse/services/combo/comboStructure.ts | 13 + open-sse/services/combo/rrState.ts | 8 +- open-sse/services/combo/runtimeUnits.ts | 256 ++++++++++++++++++ open-sse/services/combo/types.ts | 34 ++- open-sse/services/comboConfig.ts | 1 + src/app/(dashboard)/dashboard/combos/page.tsx | 28 ++ src/shared/validation/schemas/combo.ts | 1 + src/sse/handlers/chat.ts | 6 +- tests/unit/combo-config.test.ts | 36 +++ tests/unit/combo-strategy-fallbacks.test.ts | 158 +++++++++++ 13 files changed, 669 insertions(+), 21 deletions(-) create mode 100644 open-sse/services/combo/runtimeUnits.ts diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index 0dfc1f4569..3b7ac3041a 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 1913, + "count": 1915, + "_rebaseline_2026_06_21_v3833_4537_nested_combo": "Reconciliacao 1913->1915 (+2) do PR #4537 (nestedComboMode execute — black-box combo-ref execution). O +2 vem do branch novo de dispatch nested em handleComboChat (combo.ts: normalizeNestedComboMode + executeModeUnits/hasExecutableComboRef + o ramo simpleExecuteStrategies que roda resolveComboRuntimeUnits com recursion caps depth/cycle/budget) — ramos condicionais no chokepoint de dispatch do combo. Medido com `node scripts/check/check-complexity.mjs` no estado merged. Crescimento de feature legitimo recem-TDD'd, nao regressao; reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_21_v3833_reviewprs_r4_owner": "Reconciliacao release-volatil 1911->1913 (+2) apos o lote de PRs do owner desta rodada (#4560 RTK cache_control, #4552 Cursor auto-import macOS, #4551 Codex /responses probe, #4554 Cursor Composer decode + o stack #3501 #4538/#4544/#4548). O fast-path do release nao roda check:complexity (so release->main). As 3 extracoes chatCore #3501 (#4538 recupera 4 leaves, #4544 failureUsage, #4548 claudeSystemRole+upstreamExecuteHeaders) sao PURAS/complexity-neutras (movem codigo p/ leaves sob o teto, chatCore ENCOLHE); o +2 vem dos condicionais NOVOS das features .32-portadas (#4554 decode de bloco visivel do Composer + #4551 probe do endpoint real Codex /responses). Medido com `node scripts/check/check-complexity.mjs` no tip 4b34a75fe. Crescimento de feature legitimo recem-TDD'd, nao regressao; reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_21_v3833_reviewprs_r4": "Reconciliacao release-volatil 1906->1911 (+5) apos o lote /review-prs r4 (5 PRs de contribuidores mergeados em release/v3.8.33: #4557 health-polls, #4556 mobile-table, #4545 provider-wildcard, #4558 isHidden, #4489 sticky-weighted) + 1 merge concorrente de sessao paralela (#4565 quota perf). O fast-path do release nao roda check:complexity (so release->main), entao o ramo acumula sem rebaselinar. Condicionais NOVOS legitimos: #4545 (expandProviderWildcardsInCombo/Collection + wildcardMatch glob/registry branching em providerWildcard.ts/wildcardRouter.ts), #4489 (eligibility pass sticky-weighted: resolveWeightedStepGroups + isTargetSelectableForWeighted + renormalizacao em combo.ts), #4558 (filtro isHidden em buildAutoCandidates/virtualFactory) e #4565 (guardas de skip de quota_snapshots idle). Medido com `node scripts/check/check-complexity.mjs` no tip 39b2bbfea. Mesma familia dos rebaselines anteriores — crescimento de feature legitimo recem-TDD'd, nao regressao; reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_21_v3833_cycle_open_stranded": "1905 -> 1906 (+1). Abertura do ciclo v3.8.33: o +1 vem dos 4 commits stranded na release/v3.8.32 (pós-merge da v3.8.32) trazidos via cherry-pick para release/v3.8.33 — isolado em #4483 (autoStrategy.ts evaluateQuotaCutoff/quotaPreflight.ts, guardas de quota-cutoff inerentemente ramificadas, opt-in default-OFF). main mede 1905; o .33 branch (main + cherry-picks) mede 1906. Crescimento de feature legitimo recém-mergeado, não regressão; redução fica como debt (#3501).", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index e9f9f4a82f..574cd53bf7 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -130,7 +130,7 @@ "open-sse/services/batchProcessor.ts": 828, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, - "open-sse/services/combo.ts": 2818, + "open-sse/services/combo.ts": 2955, "open-sse/services/rateLimitManager.ts": 1035, "open-sse/services/tokenRefresh.ts": 1997, "open-sse/services/usage.ts": 3450, @@ -145,7 +145,7 @@ "src/app/(dashboard)/dashboard/cache/page.tsx": 841, "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 900, "src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 922, - "src/app/(dashboard)/dashboard/combos/page.tsx": 4406, + "src/app/(dashboard)/dashboard/combos/page.tsx": 4434, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495, "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007, "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570, @@ -205,7 +205,7 @@ "src/shared/constants/sidebarVisibility.ts": 1100, "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, - "src/sse/handlers/chat.ts": 1513, + "src/sse/handlers/chat.ts": 1515, "src/sse/services/auth.ts": 2279 }, "testCap": 800, @@ -221,6 +221,7 @@ "tests/unit/chatcore-translation-paths.test.ts": 2810, "tests/unit/chatgpt-web.test.ts": 2809, "tests/unit/combo-routing-engine.test.ts": 3213, + "tests/unit/combo-strategy-fallbacks.test.ts": 880, "tests/unit/db-core-init.test.ts": 864, "tests/unit/db-migration-runner.test.ts": 1491, "tests/unit/db-settings-crud.test.ts": 941, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index bc7091b93c..1b85b98821 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -97,7 +97,9 @@ import type { SingleModelTarget, HandleComboChatOptions, HandleRoundRobinOptions, + NestedComboMode, ResolvedComboTarget, + ResolvedComboUnit, AutoProviderCandidate, ComboRuntimeStep, HistoricalLatencyStatsEntry, @@ -132,6 +134,7 @@ import { getExhaustedTargetSkipReason, } from "./combo/comboPredicates.ts"; import { applyComboTargetExhaustion } from "./combo/targetExhaustion.ts"; +import { executeRuntimeUnitCombo } from "./combo/runtimeUnits.ts"; import { dedupeTargetsByExecutionKey, isRecord } from "./combo/comboData.ts"; import { expandProviderWildcardsInCombo, @@ -146,6 +149,7 @@ import { import { filterTargetsByRequestCompatibility, getModelContextLimitForModelString, + resolveComboRuntimeUnits, resolveComboTargets, resolveWeightedTargets, resolveWeightedStepGroups, @@ -191,7 +195,7 @@ export { resolveShadowTargets, scheduleShadowRouting }; // preScreenTargets was public from combo.ts before the reset-aware quota // extraction (combo split D7b). Keep the external surface stable. export { preScreenTargets }; -export { resolveComboTargets, filterTargetsByRequestCompatibility }; +export { resolveComboRuntimeUnits, resolveComboTargets, filterTargetsByRequestCompatibility }; export { getComboFromData, getComboModelsFromData, @@ -225,6 +229,10 @@ const MIN_HISTORY_SAMPLES = 10; // Matches the example in GitHub issue #1812 (e.g. o3-like model: $3 input/$15 output). const OUTPUT_TOKEN_RATIO = 0.4; +function normalizeNestedComboMode(value: unknown): NestedComboMode { + return value === "execute" ? "execute" : "flatten"; +} + function calculateTargetContextAffinity( target: ResolvedComboTarget, sessionId: string | null | undefined @@ -571,6 +579,7 @@ export async function handleComboChat({ relayOptions, signal, apiKeyAllowedConnections = null, + nesting = null, }: HandleComboChatOptions): Promise { // Combo setup phase (god-file decomposition fase 1): strategy / relay / resilience / // universal-handoff / context-cache pinning / agent middleware / config cascade / timeout. @@ -683,6 +692,132 @@ export async function handleComboChat({ return handleSingleModelWithTimeout(body, pinnedModel); } + const nestingContext = nesting || { + depth: 0, + maxDepth: clampComboDepth(config.maxComboDepth), + visitedComboNames: [combo.name], + rootComboName: combo.name, + attemptBudget: { count: 0, limit: MAX_GLOBAL_ATTEMPTS }, + }; + const nestedComboMode = normalizeNestedComboMode(config.nestedComboMode); + + const executeModeUnits = + nestedComboMode === "execute" && allCombos + ? resolveComboRuntimeUnits(combo, allCombos, "execute", nestingContext.maxDepth) + : []; + const hasExecutableComboRef = executeModeUnits.some((unit) => unit.kind === "combo-ref"); + const simpleExecuteStrategies = new Set([ + "priority", + "round-robin", + "random", + "strict-random", + "weighted", + "fill-first", + ]); + + if (hasExecutableComboRef && simpleExecuteStrategies.has(strategy)) { + let runtimeUnits = executeModeUnits; + let unitExecutionStrategy = strategy; + if (strategy === "weighted") { + const stickyLimit = clampStickyWeightedTargetLimit( + (config as Record).stickyWeightedLimit + ); + const stickyKey = getStickyWeightedExecutionKey(combo.name, stickyLimit); + const stickyUnit = stickyKey + ? runtimeUnits.find((unit) => unit.executionKey === stickyKey) + : null; + if (stickyUnit) { + runtimeUnits = [ + stickyUnit, + ...runtimeUnits.filter((unit) => unit.executionKey !== stickyUnit.executionKey), + ]; + unitExecutionStrategy = "priority"; + } + } + if (strategy === "random") runtimeUnits = fisherYatesShuffle([...runtimeUnits]); + if (strategy === "strict-random") { + const key = await getNextFromDeck( + `combo:${combo.name}`, + runtimeUnits.map((unit) => unit.executionKey) + ); + const selected = runtimeUnits.find((unit) => unit.executionKey === key) || runtimeUnits[0]; + runtimeUnits = [ + selected, + ...runtimeUnits.filter((unit) => unit.executionKey !== selected.executionKey), + ]; + } + let runtimeStickyLimit: number | null = null; + let runtimeStickyTargets: ResolvedComboUnit[] = runtimeUnits; + if (strategy === "round-robin") { + const perComboStickyLimit = (config as Record).stickyRoundRobinLimit; + runtimeStickyLimit = clampStickyRoundRobinTargetLimit( + perComboStickyLimit !== undefined && perComboStickyLimit !== null + ? perComboStickyLimit + : (settings as Record | null)?.stickyRoundRobinLimit + ); + const { startIndex, counter } = getStickyRoundRobinStartIndex( + combo.name, + runtimeUnits, + runtimeStickyLimit + ); + if (runtimeStickyLimit <= 1) rrCounters.set(combo.name, counter + 1); + runtimeUnits = runtimeUnits.map( + (_, offset) => runtimeUnits[(startIndex + offset) % runtimeUnits.length] + ); + runtimeStickyTargets = executeModeUnits; + } + const execution = await executeRuntimeUnitCombo({ + body, + combo, + strategy: unitExecutionStrategy, + effectiveComboStrategy: strategy, + units: runtimeUnits, + handleSingleModel: handleSingleModelWithTimeout, + isModelAvailable, + log, + config, + settings, + allCombos, + signal, + nesting: nestingContext, + baseOptions: { + body, + combo, + handleSingleModel, + isModelAvailable, + log, + settings, + allCombos, + relayOptions, + signal, + apiKeyAllowedConnections, + }, + runCombo: handleComboChat, + }); + if (strategy === "weighted" && execution.response.ok && execution.unit) { + const stickyLimit = clampStickyWeightedTargetLimit( + (config as Record).stickyWeightedLimit + ); + if (stickyLimit > 1) + recordStickyWeightedSuccess(combo.name, execution.unit.executionKey, stickyLimit); + } + if ( + strategy === "round-robin" && + execution.response.ok && + execution.unit && + runtimeStickyLimit && + runtimeStickyLimit > 1 + ) { + recordStickyRoundRobinSuccess( + combo.name, + execution.unit, + runtimeStickyLimit, + runtimeStickyTargets + ); + } + return execution.response; + } + // Route to round-robin handler if strategy matches if (strategy === "round-robin") { return handleRoundRobinCombo({ @@ -1549,6 +1684,7 @@ export async function handleComboChat({ } const result = await handleSingleModelWithTimeout(attemptBody, modelStr, { ...targetForAttempt, + effectiveComboStrategy: strategy, failoverBeforeRetry: config.failoverBeforeRetry, }); @@ -2499,6 +2635,7 @@ async function handleRoundRobinCombo({ const result = await handleSingleModel(attemptBody, modelStr, { ...targetForAttempt, + effectiveComboStrategy: "round-robin", failoverBeforeRetry: config.failoverBeforeRetry, }); diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index cccb429f29..0930068693 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -29,7 +29,9 @@ import type { ComboLike, ComboLogger, ComboRuntimeStep, + NestedComboMode, ResolvedComboTarget, + ResolvedComboUnit, } from "./types.ts"; function toTrimmedString(value: unknown): string | null { @@ -607,6 +609,17 @@ export function resolveComboTargets( : getDirectComboTargets(combo); } +export function resolveComboRuntimeUnits( + combo: ComboLike, + allCombos: ComboCollectionLike, + mode: NestedComboMode, + maxDepth: number = MAX_COMBO_DEPTH +): ResolvedComboUnit[] { + if (mode === "flatten" || !allCombos) return resolveComboTargets(combo, allCombos, maxDepth); + validateComboDAG(combo.name, allCombos, new Set(), 0, maxDepth); + return getOrderedTopLevelRuntimeSteps(combo, allCombos); +} + export function resolveWeightedStepGroups( combo: ComboLike, allCombos: ComboCollectionLike diff --git a/open-sse/services/combo/rrState.ts b/open-sse/services/combo/rrState.ts index a627a75536..9f2c3c67ef 100644 --- a/open-sse/services/combo/rrState.ts +++ b/open-sse/services/combo/rrState.ts @@ -13,7 +13,7 @@ * Pure leaf: this module never imports from the combo barrel. */ -import type { ResolvedComboTarget } from "./types.ts"; +import type { ResolvedComboUnit } from "./types.ts"; // In-memory atomic counter per combo for round-robin distribution // Resets on server restart (by design — no stale state) @@ -37,7 +37,7 @@ export const clampStickyWeightedTargetLimit = clampStickyRoundRobinTargetLimit; export function getStickyRoundRobinStartIndex( comboName: string, - targets: ResolvedComboTarget[], + targets: ResolvedComboUnit[], stickyLimit: number ): { startIndex: number; counter: number } { const sticky = rrStickyTargets.get(comboName); @@ -54,9 +54,9 @@ export function getStickyRoundRobinStartIndex( export function recordStickyRoundRobinSuccess( comboName: string, - target: ResolvedComboTarget, + target: ResolvedComboUnit, stickyLimit: number, - targets: ResolvedComboTarget[] + targets: ResolvedComboUnit[] ): void { const sticky = rrStickyTargets.get(comboName); const successCount = sticky?.executionKey === target.executionKey ? sticky.successCount + 1 : 1; diff --git a/open-sse/services/combo/runtimeUnits.ts b/open-sse/services/combo/runtimeUnits.ts new file mode 100644 index 0000000000..bceadb2fa4 --- /dev/null +++ b/open-sse/services/combo/runtimeUnits.ts @@ -0,0 +1,256 @@ +// Nested combo runtime unit execution — see combo.ts for integration. +import { errorResponse } from "../../utils/error.ts"; +import { recordComboRequest } from "../comboMetrics.ts"; +import { resolveDelayMs } from "./comboPredicates.ts"; +import { validateResponseQuality } from "./validateQuality.ts"; +import type { + ComboCollectionLike, + ComboLike, + ComboLogger, + ComboNestingContext, + HandleComboChatOptions, + HandleSingleModel, + IsModelAvailable, + ResolvedComboRefTarget, + ResolvedComboUnit, +} from "./types.ts"; + +export type RuntimeUnitExecutionResult = { + response: Response; + unit: ResolvedComboUnit | null; +}; + +type RuntimeUnitRunner = (options: HandleComboChatOptions) => Promise; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function getCombosList(allCombos: ComboCollectionLike): ComboLike[] { + const combos = Array.isArray(allCombos) ? allCombos : allCombos?.combos || []; + return combos.filter( + (combo): combo is ComboLike => isRecord(combo) && typeof combo.name === "string" + ); +} + +function findComboByName(allCombos: ComboCollectionLike, name: string): ComboLike | null { + return getCombosList(allCombos).find((combo) => combo.name === name) || null; +} + +function unitDisplayName(unit: ResolvedComboUnit): string { + return unit.kind === "combo-ref" ? `combo:${unit.comboName}` : unit.modelStr; +} + +function shuffleUnits(units: ResolvedComboUnit[]): ResolvedComboUnit[] { + const result = [...units]; + for (let index = result.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(Math.random() * (index + 1)); + [result[index], result[swapIndex]] = [result[swapIndex], result[index]]; + } + return result; +} + +function selectWeightedUnit(units: ResolvedComboUnit[]): ResolvedComboUnit | null { + const total = units.reduce((sum, unit) => sum + Math.max(0, Number(unit.weight) || 0), 0); + if (total <= 0) return units[0] || null; + let draw = Math.random() * total; + for (const unit of units) { + draw -= Math.max(0, Number(unit.weight) || 0); + if (draw <= 0) return unit; + } + return units[units.length - 1] || null; +} + +async function executeModelUnit(args: { + body: Record; + unit: Extract; + handleSingleModel: HandleSingleModel; + isModelAvailable?: IsModelAvailable; + failoverBeforeRetry: unknown; + effectiveComboStrategy: string; +}): Promise { + if (args.isModelAvailable) { + const available = await args.isModelAvailable(args.unit.modelStr, args.unit); + if (!available) return errorResponse(503, `Model ${args.unit.modelStr} is unavailable`); + } + return args.handleSingleModel(args.body, args.unit.modelStr, { + ...args.unit, + effectiveComboStrategy: args.effectiveComboStrategy, + failoverBeforeRetry: args.failoverBeforeRetry, + }); +} + +function buildChildNestingContext(args: { + context: ComboNestingContext; + childComboName: string; +}): ComboNestingContext | Response { + if (args.context.depth >= args.context.maxDepth) { + return errorResponse(503, `Max combo nesting depth (${args.context.maxDepth}) exceeded`); + } + if (args.context.visitedComboNames.includes(args.childComboName)) { + return errorResponse(503, `Circular combo reference detected: ${args.childComboName}`); + } + return { + ...args.context, + depth: args.context.depth + 1, + visitedComboNames: [...args.context.visitedComboNames, args.childComboName], + }; +} + +async function executeComboRefUnit(args: { + body: Record; + unit: ResolvedComboRefTarget; + allCombos: ComboCollectionLike; + runCombo: RuntimeUnitRunner; + baseOptions: HandleComboChatOptions; + nesting: ComboNestingContext; +}): Promise { + const childCombo = findComboByName(args.allCombos, args.unit.comboName); + if (!childCombo) return errorResponse(503, `Nested combo "${args.unit.comboName}" not found`); + const childNesting = buildChildNestingContext({ + context: args.nesting, + childComboName: childCombo.name, + }); + if (childNesting instanceof Response) return childNesting; + return args.runCombo({ + ...args.baseOptions, + body: args.body, + combo: childCombo, + nesting: childNesting, + }); +} + +async function executeRuntimeUnit(args: { + body: Record; + unit: ResolvedComboUnit; + allCombos: ComboCollectionLike; + handleSingleModel: HandleSingleModel; + isModelAvailable?: IsModelAvailable; + runCombo: RuntimeUnitRunner; + baseOptions: HandleComboChatOptions; + nesting: ComboNestingContext; + failoverBeforeRetry: unknown; + effectiveComboStrategy: string; +}): Promise { + if (args.unit.kind === "model") { + return executeModelUnit({ + body: args.body, + unit: args.unit, + handleSingleModel: args.handleSingleModel, + isModelAvailable: args.isModelAvailable, + failoverBeforeRetry: args.failoverBeforeRetry, + effectiveComboStrategy: args.effectiveComboStrategy, + }); + } + return executeComboRefUnit({ + body: args.body, + unit: args.unit, + allCombos: args.allCombos, + runCombo: args.runCombo, + baseOptions: args.baseOptions, + nesting: args.nesting, + }); +} + +function orderUnitsForStrategy(strategy: string, units: ResolvedComboUnit[]): ResolvedComboUnit[] { + if (strategy === "random") return shuffleUnits(units); + if (strategy === "weighted") { + const selected = selectWeightedUnit(units); + if (!selected) return units; + return [selected, ...units.filter((unit) => unit.executionKey !== selected.executionKey)]; + } + return units; +} + +export async function executeRuntimeUnitCombo(args: { + body: Record; + combo: ComboLike; + strategy: string; + effectiveComboStrategy?: string; + units: ResolvedComboUnit[]; + handleSingleModel: HandleSingleModel; + isModelAvailable?: IsModelAvailable; + log: ComboLogger; + config: Record; + settings?: Record | null; + allCombos: ComboCollectionLike; + signal?: AbortSignal | null; + nesting: ComboNestingContext; + baseOptions: HandleComboChatOptions; + runCombo: RuntimeUnitRunner; +}): Promise { + const maxRetries = Number(args.config.maxRetries ?? 1); + const retryDelayMs = resolveDelayMs(args.config.retryDelayMs, 2000); + const orderedUnits = orderUnitsForStrategy(args.strategy, args.units); + const clientRequestedStream = args.body?.stream === true; + const startTime = Date.now(); + const effectiveStrategy = args.effectiveComboStrategy ?? args.strategy; + let lastResponse: Response | null = null; + let fallbackCount = 0; + + for (const unit of orderedUnits) { + for (let retry = 0; retry <= maxRetries; retry += 1) { + if (args.signal?.aborted) + return { response: errorResponse(499, "Client disconnected"), unit }; + args.nesting.attemptBudget.count += 1; + if (args.nesting.attemptBudget.count > args.nesting.attemptBudget.limit) { + return { response: errorResponse(503, "Maximum combo retry limit reached"), unit }; + } + if (retry > 0) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + args.log.info( + "COMBO", + `Trying ${unit.kind} ${unitDisplayName(unit)}${retry > 0 ? ` (retry ${retry})` : ""}` + ); + const response = await executeRuntimeUnit({ + body: args.body, + unit, + allCombos: args.allCombos, + handleSingleModel: args.handleSingleModel, + isModelAvailable: args.isModelAvailable, + runCombo: args.runCombo, + baseOptions: args.baseOptions, + nesting: args.nesting, + failoverBeforeRetry: args.config.failoverBeforeRetry, + effectiveComboStrategy: effectiveStrategy, + }); + lastResponse = response; + if (response.ok) { + if (unit.kind === "combo-ref") { + recordComboRequest(args.combo.name, null, { + success: true, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy: effectiveStrategy, + target: { executionKey: unit.executionKey, stepId: unit.stepId, label: unit.label }, + }); + return { response, unit }; + } + const quality = await validateResponseQuality(response, clientRequestedStream, args.log); + if (quality.valid) { + recordComboRequest(args.combo.name, unit.modelStr, { + success: true, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy: effectiveStrategy, + target: { executionKey: unit.executionKey, stepId: unit.stepId, label: unit.label }, + }); + return { response: quality.clonedResponse ?? response, unit }; + } + } + if (![408, 429, 500, 502, 503, 504].includes(response.status)) break; + } + fallbackCount += 1; + } + recordComboRequest(args.combo.name, null, { + success: false, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy: effectiveStrategy, + }); + return { + response: lastResponse || errorResponse(503, "All nested combo units unavailable"), + unit: null, + }; +} diff --git a/open-sse/services/combo/types.ts b/open-sse/services/combo/types.ts index 035fc20a47..651eaa7061 100644 --- a/open-sse/services/combo/types.ts +++ b/open-sse/services/combo/types.ts @@ -44,6 +44,7 @@ export type ComboLogger = { export type SingleModelTarget = | (ResolvedComboTarget & { allowRateLimitedConnection?: boolean; + effectiveComboStrategy?: string | null; modelAbortSignal?: AbortSignal | null; }) | { modelAbortSignal: AbortSignal }; @@ -65,6 +66,16 @@ export type ComboRelayOptions = { [key: string]: unknown; }; +export type NestedComboMode = "flatten" | "execute"; + +export type ComboNestingContext = { + depth: number; + maxDepth: number; + visitedComboNames: string[]; + rootComboName: string; + attemptBudget: { count: number; limit: number }; +}; + export type HandleComboChatOptions = { body: Record; combo: ComboLike; @@ -76,6 +87,7 @@ export type HandleComboChatOptions = { relayOptions?: ComboRelayOptions | null; signal?: AbortSignal | null; apiKeyAllowedConnections?: string[] | null; + nesting?: ComboNestingContext | null; }; export type HandleRoundRobinOptions = Omit< @@ -130,13 +142,15 @@ export type ShadowRoutingConfig = { timeoutMs: number; }; -export type ComboRuntimeStep = - | ResolvedComboTarget - | { - kind: "combo-ref"; - stepId: string; - executionKey: string; - comboName: string; - weight: number; - label: string | null; - }; +export type ResolvedComboRefTarget = { + kind: "combo-ref"; + stepId: string; + executionKey: string; + comboName: string; + weight: number; + label: string | null; +}; + +export type ResolvedComboUnit = ResolvedComboTarget | ResolvedComboRefTarget; + +export type ComboRuntimeStep = ResolvedComboUnit; diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index 932f67e1cb..1378283796 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -50,6 +50,7 @@ const DEFAULT_COMBO_CONFIG = { handoffProviders: ["codex"], maxMessagesForSummary: 30, maxComboDepth: 3, + nestedComboMode: "flatten", trackMetrics: true, reasoningTokenBufferEnabled: true, manifestRouting: false, diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 1293e040ed..5906352812 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -169,6 +169,8 @@ const ADVANCED_FIELD_HELP_FALLBACK = { "Number of times to retry the full target set when every target fails. 0 = no set-level retry.", setRetryDelayMs: "Delay between set-level retry attempts, giving transient issues time to resolve.", + nestedComboMode: + "How references to other combos are handled. Flatten expands a combo ref into this combo's target list (legacy). Execute treats a combo ref as a black-box target: the parent strategy selects the child combo, then the child runs its own strategy and retries.", }; const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ @@ -3880,6 +3882,32 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo )} +
+
+ + +
+
{strategy === "context-relay" && (
diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts index 75d7774517..65e27bceb2 100644 --- a/src/shared/validation/schemas/combo.ts +++ b/src/shared/validation/schemas/combo.ts @@ -146,6 +146,7 @@ export const comboRuntimeConfigSchema = z handoffProviders: z.array(z.string().trim().min(1).max(100)).max(10).optional(), maxMessagesForSummary: z.coerce.number().int().min(5).max(100).optional(), maxComboDepth: z.coerce.number().int().min(1).max(10).optional(), + nestedComboMode: z.enum(["flatten", "execute"]).optional(), trackMetrics: z.boolean().optional(), reasoningTokenBufferEnabled: z.boolean().optional(), compressionMode: compressionModeSchema.optional(), diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index d20b5729d8..3101069c23 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -636,6 +636,7 @@ export async function handleChat( allowedConnectionIds?: string[] | null; failoverBeforeRetry?: boolean; providerId?: string | null; + effectiveComboStrategy?: string | null; } ) => handleSingleModelChat( @@ -662,7 +663,7 @@ export async function handleChat( cachedSettings: settings, providerId: target?.providerId ?? null, }, - combo.strategy, + target?.effectiveComboStrategy ?? combo.strategy, true ), isModelAvailable: checkModelAvailable, @@ -825,6 +826,7 @@ async function handleSingleModelChat( failoverBeforeRetry?: boolean; allowRateLimitedConnection?: boolean; providerId?: string | null; + effectiveComboStrategy?: string | null; } ) => handleSingleModelChat( @@ -846,7 +848,7 @@ async function handleSingleModelChat( allowRateLimitedConnection: target?.allowRateLimitedConnection === true, providerId: target?.providerId ?? null, }, - redirectCombo.strategy ?? "priority", + target?.effectiveComboStrategy ?? redirectCombo.strategy ?? "priority", false ), isModelAvailable: async () => true, diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts index 576e734c3c..1e9a04b9c3 100644 --- a/tests/unit/combo-config.test.ts +++ b/tests/unit/combo-config.test.ts @@ -26,6 +26,7 @@ test("getDefaultComboConfig returns a fresh copy of the defaults", () => { assert.equal(first.handoffThreshold, 0.85); assert.equal(first.maxMessagesForSummary, 30); assert.deepEqual(first.handoffProviders, ["codex"]); + assert.equal(first.nestedComboMode, "flatten"); assert.equal(first.failoverBeforeRetry, true); assert.equal(first.maxSetRetries, 0); assert.equal(first.setRetryDelayMs, 2000); @@ -531,6 +532,30 @@ test("createComboSchema accepts failoverBeforeRetry, maxSetRetries and setRetryD assert.equal(parsed.config.setRetryDelayMs, 1500); }); +test("createComboSchema accepts nestedComboMode and rejects invalid values", () => { + const parsed = createComboSchema.parse({ + name: "nested-execute", + models: [{ kind: "combo-ref", comboName: "child" }], + strategy: "priority", + config: { nestedComboMode: "execute" }, + }); + assert.equal(parsed.config.nestedComboMode, "execute"); + + const flatten = createComboSchema.parse({ + name: "nested-flatten", + models: ["openai/gpt-4o-mini"], + config: { nestedComboMode: "flatten" }, + }); + assert.equal(flatten.config.nestedComboMode, "flatten"); + + const invalid = createComboSchema.safeParse({ + name: "nested-invalid", + models: ["openai/gpt-4o-mini"], + config: { nestedComboMode: "redirect" }, + }); + assert.equal(invalid.success, false); +}); + test("createComboSchema accepts per-combo stickyRoundRobinLimit and rejects out-of-range", () => { const parsed = createComboSchema.parse({ name: "sticky-override", @@ -618,6 +643,17 @@ test("createComboSchema rejects setRetryDelayMs out of range", () => { assert.equal(negative.success, false); }); +test("resolveComboConfig cascades nestedComboMode", () => { + const result = resolveComboConfig( + { config: { nestedComboMode: "execute" } }, + { comboDefaults: { nestedComboMode: "flatten" } } + ); + assert.equal(result.nestedComboMode, "execute"); + + const defaulted = resolveComboConfig({ config: {} }, { comboDefaults: {} }); + assert.equal(defaulted.nestedComboMode, "flatten"); +}); + test("resolveComboConfig cascades failoverBeforeRetry, maxSetRetries and setRetryDelayMs", () => { const result = resolveComboConfig( { diff --git a/tests/unit/combo-strategy-fallbacks.test.ts b/tests/unit/combo-strategy-fallbacks.test.ts index 8948c64c6f..efb0189403 100644 --- a/tests/unit/combo-strategy-fallbacks.test.ts +++ b/tests/unit/combo-strategy-fallbacks.test.ts @@ -600,6 +600,164 @@ test("strict-random survives a stale deck entry after a target is removed", asyn assert.deepEqual(calls, ["claude/sonnet"]); }); +test("nested execute mode treats combo refs as black-box priority targets", async () => { + const calls: string[] = []; + const outer = { + name: "outer-execute-priority", + strategy: "priority", + models: [{ kind: "combo-ref", comboName: "child-priority" }, "gemini/fallback"], + config: { nestedComboMode: "execute", maxRetries: 0, retryDelayMs: 0 }, + }; + const child = { + name: "child-priority", + strategy: "priority", + models: ["openai/a", "claude/b"], + config: { maxRetries: 0, retryDelayMs: 0 }, + }; + const result = await handleComboChat({ + body: {}, + combo: outer, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: [outer, child], + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/a"]); +}); + +test("nested execute mode falls back to the next parent target after a child combo fails", async () => { + const calls: string[] = []; + const outer = { + name: "outer-execute-fallback", + strategy: "priority", + models: [{ kind: "combo-ref", comboName: "child-down" }, "gemini/fallback"], + config: { nestedComboMode: "execute", maxRetries: 0, retryDelayMs: 0 }, + }; + const child = { + name: "child-down", + strategy: "priority", + models: ["openai/a", "claude/b"], + config: { maxRetries: 0, retryDelayMs: 0 }, + }; + const result = await handleComboChat({ + body: {}, + combo: outer, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + return modelStr === "gemini/fallback" ? okResponse() : errorResponse(503); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: [outer, child], + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/a", "claude/b", "gemini/fallback"]); +}); + +test("nested execute mode supports 3-level nesting", async () => { + const calls: string[] = []; + const a = { + name: "level-a", + strategy: "priority", + models: [{ kind: "combo-ref", comboName: "level-b" }], + config: { nestedComboMode: "execute", maxRetries: 0 }, + }; + const b = { + name: "level-b", + strategy: "priority", + models: [{ kind: "combo-ref", comboName: "level-c" }], + config: { nestedComboMode: "execute", maxRetries: 0 }, + }; + const c = { + name: "level-c", + strategy: "priority", + models: ["openai/leaf"], + config: { maxRetries: 0 }, + }; + const result = await handleComboChat({ + body: {}, + combo: a, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: [a, b, c], + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/leaf"]); +}); + +test("nested execute mode fails cleanly on a circular combo reference", async () => { + const a = { + name: "cycle-a", + strategy: "priority", + models: [{ kind: "combo-ref", comboName: "cycle-b" }], + config: { nestedComboMode: "execute", maxRetries: 0 }, + }; + const b = { + name: "cycle-b", + strategy: "priority", + models: [{ kind: "combo-ref", comboName: "cycle-a" }], + config: { nestedComboMode: "execute", maxRetries: 0 }, + }; + await assert.rejects( + async () => + handleComboChat({ + body: {}, + combo: a, + handleSingleModel: async () => okResponse(), + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: [a, b], + }), + /[Cc]ircular/ + ); +}); + +test("flatten mode (default) expands nested combo refs into leaf targets", async () => { + const calls: string[] = []; + const outer = { + name: "outer-flatten-default", + strategy: "priority", + models: [{ kind: "combo-ref", comboName: "child-flat" }], + config: { maxRetries: 0 }, + }; + const child = { + name: "child-flat", + strategy: "priority", + models: ["openai/a", "claude/b"], + config: { maxRetries: 0 }, + }; + const result = await handleComboChat({ + body: {}, + combo: outer, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + return modelStr === "openai/a" ? errorResponse(503) : okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: [outer, child], + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/a", "claude/b"]); +}); + test("unknown strategy value normalizes to priority order", async () => { const calls: string[] = []; const result = await handleComboChat({