diff --git a/open-sse/services/autoCombo/pipelineRouter.ts b/open-sse/services/autoCombo/pipelineRouter.ts index e957fd83f7..5fbc9eea02 100644 --- a/open-sse/services/autoCombo/pipelineRouter.ts +++ b/open-sse/services/autoCombo/pipelineRouter.ts @@ -54,6 +54,7 @@ const INTENT_TO_TASK: Record = { export interface PipelineComboParams { body: Record; combo: Record; + availableModels?: readonly string[]; handleChatCore: (body: Record, modelStr?: string) => Promise; log: { info: (...args: unknown[]) => void; @@ -85,7 +86,7 @@ export interface StageExecutorResult { */ function resolveModelForTier( tier: FitnessTier, - availableModels: string[], + availableModels: readonly string[], taskType: string ): string { // Score each available model for this task type and tier @@ -125,7 +126,7 @@ function createStageExecutor( body: Record, handleChatCore: (body: Record, modelStr?: string) => Promise, log: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }, - availableModels: string[], + availableModels: readonly string[], taskType: string ): (args: StageExecutorArgs & { fitnessTier?: FitnessTier }) => Promise { return async ({ @@ -222,6 +223,7 @@ function estimateTokens(messages: Array<{ role: string; content: unknown }>): nu export async function handlePipelineCombo({ body, combo, + availableModels: routedModels, handleChatCore, log, settings, @@ -291,7 +293,13 @@ export async function handlePipelineCombo({ }) .filter((model): model is string => typeof model === "string" && model.length > 0) : []; - const availableModels = comboModels.length ? comboModels : ["deepseek-chat"]; + const availableModels = + routedModels === undefined + ? comboModels.length + ? comboModels + : ["deepseek-chat"] + : routedModels; + if (availableModels.length === 0) throw new Error("PIPELINE_NO_MODELS"); // ── Create stage executor ───────────────────────────────────────────────── const stageExecutor = createStageExecutor(body, handleChatCore, log, availableModels, taskType); diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index c4a0d5cc63..acfb0e81c6 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -570,6 +570,7 @@ export async function handleComboChat({ signal, apiKeyAllowedConnections = null, nesting = null, + hiddenModelsByProvider = getHiddenModelsByProvider(), }: HandleComboChatOptions): Promise { const comboCtx = createComboContext({ body, combo, settings, relayOptions, log }); const { @@ -607,6 +608,7 @@ export async function handleComboChat({ clientRequestedStream, handleSingleModelWithTimeout, log, + hiddenModelsByProvider, }); if (pinnedDispatch) return pinnedDispatch; } @@ -628,6 +630,7 @@ export async function handleComboChat({ relayOptions, signal, apiKeyAllowedConnections, + hiddenModelsByProvider, runCombo: handleComboChat, }); if (fusionDispatch) return fusionDispatch; @@ -636,7 +639,12 @@ export async function handleComboChat({ // chaosEngine.ts (dispatchChaosFromCombo), returning null when not chaos-enabled. const chaosDispatch = dispatchChaosFromCombo({ cfg, - comboModels: combo.models || [], + comboModels: resolveComboTargets( + combo, + allCombos, + clampComboDepth(config.maxComboDepth), + hiddenModelsByProvider + ).map((target) => target.modelStr), comboName: combo.name, body, handleSingleModel: handleSingleModelWithTimeout, @@ -649,8 +657,10 @@ export async function handleComboChat({ combo, config, strategy, + allCombos, handleSingleModelWithTimeout, log, + hiddenModelsByProvider, }); if (pipelineDispatch) return pipelineDispatch; @@ -669,6 +679,7 @@ export async function handleComboChat({ relayOptions, signal, apiKeyAllowedConnections, + hiddenModelsByProvider, runCombo: handleComboChat, }); if (runtimeUnitDispatch) return runtimeUnitDispatch; @@ -684,6 +695,7 @@ export async function handleComboChat({ settings, allCombos, signal, + hiddenModelsByProvider, }); } @@ -708,6 +720,7 @@ export async function handleComboChat({ isModelAvailable, handleSingleModelWithTimeout, buildAutoCandidates, + hiddenModelsByProvider, }); if ("earlyResponse" in targetResolution) return targetResolution.earlyResponse; const { stickyWeightedLimit, getWeightedStepKeyForTarget, preScreenMap } = targetResolution; @@ -749,7 +762,7 @@ export async function handleComboChat({ combo, config, body, - resolveShadowTargets(combo, config, allCombos), + resolveShadowTargets(combo, config, allCombos, hiddenModelsByProvider), handleSingleModel, isModelAvailable, strategy, @@ -2184,6 +2197,7 @@ async function handleRoundRobinCombo({ settings, allCombos, signal, + hiddenModelsByProvider = getHiddenModelsByProvider(), }: HandleRoundRobinOptions): Promise { const config = settings ? resolveComboConfig(combo, settings) @@ -2225,7 +2239,8 @@ async function handleRoundRobinCombo({ const orderedTargets = resolveComboTargets( rrExpandedCombo, rrExpandedAllCombos, - clampComboDepth(config.maxComboDepth) + clampComboDepth(config.maxComboDepth), + hiddenModelsByProvider ); const tagFilteredTargets = await applyRequestTagRouting(orderedTargets, body, log); const evalRankedTargets = orderTargetsByEvalScores(tagFilteredTargets, config.evalRouting, log); @@ -2298,7 +2313,7 @@ async function handleRoundRobinCombo({ combo, config, body, - resolveShadowTargets(combo, config, allCombos), + resolveShadowTargets(combo, config, allCombos, hiddenModelsByProvider), handleSingleModel, isModelAvailable, "round-robin", diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 554e162985..fa711c6b8c 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -12,12 +12,14 @@ */ import { getModelContextLimit } from "../../../src/lib/modelCapabilities"; +import { getHiddenModelsByProvider } from "../../../src/lib/db/models"; import { getComboModelString, normalizeComboStep } from "../../../src/lib/combos/steps.ts"; import { getProviderByAlias, getProviderById } from "../../../src/shared/constants/providers.ts"; import { estimateTokens } from "../contextManager.ts"; import { getResolvedModelCapabilities } from "../modelCapabilities.ts"; import { parseModel, stripContextWindowSuffix } from "../model.ts"; import { dedupeTargetsByExecutionKey, isRecord } from "./comboData.ts"; +import { isComboModelVisible } from "./comboVisibility.ts"; import { getTargetProvider, MAX_COMBO_DEPTH } from "./comboPredicates.ts"; import { evaluateContextLimit } from "./contextOverrideGate.ts"; import { hasEstimableContent } from "./knownContextOverflow.ts"; @@ -32,6 +34,7 @@ import type { ComboLike, ComboLogger, ComboRuntimeStep, + HiddenModelsByProvider, NestedComboMode, ResolvedComboTarget, ResolvedComboUnit, @@ -132,15 +135,28 @@ function normalizeRuntimeStep( : {}), weight, label, + prompt: step.prompt || null, } satisfies ResolvedComboTarget; } -function getDirectComboTargets(combo: ComboLike): ResolvedComboTarget[] { - return getOrderedTopLevelRuntimeSteps(combo, null).filter( - (entry): entry is ResolvedComboTarget => entry?.kind === "model" +function isComboTargetVisible( + target: ResolvedComboTarget, + hiddenModelsByProvider: HiddenModelsByProvider +): boolean { + return isComboModelVisible( + target.modelStr, + target.providerId || target.provider, + hiddenModelsByProvider ); } +export function filterVisibleComboTargets( + targets: ResolvedComboTarget[], + hiddenModelsByProvider: HiddenModelsByProvider = getHiddenModelsByProvider() +): ResolvedComboTarget[] { + return targets.filter((target) => isComboTargetVisible(target, hiddenModelsByProvider)); +} + function getTopLevelRuntimeSteps( combo: ComboLike, allCombos: ComboCollectionLike, @@ -832,36 +848,50 @@ export function sortTargetsByContextSize(targets: ResolvedComboTarget[]) { export function resolveComboTargets( combo: ComboLike, allCombos: ComboCollectionLike, - maxDepth: number = MAX_COMBO_DEPTH + maxDepth: number = MAX_COMBO_DEPTH, + hiddenModelsByProvider: HiddenModelsByProvider = getHiddenModelsByProvider() ): ResolvedComboTarget[] { - return allCombos - ? resolveNestedComboTargets(combo, allCombos, new Set(), 0, [], maxDepth) - : getDirectComboTargets(combo); + return filterVisibleComboTargets( + allCombos + ? resolveNestedComboTargets(combo, allCombos, new Set(), 0, [], maxDepth) + : getOrderedTopLevelRuntimeSteps(combo, null).filter( + (entry): entry is ResolvedComboTarget => entry?.kind === "model" + ), + hiddenModelsByProvider + ); } export function resolveComboRuntimeUnits( combo: ComboLike, allCombos: ComboCollectionLike, mode: NestedComboMode, - maxDepth: number = MAX_COMBO_DEPTH + maxDepth: number = MAX_COMBO_DEPTH, + hiddenModelsByProvider: HiddenModelsByProvider = getHiddenModelsByProvider() ): ResolvedComboUnit[] { - if (mode === "flatten" || !allCombos) return resolveComboTargets(combo, allCombos, maxDepth); + if (mode === "flatten" || !allCombos) + return resolveComboTargets(combo, allCombos, maxDepth, hiddenModelsByProvider); validateComboDAG(combo.name, allCombos, new Set(), 0, maxDepth); - return getOrderedTopLevelRuntimeSteps(combo, allCombos); + return getOrderedTopLevelRuntimeSteps(combo, allCombos).filter( + (unit) => unit.kind === "combo-ref" || isComboTargetVisible(unit, hiddenModelsByProvider) + ); } export function resolveWeightedStepGroups( combo: ComboLike, - allCombos: ComboCollectionLike + allCombos: ComboCollectionLike, + hiddenModelsByProvider: HiddenModelsByProvider = getHiddenModelsByProvider() ): Array<{ step: ComboRuntimeStep; targets: ResolvedComboTarget[] }> { return getOrderedTopLevelRuntimeSteps(combo, allCombos) .map((step) => ({ step, - targets: !allCombos - ? step.kind === "model" - ? [step] - : [] - : expandRuntimeStep(step, allCombos, new Set([combo.name])), + targets: filterVisibleComboTargets( + !allCombos + ? step.kind === "model" + ? [step] + : [] + : expandRuntimeStep(step, allCombos, new Set([combo.name])), + hiddenModelsByProvider + ), })) .filter((group) => group.targets.length > 0); } diff --git a/open-sse/services/combo/comboVisibility.ts b/open-sse/services/combo/comboVisibility.ts new file mode 100644 index 0000000000..e3d363ef59 --- /dev/null +++ b/open-sse/services/combo/comboVisibility.ts @@ -0,0 +1,23 @@ +import { getHiddenModelsByProvider } from "../../../src/lib/db/models"; +import { parseModel, resolveCanonicalProviderModel } from "../model.ts"; +import type { HiddenModelsByProvider } from "./types.ts"; + +export function isComboModelVisible( + modelStr: string, + providerId: string | null = null, + hiddenModelsByProvider: HiddenModelsByProvider = getHiddenModelsByProvider() +): boolean { + const parsed = parseModel(modelStr); + const hasExplicitProvider = + providerId && providerId !== parsed.provider && providerId !== parsed.providerAlias; + const rawModel = hasExplicitProvider ? modelStr : parsed.model || modelStr; + const resolved = resolveCanonicalProviderModel( + providerId || parsed.provider || parsed.providerAlias, + rawModel + ); + return ( + !resolved.provider || + !resolved.model || + !hiddenModelsByProvider.get(resolved.provider)?.has(resolved.model) + ); +} diff --git a/open-sse/services/combo/dispatchPrelude.ts b/open-sse/services/combo/dispatchPrelude.ts index 69d2576c00..9c30bf8233 100644 --- a/open-sse/services/combo/dispatchPrelude.ts +++ b/open-sse/services/combo/dispatchPrelude.ts @@ -45,6 +45,7 @@ import type { HandleComboChatOptions, HandleSingleModel, IsModelAvailable, + HiddenModelsByProvider, NestedComboMode, ResolvedComboUnit, SingleModelTarget, @@ -68,6 +69,7 @@ type PreludeBaseOptionArgs = { relayOptions?: HandleComboChatOptions["relayOptions"]; signal?: AbortSignal | null; apiKeyAllowedConnections?: string[] | null; + hiddenModelsByProvider?: HiddenModelsByProvider; }; /** Rebuild handleComboChat's option bag verbatim for a recursive dispatch. */ @@ -83,6 +85,7 @@ function buildBaseOptions(a: PreludeBaseOptionArgs): HandleComboChatOptions { relayOptions: a.relayOptions, signal: a.signal, apiKeyAllowedConnections: a.apiKeyAllowedConnections, + hiddenModelsByProvider: a.hiddenModelsByProvider, }; } @@ -232,6 +235,7 @@ export async function tryPinnedModelDispatch(args: { clientRequestedStream: boolean; handleSingleModelWithTimeout: HandleSingleModel; log: ComboLogger; + hiddenModelsByProvider?: HiddenModelsByProvider; }): Promise { const { body, @@ -242,6 +246,7 @@ export async function tryPinnedModelDispatch(args: { clientRequestedStream, handleSingleModelWithTimeout, log, + hiddenModelsByProvider, } = args; // The pin is read from session_model_history (a PRIOR turn) and may name a // model that has since been removed from this combo, or a provider whose @@ -254,11 +259,12 @@ export async function tryPinnedModelDispatch(args: { // when allCombos is authoritative (non-empty) so we can resolve combo-refs; // the auto-combo redirect path passes an empty list and keeps prior behavior. const haveFullCombos = Array.isArray(allCombos) ? allCombos.length > 0 : !!allCombos; - const pinInCombo = - !haveFullCombos || - resolveComboTargets(combo, allCombos, clampComboDepth(config.maxComboDepth)).some( - (t) => t.modelStr === pinnedModel - ); + const pinInCombo = resolveComboTargets( + combo, + haveFullCombos ? allCombos : null, + clampComboDepth(config.maxComboDepth), + hiddenModelsByProvider + ).some((target) => target.modelStr === pinnedModel); // Honor the pin only if it is still a combo target AND its provider is not // DURABLY down. Without the health gate a pin keeps routing a session to a // dead/credits-exhausted/throttled account forever (strategy bypassed, no @@ -330,6 +336,7 @@ export async function tryFusionDispatch(args: { relayOptions?: HandleComboChatOptions["relayOptions"]; signal?: AbortSignal | null; apiKeyAllowedConnections?: string[] | null; + hiddenModelsByProvider?: HiddenModelsByProvider; runCombo: RunCombo; }): Promise { const { cfg, combo, config, strategy, log } = args; @@ -347,9 +354,14 @@ export async function tryFusionDispatch(args: { if (strategy !== "fusion") return null; const { panel: fusionModels, comboRefUnits } = extractFusionPanelSpec( - combo.models || [], + resolveComboTargets( + combo, + args.allCombos, + clampComboDepth(config.maxComboDepth), + args.hiddenModelsByProvider + ).map((target) => target.modelStr), combo.name, - args.allCombos + null ); // Untyped like the existing `nestingContext` further down — `nesting` is // already `ComboNestingContext | null` per HandleComboChatOptions, no new @@ -389,26 +401,28 @@ export async function tryPipelineDispatch(args: { combo: ComboLike; config: ComboSetupConfig; strategy: string; + allCombos?: ComboCollectionLike; handleSingleModelWithTimeout: HandleSingleModel; log: ComboLogger; + hiddenModelsByProvider?: HiddenModelsByProvider; }): Promise { - const { body, combo, config, strategy, handleSingleModelWithTimeout, log } = args; + const { + body, + combo, + config, + strategy, + allCombos, + handleSingleModelWithTimeout, + log, + hiddenModelsByProvider, + } = args; if (strategy !== "pipeline") return null; - const pipelineSteps = (combo.models || []) - .map((m): PipelineStep | null => { - if (typeof m === "string") return { model: m }; - if (m && typeof m === "object") { - const obj = m as Record; - if (typeof obj.model === "string") { - return { - model: obj.model, - prompt: typeof obj.prompt === "string" ? obj.prompt : undefined, - }; - } - } - return null; - }) - .filter((s): s is PipelineStep => Boolean(s)); + const pipelineSteps: PipelineStep[] = resolveComboTargets( + combo, + allCombos, + clampComboDepth(config.maxComboDepth), + hiddenModelsByProvider + ).map((target) => ({ target, prompt: target.prompt })); return handlePipelineChat({ body, steps: pipelineSteps, @@ -523,6 +537,7 @@ export async function tryRuntimeUnitDispatch(args: { relayOptions?: HandleComboChatOptions["relayOptions"]; signal?: AbortSignal | null; apiKeyAllowedConnections?: string[] | null; + hiddenModelsByProvider?: HiddenModelsByProvider; runCombo: RunCombo; }): Promise { const { body, combo, config, strategy, allCombos, log, settings } = args; @@ -531,7 +546,13 @@ export async function tryRuntimeUnitDispatch(args: { const executeModeUnits = nestedComboMode === "execute" && allCombos - ? resolveComboRuntimeUnits(combo, allCombos, "execute", nestingContext.maxDepth) + ? resolveComboRuntimeUnits( + combo, + allCombos, + "execute", + nestingContext.maxDepth, + args.hiddenModelsByProvider + ) : []; const hasExecutableComboRef = executeModeUnits.some((unit) => unit.kind === "combo-ref"); const simpleExecuteStrategies = new Set([ diff --git a/open-sse/services/combo/shadowRouting.ts b/open-sse/services/combo/shadowRouting.ts index 5483b4320d..a04a23687f 100644 --- a/open-sse/services/combo/shadowRouting.ts +++ b/open-sse/services/combo/shadowRouting.ts @@ -16,13 +16,14 @@ import { secureRandomFloat } from "../../../src/shared/utils/secureRandom"; import { recordComboShadowRequest } from "../comboMetrics.ts"; import { isRecord } from "./comboData.ts"; -import { resolveNestedComboTargets } from "./comboStructure.ts"; +import { filterVisibleComboTargets, resolveNestedComboTargets } from "./comboStructure.ts"; import { toRecordedTarget } from "./comboPredicates.ts"; import type { ComboLike, ComboCollectionLike, ComboLogger, HandleSingleModel, + HiddenModelsByProvider, IsModelAvailable, ResolvedComboTarget, ShadowRoutingConfig, @@ -47,7 +48,8 @@ function normalizeShadowRoutingConfig(config: Record): ShadowRo export function resolveShadowTargets( combo: ComboLike, config: Record, - allCombos: ComboCollectionLike + allCombos: ComboCollectionLike, + hiddenModelsByProvider?: HiddenModelsByProvider ): ResolvedComboTarget[] { const shadowConfig = normalizeShadowRoutingConfig(config); if (!shadowConfig.enabled || shadowConfig.targets.length === 0) return []; @@ -58,7 +60,10 @@ export function resolveShadowTargets( name: `${combo.name}:shadow`, models: shadowConfig.targets, }; - return resolveNestedComboTargets(shadowCombo, allCombos, new Set([combo.name]), 0, ["shadow"]) + return filterVisibleComboTargets( + resolveNestedComboTargets(shadowCombo, allCombos, new Set([combo.name]), 0, ["shadow"]), + hiddenModelsByProvider + ) .slice(0, shadowConfig.maxTargets) .map((target) => ({ ...target, diff --git a/open-sse/services/combo/targetResolution.ts b/open-sse/services/combo/targetResolution.ts index 82f23b8c3d..e6b4771239 100644 --- a/open-sse/services/combo/targetResolution.ts +++ b/open-sse/services/combo/targetResolution.ts @@ -88,6 +88,7 @@ import type { ComboRuntimeStep, HandleSingleModel, IsModelAvailable, + HiddenModelsByProvider, ResolvedComboTarget, } from "./types.ts"; @@ -111,6 +112,7 @@ export interface ResolveComboTargetPipelineDeps { * this leaf), so importing it directly would create an import cycle. */ buildAutoCandidates: ResolveAutoStrategyDeps["buildAutoCandidates"]; + hiddenModelsByProvider?: HiddenModelsByProvider; } export interface ResolvedComboTargetPipeline { @@ -204,10 +206,15 @@ async function collectWeightedEligibility( expandedCombo: ComboLike, expandedAllCombos: ComboCollectionLike, resilienceSettings: ResilienceSettings, - isModelAvailable?: IsModelAvailable + isModelAvailable?: IsModelAvailable, + hiddenModelsByProvider?: HiddenModelsByProvider ): Promise<{ stepGroups: WeightedStepGroups; weightedEligibleKeys: Set }> { const weightedEligibleKeys = new Set(); - const stepGroups = resolveWeightedStepGroups(expandedCombo, expandedAllCombos); + const stepGroups = resolveWeightedStepGroups( + expandedCombo, + expandedAllCombos, + hiddenModelsByProvider + ); for (const group of stepGroups) { const availability = await Promise.all( group.targets.map((target) => @@ -260,7 +267,8 @@ async function resolveWeightedSelection( expandedCombo, expandedAllCombos, deps.resilienceSettings, - deps.isModelAvailable + deps.isModelAvailable, + deps.hiddenModelsByProvider ); stepGroups = eligibility.stepGroups; weightedEligibleKeys = eligibility.weightedEligibleKeys; @@ -351,7 +359,8 @@ function logTargetPoolSize( * auto routing (pipeline disabled, below token threshold, or dispatch failure). */ async function dispatchSmartPipeline( - deps: ResolveComboTargetPipelineDeps + deps: ResolveComboTargetPipelineDeps, + availableModels: readonly string[] ): Promise { const { body, combo, strategy, config, settings, signal, log } = deps; if (strategy !== "auto") return null; @@ -362,6 +371,7 @@ async function dispatchSmartPipeline( const pipelineRaw = await handlePipelineCombo({ body, combo, + availableModels, handleChatCore: deps.handleSingleModelWithTimeout, log: { info: log.info, @@ -687,7 +697,8 @@ export async function resolveComboTargetPipeline( : resolveComboTargets( expandedCombo, expandedAllCombos, - clampComboDepth(config.maxComboDepth) + clampComboDepth(config.maxComboDepth), + deps.hiddenModelsByProvider ); orderedTargets = await applyRequestTagRouting(orderedTargets, body, log); @@ -699,7 +710,10 @@ export async function resolveComboTargetPipeline( logTargetPoolSize(strategy, allCombos, orderedTargets, stickyWeightedKey, log); - const pipelineResponse = await dispatchSmartPipeline(deps); + const pipelineResponse = await dispatchSmartPipeline( + deps, + orderedTargets.map((target) => target.modelStr) + ); if (pipelineResponse) return { earlyResponse: pipelineResponse }; const ordering = await orderByStrategy(deps, orderedTargets); diff --git a/open-sse/services/combo/types.ts b/open-sse/services/combo/types.ts index 9371d11529..ca00fedbce 100644 --- a/open-sse/services/combo/types.ts +++ b/open-sse/services/combo/types.ts @@ -95,6 +95,8 @@ export type ComboNestingContext = { attemptBudget: { count: number; limit: number }; }; +export type HiddenModelsByProvider = ReadonlyMap>; + export type HandleComboChatOptions = { body: Record; combo: ComboLike; @@ -107,6 +109,7 @@ export type HandleComboChatOptions = { signal?: AbortSignal | null; apiKeyAllowedConnections?: string[] | null; nesting?: ComboNestingContext | null; + hiddenModelsByProvider?: HiddenModelsByProvider; }; export type HandleRoundRobinOptions = Omit< @@ -167,6 +170,7 @@ export type ResolvedComboTarget = { allowedConnectionIds?: string[] | null; weight: number; label: string | null; + prompt?: string | null; failoverBeforeRetry?: unknown; trafficType?: "production" | "shadow"; /** diff --git a/open-sse/services/fusion.ts b/open-sse/services/fusion.ts index dd92c3bdbc..767da61531 100644 --- a/open-sse/services/fusion.ts +++ b/open-sse/services/fusion.ts @@ -20,7 +20,7 @@ */ import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts"; import { extractTextContent } from "../translator/helpers/geminiHelper.ts"; -import type { ComboLogger, HandleSingleModel } from "./combo/types.ts"; +import type { ComboLogger, HandleSingleModel, ResolvedComboTarget } from "./combo/types.ts"; // Fusion tuning. Overridable per-combo via combo.config.fusionTuning. export const FUSION_DEFAULTS = { @@ -108,10 +108,7 @@ export function appendUserTurn(body: Body, text: string): Body { } 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 }] }, - ]; + next.contents = [...(body.contents as unknown[]), { role: "user", parts: [{ text }] }]; } else { next.messages = [{ role: "user", content: text }]; } @@ -159,10 +156,7 @@ export function isToolBearingRequest(body: Body): boolean { type Sentinel = { __timeout?: true; __error?: unknown }; // Resolve a Response (or sentinel) within ms; the loser keeps running but is ignored. -function withTimeout( - promise: Promise, - ms: number -): Promise { +function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve) => { const t = setTimeout(() => resolve({ __timeout: true }), ms); Promise.resolve(promise) @@ -224,16 +218,33 @@ export function collectPanel( }); } +export type FusionModel = ResolvedComboTarget | string; + export type HandleFusionChatOptions = { body: Body; - models: string[]; + models: FusionModel[]; handleSingleModel: HandleSingleModel; log: ComboLogger; comboName?: string; judgeModel?: string | null; + judgeTarget?: ResolvedComboTarget | null; tuning?: FusionTuning | null; }; +function getFusionModelString(model: FusionModel): string { + return typeof model === "string" ? model : model.modelStr; +} + +function dispatchFusionModel( + handleSingleModel: HandleSingleModel, + body: Body, + model: FusionModel +): Promise { + 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. @@ -260,6 +271,7 @@ export async function handleFusionChat({ log, comboName, judgeModel, + judgeTarget, tuning, }: HandleFusionChatOptions): Promise { const panel = Array.isArray(models) ? models.filter(Boolean) : []; @@ -269,7 +281,7 @@ export async function handleFusionChat({ // A single-model fusion has nothing to fuse — just answer directly. if (panel.length === 1) { - return handleSingleModel(body, panel[0]); + return dispatchFusionModel(handleSingleModel, body, panel[0]); } // Reject an oversized panel BEFORE fan-out (issue #1905): fanning out N @@ -296,10 +308,10 @@ export async function handleFusionChat({ // gracefully via the answers.length===1 branch below (issue #6454). const minPanel = Math.min(Math.max(1, cfg.minPanel), panel.length); const hasExplicitJudge = Boolean(judgeModel && judgeModel.trim()); - const judge = hasExplicitJudge ? (judgeModel as string).trim() : panel[0]; + const judge = hasExplicitJudge ? (judgeModel as string).trim() : getFusionModelString(panel[0]); log.info( "FUSION", - `Combo "${comboName ?? ""}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}` + `Combo "${comboName ?? ""}" | panel=${panel.length} [${panel.map(getFusionModelString).join(", ")}] | judge=${judge} | quorum=${minPanel}` ); // Tool-bearing requests get no value from panel synthesis — panel members @@ -322,8 +334,8 @@ export async function handleFusionChat({ void _tc; const panelBody: Body = { ...rest, stream: false }; const t0 = Date.now(); - const calls = panel.map((m) => - withTimeout(handleSingleModel(panelBody, m), cfg.panelHardTimeoutMs) + const calls = panel.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`); @@ -333,7 +345,7 @@ export async function handleFusionChat({ const failures: Array<{ model: string; reason: string }> = []; for (let i = 0; i < settled.length; i++) { const res = settled[i]; - const model = panel[i]; + const model = getFusionModelString(panel[i]); if (!res) { log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`); failures.push({ model, reason: "straggler_dropped" }); @@ -399,10 +411,7 @@ export async function handleFusionChat({ // 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)` - ); + 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 @@ -421,8 +430,8 @@ export async function handleFusionChat({ // SURVIVOR: prefer panel[0] when it survived, otherwise the first survivor. const effectiveJudge = hasExplicitJudge ? judge - : answers.some((a) => a.model === panel[0]) - ? panel[0] + : answers.some((a) => a.model === getFusionModelString(panel[0])) + ? getFusionModelString(panel[0]) : answers[0].model; if (answers.length === 1) { @@ -435,5 +444,7 @@ export async function handleFusionChat({ // 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 handleSingleModel(judgeBody, effectiveJudge); + return judgeTarget + ? handleSingleModel(judgeBody, judgeTarget.modelStr, judgeTarget) + : handleSingleModel(judgeBody, effectiveJudge); } diff --git a/open-sse/services/pipeline.ts b/open-sse/services/pipeline.ts index 03dec5dc94..a759ddfeec 100644 --- a/open-sse/services/pipeline.ts +++ b/open-sse/services/pipeline.ts @@ -40,14 +40,30 @@ * a bad-request or auth error wastes quota and will never succeed. */ import { errorResponse } from "../utils/error.ts"; -import type { ComboLogger, HandleSingleModel } from "./combo/types.ts"; +import type { ComboLogger, HandleSingleModel, ResolvedComboTarget } from "./combo/types.ts"; // extractPanelText is a generic assistant-text extractor (OpenAI chat / Claude / // Gemini / Responses) — reused here to read each step's output, not fusion-specific. import { extractPanelText } from "./fusion.ts"; type Body = Record; -export type PipelineStep = { model: string; prompt?: string | null }; +export type PipelineStep = + | { + target: ResolvedComboTarget; + prompt?: string | null; + } + | { + model: string; + prompt?: string | null; + }; + +function getStepModel(step: PipelineStep): string { + return "target" in step ? step.target.modelStr : step.model; +} + +function getStepTarget(step: PipelineStep): ResolvedComboTarget | undefined { + return "target" in step ? step.target : undefined; +} /** * Prepend a system instruction to the client's original conversation (format-aware), @@ -146,23 +162,32 @@ export async function handlePipelineChat({ maxRetries = 0, retryDelayMs = 1000, }: HandlePipelineChatOptions): Promise { - const chain = (Array.isArray(steps) ? steps : []).filter((s) => s && s.model); + const chain = (Array.isArray(steps) ? steps : []).filter((step): step is PipelineStep => + Boolean(step && getStepModel(step)) + ); if (chain.length === 0) { return errorResponse(400, "Pipeline combo has no models"); } log.info( "PIPELINE", - `Combo "${comboName ?? ""}" | steps=${chain.length} [${chain.map((s) => s.model).join(" -> ")}]` + `Combo "${comboName ?? ""}" | steps=${chain.length} [${chain.map(getStepModel).join(" -> ")}]` ); // Single-step pipeline: nothing to chain — run it directly (streams to client). if (chain.length === 1) { - return handleSingleModel(prependSystemInstruction(body, chain[0].prompt), chain[0].model); + const step = chain[0]; + return handleSingleModel( + prependSystemInstruction(body, step.prompt), + getStepModel(step), + getStepTarget(step) + ); } let prevOutput = ""; for (let i = 0; i < chain.length; i++) { const step = chain[i]; + const stepModel = getStepModel(step); + const stepTarget = getStepTarget(step); const isFinal = i === chain.length - 1; const isFirst = i === 0; @@ -174,46 +199,55 @@ export async function handlePipelineChat({ if (!isFinal) stepBody = stripStreaming(stepBody); const t0 = Date.now(); - let res = await handleSingleModel(stepBody, step.model); + let res = await handleSingleModel(stepBody, stepModel, stepTarget); if (isFinal) { - log.info("PIPELINE", `Final step ${step.model} responded (${Date.now() - t0}ms)`); + log.info("PIPELINE", `Final step ${stepModel} responded (${Date.now() - t0}ms)`); return res; } // Transient retry: if the intermediate step failed with a retryable status // (429/502/503/504), retry the same step up to maxRetries times before // giving up. Non-transient errors (400/401/403/404) fail immediately. - for (let attempt = 0; attempt < maxRetries && !res.ok && TRANSIENT_STATUS.has(res.status); attempt++) { + for ( + let attempt = 0; + attempt < maxRetries && !res.ok && TRANSIENT_STATUS.has(res.status); + attempt++ + ) { log.warn( "PIPELINE", - `Step ${i + 1} (${step.model}) transient ${res.status}, retrying ${attempt + 1}/${maxRetries} in ${retryDelayMs}ms` + `Step ${i + 1} (${stepModel}) transient ${res.status}, retrying ${attempt + 1}/${maxRetries} in ${retryDelayMs}ms` ); await sleep(retryDelayMs); - res = await handleSingleModel(stepBody, step.model); + res = await handleSingleModel(stepBody, stepModel, stepTarget); } // An intermediate step must succeed with usable text — otherwise fail the whole // pipeline (never silently swallow; the client gets a clear, sanitized error). if (!res.ok) { - log.warn("PIPELINE", `Step ${i + 1} (${step.model}) failed`, { status: res.status }); + log.warn("PIPELINE", `Step ${i + 1} (${stepModel}) failed`, { + status: res.status, + }); const status = res.status >= 400 && res.status <= 599 ? res.status : 502; - return errorResponse(status, `Pipeline step ${i + 1} (${step.model}) failed`); + return errorResponse(status, `Pipeline step ${i + 1} (${stepModel}) failed`); } try { const json = await res.clone().json(); prevOutput = extractPanelText(json); } catch { - log.warn("PIPELINE", `Step ${i + 1} (${step.model}) returned an unparseable body`); - return errorResponse(502, `Pipeline step ${i + 1} (${step.model}) returned an unparseable body`); + log.warn("PIPELINE", `Step ${i + 1} (${stepModel}) returned an unparseable body`); + return errorResponse( + 502, + `Pipeline step ${i + 1} (${stepModel}) returned an unparseable body` + ); } if (!prevOutput.trim()) { - log.warn("PIPELINE", `Step ${i + 1} (${step.model}) returned empty output`); - return errorResponse(502, `Pipeline step ${i + 1} (${step.model}) returned empty output`); + log.warn("PIPELINE", `Step ${i + 1} (${stepModel}) returned empty output`); + return errorResponse(502, `Pipeline step ${i + 1} (${stepModel}) returned empty output`); } log.info( "PIPELINE", - `Step ${i + 1} ${step.model} ok (${prevOutput.length} chars, ${Date.now() - t0}ms)` + `Step ${i + 1} ${stepModel} ok (${prevOutput.length} chars, ${Date.now() - t0}ms)` ); } diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index a5cda7fc3c..1e0595a42f 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -489,14 +489,13 @@ async function buildUnifiedModelsResponseCore( const buildComboCatalogMetadata = ( combo: Parameters[0], - allCombos: Parameters[1] + targets: ComboCatalogTarget[] ) => { const explicitContextLength = isPositiveFiniteNumber(combo.context_length) ? combo.context_length : undefined; const baseMetadata = explicitContextLength ? { context_length: explicitContextLength } : {}; - const targets = resolveNestedComboTargets(combo, allCombos) as ComboCatalogTarget[]; if (targets.length === 0) return baseMetadata; const targetMetadata = targets.map((target) => getComboTargetCatalogMetadata(target)); @@ -641,16 +640,13 @@ async function buildUnifiedModelsResponseCore( combo as Parameters[0], combos as Parameters[1] ) as ComboCatalogTarget[]; - if ( - comboTargets.some((target) => { - const resolved = getComboTargetModelId(target); - return resolved ? getModelIsHidden(resolved.providerId, resolved.modelId) : false; - }) - ) { - continue; - } + const visibleTargets = comboTargets.filter((target) => { + const resolved = getComboTargetModelId(target); + return resolved ? !getModelIsHidden(resolved.providerId, resolved.modelId) : true; + }); + if (visibleTargets.length === 0) continue; - const comboMetadata = buildComboCatalogMetadata(combo, combos); + const comboMetadata = buildComboCatalogMetadata(combo, visibleTargets); listedIds.add(combo.name); models.push({ diff --git a/src/app/api/v1/models/catalogHelpers.ts b/src/app/api/v1/models/catalogHelpers.ts index 71acac3628..20ca9ffe55 100644 --- a/src/app/api/v1/models/catalogHelpers.ts +++ b/src/app/api/v1/models/catalogHelpers.ts @@ -27,6 +27,7 @@ export interface CustomModelEntry { export type ComboCatalogTarget = { modelStr?: string; provider?: string | null; + providerId?: string | null; }; export type ComboTargetCatalogMetadata = { diff --git a/src/app/api/v1/models/catalogProviderMaps.ts b/src/app/api/v1/models/catalogProviderMaps.ts index 6de91e6458..b70427d43e 100644 --- a/src/app/api/v1/models/catalogProviderMaps.ts +++ b/src/app/api/v1/models/catalogProviderMaps.ts @@ -1,6 +1,6 @@ import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; import { AI_PROVIDERS } from "@/shared/constants/providers"; -import { parseModel } from "@omniroute/open-sse/services/model"; +import { parseModel, resolveCanonicalProviderModel } from "@omniroute/open-sse/services/model"; // Alias <-> providerId resolution maps for the unified model catalog. Extracted // verbatim from ./catalog.ts. `FALLBACK_ALIAS_TO_PROVIDER` is also consumed directly by @@ -77,6 +77,7 @@ export type AliasMaps = ReturnType; export type ProviderPrefixedTarget = { modelStr?: string; provider?: string | null; + providerId?: string | null; }; /** @@ -142,20 +143,30 @@ export function getComboTargetModelId( maps: AliasMaps, target: ProviderPrefixedTarget ): { providerId: string; modelId: string } | null { - const rawProvider = typeof target.provider === "string" ? target.provider.trim() : ""; + const rawProvider = + typeof target.providerId === "string" + ? target.providerId.trim() + : typeof target.provider === "string" + ? target.provider.trim() + : ""; const modelStr = typeof target.modelStr === "string" ? target.modelStr.trim() : ""; if (!rawProvider || rawProvider === "unknown" || !modelStr) return null; const providerId = resolveCanonicalProviderId(maps.aliasToProviderId, rawProvider); if (!providerId || providerId === "unknown") return null; + let modelId = modelStr; for (const prefix of getProviderPrefixes(maps, providerId, rawProvider)) { const prefixWithSlash = `${prefix}/`; if (modelStr.startsWith(prefixWithSlash)) { - const modelId = modelStr.slice(prefixWithSlash.length).trim(); - return modelId ? { providerId, modelId } : null; + modelId = modelStr.slice(prefixWithSlash.length).trim(); + break; } } - return { providerId, modelId: modelStr }; + if (!modelId) return null; + const canonical = resolveCanonicalProviderModel(providerId, modelId); + return canonical.provider && canonical.model + ? { providerId: canonical.provider, modelId: canonical.model } + : null; } diff --git a/src/lib/combos/steps.ts b/src/lib/combos/steps.ts index 0b490e369a..155bcd77b4 100644 --- a/src/lib/combos/steps.ts +++ b/src/lib/combos/steps.ts @@ -16,6 +16,7 @@ export interface ComboModelStep { allowedConnectionIds?: string[] | null; weight: number; label?: string; + prompt?: string | null; tags?: string[]; } @@ -237,6 +238,7 @@ export function normalizeComboStep( const explicitId = toTrimmedString(value.id); const weight = toWeight(value.weight); const label = toTrimmedString(value.label); + const prompt = toTrimmedString(value.prompt); if (value.kind === "combo-ref") { const comboRefName = toTrimmedString(value.comboName); @@ -291,6 +293,7 @@ export function normalizeComboStep( ...(connectionId !== undefined ? { connectionId } : {}), weight, ...(label ? { label } : {}), + ...(prompt ? { prompt } : {}), ...(tags && tags.length > 0 ? { tags } : {}), ...(allowedConnectionIds && allowedConnectionIds.length > 0 ? { allowedConnectionIds } : {}), }; diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 58f6457b5a..0f6499d737 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -857,35 +857,45 @@ export function getModelIsHidden(providerId: string, modelId: string): boolean { */ export function getHiddenModelsByProvider(): Map> { const db = getDbInstance(); - const result = new Map>(); - - // Query all rows from key_value for both namespaces + const visibilityByProvider = new Map>(); const rows = db .prepare( - "SELECT key, value FROM key_value WHERE namespace IN ('modelCompatOverrides', 'customModels')" + "SELECT namespace, key, value FROM key_value WHERE namespace IN ('modelCompatOverrides', 'customModels')" ) - .all() as Array<{ key: string; value: string | null }>; + .all() as Array<{ namespace: string; key: string; value: string | null }>; - for (const row of rows) { - if (!row.value) continue; - try { - const parsed = JSON.parse(row.value); - if (!Array.isArray(parsed)) continue; - for (const entry of parsed) { - if (entry && typeof entry === "object" && entry.isHidden) { - const modelId = entry.id; - if (typeof modelId === "string" && modelId.length > 0) { - if (!result.has(row.key)) result.set(row.key, new Set()); - result.get(row.key)!.add(modelId); + for (const namespace of ["modelCompatOverrides", "customModels"]) { + for (const row of rows) { + if (row.namespace !== namespace || !row.value) continue; + try { + const parsed = JSON.parse(row.value); + if (!Array.isArray(parsed)) continue; + for (const entry of parsed) { + if (!entry || typeof entry !== "object") continue; + const modelId = (entry as { id?: unknown }).id; + if (typeof modelId !== "string" || modelId.length === 0) continue; + if (!Object.prototype.hasOwnProperty.call(entry, "isHidden")) continue; + let visibility = visibilityByProvider.get(row.key); + if (!visibility) { + visibility = new Map(); + visibilityByProvider.set(row.key, visibility); } + visibility.set(modelId, Boolean((entry as { isHidden?: unknown }).isHidden)); } + } catch { + // Skip malformed entries } - } catch { - // Skip malformed entries } } - return result; + return new Map( + [...visibilityByProvider].flatMap(([providerId, visibility]) => { + const hiddenModels = [...visibility].flatMap(([modelId, isHidden]) => + isHidden ? [modelId] : [] + ); + return hiddenModels.length > 0 ? [[providerId, new Set(hiddenModels)] as const] : []; + }) + ); } /** diff --git a/tests/unit/auto-combo-hidden-models-4558.test.ts b/tests/unit/auto-combo-hidden-models-4558.test.ts index 03fd3dcb0b..29439b2698 100644 --- a/tests/unit/auto-combo-hidden-models-4558.test.ts +++ b/tests/unit/auto-combo-hidden-models-4558.test.ts @@ -27,9 +27,13 @@ import fs from "node:fs"; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-hidden-4558-")); process.env.DATA_DIR = tmpDir; -const { mergeModelCompatOverride, getHiddenModelsByProvider, getModelIsHidden } = await import( - "../../src/lib/localDb.ts" -); +const { + addCustomModel, + getHiddenModelsByProvider, + getModelIsHidden, + mergeModelCompatOverride, + updateCustomModel, +} = await import("../../src/lib/localDb.ts"); const { resetDbInstance } = await import("../../src/lib/db/core.ts"); before(() => { @@ -79,3 +83,13 @@ test("un-hiding a model (isHidden: null) removes it from the map", () => { "un-hidden model must drop out of the hidden set" ); }); + +test("an explicit visible custom-model setting overrides a hidden compat fallback", async () => { + const modelId = "gpt-custom-visible-override"; + mergeModelCompatOverride(PROVIDER, modelId, { isHidden: true }); + await addCustomModel(PROVIDER, modelId); + await updateCustomModel(PROVIDER, modelId, { isHidden: false }); + + assert.equal(getModelIsHidden(PROVIDER, modelId), false); + assert.equal(getHiddenModelsByProvider().get(PROVIDER)?.has(modelId) ?? false, false); +}); diff --git a/tests/unit/combo-fusion-strategy.test.ts b/tests/unit/combo-fusion-strategy.test.ts index a6d1218641..0c7ec620ae 100644 --- a/tests/unit/combo-fusion-strategy.test.ts +++ b/tests/unit/combo-fusion-strategy.test.ts @@ -16,6 +16,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-fusion-test-secret"; const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); const noop = () => {}; const log = { info: noop, warn: noop, debug: noop, error: noop }; @@ -224,6 +225,91 @@ test("fusion: honors an explicit judgeModel even with a single surviving panel a assert.equal(res.status, 200); }); +test("fusion: preserves explicit providers when filtering structured panel targets", async () => { + modelsDb.mergeModelCompatOverride("nvidia", "openai/gpt-oss-120b", { isHidden: true }); + const seen: string[] = []; + + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "Q" }] }, + combo: { + name: "structured-fusion-combo", + strategy: "fusion", + models: [ + { model: "openai/gpt-oss-120b", providerId: "nvidia" }, + { model: "p/a" }, + { model: "p/b" }, + ], + config: {}, + }, + handleSingleModel: async (_body: Body, model: string) => { + seen.push(model); + return okResponse(`answer-${model}`); + }, + log, + settings: {}, + allCombos: [], + }); + + assert.equal(res.status, 200); + assert.deepEqual(seen, ["p/a", "p/b", "p/a"]); +}); + +test("fusion: dispatches visible structured panel targets with their provider identity", async () => { + const targets: Array<{ model: string; providerId?: string | null }> = []; + + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "Q" }] }, + combo: { + name: "structured-fusion-dispatch", + strategy: "fusion", + models: [ + { model: "vendor/model-a", providerId: "nvidia" }, + { model: "vendor/model-b", providerId: "nvidia" }, + ], + config: {}, + }, + handleSingleModel: async (_body: Body, model: string, target) => { + targets.push({ + model, + providerId: target && "providerId" in target ? target.providerId : undefined, + }); + return okResponse(`answer-${model}`); + }, + log, + settings: {}, + allCombos: [], + }); + + assert.equal(res.status, 200); + assert.deepEqual(targets.slice(0, 2), [ + { model: "vendor/model-a", providerId: "nvidia" }, + { model: "vendor/model-b", providerId: "nvidia" }, + ]); +}); + +test("fusion: never dispatches hidden panel members or a hidden explicit judge", async () => { + modelsDb.mergeModelCompatOverride("p", "hidden-panel", { isHidden: true }); + modelsDb.mergeModelCompatOverride("p", "hidden-judge", { isHidden: true }); + const seen: string[] = []; + + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "Q" }] }, + combo: fusionCombo(["p/hidden-panel", "p/a", "p/b"], { + judgeModel: "p/hidden-judge", + }), + handleSingleModel: async (_body: Body, model: string) => { + seen.push(model); + return okResponse(`answer-${model}`); + }, + log, + settings: {}, + allCombos: [], + }); + + assert.equal(res.status, 200); + assert.deepEqual(seen, ["p/a", "p/b", "p/a"]); +}); + test("fusion: returns 503 when the whole panel fails", async () => { const handleSingleModel = async () => errResponse(500); const res = await handleComboChat({ diff --git a/tests/unit/combo-hidden-leaf-routing.test.ts b/tests/unit/combo-hidden-leaf-routing.test.ts new file mode 100644 index 0000000000..3c340c5b20 --- /dev/null +++ b/tests/unit/combo-hidden-leaf-routing.test.ts @@ -0,0 +1,328 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-hidden-combo-routing-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { handleComboChat, resolveShadowTargets } = await import("../../open-sse/services/combo.ts"); +const core = await import("../../src/lib/db/core.ts"); +const contextHandoffsDb = await import("../../src/lib/db/contextHandoffs.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); + +const noop = (..._args: unknown[]) => {}; +const log = { info: noop, warn: noop, error: noop, debug: noop }; + +function okResponse(): Response { + return Response.json({ choices: [{ message: { content: "ok" } }] }); +} + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("handleComboChat never routes hidden leaves in priority, weighted, or round-robin combos", async () => { + modelsDb.mergeModelCompatOverride("openai", "hidden-combo-leaf", { isHidden: true }); + + for (const strategy of ["priority", "weighted", "round-robin"] as const) { + const calls: string[] = []; + const result = await handleComboChat({ + body: {}, + combo: { + name: `hidden-${strategy}`, + strategy, + models: [ + { model: "openai/hidden-combo-leaf", weight: 100 }, + { model: "openai/visible-combo-leaf", weight: 1 }, + ], + config: { maxRetries: 0, concurrencyPerModel: 1, queueTimeoutMs: 1000 }, + }, + handleSingleModel: async (_body: unknown, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log, + settings: null, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/visible-combo-leaf"]); + } +}); + +test("handleComboChat preserves explicit providers for custom slashful model ids", async () => { + modelsDb.mergeModelCompatOverride("nvidia", "vendor/model-x", { isHidden: true }); + const calls: string[] = []; + + const result = await handleComboChat({ + body: {}, + combo: { + name: "structured-custom-slashful", + strategy: "priority", + models: [ + { model: "vendor/model-x", providerId: "nvidia" }, + { model: "openai/visible-sibling", providerId: "openai" }, + ], + config: { maxRetries: 0 }, + }, + handleSingleModel: async (_body: unknown, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log, + settings: null, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/visible-sibling"]); +}); + +test("handleComboChat filters hidden leaves through nested flatten and execute modes", async () => { + modelsDb.mergeModelCompatOverride("openai", "hidden-nested-leaf", { isHidden: true }); + const child = { + name: "hidden-leaf-child", + strategy: "priority", + models: ["openai/hidden-nested-leaf", "openai/visible-nested-leaf"], + }; + + for (const nestedComboMode of ["flatten", "execute"] as const) { + const calls: string[] = []; + const parent = { + name: `hidden-leaf-parent-${nestedComboMode}`, + strategy: "priority", + models: [{ kind: "combo-ref", comboName: child.name }], + config: { nestedComboMode, maxRetries: 0 }, + }; + const result = await handleComboChat({ + body: {}, + combo: parent, + handleSingleModel: async (_body: unknown, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log, + settings: null, + relayOptions: null, + allCombos: [parent, child], + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/visible-nested-leaf"]); + } +}); + +test("handleComboChat auto pipeline never dispatches hidden combo leaves", async () => { + modelsDb.mergeModelCompatOverride("openai", "o3", { isHidden: true }); + const calls: string[] = []; + const result = await handleComboChat({ + body: { + messages: [ + { + role: "user", + content: + "Analyze this complex distributed systems failure and produce a rigorous implementation plan with explicit tradeoffs, invariants, edge cases, and verification steps for every stage.", + }, + ], + }, + combo: { + name: "auto/smart", + strategy: "auto", + models: ["openai/o3", "openai/gpt-4o-mini"], + config: { + pipeline_enabled: true, + skip_pipeline_for_tokens_under: 0, + max_reflection_loops: 0, + }, + }, + handleSingleModel: async (_body: unknown, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log, + settings: null, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.ok(calls.length > 0); + assert.deepEqual(new Set(calls), new Set(["openai/gpt-4o-mini"])); +}); + +test("handleComboChat drops a context-cache pin when the pinned model becomes hidden", async () => { + const sessionId = "hidden-pin-session"; + const comboName = "hidden-pin-combo"; + modelsDb.mergeModelCompatOverride("openai", "hidden-pinned-leaf", { isHidden: true }); + contextHandoffsDb.recordSessionModelUsage( + sessionId, + comboName, + "openai/hidden-pinned-leaf", + "openai" + ); + const calls: string[] = []; + + const result = await handleComboChat({ + body: { messages: [{ role: "user", content: "continue" }] }, + combo: { + name: comboName, + strategy: "priority", + models: ["openai/hidden-pinned-leaf", "openai/visible-pinned-leaf"], + context_cache_protection: true, + }, + handleSingleModel: async (_body: unknown, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log, + settings: null, + relayOptions: { sessionId }, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/visible-pinned-leaf"]); +}); + +test("resolveShadowTargets excludes hidden leaves before applying the target limit", () => { + modelsDb.mergeModelCompatOverride("openai", "hidden-shadow-leaf", { isHidden: true }); + const targets = resolveShadowTargets( + { name: "hidden-shadow-combo", models: [] }, + { + shadowRouting: { + enabled: true, + targets: ["openai/hidden-shadow-leaf", "openai/visible-shadow-leaf"], + sampleRate: 1, + maxTargets: 1, + }, + }, + null + ); + + assert.deepEqual( + targets.map((target) => target.modelStr), + ["openai/visible-shadow-leaf"] + ); +}); + +test("handleComboChat canonicalizes provider and model aliases before filtering hidden leaves", async () => { + modelsDb.mergeModelCompatOverride("github", "claude-opus-4-5-20251101", { isHidden: true }); + const calls: string[] = []; + + const result = await handleComboChat({ + body: {}, + combo: { + name: "hidden-alias-leaf", + strategy: "priority", + models: ["gh/claude-4.5-opus", "openai/visible-alias-sibling"], + config: { maxRetries: 0 }, + }, + handleSingleModel: async (_body: unknown, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log, + settings: null, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/visible-alias-sibling"]); +}); + +test("handleComboChat preserves an explicit provider for slashful model ids", async () => { + modelsDb.mergeModelCompatOverride("nvidia", "openai/gpt-oss-120b", { isHidden: true }); + const calls: string[] = []; + + const result = await handleComboChat({ + body: {}, + combo: { + name: "hidden-structured-leaf", + strategy: "priority", + models: [ + { model: "openai/gpt-oss-120b", providerId: "nvidia" }, + { model: "openai/visible-structured-sibling", providerId: "openai" }, + ], + config: { maxRetries: 0 }, + }, + handleSingleModel: async (_body: unknown, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log, + settings: null, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/visible-structured-sibling"]); +}); + +test("handleComboChat reuses one hidden-model snapshot through nested execute mode", async () => { + modelsDb.mergeModelCompatOverride("openai", "hidden-snapshot-leaf", { isHidden: true }); + const child = { + name: "hidden-snapshot-child", + strategy: "priority", + models: ["openai/hidden-snapshot-leaf", "openai/visible-snapshot-leaf"], + config: { nestedComboMode: "execute", maxRetries: 0 }, + }; + const parent = { + name: "hidden-snapshot-parent", + strategy: "priority", + models: [{ kind: "combo-ref", comboName: child.name }], + config: { nestedComboMode: "execute", maxRetries: 0 }, + }; + const db = core.getDbInstance(); + const originalPrepare = db.prepare.bind(db); + let hiddenSnapshotReads = 0; + db.prepare = (sql: string) => { + if (sql.includes("namespace IN ('modelCompatOverrides', 'customModels')")) { + hiddenSnapshotReads += 1; + } + return originalPrepare(sql); + }; + + try { + const calls: string[] = []; + const result = await handleComboChat({ + body: {}, + combo: parent, + handleSingleModel: async (_body: unknown, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log, + settings: null, + relayOptions: null, + allCombos: [parent, child], + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/visible-snapshot-leaf"]); + assert.equal(hiddenSnapshotReads, 1); + } finally { + db.prepare = originalPrepare; + } +}); diff --git a/tests/unit/combo-pipeline-strategy.test.ts b/tests/unit/combo-pipeline-strategy.test.ts index 594479f6c1..3c34a2e8de 100644 --- a/tests/unit/combo-pipeline-strategy.test.ts +++ b/tests/unit/combo-pipeline-strategy.test.ts @@ -16,6 +16,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-pipeline-test-secret"; const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); const noop = () => {}; const log = { info: noop, warn: noop, debug: noop, error: noop }; @@ -103,6 +104,55 @@ test("pipeline: 2 steps — step 1 gets the original input, step 2 gets step 1's assert.equal(json.choices[0].message.content, "FINAL_B"); }); +test("pipeline: skips hidden steps without dispatching them", async () => { + modelsDb.mergeModelCompatOverride("p", "hidden-step", { isHidden: true }); + const seen: string[] = []; + + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + combo: pipelineCombo([{ model: "p/a" }, { model: "p/hidden-step" }, { model: "p/b" }]), + handleSingleModel: async (_body: Body, model: string) => { + seen.push(model); + return okResponse(`answer-${model}`); + }, + log, + settings: {}, + allCombos: [], + }); + + assert.equal(res.status, 200); + assert.deepEqual(seen, ["p/a", "p/b"]); +}); + +test("pipeline: preserves explicit providers when filtering structured steps", async () => { + modelsDb.mergeModelCompatOverride("nvidia", "openai/gpt-oss-120b", { isHidden: true }); + const seen: string[] = []; + + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + combo: { + name: "structured-pipeline-combo", + strategy: "pipeline", + models: [ + { model: "p/a" }, + { model: "openai/gpt-oss-120b", providerId: "nvidia" }, + { model: "p/b" }, + ], + config: {}, + }, + handleSingleModel: async (_body: Body, model: string) => { + seen.push(model); + return okResponse(`answer-${model}`); + }, + log, + settings: {}, + allCombos: [], + }); + + assert.equal(res.status, 200); + assert.deepEqual(seen, ["p/a", "p/b"]); +}); + test("pipeline: 3-step chain threads output → input correctly", async () => { const seen: string[] = []; const seenBodies: Body[] = []; @@ -203,3 +253,29 @@ test("pipeline: a single-step pipeline runs the one model directly and streams t assert.equal(seenBodies[0].stream, true); assert.equal(res.status, 200); }); + +test("pipeline: dispatches visible structured steps with their provider identity", async () => { + const targets: Array<{ model: string; providerId?: string | null }> = []; + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + combo: { + name: "structured-pipeline-dispatch", + strategy: "pipeline", + models: [{ model: "vendor/model", providerId: "nvidia" }], + config: {}, + }, + handleSingleModel: async (_body: Body, model: string, target) => { + targets.push({ + model, + providerId: target && "providerId" in target ? target.providerId : undefined, + }); + return okResponse("done"); + }, + log, + settings: {}, + allCombos: [], + }); + + assert.equal(res.status, 200); + assert.deepEqual(targets, [{ model: "vendor/model", providerId: "nvidia" }]); +}); diff --git a/tests/unit/embeddings-combo-family-reject.test.ts b/tests/unit/embeddings-combo-family-reject.test.ts index f277cfdb49..e2c0b5a0ab 100644 --- a/tests/unit/embeddings-combo-family-reject.test.ts +++ b/tests/unit/embeddings-combo-family-reject.test.ts @@ -12,6 +12,7 @@ process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-embed-combo-reject- const { createCombo } = await import("../../src/lib/db/combos.ts"); const { resetDbInstance } = await import("../../src/lib/db/core.ts"); const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts"); +const { mergeModelCompatOverride } = await import("../../src/lib/db/models.ts"); test.after(() => { // Release the SQLite handle so the native test runner can exit (CLAUDE.md #3). @@ -76,3 +77,21 @@ test("createEmbeddingResponse allows a uniform-dimension embedding combo to proc "uniform combo must not trip the dimension guard" ); }); + +test("createEmbeddingResponse excludes hidden leaves before embedding family validation and dispatch", async () => { + mergeModelCompatOverride("nebius", "Qwen/Qwen3-Embedding-8B", { isHidden: true }); + await createCombo({ + name: "partial-hidden-embeds-combo", + strategy: "priority", + models: ["nebius/Qwen/Qwen3-Embedding-8B", "openai/text-embedding-3-small"], + }); + + const res = await createEmbeddingResponse({ + model: "partial-hidden-embeds-combo", + input: "hello world", + }); + const body = JSON.stringify(await res.json()); + + assert.doesNotMatch(body, /incompatible vector dimensions/); + assert.doesNotMatch(body, /Qwen3-Embedding-8B/); +}); diff --git a/tests/unit/models-catalog-hidden-combo-leaves.test.ts b/tests/unit/models-catalog-hidden-combo-leaves.test.ts new file mode 100644 index 0000000000..670ef653b4 --- /dev/null +++ b/tests/unit/models-catalog-hidden-combo-leaves.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-hidden-combo-catalog-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET ||= "hidden-combo-catalog-test-secret"; + +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +type CatalogModel = { + id: string; + context_length?: number; + max_output_tokens?: number; +}; + +function capability(limitContext: number, limitOutput: number) { + return { + tool_call: null, + reasoning: null, + attachment: null, + structured_output: null, + temperature: null, + modalities_input: JSON.stringify([]), + modalities_output: JSON.stringify([]), + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: null, + limit_context: limitContext, + limit_input: null, + limit_output: limitOutput, + interleaved_field: null, + }; +} + +async function getCatalogData(): Promise { + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body: { data: CatalogModel[] } = await response.json(); + assert.equal(response.status, 200); + return body.data; +} + +test.beforeEach(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +}); + +test.after(() => { + modelsDevSync.saveModelsDevCapabilities({}); + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("v1 models catalog keeps partially hidden combos and derives metadata from visible targets", async () => { + modelsDevSync.saveModelsDevCapabilities({ + openai: { + "catalog-hidden-leaf": capability(1000, 100), + "catalog-visible-leaf": capability(8000, 800), + }, + }); + modelsDb.mergeModelCompatOverride("openai", "catalog-hidden-leaf", { isHidden: true }); + await combosDb.createCombo({ + name: "partially-hidden-router", + strategy: "priority", + models: ["openai/catalog-hidden-leaf", "openai/catalog-visible-leaf"], + }); + + const combo = (await getCatalogData()).find((item) => item.id === "partially-hidden-router"); + + assert.ok(combo); + assert.equal(combo.context_length, 8000); + assert.equal(combo.max_output_tokens, 800); +}); + +test("v1 models catalog omits combos when every resolved target is hidden", async () => { + modelsDb.mergeModelCompatOverride("openai", "catalog-hidden-alpha", { isHidden: true }); + modelsDb.mergeModelCompatOverride("openai", "catalog-hidden-beta", { isHidden: true }); + await combosDb.createCombo({ + name: "fully-hidden-router", + strategy: "priority", + models: ["openai/catalog-hidden-alpha", "openai/catalog-hidden-beta"], + }); + + const data = await getCatalogData(); + + assert.equal( + data.some((item) => item.id === "fully-hidden-router"), + false + ); +}); + +test("v1 models catalog applies hidden leaf filtering through nested combos", async () => { + modelsDevSync.saveModelsDevCapabilities({ + openai: { + "nested-hidden-leaf": capability(2000, 200), + "nested-visible-leaf": capability(16000, 1600), + "nested-hidden-only": capability(4000, 400), + }, + }); + modelsDb.mergeModelCompatOverride("openai", "nested-hidden-leaf", { isHidden: true }); + modelsDb.mergeModelCompatOverride("openai", "nested-hidden-only", { isHidden: true }); + await combosDb.createCombo({ + name: "partially-hidden-child", + strategy: "priority", + models: ["openai/nested-hidden-leaf", "openai/nested-visible-leaf"], + }); + await combosDb.createCombo({ + name: "partially-hidden-parent", + strategy: "priority", + models: ["partially-hidden-child"], + }); + await combosDb.createCombo({ + name: "fully-hidden-child", + strategy: "priority", + models: ["openai/nested-hidden-only"], + }); + await combosDb.createCombo({ + name: "fully-hidden-parent", + strategy: "priority", + models: ["fully-hidden-child"], + }); + + const data = await getCatalogData(); + const parent = data.find((item) => item.id === "partially-hidden-parent"); + + assert.ok(parent); + assert.equal(parent.context_length, 16000); + assert.equal(parent.max_output_tokens, 1600); + assert.equal( + data.some((item) => item.id === "fully-hidden-child"), + false + ); + assert.equal( + data.some((item) => item.id === "fully-hidden-parent"), + false + ); +}); + +test("v1 models catalog canonicalizes aliases before deciding that a combo is fully hidden", async () => { + modelsDb.mergeModelCompatOverride("github", "claude-opus-4-5-20251101", { isHidden: true }); + await combosDb.createCombo({ + name: "hidden-alias-router", + strategy: "priority", + models: ["gh/claude-4.5-opus"], + }); + + const data = await getCatalogData(); + + assert.equal( + data.some((item) => item.id === "hidden-alias-router"), + false + ); +}); + +test("v1 models catalog preserves explicit providers for slashful model ids", async () => { + modelsDb.mergeModelCompatOverride("nvidia", "openai/gpt-oss-120b", { isHidden: true }); + await combosDb.createCombo({ + name: "hidden-slashful-router", + strategy: "priority", + models: [{ model: "openai/gpt-oss-120b", providerId: "nvidia" }], + }); + + const data = await getCatalogData(); + + assert.equal( + data.some((item) => item.id === "hidden-slashful-router"), + false + ); +}); + +test("v1 models catalog omits combos with no resolved targets", async () => { + await combosDb.createCombo({ + name: "empty-router", + strategy: "priority", + models: [], + }); + + const data = await getCatalogData(); + + assert.equal( + data.some((item) => item.id === "empty-router"), + false + ); +});