/** * Combo target-ordering / sorting helpers extracted from combo.ts. * * Weighted selection, cost / usage / power-of-two-choices ordering, and the * model-entry normalizer moved out of the combo.ts god-file (Quality Gate v2 / * Fase 9). Logic unchanged; the helpers used by the combo handlers (which stay * in combo.ts) are imported back from this module. No barrel import — pure leaf. */ import { getCircuitBreaker } from "../../../src/shared/utils/circuitBreaker"; import { secureRandomFloat, secureRandomInt } from "../../../src/shared/utils/secureRandom"; import { getComboStepTarget, getComboStepWeight } from "../../../src/lib/combos/steps.ts"; import { getComboMetrics } from "../comboMetrics.ts"; import { parseModel } from "../model.ts"; import type { ResolvedComboTarget } from "./types.ts"; /** * Normalize a model entry to { model, weight } * Supports both legacy string format and new object format */ export function normalizeModelEntry(entry: unknown): { model: string; weight: number } { return { model: getComboStepTarget(entry) || "", weight: getComboStepWeight(entry), }; } export function selectWeightedTarget(targets: T[]) { if (targets.length === 0) return null; const totalWeight = targets.reduce((sum, target) => sum + (target.weight || 0), 0); if (totalWeight <= 0) { return targets[secureRandomInt(targets.length)]; } let random = secureRandomFloat() * totalWeight; for (const target of targets) { random -= target.weight || 0; if (random <= 0) return target; } return targets.at(-1); } export function orderTargetsForWeightedFallback( targets: T[], selectedExecutionKey: string, preserveExistingOrder = false ): T[] { const selected = targets.find((target) => target.executionKey === selectedExecutionKey); const rest = targets.filter((target) => target.executionKey !== selectedExecutionKey); if (!preserveExistingOrder) { rest.sort((a, b) => b.weight - a.weight); } return selected ? [selected, ...rest] : rest; } // shuffleArray and getNextModelFromDeck moved to src/shared/utils/shuffleDeck.ts // combo.ts now uses the shared, mutex-protected getNextFromDeck with "combo:" namespace. /** * Sort models by pricing (cheapest first) for cost-optimized strategy * @param {Array} models - Model strings in "provider/model" format * @returns {Promise>} Sorted model strings */ export async function sortModelsByCost(models: string[]): Promise { try { const { getPricingForModel } = await import("../../../src/lib/localDb"); const withCost = await Promise.all( models.map(async (modelStr) => { const parsed = parseModel(modelStr); const provider = parsed.provider || parsed.providerAlias || "unknown"; const model = parsed.model || modelStr; try { const pricing = await getPricingForModel(provider, model); const cost = Number(pricing?.input); return { modelStr, cost: Number.isFinite(cost) ? cost : Infinity }; } catch { return { modelStr, cost: Infinity }; } }) ); withCost.sort((a, b) => a.cost - b.cost); return withCost.map((e) => e.modelStr); } catch { // If pricing lookup fails entirely, return original order return models; } } export async function sortTargetsByCost(targets: ResolvedComboTarget[]) { const orderedModels = await sortModelsByCost(targets.map((target) => target.modelStr)); const byModel = new Map(); for (const target of targets) { const queue = byModel.get(target.modelStr) || []; queue.push(target); byModel.set(target.modelStr, queue); } return orderedModels .map((modelStr) => { const queue = byModel.get(modelStr); return queue?.shift() || null; }) .filter((target): target is ResolvedComboTarget => target !== null); } /** * Sort models by usage count (least-used first) for least-used strategy * @param {Array} models - Model strings * @param {string} comboName - Combo name for metrics lookup * @returns {Array} Sorted model strings */ export function sortModelsByUsage(models: string[], comboName: string): string[] { const metrics = getComboMetrics(comboName); if (!metrics?.byModel) return models; const withUsage = models.map((modelStr) => ({ modelStr, requests: metrics.byModel[modelStr]?.requests ?? 0, })); withUsage.sort((a, b) => a.requests - b.requests); return withUsage.map((e) => e.modelStr); } export function sortTargetsByUsage(targets: ResolvedComboTarget[], comboName: string) { const orderedModels = sortModelsByUsage( targets.map((target) => target.modelStr), comboName ); const byModel = new Map(); for (const target of targets) { const queue = byModel.get(target.modelStr) || []; queue.push(target); byModel.set(target.modelStr, queue); } return orderedModels .map((modelStr) => { const queue = byModel.get(modelStr); return queue?.shift() || null; }) .filter((target): target is ResolvedComboTarget => target !== null); } function getP2CTargetScore( target: ResolvedComboTarget, metrics: ReturnType ): number { const breakerState = getCircuitBreaker(target.provider)?.getStatus?.()?.state; if (breakerState === "OPEN") return -Infinity; const modelMetric = metrics?.byModel?.[target.modelStr] || null; const successRate = Number(modelMetric?.successRate); const avgLatency = Number(modelMetric?.avgLatencyMs); const successScore = Number.isFinite(successRate) ? successRate / 100 : 0.5; const latencyScore = Number.isFinite(avgLatency) && avgLatency > 0 ? 1 / Math.log10(avgLatency + 10) : 0.25; const breakerPenalty = breakerState === "HALF_OPEN" ? 0.25 : 0; return successScore + latencyScore - breakerPenalty; } export function orderTargetsByPowerOfTwoChoices(targets: ResolvedComboTarget[], comboName: string) { if (targets.length <= 1) return targets; const metrics = getComboMetrics(comboName); const firstIndex = secureRandomInt(targets.length); let secondIndex = secureRandomInt(targets.length - 1); if (secondIndex >= firstIndex) secondIndex++; const first = targets[firstIndex]; const second = targets[secondIndex]; const selectedIndex = getP2CTargetScore(second, metrics) > getP2CTargetScore(first, metrics) ? secondIndex : firstIndex; return [targets[selectedIndex], ...targets.filter((_, index) => index !== selectedIndex)]; }