Files
2026-07-13 13:39:12 +08:00

175 lines
6.5 KiB
TypeScript

/**
* 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<T extends { weight?: number }>(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<T extends { executionKey: string; weight: number }>(
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<string>} models - Model strings in "provider/model" format
* @returns {Promise<Array<string>>} Sorted model strings
*/
export async function sortModelsByCost(models: string[]): Promise<string[]> {
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<string, ResolvedComboTarget[]>();
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<string>} models - Model strings
* @param {string} comboName - Combo name for metrics lookup
* @returns {Array<string>} 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<string, ResolvedComboTarget[]>();
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<typeof getComboMetrics>
): 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)];
}