free-coding-models 0.1.84 β 0.1.85
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 +7 -7
- package/bin/free-coding-models.js +153 -68
- package/package.json +1 -1
- package/src/config.js +1 -1
- package/src/constants.js +3 -1
- package/src/key-handler.js +15 -9
- package/src/overlays.js +5 -6
- package/src/render-table.js +38 -14
- package/src/utils.js +31 -26
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
- **π Parallel pings** β All models tested simultaneously via native `fetch`
|
|
73
73
|
- **π Real-time animation** β Watch latency appear live in alternate screen buffer
|
|
74
74
|
- **π Smart ranking** β Top 3 fastest models highlighted with medals π₯π₯π₯
|
|
75
|
-
- **β±
|
|
75
|
+
- **β± Adaptive monitoring** β Starts in a fast 2s cadence for 60s, settles to 10s, slows to 30s after 5 minutes idle, and supports a forced 4s mode
|
|
76
76
|
- **π Rolling averages** β Avg calculated from ALL successful pings since start
|
|
77
77
|
- **π Uptime tracking** β Percentage of successful pings shown in real-time
|
|
78
78
|
- **π Stability score** β Composite 0β100 score measuring consistency (p95, jitter, spikes, uptime)
|
|
@@ -200,7 +200,7 @@ Use `ββ` arrows to select, `Enter` to confirm. Then the TUI launches with yo
|
|
|
200
200
|
|
|
201
201
|
**How it works:**
|
|
202
202
|
1. **Ping phase** β All enabled models are pinged in parallel (up to 150 across 20 providers)
|
|
203
|
-
2. **Continuous monitoring** β Models
|
|
203
|
+
2. **Continuous monitoring** β Models start at 2s re-pings for 60s, then fall back to 10s automatically
|
|
204
204
|
3. **Real-time updates** β Watch "Latest", "Avg", and "Up%" columns update live
|
|
205
205
|
4. **Select anytime** β Use ββ arrows to navigate, press Enter on a model to act
|
|
206
206
|
5. **Smart detection** β Automatically detects if NVIDIA NIM is configured in OpenCode or OpenClaw
|
|
@@ -725,7 +725,7 @@ This script:
|
|
|
725
725
|
β 1. Enter alternate screen buffer (like vim/htop/less) β
|
|
726
726
|
β 2. Ping ALL models in parallel β
|
|
727
727
|
β 3. Display real-time table with Latest/Avg/Stability/Up% β
|
|
728
|
-
β 4. Re-ping ALL models
|
|
728
|
+
β 4. Re-ping ALL models at 2s on startup, then 10s steady-state β
|
|
729
729
|
β 5. Update rolling averages + stability scores per model β
|
|
730
730
|
β 6. User can navigate with ββ and select with Enter β
|
|
731
731
|
β 7. On Enter (OpenCode): set model, launch OpenCode β
|
|
@@ -803,7 +803,7 @@ This script:
|
|
|
803
803
|
|
|
804
804
|
**Configuration:**
|
|
805
805
|
- **Ping timeout**: 15 seconds per attempt (slow models get more time)
|
|
806
|
-
- **Ping
|
|
806
|
+
- **Ping cadence**: startup burst at 2 seconds for 60s, then 10 seconds normally, 30 seconds when idle for 5 minutes, or forced 4 seconds via `W`
|
|
807
807
|
- **Monitor mode**: Interface stays open forever, press Ctrl+C to exit
|
|
808
808
|
|
|
809
809
|
**Flags:**
|
|
@@ -831,12 +831,12 @@ This script:
|
|
|
831
831
|
- **T** β Cycle tier filter (All β S+ β S β A+ β A β A- β B+ β B β C β All)
|
|
832
832
|
- **D** β Cycle provider filter (All β NIM β Groq β ...)
|
|
833
833
|
- **Z** β Cycle mode (OpenCode CLI β OpenCode Desktop β OpenClaw)
|
|
834
|
-
- **X** β **Toggle
|
|
834
|
+
- **X** β **Toggle Token Logs** (view recent request/token usage logs)
|
|
835
835
|
- **P** β Open Settings (manage API keys, toggles, updates, profiles)
|
|
836
836
|
- **Shift+P** β Cycle through saved profiles (switches live TUI settings)
|
|
837
837
|
- **Shift+S** β Save current TUI settings as a named profile (inline prompt)
|
|
838
838
|
- **Q** β Open Smart Recommend overlay (find the best model for your task)
|
|
839
|
-
- **W
|
|
839
|
+
- **W** β Cycle ping mode (`FAST` 2s β `NORMAL` 10s β `SLOW` 30s β `FORCED` 4s)
|
|
840
840
|
- **J / I** β Request feature / Report bug
|
|
841
841
|
- **K / Esc** β Show help overlay / Close overlay
|
|
842
842
|
- **Ctrl+C** β Exit
|
|
@@ -862,7 +862,7 @@ Profiles let you save and restore different TUI configurations β useful if you
|
|
|
862
862
|
- Favorites (starred models)
|
|
863
863
|
- Sort column and direction
|
|
864
864
|
- Tier filter
|
|
865
|
-
- Ping
|
|
865
|
+
- Ping mode
|
|
866
866
|
- API keys
|
|
867
867
|
|
|
868
868
|
**Saving a profile:**
|
|
@@ -288,6 +288,7 @@ async function main() {
|
|
|
288
288
|
status: 'pending',
|
|
289
289
|
pings: [], // π All ping results (ms or 'TIMEOUT')
|
|
290
290
|
httpCode: null,
|
|
291
|
+
isPinging: false, // π Per-row live flag so Latest Ping can keep last value and show a spinner during refresh.
|
|
291
292
|
hidden: false, // π Simple flag to hide/show models
|
|
292
293
|
}))
|
|
293
294
|
syncFavoriteFlags(results, config)
|
|
@@ -305,8 +306,30 @@ async function main() {
|
|
|
305
306
|
// π Add interactive selection state - cursor index and user's choice
|
|
306
307
|
// π sortColumn: 'rank'|'tier'|'origin'|'model'|'ping'|'avg'|'status'|'verdict'|'uptime'
|
|
307
308
|
// π sortDirection: 'asc' (default) or 'desc'
|
|
308
|
-
|
|
309
|
-
|
|
309
|
+
// π ping cadence is now mode-driven:
|
|
310
|
+
// π speed = 2s for 1 minute bursts
|
|
311
|
+
// π normal = 10s steady state
|
|
312
|
+
// π slow = 30s after 5 minutes of inactivity
|
|
313
|
+
// π forced = 4s and ignores inactivity / auto slowdowns
|
|
314
|
+
const PING_MODE_INTERVALS = {
|
|
315
|
+
speed: 2_000,
|
|
316
|
+
normal: 10_000,
|
|
317
|
+
slow: 30_000,
|
|
318
|
+
forced: 4_000,
|
|
319
|
+
}
|
|
320
|
+
const PING_MODE_CYCLE = ['speed', 'normal', 'slow', 'forced']
|
|
321
|
+
const SPEED_MODE_DURATION_MS = 60_000
|
|
322
|
+
const IDLE_SLOW_AFTER_MS = 5 * 60_000
|
|
323
|
+
const now = Date.now()
|
|
324
|
+
|
|
325
|
+
const intervalToPingMode = (intervalMs) => {
|
|
326
|
+
if (intervalMs <= 3000) return 'speed'
|
|
327
|
+
if (intervalMs <= 5000) return 'forced'
|
|
328
|
+
if (intervalMs >= 30000) return 'slow'
|
|
329
|
+
return 'normal'
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// π tierFilter: current tier filter letter (null = all, 'S' = S+/S, 'A' = A+/A/A-, etc.)
|
|
310
333
|
const state = {
|
|
311
334
|
results,
|
|
312
335
|
pendingPings: 0,
|
|
@@ -315,8 +338,13 @@ async function main() {
|
|
|
315
338
|
selectedModel: null,
|
|
316
339
|
sortColumn: 'avg',
|
|
317
340
|
sortDirection: 'asc',
|
|
318
|
-
pingInterval:
|
|
319
|
-
|
|
341
|
+
pingInterval: PING_MODE_INTERVALS.speed, // π Effective live interval derived from the active ping mode.
|
|
342
|
+
pingMode: 'speed', // π Current ping mode: speed | normal | slow | forced.
|
|
343
|
+
pingModeSource: 'startup', // π Why this mode is active: startup | manual | auto | idle | activity.
|
|
344
|
+
speedModeUntil: now + SPEED_MODE_DURATION_MS, // π Speed bursts auto-fall back to normal after 60 seconds.
|
|
345
|
+
lastPingTime: now, // π Track when last ping cycle started
|
|
346
|
+
lastUserActivityAt: now, // π Any keypress refreshes this timer; inactivity can force slow mode.
|
|
347
|
+
resumeSpeedOnActivity: false, // π Set after idle slowdown so the next activity restarts a 60s speed burst.
|
|
320
348
|
mode, // π 'opencode' or 'openclaw' β controls Enter action
|
|
321
349
|
tierFilterMode: 0, // π Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
|
|
322
350
|
originFilterMode: 0, // π Index into ORIGIN_CYCLE (0=All, then providers)
|
|
@@ -383,6 +411,53 @@ async function main() {
|
|
|
383
411
|
adjustScrollOffset(state)
|
|
384
412
|
})
|
|
385
413
|
|
|
414
|
+
let ticker = null
|
|
415
|
+
let onKeyPress = null
|
|
416
|
+
let pingModel = null
|
|
417
|
+
|
|
418
|
+
const scheduleNextPing = () => {
|
|
419
|
+
clearTimeout(state.pingIntervalObj)
|
|
420
|
+
const elapsed = Date.now() - state.lastPingTime
|
|
421
|
+
const delay = Math.max(0, state.pingInterval - elapsed)
|
|
422
|
+
state.pingIntervalObj = setTimeout(runPingCycle, delay)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const setPingMode = (nextMode, source = 'manual') => {
|
|
426
|
+
const modeInterval = PING_MODE_INTERVALS[nextMode] ?? PING_MODE_INTERVALS.normal
|
|
427
|
+
state.pingMode = nextMode
|
|
428
|
+
state.pingModeSource = source
|
|
429
|
+
state.pingInterval = modeInterval
|
|
430
|
+
state.speedModeUntil = nextMode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
|
|
431
|
+
state.resumeSpeedOnActivity = source === 'idle'
|
|
432
|
+
if (state.pingIntervalObj) scheduleNextPing()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const noteUserActivity = () => {
|
|
436
|
+
state.lastUserActivityAt = Date.now()
|
|
437
|
+
if (state.pingMode === 'forced') return
|
|
438
|
+
if (state.resumeSpeedOnActivity) {
|
|
439
|
+
setPingMode('speed', 'activity')
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const refreshAutoPingMode = () => {
|
|
444
|
+
const currentTime = Date.now()
|
|
445
|
+
if (state.pingMode === 'forced') return
|
|
446
|
+
|
|
447
|
+
if (state.speedModeUntil && currentTime >= state.speedModeUntil) {
|
|
448
|
+
setPingMode('normal', 'auto')
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (currentTime - state.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
|
|
453
|
+
if (state.pingMode !== 'slow' || state.pingModeSource !== 'idle') {
|
|
454
|
+
setPingMode('slow', 'idle')
|
|
455
|
+
} else {
|
|
456
|
+
state.resumeSpeedOnActivity = true
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
386
461
|
// π Auto-start proxy on launch if OpenCode config already has an fcm-proxy provider.
|
|
387
462
|
// π Fire-and-forget: does not block UI startup. state.proxyStartupStatus is updated async.
|
|
388
463
|
if (mode === 'opencode' || mode === 'opencode-desktop') {
|
|
@@ -425,9 +500,6 @@ async function main() {
|
|
|
425
500
|
}
|
|
426
501
|
|
|
427
502
|
// βββ Overlay renderers + key handler βββββββββββββββββββββββββββββββββββββ
|
|
428
|
-
let pingModel = null
|
|
429
|
-
let ticker = null
|
|
430
|
-
let onKeyPress = null
|
|
431
503
|
const stopUi = ({ resetRawMode = false } = {}) => {
|
|
432
504
|
if (ticker) clearInterval(ticker)
|
|
433
505
|
clearTimeout(state.pingIntervalObj)
|
|
@@ -518,6 +590,10 @@ async function main() {
|
|
|
518
590
|
mergedModels,
|
|
519
591
|
apiKey,
|
|
520
592
|
chalk,
|
|
593
|
+
setPingMode,
|
|
594
|
+
noteUserActivity,
|
|
595
|
+
intervalToPingMode,
|
|
596
|
+
PING_MODE_CYCLE,
|
|
521
597
|
setResults: (next) => { results = next },
|
|
522
598
|
readline,
|
|
523
599
|
})
|
|
@@ -544,9 +620,11 @@ async function main() {
|
|
|
544
620
|
}
|
|
545
621
|
|
|
546
622
|
process.stdin.on('keypress', onKeyPress)
|
|
623
|
+
process.on('SIGCONT', noteUserActivity)
|
|
547
624
|
|
|
548
625
|
// π Animation loop: render settings overlay, recommend overlay, help overlay, feature request overlay, bug report overlay, OR main table
|
|
549
626
|
ticker = setInterval(() => {
|
|
627
|
+
refreshAutoPingMode()
|
|
550
628
|
state.frame++
|
|
551
629
|
// π Cache visible+sorted models each frame so Enter handler always matches the display
|
|
552
630
|
if (!state.settingsOpen && !state.recommendOpen && !state.featureRequestOpen && !state.bugReportOpen) {
|
|
@@ -561,11 +639,11 @@ async function main() {
|
|
|
561
639
|
? overlays.renderFeatureRequest()
|
|
562
640
|
: state.bugReportOpen
|
|
563
641
|
? overlays.renderBugReport()
|
|
564
|
-
|
|
565
|
-
|
|
642
|
+
: state.helpVisible
|
|
643
|
+
? overlays.renderHelp()
|
|
566
644
|
: state.logVisible
|
|
567
645
|
? overlays.renderLog()
|
|
568
|
-
: renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus)
|
|
646
|
+
: renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource)
|
|
569
647
|
process.stdout.write(ALT_HOME + content)
|
|
570
648
|
}, Math.round(1000 / FPS))
|
|
571
649
|
|
|
@@ -573,7 +651,7 @@ async function main() {
|
|
|
573
651
|
const initialVisible = state.results.filter(r => !r.hidden)
|
|
574
652
|
state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
|
|
575
653
|
|
|
576
|
-
process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus))
|
|
654
|
+
process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource))
|
|
577
655
|
|
|
578
656
|
// π If --recommend was passed, auto-open the Smart Recommend overlay on start
|
|
579
657
|
if (cliArgs.recommendMode) {
|
|
@@ -593,82 +671,89 @@ async function main() {
|
|
|
593
671
|
// π Uses per-provider API key and URL from sources.js
|
|
594
672
|
// π If no API key is configured, pings without auth β a 401 still tells us latency + server is up
|
|
595
673
|
pingModel = async (r) => {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
674
|
+
state.pendingPings += 1
|
|
675
|
+
r.isPinging = true
|
|
676
|
+
|
|
677
|
+
try {
|
|
678
|
+
const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
|
|
679
|
+
const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
|
|
680
|
+
let { code, ms, quotaPercent } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
|
|
681
|
+
|
|
682
|
+
if ((quotaPercent === null || quotaPercent === undefined) && providerApiKey) {
|
|
683
|
+
const providerQuota = await getProviderQuotaPercentCached(r.providerKey, providerApiKey)
|
|
684
|
+
if (typeof providerQuota === 'number' && Number.isFinite(providerQuota)) {
|
|
685
|
+
quotaPercent = providerQuota
|
|
686
|
+
}
|
|
604
687
|
}
|
|
605
|
-
}
|
|
606
688
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
689
|
+
// π Store ping result as object with ms and code
|
|
690
|
+
// π ms = actual response time (even for errors like 429)
|
|
691
|
+
// π code = HTTP status code ('200', '429', '500', '000' for timeout)
|
|
692
|
+
r.pings.push({ ms, code })
|
|
693
|
+
|
|
694
|
+
// π Update status based on latest ping
|
|
695
|
+
if (code === '200') {
|
|
696
|
+
r.status = 'up'
|
|
697
|
+
} else if (code === '000') {
|
|
698
|
+
r.status = 'timeout'
|
|
699
|
+
} else if (code === '401') {
|
|
700
|
+
// π 401 = server is reachable but no API key set (or wrong key)
|
|
701
|
+
// π Treated as 'noauth' β server is UP, latency is real, just needs a key
|
|
702
|
+
r.status = 'noauth'
|
|
703
|
+
r.httpCode = code
|
|
704
|
+
} else {
|
|
705
|
+
r.status = 'down'
|
|
706
|
+
r.httpCode = code
|
|
707
|
+
}
|
|
626
708
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
709
|
+
if (typeof quotaPercent === 'number' && Number.isFinite(quotaPercent)) {
|
|
710
|
+
r.usagePercent = quotaPercent
|
|
711
|
+
// Provider-level fallback: apply latest known quota to sibling rows on same provider.
|
|
712
|
+
for (const sibling of state.results) {
|
|
713
|
+
if (sibling.providerKey === r.providerKey && (sibling.usagePercent === undefined || sibling.usagePercent === null)) {
|
|
714
|
+
sibling.usagePercent = quotaPercent
|
|
715
|
+
}
|
|
633
716
|
}
|
|
634
717
|
}
|
|
718
|
+
} finally {
|
|
719
|
+
r.isPinging = false
|
|
720
|
+
state.pendingPings = Math.max(0, state.pendingPings - 1)
|
|
635
721
|
}
|
|
636
722
|
}
|
|
637
723
|
|
|
638
724
|
// π Initial ping of all models
|
|
639
725
|
const initialPing = Promise.all(state.results.map(r => pingModel(r)))
|
|
640
726
|
|
|
641
|
-
// π Continuous ping loop with
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
}
|
|
727
|
+
// π Continuous ping loop with mode-driven cadence.
|
|
728
|
+
const runPingCycle = async () => {
|
|
729
|
+
refreshAutoPingMode()
|
|
730
|
+
state.lastPingTime = Date.now()
|
|
731
|
+
|
|
732
|
+
// π Refresh persisted usage snapshots each cycle so proxy writes appear live in table.
|
|
733
|
+
// π Freshness-aware: stale snapshots (>30m) are excluded and row reverts to undefined.
|
|
734
|
+
for (const r of state.results) {
|
|
735
|
+
const pct = _usageForRow(r.providerKey, r.modelId)
|
|
736
|
+
if (typeof pct === 'number' && Number.isFinite(pct)) {
|
|
737
|
+
r.usagePercent = pct
|
|
738
|
+
} else {
|
|
739
|
+
// If snapshot is now stale or gone, clear the cached value so UI shows N/A.
|
|
740
|
+
r.usagePercent = undefined
|
|
656
741
|
}
|
|
742
|
+
}
|
|
657
743
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
})
|
|
744
|
+
state.results.forEach(r => {
|
|
745
|
+
pingModel(r).catch(() => {
|
|
746
|
+
// Individual ping failures don't crash the loop
|
|
662
747
|
})
|
|
748
|
+
})
|
|
663
749
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
}, state.pingInterval)
|
|
750
|
+
refreshAutoPingMode()
|
|
751
|
+
scheduleNextPing()
|
|
667
752
|
}
|
|
668
753
|
|
|
669
754
|
// π Start the ping loop
|
|
670
755
|
state.pingIntervalObj = null
|
|
671
|
-
|
|
756
|
+
scheduleNextPing()
|
|
672
757
|
|
|
673
758
|
await initialPing
|
|
674
759
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.85",
|
|
4
4
|
"description": "Find the fastest coding LLM models in seconds β ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nvidia",
|
package/src/config.js
CHANGED
|
@@ -392,7 +392,7 @@ export function _emptyProfileSettings() {
|
|
|
392
392
|
tierFilter: null, // π null = show all tiers, or 'S'|'A'|'B'|'C'|'D'
|
|
393
393
|
sortColumn: 'avg', // π default sort column
|
|
394
394
|
sortAsc: true, // π true = ascending (fastest first for latency)
|
|
395
|
-
pingInterval:
|
|
395
|
+
pingInterval: 10000, // π default ms between pings in the steady "normal" mode
|
|
396
396
|
}
|
|
397
397
|
}
|
|
398
398
|
|
package/src/constants.js
CHANGED
|
@@ -51,7 +51,9 @@ export const ALT_HOME = '\x1b[H'
|
|
|
51
51
|
|
|
52
52
|
// π Timing constants β control how fast the health-check loop runs.
|
|
53
53
|
export const PING_TIMEOUT = 15_000 // π 15s per attempt before abort
|
|
54
|
-
|
|
54
|
+
// π PING_INTERVAL is the baseline "normal" cadence. Startup can still temporarily
|
|
55
|
+
// π boost to faster modes, but steady-state uses 10s unless the user picks another mode.
|
|
56
|
+
export const PING_INTERVAL = 10_000
|
|
55
57
|
|
|
56
58
|
// π Animation and column-width constants.
|
|
57
59
|
export const FPS = 12
|
package/src/key-handler.js
CHANGED
|
@@ -65,6 +65,10 @@ export function createKeyHandler(ctx) {
|
|
|
65
65
|
mergedModels,
|
|
66
66
|
apiKey,
|
|
67
67
|
chalk,
|
|
68
|
+
setPingMode,
|
|
69
|
+
noteUserActivity,
|
|
70
|
+
intervalToPingMode,
|
|
71
|
+
PING_MODE_CYCLE,
|
|
68
72
|
setResults,
|
|
69
73
|
readline,
|
|
70
74
|
} = ctx
|
|
@@ -121,6 +125,7 @@ export function createKeyHandler(ctx) {
|
|
|
121
125
|
|
|
122
126
|
return async (str, key) => {
|
|
123
127
|
if (!key) return
|
|
128
|
+
noteUserActivity()
|
|
124
129
|
|
|
125
130
|
// π Profile save mode: intercept ALL keys while inline name input is active.
|
|
126
131
|
// π Enter β save, Esc β cancel, Backspace β delete char, printable β append to buffer.
|
|
@@ -503,7 +508,7 @@ export function createKeyHandler(ctx) {
|
|
|
503
508
|
// π Try to reuse existing result to keep ping history
|
|
504
509
|
const existing = state.results.find(r => r.modelId === modelId && r.providerKey === providerKey)
|
|
505
510
|
if (existing) return existing
|
|
506
|
-
return { idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey, status: 'pending', pings: [], httpCode: null, hidden: false }
|
|
511
|
+
return { idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey, status: 'pending', pings: [], httpCode: null, isPinging: false, hidden: false }
|
|
507
512
|
})
|
|
508
513
|
// π Re-index results
|
|
509
514
|
nextResults.forEach((r, i) => { r.idx = i + 1 })
|
|
@@ -524,6 +529,7 @@ export function createKeyHandler(ctx) {
|
|
|
524
529
|
r.status = 'pending'
|
|
525
530
|
r.pings = []
|
|
526
531
|
r.httpCode = null
|
|
532
|
+
r.isPinging = false
|
|
527
533
|
pingModel(r).catch(() => {})
|
|
528
534
|
}
|
|
529
535
|
})
|
|
@@ -582,7 +588,7 @@ export function createKeyHandler(ctx) {
|
|
|
582
588
|
if (settings) {
|
|
583
589
|
state.sortColumn = settings.sortColumn || 'avg'
|
|
584
590
|
state.sortDirection = settings.sortAsc ? 'asc' : 'desc'
|
|
585
|
-
|
|
591
|
+
setPingMode(intervalToPingMode(settings.pingInterval || PING_INTERVAL), 'manual')
|
|
586
592
|
if (settings.tierFilter) {
|
|
587
593
|
const tierIdx = TIER_CYCLE.indexOf(settings.tierFilter)
|
|
588
594
|
if (tierIdx >= 0) state.tierFilterMode = tierIdx
|
|
@@ -773,7 +779,7 @@ export function createKeyHandler(ctx) {
|
|
|
773
779
|
// π Apply profile's TUI settings to live state
|
|
774
780
|
state.sortColumn = settings.sortColumn || 'avg'
|
|
775
781
|
state.sortDirection = settings.sortAsc ? 'asc' : 'desc'
|
|
776
|
-
|
|
782
|
+
setPingMode(intervalToPingMode(settings.pingInterval || PING_INTERVAL), 'manual')
|
|
777
783
|
if (settings.tierFilter) {
|
|
778
784
|
const tierIdx = TIER_CYCLE.indexOf(settings.tierFilter)
|
|
779
785
|
if (tierIdx >= 0) state.tierFilterMode = tierIdx
|
|
@@ -871,13 +877,13 @@ export function createKeyHandler(ctx) {
|
|
|
871
877
|
return
|
|
872
878
|
}
|
|
873
879
|
|
|
874
|
-
// π
|
|
875
|
-
// π
|
|
876
|
-
// π
|
|
880
|
+
// π W cycles the supported ping modes:
|
|
881
|
+
// π speed (2s) β normal (10s) β slow (30s) β forced (4s) β speed.
|
|
882
|
+
// π forced ignores auto speed/slow transitions until the user leaves it manually.
|
|
877
883
|
if (key.name === 'w') {
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
884
|
+
const currentIdx = PING_MODE_CYCLE.indexOf(state.pingMode)
|
|
885
|
+
const nextIdx = currentIdx >= 0 ? (currentIdx + 1) % PING_MODE_CYCLE.length : 0
|
|
886
|
+
setPingMode(PING_MODE_CYCLE[nextIdx], 'manual')
|
|
881
887
|
}
|
|
882
888
|
|
|
883
889
|
// π Tier toggle key: T = cycle through each individual tier (All β S+ β S β A+ β A β A- β B+ β B β C β All)
|
package/src/overlays.js
CHANGED
|
@@ -250,8 +250,8 @@ export function createOverlayRenderers(state, deps) {
|
|
|
250
250
|
lines.push(` ${chalk.cyan('Latest')} Most recent ping response time (ms) ${chalk.dim('Sort:')} ${chalk.yellow('L')}`)
|
|
251
251
|
lines.push(` ${chalk.dim('Shows how fast the server is responding right now β useful to catch live slowdowns.')}`)
|
|
252
252
|
lines.push('')
|
|
253
|
-
lines.push(` ${chalk.cyan('Avg Ping')} Average response time across all
|
|
254
|
-
lines.push(` ${chalk.dim('The long-term truth.
|
|
253
|
+
lines.push(` ${chalk.cyan('Avg Ping')} Average response time across all measurable pings (200 + 401) (ms) ${chalk.dim('Sort:')} ${chalk.yellow('A')}`)
|
|
254
|
+
lines.push(` ${chalk.dim('The long-term truth. Even without a key, a 401 still gives real latency so the average stays useful.')}`)
|
|
255
255
|
lines.push('')
|
|
256
256
|
lines.push(` ${chalk.cyan('Health')} Live status: β
UP / π₯ 429 / β³ TIMEOUT / β ERR / π NO KEY ${chalk.dim('Sort:')} ${chalk.yellow('H')}`)
|
|
257
257
|
lines.push(` ${chalk.dim('Tells you instantly if a model is reachable or down β no guesswork needed.')}`)
|
|
@@ -278,10 +278,9 @@ export function createOverlayRenderers(state, deps) {
|
|
|
278
278
|
lines.push(` ${chalk.yellow('Enter')} Select model and launch`)
|
|
279
279
|
lines.push('')
|
|
280
280
|
lines.push(` ${chalk.bold('Controls')}`)
|
|
281
|
-
lines.push(` ${chalk.yellow('W')}
|
|
282
|
-
lines.push(` ${chalk.yellow('
|
|
283
|
-
lines.push(` ${chalk.yellow('
|
|
284
|
-
lines.push(` ${chalk.yellow('Z')} Cycle launch mode ${chalk.dim('(OpenCode CLI β OpenCode Desktop β OpenClaw)')}`)
|
|
281
|
+
lines.push(` ${chalk.yellow('W')} Toggle ping mode ${chalk.dim('(speed 2s β normal 10s β slow 30s β forced 4s)')}`)
|
|
282
|
+
lines.push(` ${chalk.yellow('X')} Toggle token log page ${chalk.dim('(shows recent request usage from request-log.jsonl)')}`)
|
|
283
|
+
lines.push(` ${chalk.yellow('Z')} Cycle tool mode ${chalk.dim('(OpenCode CLI β OpenCode Desktop β OpenClaw)')}`)
|
|
285
284
|
lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(β pinned at top, persisted)')}`)
|
|
286
285
|
lines.push(` ${chalk.yellow('Q')} Smart Recommend ${chalk.dim('(π― find the best model for your task β questionnaire + live analysis)')}`)
|
|
287
286
|
lines.push(` ${chalk.rgb(57, 255, 20).bold('J')} Request Feature ${chalk.dim('(π send anonymous feedback to the project team)')}`)
|
package/src/render-table.js
CHANGED
|
@@ -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, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto') {
|
|
81
81
|
// π Filter out hidden models for display
|
|
82
82
|
const visibleResults = results.filter(r => !r.hidden)
|
|
83
83
|
|
|
@@ -97,6 +97,23 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
97
97
|
? chalk.dim(`pinging β ${pendingPings} in flightβ¦`)
|
|
98
98
|
: chalk.dim(`next ping ${secondsUntilNext}s`)
|
|
99
99
|
|
|
100
|
+
const intervalSec = Math.round(pingInterval / 1000)
|
|
101
|
+
const pingModeMeta = {
|
|
102
|
+
speed: { label: `${intervalSec}s speed`, color: chalk.bold.rgb(255, 210, 80) },
|
|
103
|
+
normal: { label: `${intervalSec}s normal`, color: chalk.bold.rgb(120, 210, 255) },
|
|
104
|
+
slow: { label: `${intervalSec}s slow`, color: chalk.bold.rgb(255, 170, 90) },
|
|
105
|
+
forced: { label: `${intervalSec}s forced`, color: chalk.bold.rgb(255, 120, 120) },
|
|
106
|
+
}
|
|
107
|
+
const activePingMode = pingModeMeta[pingMode] ?? pingModeMeta.normal
|
|
108
|
+
const pingModeBadge = activePingMode.color(` [${activePingMode.label}]`)
|
|
109
|
+
const pingModeHint = pingModeSource === 'idle'
|
|
110
|
+
? chalk.dim(' idle')
|
|
111
|
+
: pingModeSource === 'activity'
|
|
112
|
+
? chalk.dim(' resumed')
|
|
113
|
+
: pingModeSource === 'startup'
|
|
114
|
+
? chalk.dim(' startup')
|
|
115
|
+
: ''
|
|
116
|
+
|
|
100
117
|
// π Mode badge shown in header so user knows what Enter will do
|
|
101
118
|
// π Now includes key hint for mode toggle
|
|
102
119
|
let modeBadge
|
|
@@ -160,7 +177,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
160
177
|
const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
|
|
161
178
|
|
|
162
179
|
const lines = [
|
|
163
|
-
` ${chalk.greenBright.bold('β
FCM')}${modeBadge}${modeHint}${tierBadge}${originBadge}${profileBadge} ` +
|
|
180
|
+
` ${chalk.greenBright.bold('β
FCM')}${modeBadge}${pingModeBadge}${modeHint}${tierBadge}${originBadge}${profileBadge} ` +
|
|
164
181
|
chalk.greenBright(`β
${up}`) + chalk.dim(' up ') +
|
|
165
182
|
chalk.yellow(`β³ ${timeout}`) + chalk.dim(' timeout ') +
|
|
166
183
|
chalk.red(`β ${down}`) + chalk.dim(' down ') +
|
|
@@ -315,22 +332,30 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
315
332
|
? chalk.cyan(ctxRaw.padEnd(W_CTX))
|
|
316
333
|
: chalk.dim(ctxRaw.padEnd(W_CTX))
|
|
317
334
|
|
|
335
|
+
// π Keep the row-local spinner small and inline so users can still read the last measured latency.
|
|
336
|
+
const buildLatestPingDisplay = (value) => {
|
|
337
|
+
const spinner = r.isPinging ? ` ${FRAMES[frame % FRAMES.length]}` : ''
|
|
338
|
+
return `${value}${spinner}`.padEnd(W_PING)
|
|
339
|
+
}
|
|
340
|
+
|
|
318
341
|
// π Latest ping - pings are objects: { ms, code }
|
|
319
342
|
// π Show response time for 200 (success) and 401 (no-auth but server is reachable)
|
|
320
343
|
const latestPing = r.pings.length > 0 ? r.pings[r.pings.length - 1] : null
|
|
321
344
|
let pingCell
|
|
322
345
|
if (!latestPing) {
|
|
323
|
-
|
|
346
|
+
const placeholder = r.isPinging ? buildLatestPingDisplay('βββ') : 'βββ'.padEnd(W_PING)
|
|
347
|
+
pingCell = chalk.dim(placeholder)
|
|
324
348
|
} else if (latestPing.code === '200') {
|
|
325
349
|
// π Success - show response time
|
|
326
|
-
const str = String(latestPing.ms)
|
|
350
|
+
const str = buildLatestPingDisplay(String(latestPing.ms))
|
|
327
351
|
pingCell = latestPing.ms < 500 ? chalk.greenBright(str) : latestPing.ms < 1500 ? chalk.yellow(str) : chalk.red(str)
|
|
328
352
|
} else if (latestPing.code === '401') {
|
|
329
353
|
// π 401 = no API key but server IS reachable β still show latency in dim
|
|
330
|
-
pingCell = chalk.dim(String(latestPing.ms)
|
|
354
|
+
pingCell = chalk.dim(buildLatestPingDisplay(String(latestPing.ms)))
|
|
331
355
|
} else {
|
|
332
356
|
// π Error or timeout - show "βββ" (error code is already in Status column)
|
|
333
|
-
|
|
357
|
+
const placeholder = r.isPinging ? buildLatestPingDisplay('βββ') : 'βββ'.padEnd(W_PING)
|
|
358
|
+
pingCell = chalk.dim(placeholder)
|
|
334
359
|
}
|
|
335
360
|
|
|
336
361
|
// π Avg ping (just number, no "ms")
|
|
@@ -523,18 +548,17 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
523
548
|
} else {
|
|
524
549
|
lines.push('')
|
|
525
550
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
// π Footer hints adapt based on active mode
|
|
551
|
+
// π Footer hints adapt based on active mode.
|
|
552
|
+
const hotkey = (keyLabel, text) => chalk.yellow(keyLabel) + chalk.dim(text)
|
|
529
553
|
const actionHint = mode === 'openclaw'
|
|
530
|
-
?
|
|
554
|
+
? hotkey('Enter', 'βSetOpenClaw')
|
|
531
555
|
: mode === 'opencode-desktop'
|
|
532
|
-
?
|
|
533
|
-
:
|
|
556
|
+
? hotkey('Enter', 'βOpenDesktop')
|
|
557
|
+
: hotkey('Enter', 'βOpenCode')
|
|
534
558
|
// π Line 1: core navigation + sorting shortcuts
|
|
535
|
-
lines.push(chalk.dim(` ββ Navigate β’ `) + actionHint + chalk.dim(` β’ `) +
|
|
559
|
+
lines.push(chalk.dim(` ββ Navigate β’ `) + actionHint + chalk.dim(` β’ `) + hotkey('F', ' Toggle Favorite') + chalk.dim(` β’ Press Highlighted letters in column named to sort & filter β’ `) + hotkey('T', ' Tier') + chalk.dim(` β’ `) + hotkey('D', ' Provider') + chalk.dim(` β’ `) + hotkey('W', ' Ping Mode : FAST/NORMAL/SLOW/FORCED') + chalk.dim(` β’ `) + hotkey('Z', ' Tool Mode') + chalk.dim(` β’ `) + hotkey('X', ' Token Logs') + chalk.dim(` β’ `) + hotkey('P', ' Settings') + chalk.dim(` β’ `) + hotkey('K', ' Help'))
|
|
536
560
|
// π Line 2: profiles, recommend, feature request, bug report, and extended hints β gives visibility to less-obvious features
|
|
537
|
-
lines.push(chalk.dim(` `) +
|
|
561
|
+
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
562
|
// π Proxy status line β always rendered with explicit state (starting/running/failed/stopped)
|
|
539
563
|
lines.push(renderProxyStatusLine(proxyStartupStatus, activeProxyRef))
|
|
540
564
|
lines.push(
|
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)))
|