mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
SHOULD-B (v0.22 review round, round 2): the doc comment still showed "(92%)" for 12 attempts/1 failure — stale against the floor-not-round fix (formatRatePercent) landed earlier in this same file, which the spec's own test asserts as 91%. No functional change; comment only. No admin-asset regen needed — verified byte-identical dist/ output before and after (a doc comment does not reach the minified bundle).
133 lines
6.5 KiB
TypeScript
133 lines
6.5 KiB
TypeScript
// Feature A (v0.22): per-provider / per-(provider,model) success-rate math
|
|
// for the Providers tab's badges (ProviderRateBadge.vue). Kept as pure
|
|
// functions in lib/, not inline in the component — the same "share once,
|
|
// import everywhere" discipline lib/format.ts and lib/search-expand.ts
|
|
// already apply, and it lets vitest's node-environment config (vite.config.
|
|
// ts's `test` block) exercise the threshold math directly, with no
|
|
// component mount required.
|
|
|
|
/**
|
|
* PROVIDER_RATE_AMBER_THRESHOLD is the day-window success-rate fraction
|
|
* (0-1) at and above which a provider or model reads as healthy ("muted"
|
|
* badge, spec: "100% muted"). Below it, the badge turns amber
|
|
* ("degraded", spec: "<99% amber") down to PROVIDER_RATE_DESTRUCTIVE_
|
|
* THRESHOLD.
|
|
*/
|
|
export const PROVIDER_RATE_AMBER_THRESHOLD = 0.99
|
|
|
|
/**
|
|
* PROVIDER_RATE_DESTRUCTIVE_THRESHOLD is the day-window success-rate
|
|
* fraction below which the badge turns destructive (spec: "<90%
|
|
* destructive") — a provider or model failing more than 1 in 10 attempts
|
|
* today.
|
|
*/
|
|
export const PROVIDER_RATE_DESTRUCTIVE_THRESHOLD = 0.9
|
|
|
|
/**
|
|
* ProviderRateStatus is the Providers tab's badge tier a success rate maps
|
|
* to: 'no-traffic' (attempts === 0 — never a misleadingly perfect 100%),
|
|
* 'healthy' (>= PROVIDER_RATE_AMBER_THRESHOLD, rendered muted), 'degraded'
|
|
* (below the amber threshold, at or above the destructive one), or
|
|
* 'severe' (below PROVIDER_RATE_DESTRUCTIVE_THRESHOLD).
|
|
*/
|
|
export type ProviderRateStatus = 'no-traffic' | 'healthy' | 'degraded' | 'severe'
|
|
|
|
/**
|
|
* providerSuccessRate computes the (attempts-failures)/attempts success
|
|
* rate as a 0-1 fraction, or null when attempts is 0, negative, or not a
|
|
* finite number — the "no traffic yet" case a caller must render as its
|
|
* own state, never as a fraction (a 0/0 division would otherwise read as
|
|
* NaN, or worse, as a misleading 100%). failures is defensively treated
|
|
* as 0 when it is not a finite number (folded review minor, v0.22 review
|
|
* round): every real caller sources both counters from the same
|
|
* admin.go response, so this only guards a version-skew or malformed-
|
|
* response case, not a path normal operation reaches.
|
|
*/
|
|
export function providerSuccessRate(attempts: number, failures: number): number | null {
|
|
if (!Number.isFinite(attempts) || attempts <= 0) return null
|
|
const safeFailures = Number.isFinite(failures) ? failures : 0
|
|
return (attempts - safeFailures) / attempts
|
|
}
|
|
|
|
/**
|
|
* providerRateStatus classifies a providerSuccessRate result into the
|
|
* Providers tab's badge tiers (see ProviderRateStatus). A null rate (no
|
|
* traffic) always maps to 'no-traffic', regardless of the threshold
|
|
* constants.
|
|
*/
|
|
export function providerRateStatus(rate: number | null): ProviderRateStatus {
|
|
if (rate === null) return 'no-traffic'
|
|
if (rate < PROVIDER_RATE_DESTRUCTIVE_THRESHOLD) return 'severe'
|
|
if (rate < PROVIDER_RATE_AMBER_THRESHOLD) return 'degraded'
|
|
return 'healthy'
|
|
}
|
|
|
|
/**
|
|
* isModelDegraded reports whether a model's OWN day-window rate should
|
|
* show a badge beside its ModelChip at all — the spec's "only when that
|
|
* model is degraded (<99% w/ attempts>0)" rule: a healthy or no-traffic
|
|
* model gets no badge, so the accordion is not cluttered with a badge
|
|
* beside every single model.
|
|
*/
|
|
export function isModelDegraded(attemptsDay: number, failuresDay: number): boolean {
|
|
const rate = providerSuccessRate(attemptsDay, failuresDay)
|
|
return rate !== null && rate < PROVIDER_RATE_AMBER_THRESHOLD
|
|
}
|
|
|
|
/**
|
|
* formatRatePercent renders a 0-1 fraction as a whole-percent string,
|
|
* FLOORED rather than rounded (folded review minor, v0.22 review round):
|
|
* only a true 100% rate — zero failures — ever renders "100%"; a rate
|
|
* like 0.996 (unrounded, sub-1% failure) renders "99%", not a
|
|
* round-tripped "100%" that would read as flawless when it is not quite.
|
|
* A rate at or above 1 (the only way to reach exactly 100%, given
|
|
* providerSuccessRate's own (attempts-failures)/attempts shape) is
|
|
* special-cased rather than left to Math.floor's own rounding, purely for
|
|
* clarity at the boundary.
|
|
*/
|
|
export function formatRatePercent(rate: number): string {
|
|
if (rate >= 1) return '100%'
|
|
return `${Math.floor(rate * 100)}%`
|
|
}
|
|
|
|
/** countWord returns singular when n is exactly 1, plural otherwise — shared by minuteRateTitle and dayRateTitle so their wording never drifts apart. */
|
|
function countWord(n: number, singular: string, plural: string): string {
|
|
return n === 1 ? singular : plural
|
|
}
|
|
|
|
/**
|
|
* minuteRateTitle builds the provider-level badge's title/aria-label —
|
|
* the "right now" minute-window detail the spec asks for beside the
|
|
* day-window badge text itself, e.g. "12 attempts, 1 failure in the last
|
|
* minute (91%)" (formatRatePercent floors, not rounds — SHOULD-B, v0.22
|
|
* review round, round 2: this example previously said "(92%)", stale
|
|
* against the floor fix below it in this same file). Singular/plural
|
|
* wording follows the actual count, and a zero-attempt minute reads as
|
|
* its own sentence rather than a "0%" that would misread as a total
|
|
* outage. Provider-level only — a per-model badge no longer has minute-
|
|
* window counters to build this from at all (SHOULD-2, v0.22 review
|
|
* round: see dayRateTitle, its own fallback).
|
|
*/
|
|
export function minuteRateTitle(attemptsMinute: number, failuresMinute: number): string {
|
|
if (attemptsMinute <= 0) return 'no traffic in the last minute'
|
|
const rate = providerSuccessRate(attemptsMinute, failuresMinute)
|
|
const pct = rate === null ? '' : ` (${formatRatePercent(rate)})`
|
|
return `${attemptsMinute} ${countWord(attemptsMinute, 'attempt', 'attempts')}, ${failuresMinute} ${countWord(failuresMinute, 'failure', 'failures')} in the last minute${pct}`
|
|
}
|
|
|
|
/**
|
|
* dayRateTitle is minuteRateTitle's day-window equivalent — the per-model
|
|
* badge's title/aria-label (SHOULD-2, v0.22 review round): a model
|
|
* scope's minute-window counters are no longer fetched anywhere
|
|
* (admin.go's buildAdminOverview, limits.go's providerCounterKeys), so a
|
|
* per-model badge cannot build minuteRateTitle's "in the last minute"
|
|
* sentence — this is the "today" equivalent, built from the same day
|
|
* counters the badge's own visible percent already uses.
|
|
*/
|
|
export function dayRateTitle(attemptsDay: number, failuresDay: number): string {
|
|
if (attemptsDay <= 0) return 'no traffic today'
|
|
const rate = providerSuccessRate(attemptsDay, failuresDay)
|
|
const pct = rate === null ? '' : ` (${formatRatePercent(rate)})`
|
|
return `${attemptsDay} ${countWord(attemptsDay, 'attempt', 'attempts')}, ${failuresDay} ${countWord(failuresDay, 'failure', 'failures')} today${pct}`
|
|
}
|