Files
OmniRoute/open-sse/services/autoCombo/scoring.ts
ikelvingo 40b1d94cbd fix(compression): restore auto-combo scoring/routing features dropped by stale rebase
PR #8438 (session-dedup OOM-guard rewrite) was built against a stale local
copy of scoring.ts/autoStrategy.ts predating #8008 and #7301: merging it
as-is silently deleted the cacheAffinity scoring factor + normalizeScoringWeights
(#8008), the cached getCachedProviderConnections read path, and the
#COMBO-REF candidate-pool guard (#7301), breaking the TypeScript build.
Restore all three while keeping the PR's own legitimate additions
(computePoolMaxima/PoolMaxima pool-maxima memoization).

Also fix a real perf regression the PR introduced in session-dedup's
single-message intra-dedup path: dedupeWithinMessage dynamically built and
ran a global RegExp per candidate suffix block to count/replace occurrences,
which is pathologically slow on large single-message inputs (the exact
"huge tool result" shape the #7849 O(n^2) guard targets). Replace it with
indexOf-based counting/replacement (same semantics, no regex compile/match
overhead).

Update the #7849 regression suite's assertions to match the new
MAX_SUFFIX_STARTS/MAX_TOTAL_BLOCK_BYTES bound (which truncates suffix-block
enumeration silently/best-effort instead of failing the whole request open
with the old "suffix work budget exceeded" warning) instead of asserting on
the removed shared-budget mechanism.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-07-25 12:54:27 -03:00

304 lines
10 KiB
TypeScript

/**
* Auto-Combo Scoring Function
*
* Calculates a weighted score for each provider candidate.
*/
import type { RoutingHint } from "../manifestAdapter";
import { clamp01 } from "../../utils/number";
import { classifyTier } from "../tierResolver";
export interface ScoringFactors {
quota: number;
health: number;
costInv: number;
latencyInv: number;
taskFit: number;
stability: number;
tierPriority: number;
tierAffinity: number;
specificityMatch: number;
contextAffinity: number;
cacheAffinity?: number;
resetWindowAffinity: number;
connectionDensity: number;
}
export interface ScoringWeights {
quota: number;
health: number;
costInv: number;
latencyInv: number;
taskFit: number;
stability: number;
tierPriority: number;
tierAffinity: number;
specificityMatch: number;
contextAffinity: number;
cacheAffinity?: number;
resetWindowAffinity: number;
connectionDensity: number;
}
export const DEFAULT_WEIGHTS: ScoringWeights = {
quota: 0.15,
health: 0.2,
costInv: 0.15,
latencyInv: 0.12,
taskFit: 0.08,
stability: 0.05,
tierPriority: 0.05,
tierAffinity: 0.05,
specificityMatch: 0.05,
contextAffinity: 0.05,
cacheAffinity: 0,
resetWindowAffinity: 0,
connectionDensity: 0.05,
};
/** Normalize independently configured UI weights into a scoring distribution. */
export function normalizeScoringWeights(
weights: Partial<ScoringWeights> | null | undefined
): ScoringWeights {
if (!weights) return { ...DEFAULT_WEIGHTS };
const entries = Object.keys(DEFAULT_WEIGHTS) as Array<keyof ScoringWeights>;
const sanitized = Object.fromEntries(
entries.map((key) => {
const value = Number(weights?.[key]);
return [key, Number.isFinite(value) && value >= 0 ? value : 0];
})
) as unknown as ScoringWeights;
const total = entries.reduce((sum, key) => sum + Number(sanitized[key] ?? 0), 0);
if (total <= 0) return { ...DEFAULT_WEIGHTS };
return Object.fromEntries(
entries.map((key) => [key, Number(sanitized[key] ?? 0) / total])
) as unknown as ScoringWeights;
}
export interface ProviderCandidate {
provider: string;
model: string;
quotaRemaining: number; // percentage 0..100
quotaTotal: number;
circuitBreakerState: "CLOSED" | "HALF_OPEN" | "OPEN";
costPer1MTokens: number;
p95LatencyMs: number;
/** Average time-to-first-token in ms, when stream telemetry is available. */
avgTtftMs?: number;
/** Average end-to-end request latency in ms, when usage telemetry is available. */
avgE2ELatencyMs?: number;
/** Average generation throughput in output tokens/sec, when token telemetry is available. */
avgTokensPerSecond?: number;
latencyStdDev: number;
errorRate: number;
/** Optional provider/model observed failure rate. Falls back to errorRate. */
failureRate?: number;
/** T10: Optional account tier for priority boosting (Ultra > Pro > Free) */
accountTier?: "ultra" | "pro" | "standard" | "free";
/** T10: Optional quota reset interval in seconds (shorter = higher priority when same quota) */
quotaResetIntervalSecs?: number;
/** Score [0..1] for staying on the current session's provider/account/model path. */
contextAffinity?: number;
/** Score [0..1] for the account selected by the stable prompt-cache key. */
cacheAffinity?: number;
/** Score [0..1] for quota reset-window preference; sooner selected reset windows score higher. */
resetWindowAffinity?: number;
connectionPoolSize?: number;
connectionId?: string;
}
export interface ScoredProvider {
provider: string;
model: string;
score: number;
factors: ScoringFactors;
connectionId?: string;
}
/**
* Calculate weighted score from factors.
* Supports tierAffinity + specificityMatch weights when manifest routing is enabled.
*/
export function calculateScore(factors: ScoringFactors, weights: ScoringWeights): number {
// clamp01 bounds the result to [0,1] and maps a non-finite sum (a NaN factor)
// to 0, so a single bad input can't yield NaN (which sorts nondeterministically)
// or a score >1 from float drift in weights that nominally sum to 1.
return clamp01(
weights.quota * factors.quota +
weights.health * factors.health +
weights.costInv * factors.costInv +
weights.latencyInv * factors.latencyInv +
weights.taskFit * factors.taskFit +
weights.stability * factors.stability +
weights.tierPriority * factors.tierPriority +
(weights.tierAffinity ?? 0) * factors.tierAffinity +
(weights.specificityMatch ?? 0) * factors.specificityMatch +
(weights.contextAffinity ?? 0) * factors.contextAffinity +
(weights.cacheAffinity ?? 0) * (factors.cacheAffinity ?? 0) +
(weights.resetWindowAffinity ?? 0) * factors.resetWindowAffinity +
(weights.connectionDensity ?? 0) * factors.connectionDensity
);
}
/**
* T10: Convert account tier string to a normalized score [0..1].
*/
export function calculateTierScore(
tier: string | undefined,
quotaResetIntervalSecs: number | undefined
): number {
const BASE_TIER_SCORES: Record<string, number> = {
ultra: 1.0,
pro: 0.67,
standard: 0.33,
free: 0.0,
};
const baseScore = BASE_TIER_SCORES[tier?.toLowerCase() ?? ""] ?? 0.33;
const resetBonus =
quotaResetIntervalSecs != null && quotaResetIntervalSecs > 0
? Math.max(0, 1 - quotaResetIntervalSecs / 2_592_000)
: 0;
return Math.min(1, baseScore * 0.8 + resetBonus * 0.2);
}
function calculateTierAffinity(
candidate: ProviderCandidate,
hint: RoutingHint | undefined | null
): number {
if (!hint) return 0.5;
try {
const assignment = classifyTier(candidate.provider, candidate.model);
const tierOrder = ["free", "cheap", "premium"];
const providerTierIdx = tierOrder.indexOf(assignment.tier);
const minTierIdx = tierOrder.indexOf(hint.recommendedMinTier);
if (providerTierIdx === minTierIdx) return 1.0;
if (Math.abs(providerTierIdx - minTierIdx) === 1) return 0.7;
return 0.3;
} catch {
return 0.5;
}
}
function calculateSpecificityMatch(
candidate: ProviderCandidate,
hint: RoutingHint | undefined | null
): number {
if (!hint) return 0.5;
try {
const assignment = classifyTier(candidate.provider, candidate.model);
const specificityScore = hint.specificity.score;
if (assignment.tier === "free") return specificityScore <= 15 ? 0.9 : 0.2;
if (assignment.tier === "cheap")
return specificityScore > 15 && specificityScore <= 50 ? 0.9 : 0.4;
if (assignment.tier === "premium") return specificityScore > 50 ? 0.9 : 0.3;
return 0.5;
} catch {
return 0.5;
}
}
/**
* Pool-wide maxima used to normalize cost/latency/stability factors. These are
* identical for every candidate in a given pool, so callers scoring many
* candidates against the same pool should compute this ONCE via
* computePoolMaxima() and pass it to calculateFactors — recomputing it inside
* a per-candidate loop turns an O(n) scoring pass into O(n^2) (#OOM incident:
* a zero-config "auto" combo with no explicit candidatePool can expand the
* pool to 1000s of provider/model targets, at which point the repeated
* `pool.map()` + spread here dominates heap churn and can OOM the process).
*/
export interface PoolMaxima {
maxCost: number;
maxLatency: number;
maxStdDev: number;
}
export function computePoolMaxima(pool: ProviderCandidate[]): PoolMaxima {
let maxCost = 0.001;
let maxLatency = 1;
let maxStdDev = 0.001;
for (const p of pool) {
if (p.costPer1MTokens > maxCost) maxCost = p.costPer1MTokens;
if (p.p95LatencyMs > maxLatency) maxLatency = p.p95LatencyMs;
if (p.latencyStdDev > maxStdDev) maxStdDev = p.latencyStdDev;
}
return { maxCost, maxLatency, maxStdDev };
}
export function calculateFactors(
candidate: ProviderCandidate,
pool: ProviderCandidate[],
taskType: string,
getTaskFitness: (model: string, taskType: string) => number,
manifestHint?: RoutingHint | null,
precomputedMaxima?: PoolMaxima
): ScoringFactors {
const { maxCost, maxLatency, maxStdDev } = precomputedMaxima ?? computePoolMaxima(pool);
// Every factor is contractually [0,1]. clamp01 guards against bad telemetry
// (negative quota / cost / latency, NaN, out-of-range candidate-supplied
// affinities) so a single bad input can't produce a negative or >1 factor
// that distorts the weighted score.
return {
quota: clamp01(candidate.quotaRemaining / 100),
health:
candidate.circuitBreakerState === "CLOSED"
? 1.0
: candidate.circuitBreakerState === "HALF_OPEN"
? 0.5
: 0.0,
costInv: clamp01(1 - candidate.costPer1MTokens / maxCost),
latencyInv: clamp01(1 - candidate.p95LatencyMs / maxLatency),
taskFit: clamp01(getTaskFitness(candidate.model, taskType)),
stability: clamp01(1 - candidate.latencyStdDev / maxStdDev),
tierPriority: calculateTierScore(candidate.accountTier, candidate.quotaResetIntervalSecs),
tierAffinity: calculateTierAffinity(candidate, manifestHint),
specificityMatch: calculateSpecificityMatch(candidate, manifestHint),
contextAffinity: clamp01(candidate.contextAffinity ?? 0.5),
cacheAffinity: clamp01(candidate.cacheAffinity ?? 0),
resetWindowAffinity: clamp01(candidate.resetWindowAffinity ?? 0.5),
connectionDensity: clamp01(((candidate.connectionPoolSize ?? 1) - 1) / 10),
};
}
export function scorePool(
pool: ProviderCandidate[],
taskType: string,
weights: ScoringWeights = DEFAULT_WEIGHTS,
getTaskFitness: (model: string, taskType: string) => number = () => 0.5,
manifestHint?: RoutingHint | null
): ScoredProvider[] {
const poolMaxima = computePoolMaxima(pool);
return pool
.map((candidate) => {
const factors = calculateFactors(
candidate,
pool,
taskType,
getTaskFitness,
manifestHint,
poolMaxima
);
return {
provider: candidate.provider,
model: candidate.model,
score: calculateScore(factors, weights),
factors,
connectionId: candidate.connectionId,
};
})
.sort((a, b) => b.score - a.score);
}
/**
* Validate that weights sum to 1.0 (±0.01 tolerance).
*/
export function validateWeights(weights: ScoringWeights): boolean {
const sum = Object.values(weights).reduce((a, b) => a + b, 0);
return Math.abs(sum - 1.0) < 0.01;
}