243 lines
7.9 KiB
TypeScript
243 lines
7.9 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;
|
|
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;
|
|
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,
|
|
resetWindowAffinity: 0,
|
|
connectionDensity: 0.05,
|
|
};
|
|
|
|
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 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.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;
|
|
}
|
|
}
|
|
|
|
export function calculateFactors(
|
|
candidate: ProviderCandidate,
|
|
pool: ProviderCandidate[],
|
|
taskType: string,
|
|
getTaskFitness: (model: string, taskType: string) => number,
|
|
manifestHint?: RoutingHint | null
|
|
): ScoringFactors {
|
|
const maxCost = Math.max(...pool.map((p) => p.costPer1MTokens), 0.001);
|
|
const maxLatency = Math.max(...pool.map((p) => p.p95LatencyMs), 1);
|
|
const maxStdDev = Math.max(...pool.map((p) => p.latencyStdDev), 0.001);
|
|
|
|
// 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),
|
|
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[] {
|
|
return pool
|
|
.map((candidate) => {
|
|
const factors = calculateFactors(candidate, pool, taskType, getTaskFitness, manifestHint);
|
|
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;
|
|
}
|