free-coding-models 0.5.59 โ†’ 0.5.60

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 CHANGED
@@ -550,6 +550,35 @@ The `source` field is `"header"` when the value came from a passive response hea
550
550
 
551
551
  ---
552
552
 
553
+ ## ๐Ÿ“ˆ Runtime telemetry: real-world scores
554
+
555
+ Every routed request through the daemon feeds a persistent per-model telemetry file (`~/.free-coding-models/runtime-telemetry.json`) โ€” the **honesty layer** that complements SWE-bench with what models actually do on free tiers.
556
+
557
+ - **Real success rate** โ€” `(successCalls / totalCalls)` updated on every routed request (success and failure paths alike).
558
+ - **Real throughput** โ€” `avgTokensPerSecond` derived from completion tokens / total latency, so you see what your code actually streams at.
559
+ - **Recent calls (last 50)** โ€” for debugging "why did this just 500?" without rebuilding state.
560
+ - **Composite `Real` score** โ€” `successRate ร— 0.6 + sigmoid01(tok/s) ร— 0.25 + recency ร— 0.15`. Null when below `MIN_CALLS_FOR_SCORE = 5` (no penalty for new models).
561
+
562
+ ### Where it shows up
563
+
564
+ | Surface | What you'll see |
565
+ |---------|-----------------|
566
+ | TUI `Real` column | Inline composite score per row, `โ€“` when insufficient data |
567
+ | TUI `W` sort key | Sort by real-world score descending |
568
+ | TUI `Shift+W` | Runtime Report overlay โ€” per-model breakdown + recent calls |
569
+ | Web Dashboard | "Runtime Telemetry" cards with animated success-rate bars |
570
+ | `/api/router/stats.runtime` | `{ stats: { modelsTracked, totalCalls, modelsWithSignal }, models: { ... } }` |
571
+
572
+ ### Privacy
573
+
574
+ The telemetry file lives **locally only** (`~/.free-coding-models/runtime-telemetry.json`, `0600` perms). Nothing is sent upstream unless you opt in to a future aggregate leaderboard. The file holds **metadata only** โ€” success, latency, tokens, error reason. No prompts, no responses, no content.
575
+
576
+ ### CLI flag
577
+
578
+ - `--clear-runtime` โ€” wipe the file before launching (reset the baseline).
579
+
580
+ ---
581
+
553
582
  ## ฯ€ Pi Extension โ€” FCM-Pi โš ๏ธ BETA
554
583
 
