Spaces:
Sleeping
Sleeping
File size: 9,992 Bytes
ed57015 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | // ── Bandit routing score ────────────────────────────────────────────────────
//
// A redesign of the analytics-driven router. Instead of summing a pile of
// hand-tuned, dimensionally-incompatible bonuses (a probability + a raw latency
// term + an intelligence term, each hand-capped to keep orderings sane), every
// signal here is normalized to [0, 1] and combined as a CONVEX COMBINATION:
//
// base = w_rel·reliability + w_speed·speed + w_intel·intelligence
// (weights are a preset that sums to 1, so base ∈ [0, 1])
//
// Two always-on GUARDRAILS then multiply the base — they never reorder good
// models against each other, they only pull a model down as it gets dangerous:
//
// effective = base × headroomFactor × rateLimitFactor
//
// headroomFactor → protects a model that is nearly out of its free quota
// rateLimitFactor → demotes a model that is currently throwing 429s
//
// Reliability is drawn from a Beta posterior (Thompson sampling) so exploration
// is automatic and proportional to uncertainty — a model is never permanently
// frozen out after a couple of failures. Speed and intelligence are
// deterministic. The result stays in a bounded, interpretable range and no term
// needs a manual cap to "still beat a 0%-success model".
export interface RoutingWeights {
reliability: number;
speed: number;
intelligence: number;
}
// Strategy is either the legacy manual chain ('priority'), one of the bandit
// presets, or 'custom' (a user-tuned weight vector persisted in settings — see
// router.ts). Each is just a weight vector — the engine is identical.
export type RoutingStrategy = 'priority' | 'balanced' | 'smartest' | 'fastest' | 'reliable' | 'intelligence' | 'custom';
export const BANDIT_PRESETS: Record<Exclude<RoutingStrategy, 'priority' | 'custom'>, RoutingWeights> = {
// Reliability leads; speed and intelligence split the rest evenly.
balanced: { reliability: 0.5, speed: 0.25, intelligence: 0.25 },
// Intelligence leads, but reliability still carries real weight so a smart
// model that keeps failing doesn't win.
smartest: { reliability: 0.35, speed: 0.1, intelligence: 0.55 },
// Speed leads; reliability keeps a fast-but-broken model from winning.
fastest: { reliability: 0.35, speed: 0.55, intelligence: 0.1 },
// Reliability dominates — for clients that just want it to work.
reliable: { reliability: 0.7, speed: 0.15, intelligence: 0.15 },
// Pure Elo-based routing — 100% intelligence. The built-in guardrails
// (rateLimitFactor, headroomFactor) still deprioritize broken/quota-exhausted
// models so you never get routed to a dead endpoint.
intelligence: { reliability: 0, speed: 0, intelligence: 1 },
};
// Default routing strategy. 'intelligence' routes purely by live Elo rank —
// the highest-scoring free model that is currently working gets every request.
// Operators can change this from the dashboard or PUT /api/fallback/routing.
export const DEFAULT_STRATEGY: RoutingStrategy = 'intelligence';
// ── Reliability ───────────────────────────────────────────────────────────
// Beta(1,1) prior = uniform: an unseen model is genuinely uncertain, not assumed
// good or bad. With decay-weighted pseudo-counts the alpha/beta are continuous.
export const PRIOR_SUCCESS = 1;
export const PRIOR_FAILURE = 1;
export function reliabilityPosterior(successes: number, failures: number): { alpha: number; beta: number } {
return {
alpha: Math.max(0, successes) + PRIOR_SUCCESS,
beta: Math.max(0, failures) + PRIOR_FAILURE,
};
}
// Deterministic expected reliability — used for the dashboard display score.
export function expectedReliability(successes: number, failures: number): number {
const { alpha, beta } = reliabilityPosterior(successes, failures);
return alpha / (alpha + beta);
}
// ── Speed (throughput + TTFB blended into one [0,1] axis) ───────────────────
// Throughput uses a saturating curve so one very fast tiny model can't make a
// perfectly-fine larger model look "slow" (the global-max-normalization bug in
// the fork). TTFB is a simple linear ramp from "instant" to "painfully slow".
export const SPEED_SCALE_TOK_S = 60; // tok/s at which throughput ≈ 0.63
export const TTFB_BEST_MS = 300; // ≤ this → full latency credit
export const TTFB_WORST_MS = 5000; // ≥ this → zero latency credit
const THROUGHPUT_WEIGHT = 0.6; // within the speed axis
const TTFB_WEIGHT = 0.4;
// Optimistic prior so unmeasured models still get explored on the speed axis.
export const SPEED_PRIOR = 0.6;
function throughputScore(tokPerSec: number): number {
if (tokPerSec <= 0) return 0;
return 1 - Math.exp(-tokPerSec / SPEED_SCALE_TOK_S);
}
function ttfbScore(ttfbMs: number): number {
if (ttfbMs <= TTFB_BEST_MS) return 1;
if (ttfbMs >= TTFB_WORST_MS) return 0;
return 1 - (ttfbMs - TTFB_BEST_MS) / (TTFB_WORST_MS - TTFB_BEST_MS);
}
/**
* Blend throughput and TTFB into a single [0,1] speed score.
* `tokPerSec <= 0` means no successful samples → return the exploration prior.
* `ttfbMs === null` means we have throughput but no first-byte timing → fall
* back to throughput alone rather than guessing latency.
*/
export function speedScore(tokPerSec: number, ttfbMs: number | null): number {
if (tokPerSec <= 0 && ttfbMs === null) return SPEED_PRIOR;
const tp = throughputScore(tokPerSec);
if (ttfbMs === null) return tp;
if (tokPerSec <= 0) return ttfbScore(ttfbMs);
return THROUGHPUT_WEIGHT * tp + TTFB_WEIGHT * ttfbScore(ttfbMs);
}
// ── Intelligence ────────────────────────────────────────────────────────────
// Caller supplies a composite (tier-first, rank-as-tiebreaker — see router) and
// the min/max across the enabled chain. We min-max normalize to [0,1], 1 = best.
export function intelligenceScore(composite: number, min: number, max: number): number {
if (max <= min) return 1; // single model or all equal → neutral-high
return (composite - min) / (max - min);
}
// ── Guardrail: free-quota headroom ──────────────────────────────────────────
// Multiplier that stays at 1 while a model has comfortable monthly headroom and
// ramps down to a floor as it approaches its free-tier cap, so we stop steering
// traffic at a model we're about to burn out. Unknown budget (0) → no opinion.
export const HEADROOM_FLOOR = 0.1;
export const HEADROOM_RAMP_START = 0.2; // start protecting at 20% remaining
export function headroomFactor(usedTokens: number, budgetTokens: number): number {
if (!budgetTokens || budgetTokens <= 0) return 1; // unknown budget → no opinion
const remaining = Math.max(0, 1 - usedTokens / budgetTokens);
if (remaining >= HEADROOM_RAMP_START) return 1;
// Linear from (0 remaining → floor) to (RAMP_START remaining → 1).
return HEADROOM_FLOOR + (1 - HEADROOM_FLOOR) * (remaining / HEADROOM_RAMP_START);
}
// ── Guardrail: live rate-limit penalty ──────────────────────────────────────
// Maps the existing 0..MAX_PENALTY 429 penalty to a multiplier. At max penalty a
// model keeps 40% of its score — demoted hard but never fully excluded, so it
// can recover once the penalty decays.
export const MAX_PENALTY = 10;
export const RATE_LIMIT_MAX_DAMP = 0.6;
export function rateLimitFactor(penalty: number): number {
const p = Math.min(Math.max(0, penalty), MAX_PENALTY);
return 1 - (p / MAX_PENALTY) * RATE_LIMIT_MAX_DAMP;
}
// ── Beta sampler (Marsaglia & Tsang via two Gamma draws) ────────────────────
function randomNormal(): number {
const u1 = Math.random() || Number.EPSILON;
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * Math.random());
}
function sampleGamma(shape: number): number {
if (shape < 1) return sampleGamma(shape + 1) * Math.pow(Math.random() || Number.EPSILON, 1 / shape);
const d = shape - 1 / 3;
const c = 1 / Math.sqrt(9 * d);
for (;;) {
let x: number, v: number;
do { x = randomNormal(); v = 1 + c * x; } while (v <= 0);
v = v ** 3;
const u = Math.random();
if (u < 1 - 0.0331 * x ** 4) return d * v;
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
}
}
export function sampleBeta(alpha: number, beta: number): number {
const x = sampleGamma(alpha);
const y = sampleGamma(beta);
const sum = x + y;
return sum > 0 ? x / sum : 0.5;
}
// ── The combined score ──────────────────────────────────────────────────────
export interface ScoreInputs {
reliability: number; // [0,1] — sampled (routing) or expected (display)
speed: number; // [0,1]
intelligence: number; // [0,1]
headroom: number; // [floor,1] multiplier
rateLimit: number; // [floor,1] multiplier
}
/**
* Convex base (∈[0,1]) × the two guardrail multipliers. The weights are assumed
* to sum to 1; if a caller passes a non-normalized vector we renormalize so the
* base never escapes [0,1].
*/
export function combineScore(inputs: ScoreInputs, weights: RoutingWeights): number {
const wSum = weights.reliability + weights.speed + weights.intelligence || 1;
const base =
(weights.reliability * inputs.reliability +
weights.speed * inputs.speed +
weights.intelligence * inputs.intelligence) / wSum;
return base * inputs.headroom * inputs.rateLimit;
}
|