free-coding-models 0.1.84 β 0.1.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -12
- package/bin/free-coding-models.js +167 -73
- package/package.json +1 -1
- package/sources.js +12 -10
- package/src/config.js +14 -3
- package/src/constants.js +3 -1
- package/src/key-handler.js +160 -17
- package/src/overlays.js +9 -7
- package/src/provider-metadata.js +20 -20
- package/src/render-table.js +105 -62
- package/src/utils.js +31 -26
package/src/render-table.js
CHANGED
|
@@ -43,29 +43,29 @@ import { calculateViewport, sortResultsWithPinnedFavorites, renderProxyStatusLin
|
|
|
43
43
|
const require = createRequire(import.meta.url)
|
|
44
44
|
const { version: LOCAL_VERSION } = require('../package.json')
|
|
45
45
|
|
|
46
|
-
// π Provider column palette:
|
|
47
|
-
// π
|
|
46
|
+
// π Provider column palette: soft pastel rainbow so each provider stays easy
|
|
47
|
+
// π to spot without turning the table into a harsh neon wall.
|
|
48
48
|
const PROVIDER_COLOR = {
|
|
49
|
-
nvidia: [
|
|
50
|
-
groq: [
|
|
51
|
-
cerebras: [
|
|
52
|
-
sambanova: [
|
|
53
|
-
openrouter: [
|
|
54
|
-
huggingface: [
|
|
55
|
-
replicate: [
|
|
56
|
-
deepinfra: [
|
|
57
|
-
fireworks: [
|
|
58
|
-
codestral: [
|
|
59
|
-
hyperbolic: [
|
|
60
|
-
scaleway: [
|
|
61
|
-
googleai: [
|
|
62
|
-
siliconflow: [
|
|
63
|
-
together: [
|
|
64
|
-
cloudflare: [
|
|
65
|
-
perplexity: [
|
|
66
|
-
qwen: [
|
|
67
|
-
zai: [
|
|
68
|
-
iflow: [
|
|
49
|
+
nvidia: [178, 235, 190],
|
|
50
|
+
groq: [255, 204, 188],
|
|
51
|
+
cerebras: [179, 229, 252],
|
|
52
|
+
sambanova: [255, 224, 178],
|
|
53
|
+
openrouter: [225, 190, 231],
|
|
54
|
+
huggingface: [255, 245, 157],
|
|
55
|
+
replicate: [187, 222, 251],
|
|
56
|
+
deepinfra: [178, 223, 219],
|
|
57
|
+
fireworks: [255, 205, 210],
|
|
58
|
+
codestral: [248, 187, 208],
|
|
59
|
+
hyperbolic: [200, 230, 201],
|
|
60
|
+
scaleway: [129, 212, 250],
|
|
61
|
+
googleai: [187, 222, 251],
|
|
62
|
+
siliconflow: [178, 235, 242],
|
|
63
|
+
together: [197, 225, 165],
|
|
64
|
+
cloudflare: [255, 204, 128],
|
|
65
|
+
perplexity: [159, 234, 201],
|
|
66
|
+
qwen: [255, 224, 130],
|
|
67
|
+
zai: [174, 213, 255],
|
|
68
|
+
iflow: [220, 231, 117],
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// π Active proxy reference for footer status line (set by bin/free-coding-models.js).
|
|
@@ -77,7 +77,7 @@ export function setActiveProxy(proxyInstance) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
// βββ renderTable: mode param controls footer hint text (opencode vs openclaw) βββββββββ
|
|
80
|
-
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null) {
|
|
80
|
+
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false) {
|
|
81
81
|
// π Filter out hidden models for display
|
|
82
82
|
const visibleResults = results.filter(r => !r.hidden)
|
|
83
83
|
|
|
@@ -85,31 +85,48 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
85
85
|
const down = visibleResults.filter(r => r.status === 'down').length
|
|
86
86
|
const timeout = visibleResults.filter(r => r.status === 'timeout').length
|
|
87
87
|
const pending = visibleResults.filter(r => r.status === 'pending').length
|
|
88
|
+
const totalVisible = visibleResults.length
|
|
89
|
+
const completedPings = Math.max(0, totalVisible - pending)
|
|
88
90
|
|
|
89
91
|
// π Calculate seconds until next ping
|
|
90
92
|
const timeSinceLastPing = Date.now() - lastPingTime
|
|
91
93
|
const timeUntilNextPing = Math.max(0, pingInterval - timeSinceLastPing)
|
|
92
|
-
const secondsUntilNext =
|
|
94
|
+
const secondsUntilNext = timeUntilNextPing / 1000
|
|
95
|
+
const secondsUntilNextLabel = secondsUntilNext.toFixed(1)
|
|
93
96
|
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
const intervalSec = Math.round(pingInterval / 1000)
|
|
98
|
+
const pingModeMeta = {
|
|
99
|
+
speed: { label: 'fast', color: chalk.bold.rgb(255, 210, 80) },
|
|
100
|
+
normal: { label: 'normal', color: chalk.bold.rgb(120, 210, 255) },
|
|
101
|
+
slow: { label: 'slow', color: chalk.bold.rgb(255, 170, 90) },
|
|
102
|
+
forced: { label: 'forced', color: chalk.bold.rgb(255, 120, 120) },
|
|
103
|
+
}
|
|
104
|
+
const activePingMode = pingModeMeta[pingMode] ?? pingModeMeta.normal
|
|
105
|
+
const pingProgressText = `${completedPings}/${totalVisible}`
|
|
106
|
+
const nextCountdownColor = secondsUntilNext > 8
|
|
107
|
+
? chalk.red.bold
|
|
108
|
+
: secondsUntilNext >= 4
|
|
109
|
+
? chalk.yellow.bold
|
|
110
|
+
: secondsUntilNext < 1
|
|
111
|
+
? chalk.greenBright.bold
|
|
112
|
+
: chalk.green.bold
|
|
113
|
+
const pingControlBadge =
|
|
114
|
+
activePingMode.color(' [ ') +
|
|
115
|
+
chalk.yellow.bold('W') +
|
|
116
|
+
activePingMode.color(` Ping Interval : ${intervalSec}s (${activePingMode.label}) - ${pingProgressText} - next : `) +
|
|
117
|
+
nextCountdownColor(`${secondsUntilNextLabel}s`) +
|
|
118
|
+
activePingMode.color(' ]')
|
|
119
|
+
|
|
120
|
+
// π Tool badge keeps the active launch target visible in the header, so the
|
|
121
|
+
// π footer no longer needs a redundant Enter action or mode toggle reminder.
|
|
102
122
|
let modeBadge
|
|
103
123
|
if (mode === 'openclaw') {
|
|
104
|
-
modeBadge = chalk.bold.rgb(255, 100, 50)(' [
|
|
124
|
+
modeBadge = chalk.bold.rgb(255, 100, 50)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(255, 100, 50)(' Tool : OpenClaw ]')
|
|
105
125
|
} else if (mode === 'opencode-desktop') {
|
|
106
|
-
modeBadge = chalk.bold.rgb(0, 200, 255)(' [
|
|
126
|
+
modeBadge = chalk.bold.rgb(0, 200, 255)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(0, 200, 255)(' Tool : OpenCode Desktop ]')
|
|
107
127
|
} else {
|
|
108
|
-
modeBadge = chalk.bold.rgb(0, 200, 255)(' [
|
|
128
|
+
modeBadge = chalk.bold.rgb(0, 200, 255)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(0, 200, 255)(' Tool : OpenCode CLI ]')
|
|
109
129
|
}
|
|
110
|
-
|
|
111
|
-
// π Add mode toggle hint
|
|
112
|
-
const modeHint = chalk.dim.yellow(' (Z to toggle)')
|
|
113
130
|
|
|
114
131
|
// π Tier filter badge shown when filtering is active (shows exact tier name)
|
|
115
132
|
const TIER_CYCLE_NAMES = [null, 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
@@ -155,16 +172,29 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
155
172
|
const W_UPTIME = 6
|
|
156
173
|
const W_TOKENS = 7
|
|
157
174
|
const W_USAGE = 7
|
|
175
|
+
const MIN_TABLE_WIDTH = 166
|
|
176
|
+
|
|
177
|
+
if (terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH) {
|
|
178
|
+
const lines = []
|
|
179
|
+
const blankLines = Math.max(0, Math.floor(((terminalRows || 24) - 3) / 2))
|
|
180
|
+
const warning = 'Please maximize your terminal for optimal use. The current terminal width is too small for the full table.'
|
|
181
|
+
const padLeft = Math.max(0, Math.floor((terminalCols - warning.length) / 2))
|
|
182
|
+
for (let i = 0; i < blankLines; i++) lines.push('')
|
|
183
|
+
lines.push(' '.repeat(padLeft) + chalk.red.bold(warning))
|
|
184
|
+
while (terminalRows > 0 && lines.length < terminalRows) lines.push('')
|
|
185
|
+
const EL = '\x1b[K'
|
|
186
|
+
return lines.map(line => line + EL).join('\n')
|
|
187
|
+
}
|
|
158
188
|
|
|
159
189
|
// π Sort models using the shared helper
|
|
160
190
|
const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
|
|
161
191
|
|
|
162
192
|
const lines = [
|
|
163
|
-
` ${chalk.greenBright.bold(
|
|
193
|
+
` ${chalk.greenBright.bold(`β
Free-Coding-Models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${profileBadge}${chalk.reset('')} ` +
|
|
164
194
|
chalk.greenBright(`β
${up}`) + chalk.dim(' up ') +
|
|
165
195
|
chalk.yellow(`β³ ${timeout}`) + chalk.dim(' timeout ') +
|
|
166
196
|
chalk.red(`β ${down}`) + chalk.dim(' down ') +
|
|
167
|
-
|
|
197
|
+
'',
|
|
168
198
|
'',
|
|
169
199
|
]
|
|
170
200
|
|
|
@@ -259,6 +289,16 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
259
289
|
chalk.dim('β'.repeat(W_USAGE))
|
|
260
290
|
)
|
|
261
291
|
|
|
292
|
+
if (sorted.length === 0) {
|
|
293
|
+
lines.push('')
|
|
294
|
+
if (hideUnconfiguredModels) {
|
|
295
|
+
lines.push(` ${chalk.redBright.bold('Press P to configure your API key.')}`)
|
|
296
|
+
lines.push(` ${chalk.dim('No configured provider currently exposes visible models in the table.')}`)
|
|
297
|
+
} else {
|
|
298
|
+
lines.push(` ${chalk.yellow.bold('No models match the current filters.')}`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
262
302
|
// π Viewport clipping: only render models that fit on screen
|
|
263
303
|
const vp = calculateViewport(terminalRows, scrollOffset, sorted.length)
|
|
264
304
|
|
|
@@ -286,6 +326,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
286
326
|
const prefixDisplayWidth = 2
|
|
287
327
|
const nameWidth = Math.max(0, W_MODEL - prefixDisplayWidth)
|
|
288
328
|
const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
|
|
329
|
+
const modelColor = chalk.rgb(...providerRgb)
|
|
289
330
|
const sweScore = r.sweScore ?? 'β'
|
|
290
331
|
// π SWE% colorized on the same gradient as Tier:
|
|
291
332
|
// β₯70% bright neon green (S+), β₯60% green (S), β₯50% yellow-green (A+),
|
|
@@ -315,22 +356,30 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
315
356
|
? chalk.cyan(ctxRaw.padEnd(W_CTX))
|
|
316
357
|
: chalk.dim(ctxRaw.padEnd(W_CTX))
|
|
317
358
|
|
|
359
|
+
// π Keep the row-local spinner small and inline so users can still read the last measured latency.
|
|
360
|
+
const buildLatestPingDisplay = (value) => {
|
|
361
|
+
const spinner = r.isPinging ? ` ${FRAMES[frame % FRAMES.length]}` : ''
|
|
362
|
+
return `${value}${spinner}`.padEnd(W_PING)
|
|
363
|
+
}
|
|
364
|
+
|
|
318
365
|
// π Latest ping - pings are objects: { ms, code }
|
|
319
366
|
// π Show response time for 200 (success) and 401 (no-auth but server is reachable)
|
|
320
367
|
const latestPing = r.pings.length > 0 ? r.pings[r.pings.length - 1] : null
|
|
321
368
|
let pingCell
|
|
322
369
|
if (!latestPing) {
|
|
323
|
-
|
|
370
|
+
const placeholder = r.isPinging ? buildLatestPingDisplay('βββ') : 'βββ'.padEnd(W_PING)
|
|
371
|
+
pingCell = chalk.dim(placeholder)
|
|
324
372
|
} else if (latestPing.code === '200') {
|
|
325
373
|
// π Success - show response time
|
|
326
|
-
const str = String(latestPing.ms)
|
|
374
|
+
const str = buildLatestPingDisplay(String(latestPing.ms))
|
|
327
375
|
pingCell = latestPing.ms < 500 ? chalk.greenBright(str) : latestPing.ms < 1500 ? chalk.yellow(str) : chalk.red(str)
|
|
328
376
|
} else if (latestPing.code === '401') {
|
|
329
377
|
// π 401 = no API key but server IS reachable β still show latency in dim
|
|
330
|
-
pingCell = chalk.dim(String(latestPing.ms)
|
|
378
|
+
pingCell = chalk.dim(buildLatestPingDisplay(String(latestPing.ms)))
|
|
331
379
|
} else {
|
|
332
380
|
// π Error or timeout - show "βββ" (error code is already in Status column)
|
|
333
|
-
|
|
381
|
+
const placeholder = r.isPinging ? buildLatestPingDisplay('βββ') : 'βββ'.padEnd(W_PING)
|
|
382
|
+
pingCell = chalk.dim(placeholder)
|
|
334
383
|
}
|
|
335
384
|
|
|
336
385
|
// π Avg ping (just number, no "ms")
|
|
@@ -465,10 +514,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
465
514
|
uptimeCell = chalk.red(uptimeStr.padEnd(W_UPTIME))
|
|
466
515
|
}
|
|
467
516
|
|
|
468
|
-
// π
|
|
469
|
-
|
|
517
|
+
// π Model text now mirrors the provider hue so provider affinity is visible
|
|
518
|
+
// π even before the eye reaches the Provider column.
|
|
519
|
+
const nameCell = isCursor ? modelColor.bold(name) : modelColor(name)
|
|
470
520
|
const sourceCursorText = providerName.padEnd(W_SOURCE)
|
|
471
|
-
const sourceCell = isCursor ? chalk.
|
|
521
|
+
const sourceCell = isCursor ? chalk.rgb(...providerRgb).bold(sourceCursorText) : source
|
|
472
522
|
|
|
473
523
|
// π Usage column β provider-scoped remaining quota when measurable,
|
|
474
524
|
// π otherwise a green dot to show "usable but not meaningfully quantifiable".
|
|
@@ -502,12 +552,12 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
502
552
|
const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell + ' ' + tokensCell + ' ' + usageCell
|
|
503
553
|
|
|
504
554
|
if (isCursor) {
|
|
505
|
-
lines.push(chalk.bgRgb(
|
|
555
|
+
lines.push(chalk.bgRgb(155, 55, 135)(row))
|
|
506
556
|
} else if (r.isRecommended) {
|
|
507
557
|
// π Medium green background for recommended models (distinguishable from favorites)
|
|
508
558
|
lines.push(chalk.bgRgb(15, 40, 15)(row))
|
|
509
559
|
} else if (r.isFavorite) {
|
|
510
|
-
lines.push(chalk.bgRgb(
|
|
560
|
+
lines.push(chalk.bgRgb(88, 64, 10)(row))
|
|
511
561
|
} else {
|
|
512
562
|
lines.push(row)
|
|
513
563
|
}
|
|
@@ -523,18 +573,13 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
523
573
|
} else {
|
|
524
574
|
lines.push('')
|
|
525
575
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
: mode === 'opencode-desktop'
|
|
532
|
-
? chalk.rgb(0, 200, 255)('EnterβOpenDesktop')
|
|
533
|
-
: chalk.rgb(0, 200, 255)('EnterβOpenCode')
|
|
534
|
-
// π Line 1: core navigation + sorting shortcuts
|
|
535
|
-
lines.push(chalk.dim(` ββ Navigate β’ `) + actionHint + chalk.dim(` β’ `) + chalk.yellow('F') + chalk.dim(` Favorite β’ R/Y/O/M/L/A/S/C/H/V/B/U/`) + chalk.yellow('G') + chalk.dim(` Sort β’ `) + chalk.yellow('T') + chalk.dim(` Tier β’ `) + chalk.yellow('D') + chalk.dim(` Provider β’ Wβ/=β (${intervalSec}s) β’ `) + chalk.rgb(255, 100, 50).bold('Z') + chalk.dim(` Mode β’ `) + chalk.yellow('X') + chalk.dim(` Logs β’ `) + chalk.yellow('P') + chalk.dim(` Settings β’ `) + chalk.rgb(0, 255, 80).bold('K') + chalk.dim(` Help`))
|
|
576
|
+
// π Footer hints keep only navigation and secondary actions now that the
|
|
577
|
+
// π active tool target is already visible in the header badge.
|
|
578
|
+
const hotkey = (keyLabel, text) => chalk.yellow(keyLabel) + chalk.dim(text)
|
|
579
|
+
// π Line 1: core navigation + filtering shortcuts
|
|
580
|
+
lines.push(chalk.dim(` ββ Navigate β’ `) + hotkey('F', ' Toggle Favorite') + chalk.dim(` β’ `) + hotkey('T', ' Tier') + chalk.dim(` β’ `) + hotkey('D', ' Provider') + chalk.dim(` β’ `) + hotkey('E', ' Configured Only') + chalk.dim(` β’ `) + hotkey('X', ' Token Logs') + chalk.dim(` β’ `) + hotkey('P', ' Settings') + chalk.dim(` β’ `) + hotkey('K', ' Help'))
|
|
536
581
|
// π Line 2: profiles, recommend, feature request, bug report, and extended hints β gives visibility to less-obvious features
|
|
537
|
-
lines.push(chalk.dim(` `) +
|
|
582
|
+
lines.push(chalk.dim(` `) + hotkey('β§P', ' Cycle profile') + chalk.dim(` β’ `) + hotkey('β§S', ' Save profile') + chalk.dim(` β’ `) + hotkey('Q', ' Smart Recommend') + chalk.dim(` β’ `) + hotkey('J', ' Request feature') + chalk.dim(` β’ `) + hotkey('I', ' Report bug'))
|
|
538
583
|
// π Proxy status line β always rendered with explicit state (starting/running/failed/stopped)
|
|
539
584
|
lines.push(renderProxyStatusLine(proxyStartupStatus, activeProxyRef))
|
|
540
585
|
lines.push(
|
|
@@ -551,8 +596,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
551
596
|
chalk.dim(' β ') +
|
|
552
597
|
chalk.rgb(200, 150, 255)('https://discord.gg/5MbTnDC3Md') +
|
|
553
598
|
chalk.dim(' β’ ') +
|
|
554
|
-
chalk.dim(`v${LOCAL_VERSION}`) +
|
|
555
|
-
chalk.dim(' β’ ') +
|
|
556
599
|
chalk.dim('Ctrl+C Exit')
|
|
557
600
|
)
|
|
558
601
|
|
package/src/utils.js
CHANGED
|
@@ -74,18 +74,23 @@ export const TIER_LETTER_MAP = {
|
|
|
74
74
|
|
|
75
75
|
// βββ Core Logic Functions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
76
76
|
|
|
77
|
-
// π
|
|
78
|
-
// π
|
|
79
|
-
|
|
77
|
+
// π measureablePingCodes: HTTP codes that still give us a real round-trip latency sample.
|
|
78
|
+
// π 200 = normal success, 401 = no key / bad key but the provider endpoint is reachable.
|
|
79
|
+
const measurablePingCodes = new Set(['200', '401'])
|
|
80
|
+
|
|
81
|
+
// π getAvg: Calculate average latency from pings that produced a real latency sample.
|
|
82
|
+
// π HTTP 200 and 401 both count because a 401 still proves the endpoint responded in X ms.
|
|
83
|
+
// π Timeouts and server failures are excluded to avoid mixing availability with raw latency.
|
|
84
|
+
// π Returns Infinity when no measurable pings exist β this sorts "unknown" models to the bottom.
|
|
80
85
|
// π The rounding to integer avoids displaying fractional milliseconds in the TUI.
|
|
81
86
|
//
|
|
82
87
|
// π Example:
|
|
83
|
-
// pings = [{ms: 200, code: '200'}, {ms:
|
|
84
|
-
// β getAvg returns
|
|
88
|
+
// pings = [{ms: 200, code: '200'}, {ms: 320, code: '401'}, {ms: 999, code: '500'}]
|
|
89
|
+
// β getAvg returns 260 (only the measurable pings count: (200+320)/2)
|
|
85
90
|
export const getAvg = (r) => {
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
88
|
-
return Math.round(
|
|
91
|
+
const measurablePings = (r.pings || []).filter(p => measurablePingCodes.has(p.code))
|
|
92
|
+
if (measurablePings.length === 0) return Infinity
|
|
93
|
+
return Math.round(measurablePings.reduce((a, b) => a + b.ms, 0) / measurablePings.length)
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
// π getVerdict: Determine a human-readable health verdict for a model.
|
|
@@ -120,16 +125,16 @@ export const getVerdict = (r) => {
|
|
|
120
125
|
if (avg === Infinity) return 'Pending'
|
|
121
126
|
|
|
122
127
|
// π Stability-aware verdict: penalize models with good avg but terrible tail latency
|
|
123
|
-
const
|
|
128
|
+
const measurablePings = (r.pings || []).filter(p => measurablePingCodes.has(p.code))
|
|
124
129
|
const p95 = getP95(r)
|
|
125
130
|
|
|
126
131
|
if (avg < 400) {
|
|
127
132
|
// π Only flag as "Spiky" when we have enough data (β₯3 pings) to judge stability
|
|
128
|
-
if (
|
|
133
|
+
if (measurablePings.length >= 3 && p95 > 3000) return 'Spiky'
|
|
129
134
|
return 'Perfect'
|
|
130
135
|
}
|
|
131
136
|
if (avg < 1000) {
|
|
132
|
-
if (
|
|
137
|
+
if (measurablePings.length >= 3 && p95 > 5000) return 'Spiky'
|
|
133
138
|
return 'Normal'
|
|
134
139
|
}
|
|
135
140
|
if (avg < 3000) return 'Slow'
|
|
@@ -148,30 +153,30 @@ export const getUptime = (r) => {
|
|
|
148
153
|
return Math.round((successful / r.pings.length) * 100)
|
|
149
154
|
}
|
|
150
155
|
|
|
151
|
-
// π getP95: Calculate the 95th percentile latency from
|
|
156
|
+
// π getP95: Calculate the 95th percentile latency from measurable pings (HTTP 200/401).
|
|
152
157
|
// π The p95 answers: "95% of requests are faster than this value."
|
|
153
158
|
// π A low p95 means consistently fast responses β a high p95 signals tail-latency spikes.
|
|
154
|
-
// π Returns Infinity when no
|
|
159
|
+
// π Returns Infinity when no measurable pings exist.
|
|
155
160
|
//
|
|
156
161
|
// π Algorithm: sort latencies ascending, pick the value at ceil(N * 0.95) - 1.
|
|
157
162
|
// π Example: [100, 200, 300, 400, 5000] β p95 index = ceil(5 * 0.95) - 1 = 4 β 5000ms
|
|
158
163
|
export const getP95 = (r) => {
|
|
159
|
-
const
|
|
160
|
-
if (
|
|
161
|
-
const sorted =
|
|
164
|
+
const measurablePings = (r.pings || []).filter(p => measurablePingCodes.has(p.code))
|
|
165
|
+
if (measurablePings.length === 0) return Infinity
|
|
166
|
+
const sorted = measurablePings.map(p => p.ms).sort((a, b) => a - b)
|
|
162
167
|
const idx = Math.ceil(sorted.length * 0.95) - 1
|
|
163
168
|
return sorted[Math.max(0, idx)]
|
|
164
169
|
}
|
|
165
170
|
|
|
166
|
-
// π getJitter: Calculate latency standard deviation (Ο) from
|
|
171
|
+
// π getJitter: Calculate latency standard deviation (Ο) from measurable pings.
|
|
167
172
|
// π Low jitter = predictable response times. High jitter = erratic, spiky latency.
|
|
168
|
-
// π Returns 0 when fewer than 2
|
|
173
|
+
// π Returns 0 when fewer than 2 measurable pings (can't compute variance from 1 point).
|
|
169
174
|
// π Uses population Ο (divides by N, not N-1) since we have ALL the data, not a sample.
|
|
170
175
|
export const getJitter = (r) => {
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
const mean =
|
|
174
|
-
const variance =
|
|
176
|
+
const measurablePings = (r.pings || []).filter(p => measurablePingCodes.has(p.code))
|
|
177
|
+
if (measurablePings.length < 2) return 0
|
|
178
|
+
const mean = measurablePings.reduce((a, b) => a + b.ms, 0) / measurablePings.length
|
|
179
|
+
const variance = measurablePings.reduce((sum, p) => sum + (p.ms - mean) ** 2, 0) / measurablePings.length
|
|
175
180
|
return Math.round(Math.sqrt(variance))
|
|
176
181
|
}
|
|
177
182
|
|
|
@@ -190,14 +195,14 @@ export const getJitter = (r) => {
|
|
|
190
195
|
// Model B: avg 400ms, p95 650ms (boringly consistent) β score ~85
|
|
191
196
|
// In real usage, Model B FEELS faster because it doesn't randomly stall.
|
|
192
197
|
export const getStabilityScore = (r) => {
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
198
|
+
const measurablePings = (r.pings || []).filter(p => measurablePingCodes.has(p.code))
|
|
199
|
+
if (measurablePings.length === 0) return -1
|
|
195
200
|
|
|
196
201
|
const p95 = getP95(r)
|
|
197
202
|
const jitter = getJitter(r)
|
|
198
203
|
const uptime = getUptime(r)
|
|
199
|
-
const spikeCount =
|
|
200
|
-
const spikeRate = spikeCount /
|
|
204
|
+
const spikeCount = measurablePings.filter(p => p.ms > 3000).length
|
|
205
|
+
const spikeRate = spikeCount / measurablePings.length
|
|
201
206
|
|
|
202
207
|
// π Normalize each component to 0β100 (higher = better)
|
|
203
208
|
const p95Score = Math.max(0, Math.min(100, 100 * (1 - p95 / 5000)))
|