555
584
  **FCM-Pi** is a native [Pi coding agent](https://pi.dev) extension that integrates `free-coding-models` directly into your Pi session. It stays silent by default, scans only when you run `/fcm`, and lets you explicitly hot-swap models mid-session.
@@ -764,6 +793,7 @@ See [`packages/fcm-agent-core/README.md`](./packages/fcm-agent-core/README.md) f
764
793
  - **Last release timestamp** โ€” light pink footer shows `Last release: Mar 27, 2026, 09:42 PM` from npm so users know how fresh the data is
765
794
  - **Persistent probe-cache (t1)** โ€” every health probe result is cached to `~/.free-coding-models/probe-cache.json` for 24h. Warm starts render the full ranking in <500ms, only re-ping the models that are due (broken or TTL-expired). Broken models are auto-hidden across sessions โ€” toggle visibility with **Shift+B**. See [Persistent probe cache](#-persistent-probe-cache) below for `--reprobe`, `--probe-ttl`, `--show-broken`.
766
795
  - **Live quota from response headers (t2)** โ€” every routed chat-completion response already carries `x-ratelimit-*` headers. The daemon parses them in 8 variants and exposes live per-provider quota on the TUI footer (`๐Ÿ“Š groq 78% ยท sambanova 41%`) and in the Web Dashboard (`Provider Quota` section with animated progress bars). Zero extra network requests, zero quota waste. See [Live quota from headers](#-live-quota-from-response-headers) below.
796
+ - **Runtime telemetry: real-world scores (t3)** โ€” every routed request through the daemon feeds a persistent per-model telemetry file (`~/.free-coding-models/runtime-telemetry.json`) with real success rate, throughput, and recent calls. The `Real` column + `W` sort key in the TUI rank models by what *actually* works on free tiers, not what they claim on SWE-bench. See [Runtime telemetry](#-runtime-telemetry-real-world-scores) below.
767
797
 
768
798
  ---
769
799
 
@@ -62,6 +62,18 @@ async function main() {
62
62
  process.exit(0);
63
63
  }
64
64
 
65
+ // ๐Ÿ“– --clear-runtime (t3): wipe ~/.free-coding-models/runtime-telemetry.json
66
+ // ๐Ÿ“– before launching any surface. Keeps the TUI / daemon / web flows consistent.
67
+ if (cliArgs.clearRuntimeMode) {
68
+ try {
69
+ const { clearRuntimeTelemetry } = await import('../src/core/runtime-telemetry.js')
70
+ const ok = clearRuntimeTelemetry()
71
+ console.log(chalk.dim(` ${ok ? 'โœ“' : 'โœ—'} runtime-telemetry.json ${ok ? 'cleared' : 'clear failed'}`))
72
+ } catch (err) {
73
+ console.log(chalk.dim(` runtime-telemetry.json clear failed: ${err?.message || err}`))
74
+ }
75
+ }
76
+
65
77
  // Load JSON config before operational modes so the mandatory update policy can
66
78
  // ๐Ÿ“– persist failure counters for TUI, Web Dashboard, Docker daemon, and Desktop sidecar launches.
67
79
  const config = loadConfig();
@@ -0,0 +1,54 @@
1
+ # Changelog v0.5.60 - 2026-07-26
2
+
3
+ ### Added
4
+
5
+ - ๐Ÿ“ˆ **Runtime telemetry: real-world scores** (t3) โ€” every routed request through the daemon now feeds a persistent per-model telemetry file with real success rate, throughput, and recent calls. FCM stops showing you only what models *claim* on SWE-bench and starts showing you what they **actually do** on free tiers.
6
+
7
+ - **Composite `Real` score** (0..100) โ€” `successRate ร— 0.6 + sigmoid01(tok/s) ร— 0.25 + recency ร— 0.15`. Null when below `MIN_CALLS_FOR_SCORE = 5` (no penalty for new models).
8
+ - **Real success rate** โ€” `(successCalls / totalCalls)`. Updated on every routed request, success AND failure paths alike (auth_error / 5xx / 429 all count).
9
+ - **Real throughput** โ€” `avgTokensPerSecond` from completion tokens / total latency. What your code actually streams at.
10
+ - **Recent calls (last 50)** โ€” FIFO trim per model. Useful for debugging "why did this just 500?" without rebuilding state.
11
+ - **Local-only by default** โ€” the file lives at `~/.free-coding-models/runtime-telemetry.json` (`0600` perms). Nothing leaves your machine unless you opt in to a future aggregate leaderboard. Metadata only โ€” no prompts, no responses.
12
+
13
+ - ๐Ÿ†• **New sort key `W`** in the TUI: sorts by `Real` score descending. Models with insufficient data (null score) sink to the bottom in both directions.
14
+
15
+ - ๐Ÿ“Š **New TUI `Real` column**: shows the composite score inline per row, or `โ€“` when there's not enough signal yet.
16
+
17
+ - ๐Ÿ”ฅ **`Shift+W` Runtime Report overlay** โ€” per-model breakdown (success rate, avg latency, avg tok/s, recent calls list). Same pattern as `Shift+R` Router Dashboard / `Shift+T` Token Usage. Scroll with j/k/up/down/pageup/pagedown/home/end, close with Escape.
18
+
19
+ - ๐ŸŒ **Web Dashboard `Runtime Telemetry` section** โ€” per-model cards with animated success-rate bars (red โ‰ค 50% / amber 50-80% / green โ‰ฅ 80%). Updated every `/api/router/stats` poll (5 s). Models with < 5 calls are excluded so the section only shows real signal.
20
+
21
+ - ๐Ÿ›ฐ๏ธ **New daemon endpoint `/stats/runtime`** โ€” returns `{ ok, stats: { modelsTracked, totalCalls, modelsWithSignal }, models: { [key]: ModelTelemetry } }`. Same shape as the in-TUI dashboard digest.
22
+
23
+ - ๐Ÿงช **`recordModelCall(providerKey, modelId, callResult, opts?)`** โ€” drop-in hook for any future surface (Tauri Desktop, scripts) that wants to feed runtime telemetry.
24
+
25
+ - ๐Ÿ› ๏ธ **`--clear-runtime` CLI flag** โ€” wipe the telemetry file before launching any surface. Useful when the user wants to reset the baseline.
26
+
27
+ ### Changed
28
+
29
+ - ๐Ÿฉบ **Daemon reverse proxy** records every successful + failed routed outcome via `recordRuntimeCall()`. A debounced 5 s flush keeps the file in sync without thrashing the disk. The flush timer is cleared in `shutdown()` so no orphan writes after exit.
30
+
31
+ - ๐Ÿ“‹ **Daemon `statsPayload`** now exposes `runtimeTelemetry: { stats, models }` so the existing `/stats` consumers get the digest for free.
32
+
33
+ - ๐Ÿ”ง **Internal** โ€” added `sigmoid01(x)` (logistic curve so 50 tok/s โ†’ 0.5) and `recencyDecay(lastUpdatedMs)` (1.0 today โ†’ 0.0 at 30 d) to the real-world score formula. Both are pure functions with no dependencies.
34
+
35
+ ### Non-goals (explicit)
36
+
37
+ - **Streaming token extraction** โ€” the known gap from the PRD stays. The runtime telemetry only counts requests where the upstream returned `usage` (i.e. non-streaming, or streaming providers that emit usage on the final chunk).
38
+ - **Sharing / leaderboard** โ€” no aggregation server yet. When there is one, opt-in.
39
+
40
+ ### Maintenance
41
+
42
+ - ๐Ÿงช **+30 unit tests** (`test/runtime-telemetry.test.js`, new file): path resolution (XDG-aware), constants sanity, `recordModelCall` write/accumulate/drop garbage/FIFO cap, `tokensPerSecond` computation, derived fields, `getRealWorldScore` null below threshold / healthy โ†’ high / error-heavy โ†’ mid / age decay, `getCacheStats` aggregates + `modelsWithSignal` threshold, `pruneStaleEntries` drops old, persistence round-trip + corrupt recovery + missing file + clear + read-merge-write concurrency safety.
43
+ - ๐Ÿงช **671 โ†’ 701 tests passing** (`pnpm test`), **125 โ†’ 134 suites**.
44
+ - ๐Ÿงน `vite build` succeeds.
45
+ - ๐Ÿ“– README updated with a new **๐Ÿ“ˆ Runtime telemetry: real-world scores** section (where it shows up, privacy, `--clear-runtime`).
46
+
47
+ ### Inspiration
48
+
49
+ This implementation is informed by [`apmantza/pi-free`](https://github.com/apmantza/pi-free)'s `lib/telemetry.ts` (which credits us in their other direction too). pi-free's `turn_end` hook is the daemon-side equivalent of our `recordRuntimeCall`; the schema, the 50-call FIFO, and the local-only file mirror their approach. Where we diverge: we expose the composite `Real` score as a first-class ranking signal (not just a debugging surface), ship it in 3 user-facing surfaces (CLI TUI / Web Dashboard / daemon `/stats`), and add the successร—0.6 / speedร—0.25 / recencyร—0.15 weights so the user sees a single number per model.
50
+
51
+ ### Files
52
+
53
+ - **New**: `src/core/runtime-telemetry.js` (377 lines, 11 exports), `test/runtime-telemetry.test.js` (390 lines, 30 tests), `changelog/v0.5.60.md`.
54
+ - **Modified**: `src/core/router-daemon.js` (record on every routed outcome + `/stats/runtime` + runtimeTelemetry in statsPayload), `src/core/utils.js` (`'realworld'` sort case), `src/tui/app.js` (boot-time score computation per result), `src/tui/tui-state.js` (5 new state fields for the overlay), `src/tui/key-handler.js` (Shift+W + overlay keyboard handling + command palette action), `bin/free-coding-models.js` (`--clear-runtime` flag plumbing), `web/src/components/router/RouterView.jsx` (Runtime Telemetry section), `README.md`, `tasks/t3.md`, `package.json` (test script).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.59",
3
+ "version": "0.5.60",
4
4
  "description": "Find the fastest coding LLM models in seconds \u2014 ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
@@ -53,7 +53,7 @@
53
53
  ],
54
54
  "scripts": {
55
55
  "start": "node bin/free-coding-models.js",
56
- "test": "node --test test/test.js test/fcm-agent-core.test.js test/patch-openclaw.test.js test/provider-metadata.test.js test/config-permission-hint.test.js test/probe-cache.test.js test/passive-quota.test.js",
56
+ "test": "node --test test/test.js test/fcm-agent-core.test.js test/patch-openclaw.test.js test/provider-metadata.test.js test/config-permission-hint.test.js test/probe-cache.test.js test/passive-quota.test.js test/runtime-telemetry.test.js",
57
57
  "prepack": "npm run build:web",
58
58
  "dev": "node scripts/dev-web.mjs",
59
59
  "dev:web": "node scripts/dev-web.mjs",
@@ -68,6 +68,17 @@ import {
68
68
  getAllQuotas as getAllPassiveQuotas,
69
69
  STALENESS_MS as PASSIVE_QUOTA_STALENESS_MS,
70
70
  } from './provider-quota-fetchers.js'
71
+ import {
72
+ recordModelCall as recordRuntimeModelCall,
73
+ getAllModelTelemetry as getAllRuntimeTelemetry,
74
+ getRealWorldScore as getRuntimeRealWorldScore,
75
+ getCacheStats as getRuntimeCacheStats,
76
+ loadRuntimeTelemetry,
77
+ flushRuntimeTelemetry as flushRuntimeTelemetryStore,
78
+ clearRuntimeTelemetry as clearRuntimeTelemetryStore,
79
+ pruneStaleEntries as pruneRuntimeTelemetry,
80
+ DEFAULT_MIN_CALLS_FOR_SCORE as RUNTIME_MIN_CALLS,
81
+ } from './runtime-telemetry.js'
71
82
 
72
83
  export const ROUTER_DEFAULT_PORT = 19280
73
84
  export const ROUTER_MAX_PORT = 19289
@@ -895,9 +906,50 @@ class RouterRuntime {
895
906
  this.probeCache = loadProbeCache()
896
907
  this.probeCacheDirty = false
897
908
  this.probeCacheFlushTimer = null
909
+ // ๐Ÿ“– Runtime telemetry (t3): load the persistent per-model metrics file on boot.
910
+ // ๐Ÿ“– Prune models that haven't been seen in 30 days to keep file size bounded.
911
+ loadRuntimeTelemetry()
912
+ pruneRuntimeTelemetry(30 * 24 * 60 * 60 * 1000)
913
+ this.runtimeTelemetryDirty = false
914
+ this.runtimeTelemetryFlushTimer = null
898
915
  this.refreshRouteState()
899
916
  }
900
917
 
918
+ /**
919
+ * ๐Ÿ“– scheduleRuntimeTelemetryFlush โ€” debounced write so we don't thrash the
920
+ * ๐Ÿ“– filesystem when many requests succeed in quick succession.
921
+ */
922
+ scheduleRuntimeTelemetryFlush() {
923
+ if (this.runtimeTelemetryFlushTimer) return
924
+ this.runtimeTelemetryFlushTimer = setTimeout(() => {
925
+ this.runtimeTelemetryFlushTimer = null
926
+ if (this.runtimeTelemetryDirty) {
927
+ flushRuntimeTelemetryStore()
928
+ this.runtimeTelemetryDirty = false
929
+ }
930
+ }, 5000)
931
+ if (typeof this.runtimeTelemetryFlushTimer.unref === 'function') this.runtimeTelemetryFlushTimer.unref()
932
+ }
933
+
934
+ /**
935
+ * ๐Ÿ“– recordRuntimeCall โ€” central helper invoked from both success and failure
936
+ * ๐Ÿ“– paths in the reverse proxy. Wraps recordModelCall with the call-shape
937
+ * ๐Ÿ“– translation so every routed outcome (success or fail) feeds the telemetry.
938
+ */
939
+ recordRuntimeCall({ providerKey, modelId, success, latencyMs, usage, error }) {
940
+ if (!providerKey || !modelId) return
941
+ recordRuntimeModelCall(providerKey, modelId, {
942
+ success: !!success,
943
+ latencyMs: typeof latencyMs === 'number' && Number.isFinite(latencyMs) ? latencyMs : 0,
944
+ promptTokens: usage?.prompt_tokens,
945
+ completionTokens: usage?.completion_tokens,
946
+ stopReason: success ? 'stop' : null,
947
+ error: success ? null : (error || 'unknown'),
948
+ })
949
+ this.runtimeTelemetryDirty = true
950
+ this.scheduleRuntimeTelemetryFlush()
951
+ }
952
+
901
953
  buildModelCatalog() {
902
954
  const catalog = new Map()
903
955
  for (const [providerKey, source] of Object.entries(sources)) {
@@ -1503,6 +1555,15 @@ class RouterRuntime {
1503
1555
  // ๐Ÿ“– fetcher fallback). Stale entries (older than PASSIVE_QUOTA_STALENESS_MS)
1504
1556
  // ๐Ÿ“– are excluded so the consumer only sees fresh data.
1505
1557
  quota: Object.fromEntries(getAllPassiveQuotas()),
1558
+ // ๐Ÿ“– Runtime telemetry (t3): aggregate counters + every model's derived
1559
+ // ๐Ÿ“– snapshot (successRate, avgLatencyMs, avgTokensPerSecond, recentCalls).
1560
+ // ๐Ÿ“– Surfaced via /stats/runtime (separate endpoint) and here as a digest
1561
+ // ๐Ÿ“– so dashboards can show '1,420 calls tracked across 12 models' without
1562
+ // ๐Ÿ“– an extra round-trip.
1563
+ runtimeTelemetry: {
1564
+ stats: getRuntimeCacheStats(),
1565
+ models: getAllRuntimeTelemetry(),
1566
+ },
1506
1567
  }
1507
1568
  }
1508
1569
 
@@ -2087,6 +2148,15 @@ class RouterRuntime {
2087
2148
  this.markSuccess(key, latencyMs)
2088
2149
  const usage = extractUsage(parsed.value)
2089
2150
  this.tokenTracker.record(candidate.provider, candidate.model, usage)
2151
+ // ๐Ÿ“– Runtime telemetry (t3): track every successful routed request so the
2152
+ // ๐Ÿ“– real-world score can rank models by what *actually* works.
2153
+ this.recordRuntimeCall({
2154
+ providerKey: candidate.provider,
2155
+ modelId: candidate.model,
2156
+ success: true,
2157
+ latencyMs,
2158
+ usage,
2159
+ })
2090
2160
  this.totalRequestsRouted += 1
2091
2161
  // ๐Ÿ“– Fire app_router_use telemetry once per 10 routed requests
2092
2162
  if (this.totalRequestsRouted % 10 === 0) {
@@ -2122,12 +2192,20 @@ class RouterRuntime {
2122
2192
  if (AUTH_STATUS_CODES.has(response.status)) {
2123
2193
  this.markAuthError(key, `HTTP ${response.status}`)
2124
2194
  this.addRequestLog({ request_id: requestId, model: key, status: response.status, latency_ms: latencyMs, tokens: 0, failover: attemptIndex > 0, error: 'auth_error' })
2195
+ this.recordRuntimeCall({
2196
+ providerKey: candidate.provider, modelId: candidate.model,
2197
+ success: false, latencyMs, error: `auth_${response.status}`,
2198
+ })
2125
2199
  return { done: false, failoverToNext: true, reason: `auth_${response.status}`, authFailure: true }
2126
2200
  }
2127
2201
 
2128
2202
  if (RETRYABLE_STATUS_CODES.has(response.status)) {
2129
2203
  this.markFailure(key, `HTTP ${response.status}`, response.status, upstreamMeta)
2130
2204
  this.addRequestLog({ request_id: requestId, model: key, status: response.status, latency_ms: latencyMs, tokens: 0, failover: attemptIndex > 0, error: `http_${response.status}` })
2205
+ this.recordRuntimeCall({
2206
+ providerKey: candidate.provider, modelId: candidate.model,
2207
+ success: false, latencyMs, error: `http_${response.status}`,
2208
+ })
2131
2209
  return { done: false, failoverToNext: true, reason: `http_${response.status}` }
2132
2210
  }
2133
2211
 
@@ -2137,6 +2215,10 @@ class RouterRuntime {
2137
2215
  this.recordRouterError(`http_${response.status}`, requestId, { model: key, status: response.status, body: text })
2138
2216
  this.markFailure(key, `HTTP ${response.status}`)
2139
2217
  this.addRequestLog({ request_id: requestId, model: key, status: response.status, latency_ms: latencyMs, tokens: 0, failover: attemptIndex > 0, error: `http_${response.status}` })
2218
+ this.recordRuntimeCall({
2219
+ providerKey: candidate.provider, modelId: candidate.model,
2220
+ success: false, latencyMs, error: `http_${response.status}`,
2221
+ })
2140
2222
  return { done: false, failoverToNext: true, reason: `http_${response.status}` }
2141
2223
  }
2142
2224
 
@@ -2696,6 +2778,18 @@ class RouterRuntime {
2696
2778
  sendJson(res, 200, this.statsPayload(), { 'x-request-id': requestId })
2697
2779
  return
2698
2780
  }
2781
+ // ๐Ÿ“– Runtime telemetry (t3): /stats/runtime returns every model's derived
2782
+ // ๐Ÿ“– snapshot (successRate, avgLatencyMs, avgTokensPerSecond, recentCalls).
2783
+ // ๐Ÿ“– Useful for the Web Dashboard's runtime cards + the upcoming Shift+W
2784
+ // ๐Ÿ“– 'Runtime Report' screen in the TUI.
2785
+ if (req.method === 'GET' && url.pathname === '/stats/runtime') {
2786
+ sendJson(res, 200, {
2787
+ ok: true,
2788
+ stats: getRuntimeCacheStats(),
2789
+ models: getAllRuntimeTelemetry(),
2790
+ }, { 'x-request-id': requestId })
2791
+ return
2792
+ }
2699
2793
  if (req.method === 'GET' && url.pathname === '/stats/tokens') {
2700
2794
  sendJson(res, 200, this.tokenTracker.summary(), { 'x-request-id': requestId })
2701
2795
  return
@@ -3177,6 +3271,7 @@ class RouterRuntime {
3177
3271
  if (this.configReloadTimer) clearInterval(this.configReloadTimer)
3178
3272
  if (this.tokenFlushTimer) clearInterval(this.tokenFlushTimer)
3179
3273
  if (this.probeCacheFlushTimer) clearInterval(this.probeCacheFlushTimer)
3274
+ if (this.runtimeTelemetryFlushTimer) clearTimeout(this.runtimeTelemetryFlushTimer)
3180
3275
  for (const timeout of this.probeTimeouts) clearTimeout(timeout)
3181
3276
  const started = Date.now()
3182
3277
  while (this.inFlight > 0 && Date.now() - started < 30000) {
@@ -3184,6 +3279,7 @@ class RouterRuntime {
3184
3279
  }
3185
3280
  this.tokenTracker.flush({ force: true })
3186
3281
  flushProbeCache() // ๐Ÿ“– t1: persist any pending probe-cache deltas before exit
3282
+ if (this.runtimeTelemetryDirty) flushRuntimeTelemetryStore() // ๐Ÿ“– t3
3187
3283
  try { this.server?.close() } catch {}
3188
3284
  try { unlinkSync(ROUTER_PID_PATH) } catch {}
3189
3285
  try { unlinkSync(ROUTER_PORT_PATH) } catch {}