mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix(combo): exclude hidden leaves from catalog and dispatch (#8878)
Validated in local merge-train T5 (base49+contributors+pacocartones)
This commit is contained in:
@@ -54,6 +54,7 @@ const INTENT_TO_TASK: Record<IntentType, TaskType> = {
|
||||
export interface PipelineComboParams {
|
||||
body: Record<string, unknown>;
|
||||
combo: Record<string, unknown>;
|
||||
availableModels?: readonly string[];
|
||||
handleChatCore: (body: Record<string, unknown>, modelStr?: string) => Promise<Response>;
|
||||
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<string, unknown>,
|
||||
handleChatCore: (body: Record<string, unknown>, modelStr?: string) => Promise<Response>,
|
||||
log: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void },
|
||||
availableModels: string[],
|
||||
availableModels: readonly string[],
|
||||
taskType: string
|
||||
): (args: StageExecutorArgs & { fitnessTier?: FitnessTier }) => Promise<StageExecutorResult> {
|
||||
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);
|
||||
|
||||
@@ -570,6 +570,7 @@ export async function handleComboChat({
|
||||
signal,
|
||||
apiKeyAllowedConnections = null,
|
||||
nesting = null,
|
||||
hiddenModelsByProvider = getHiddenModelsByProvider(),
|
||||
}: HandleComboChatOptions): Promise<Response> {
|
||||
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<Response> {
|
||||
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",
|
||||
|
||||
@@ -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<string>(), 0, [], maxDepth)
|
||||
: getDirectComboTargets(combo);
|
||||
return filterVisibleComboTargets(
|
||||
allCombos
|
||||
? resolveNestedComboTargets(combo, allCombos, new Set<string>(), 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<string>(), 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);
|
||||
}
|
||||
|
||||
23
open-sse/services/combo/comboVisibility.ts
Normal file
23
open-sse/services/combo/comboVisibility.ts
Normal file
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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<Response | null> {
|
||||
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<Response | null> {
|
||||
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<Response | null> {
|
||||
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<string, unknown>;
|
||||
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<Response | null> {
|
||||
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([
|
||||
|
||||
@@ -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<string, unknown>): ShadowRo
|
||||
export function resolveShadowTargets(
|
||||
combo: ComboLike,
|
||||
config: Record<string, unknown>,
|
||||
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,
|
||||
|
||||
@@ -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<string> }> {
|
||||
const weightedEligibleKeys = new Set<string>();
|
||||
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<Response | null> {
|
||||
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);
|
||||
|
||||
@@ -95,6 +95,8 @@ export type ComboNestingContext = {
|
||||
attemptBudget: { count: number; limit: number };
|
||||
};
|
||||
|
||||
export type HiddenModelsByProvider = ReadonlyMap<string, ReadonlySet<string>>;
|
||||
|
||||
export type HandleComboChatOptions = {
|
||||
body: Record<string, unknown>;
|
||||
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";
|
||||
/**
|
||||
|
||||
@@ -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<Response>,
|
||||
ms: number
|
||||
): Promise<Response | Sentinel> {
|
||||
function withTimeout(promise: Promise<Response>, ms: number): Promise<Response | Sentinel> {
|
||||
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<Response> {
|
||||
return typeof model === "string"
|
||||
? handleSingleModel(body, model)
|
||||
: handleSingleModel(body, model.modelStr, model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a fusion combo: fan the prompt out to every panel model in parallel,
|
||||
* then a judge model synthesizes one final answer from all panel responses.
|
||||
@@ -260,6 +271,7 @@ export async function handleFusionChat({
|
||||
log,
|
||||
comboName,
|
||||
judgeModel,
|
||||
judgeTarget,
|
||||
tuning,
|
||||
}: HandleFusionChatOptions): Promise<Response> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<Response> {
|
||||
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)`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -489,14 +489,13 @@ async function buildUnifiedModelsResponseCore(
|
||||
|
||||
const buildComboCatalogMetadata = (
|
||||
combo: Parameters<typeof resolveNestedComboTargets>[0],
|
||||
allCombos: Parameters<typeof resolveNestedComboTargets>[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<typeof resolveNestedComboTargets>[0],
|
||||
combos as Parameters<typeof resolveNestedComboTargets>[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({
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface CustomModelEntry {
|
||||
export type ComboCatalogTarget = {
|
||||
modelStr?: string;
|
||||
provider?: string | null;
|
||||
providerId?: string | null;
|
||||
};
|
||||
|
||||
export type ComboTargetCatalogMetadata = {
|
||||
|
||||
@@ -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<typeof buildAliasMaps>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -857,35 +857,45 @@ export function getModelIsHidden(providerId: string, modelId: string): boolean {
|
||||
*/
|
||||
export function getHiddenModelsByProvider(): Map<string, Set<string>> {
|
||||
const db = getDbInstance();
|
||||
const result = new Map<string, Set<string>>();
|
||||
|
||||
// Query all rows from key_value for both namespaces
|
||||
const visibilityByProvider = new Map<string, Map<string, boolean>>();
|
||||
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<string, boolean>();
|
||||
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] : [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
328
tests/unit/combo-hidden-leaf-routing.test.ts
Normal file
328
tests/unit/combo-hidden-leaf-routing.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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" }]);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
200
tests/unit/models-catalog-hidden-combo-leaves.test.ts
Normal file
200
tests/unit/models-catalog-hidden-combo-leaves.test.ts
Normal file
@@ -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<CatalogModel[]> {
|
||||
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
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user