free-coding-models 0.5.63 โ†’ 0.5.65

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.
@@ -12,7 +12,9 @@
12
12
  * ๐Ÿ“– Freshness rules (in order):
13
13
  * ๐Ÿ“– 1. No entry โ†’ due for probe
14
14
  * ๐Ÿ“– 2. probeVersion mismatch โ†’ due for probe (silently re-probed, entry overwritten)
15
- * ๐Ÿ“– 3. status === 'broken' โ†’ always due (allows recovery detection)
15
+ * ๐Ÿ“– 3. status === 'broken' โ†’ only due AFTER `brokenCooldownMs(consecutiveFailures)`
16
+ * ๐Ÿ“– โœ… (issue #146: was always-due โ†’ caused quota burn on
17
+ * ๐Ÿ“– rate-limited providers like openrouter)
16
18
  * ๐Ÿ“– 4. now - lastProbedAt >= ttlMs โ†’ due for probe
17
19
  * ๐Ÿ“– 5. otherwise โ†’ fresh, skip
18
20
  *
@@ -34,11 +36,13 @@
34
36
  * โ†’ recordProbeResults(providerKey, results, opts?) โ†’ mutates in-memory cache
35
37
  * โ†’ getCacheStats(opts?) โ†’ { total, ok, broken, freshCount, staleCount, ... }
36
38
  * โ†’ getCachedResultsForProvider(providerKey, opts?) โ†’ array of synthesized results
39
+ * โ†’ brokenCooldownMs(failureCount) โ€” Backoff ladder for broken models (issue #146)
37
40
  *
38
41
  * @exports getProbeCachePath, loadCache, flushCache, clearCache,
39
42
  * getModelsDueForProbe, isCacheFresh, recordProbeResults,
40
43
  * getCacheStats, getCachedResultsForProvider,
41
- * DEFAULT_PROBE_TTL_MS, CURRENT_PROBE_VERSION
44
+ * DEFAULT_PROBE_TTL_MS, BROKEN_COOLDOWN_STEPS_MS,
45
+ * brokenCooldownMs, CURRENT_PROBE_VERSION
42
46
  *
43
47
  * @see src/core/ping-loop.js โ€” the integration point (skips fresh entries)
44
48
  * @see src/core/cache.js โ€” older per-session ping cache (5 min TTL, distinct concern)
@@ -55,6 +59,30 @@ import { atomicWriteJson } from './shared-helpers.js'
55
59
  /** ๐Ÿ“– Default probe-result freshness window โ€” 24h. Override via opts.ttlMs. */
56
60
  export const DEFAULT_PROBE_TTL_MS = 24 * 60 * 60 * 1000
57
61
 
62
+ /**
63
+ * ๐Ÿ“– Cooldown ladder for broken models (issue #146). After a consecutive failure,
64
+ * ๐Ÿ“– the model is treated as fresh for the duration returned by `brokenCooldownMs()`,
65
+ * ๐Ÿ“– so the ping loop skips it instead of hammering it every 2โ€“30s.
66
+ * ๐Ÿ“– The ladder grows exponentially then plateaus: 30s โ†’ 1m โ†’ 2m โ†’ 5m.
67
+ * ๐Ÿ“– Reaching the plateau (5min) caps the probe pressure on a permanently-broken
68
+ * ๐Ÿ“– model at ~0.2 req/min instead of the previous ~30 req/min.
69
+ */
70
+ export const BROKEN_COOLDOWN_STEPS_MS = [30_000, 60_000, 120_000, 300_000]
71
+
72
+ /**
73
+ * ๐Ÿ“– brokenCooldownMs: Cooldown duration for a model that has failed N consecutive times.
74
+ * ๐Ÿ“– Failure count is 1-indexed (1 = first failure, 2 = second, ...).
75
+ * ๐Ÿ“– Saturates at the last entry of BROKEN_COOLDOWN_STEPS_MS.
76
+ *
77
+ * @param {number} failureCount
78
+ * @returns {number} milliseconds (always >= 0)
79
+ */
80
+ export function brokenCooldownMs(failureCount) {
81
+ const n = Math.max(1, Math.floor(Number(failureCount) || 1))
82
+ const idx = Math.min(n - 1, BROKEN_COOLDOWN_STEPS_MS.length - 1)
83
+ return BROKEN_COOLDOWN_STEPS_MS[idx]
84
+ }
85
+
58
86
  /**
59
87
  * ๐Ÿ“– Bump this number whenever ping behaviour changes (new endpoint, different prompt,
60
88
  * ๐Ÿ“– new provider factory from t7, etc.). On load, any entry whose probeVersion differs
@@ -270,8 +298,16 @@ export function getModelsDueForProbe(providerKey, modelIds, opts = {}) {
270
298
  if (typeof entry.probeVersion !== 'number' || entry.probeVersion !== probeVersion) {
271
299
  due.push(id); continue
272
300
  }
273
- // Rule 3: broken โ†’ always due (recovery detection)
274
- if (entry.status === 'broken') { due.push(id); continue }
301
+ // Rule 3: broken โ†’ due only AFTER brokenCooldownMs elapsed (issue #146)
302
+ // ๐Ÿ“– Previous behaviour re-pinged broken models every cycle, which burned
303
+ // ๐Ÿ“– rate-limited providers' quota (openrouter: ~1000 req/day cap).
304
+ // ๐Ÿ“– Now we honour an exponential backoff: 30s โ†’ 1m โ†’ 2m โ†’ 5m (plateau).
305
+ if (entry.status === 'broken') {
306
+ const cooldown = brokenCooldownMs(entry.consecutiveFailures ?? 1)
307
+ if (now - entry.lastProbedAt < cooldown) continue
308
+ due.push(id)
309
+ continue
310
+ }
275
311
  // Rule 4: TTL expired โ†’ due
276
312
  if (typeof entry.lastProbedAt !== 'number' || now - entry.lastProbedAt >= ttlMs) {
277
313
  due.push(id); continue
@@ -301,7 +337,13 @@ export function isCacheFresh(providerKey, modelId, opts = {}) {
301
337
  const entry = cache?.providers?.[providerKey]?.models?.[modelId]
302
338
  if (!entry) return false
303
339
  if (typeof entry.probeVersion !== 'number' || entry.probeVersion !== probeVersion) return false
304
- if (entry.status === 'broken') return false
340
+ if (entry.status === 'broken') {
341
+ // ๐Ÿ“– Issue #146 โ€” broken models are now FRESH for `brokenCooldownMs` after their
342
+ // ๐Ÿ“– last failure, instead of always being due. This caps the probe pressure
343
+ // ๐Ÿ“– on permanently-broken models at ~0.2 req/min instead of ~30 req/min.
344
+ const cooldown = brokenCooldownMs(entry.consecutiveFailures ?? 1)
345
+ return now - entry.lastProbedAt < cooldown
346
+ }
305
347
  if (typeof entry.lastProbedAt !== 'number') return false
306
348
  return now - entry.lastProbedAt < ttlMs
307
349
  }
@@ -350,12 +392,19 @@ export function recordProbeResults(providerKey, results, opts = {}) {
350
392
  for (const raw of results || []) {
351
393
  try {
352
394
  const r = normaliseResult(raw)
395
+ // ๐Ÿ“– Consecutive failure tracker (issue #146): drives the broken-cooldown ladder.
396
+ // ๐Ÿ“– `ok` resets to 0; `broken` increments from the previous value (or 1 if absent).
397
+ const prev = bucket[r.modelId]
398
+ const consecutiveFailures = r.status === 'broken'
399
+ ? ((prev && Number.isInteger(prev.consecutiveFailures)) ? prev.consecutiveFailures + 1 : 1)
400
+ : 0
353
401
  bucket[r.modelId] = {
354
402
  status: r.status,
355
403
  lastProbedAt: now,
356
404
  latencyMs: r.latencyMs,
357
405
  lastError: r.lastError,
358
406
  probeVersion: CURRENT_PROBE_VERSION,
407
+ consecutiveFailures,
359
408
  }
360
409
  written++
361
410
  } catch {
@@ -0,0 +1,185 @@
1
+ /**
2
+ * @file provider-cooldown.js
3
+ * @description Per-provider quota circuit-breaker for the health-ping loop.
4
+ *
5
+ * @details
6
+ * ๐Ÿ“– Why this exists:
7
+ * ๐Ÿ“– - OpenRouter (and similar rate-limited gateways) enforce rate limits at the
8
+ * ๐Ÿ“– **provider/key** level, not per-model. A single 429 "Rate limit exceeded"
9
+ * ๐Ÿ“– response means **the whole key is paused**, often for hours.
10
+ * ๐Ÿ“– - Without this breaker, FCM keeps re-pinging every model of that provider
11
+ * ๐Ÿ“– every 2โ€“30 seconds (because probe-cache treats `broken` as always-due),
12
+ * ๐Ÿ“– burning the user's daily quota in minutes (issue #146).
13
+ * ๐Ÿ“– - This module exposes a tiny in-memory map of paused providers so the ping
14
+ * ๐Ÿ“– loop can short-circuit them until the Retry-After window expires.
15
+ *
16
+ * ๐Ÿ“– Design notes:
17
+ * ๐Ÿ“– - In-memory only, intentionally. The pause is short-lived (minutes to hours)
18
+ * ๐Ÿ“– and we don't want a stale pause to outlive a server-side reset.
19
+ * ๐Ÿ“– - `pauseProviderQuota` keeps the **maximum** pause if the provider is already
20
+ * ๐Ÿ“– paused, so a rapid succession of 429s with decreasing Retry-After values
21
+ * ๐Ÿ“– never accidentally **shortens** an existing longer pause.
22
+ *
23
+ * @functions
24
+ * โ†’ isProviderQuotaPaused(providerKey, now?) โ€” Is this provider currently paused?
25
+ * โ†’ pauseProviderQuota(providerKey, ms) โ€” Set a pause for ms milliseconds
26
+ * โ†’ providerQuotaPauseRemaining(providerKey, now?) โ€” ms left in the current pause
27
+ * โ†’ listPausedProviders(now?) โ€” Diagnostic: {[providerKey]: remainingMs}
28
+ * โ†’ clearProviderQuotaPause(providerKey) โ€” Force-reset (testing/escape hatch)
29
+ * โ†’ parseRetryAfterMs(value) โ€” Header-only Retry-After parser
30
+ * โ†’ extractRetryAfterFromResponse(resp) โ€” Header + body parser (OR style message)
31
+ *
32
+ * @exports isProviderQuotaPaused, pauseProviderQuota, providerQuotaPauseRemaining,
33
+ * listPausedProviders, clearProviderQuotaPause, parseRetryAfterMs,
34
+ * extractRetryAfterFromResponse
35
+ */
36
+
37
+ // โ”€โ”€โ”€ Module state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
38
+
39
+ /**
40
+ * ๐Ÿ“– Map<providerKey, timestamp-ms> until which the provider's quota is considered
41
+ * ๐Ÿ“– paused. `now >= value` means the pause has expired and the entry is purged lazily.
42
+ */
43
+ const providerQuotaPausedUntil = new Map()
44
+
45
+ // โ”€โ”€โ”€ Pure helpers (exported, easy to unit test) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
46
+
47
+ /**
48
+ * ๐Ÿ“– parseRetryAfterMs: Parse a Retry-After header value into milliseconds.
49
+ * ๐Ÿ“– Accepts either a plain-seconds integer ("120") or an HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT").
50
+ * ๐Ÿ“– Returns null if the value is missing, malformed, or already in the past.
51
+ *
52
+ * @param {string|number|null|undefined} value
53
+ * @returns {number|null} milliseconds, or null when not parseable
54
+ */
55
+ export function parseRetryAfterMs(value) {
56
+ if (value == null) return null
57
+ if (typeof value === 'string' && value.trim() === '') return null
58
+ const seconds = Number(value)
59
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000)
60
+ const dateMs = Date.parse(value)
61
+ if (Number.isFinite(dateMs)) {
62
+ const delta = dateMs - Date.now()
63
+ return delta > 0 ? delta : null
64
+ }
65
+ return null
66
+ }
67
+
68
+ /**
69
+ * ๐Ÿ“– extractRetryAfterFromResponse: Best-effort Retry-After extraction.
70
+ * ๐Ÿ“– Tries the header first (RFC 7231), then falls back to the OpenRouter-style
71
+ * ๐Ÿ“– error body "Rate limit exceeded, please try again N seconds later.".
72
+ * ๐Ÿ“– Returns the delay in milliseconds, or 0 if no signal is found.
73
+ *
74
+ * @param {Response} resp โ€” A Fetch Response object (uses .headers + .clone()/.text()).
75
+ * @returns {Promise<number>}
76
+ */
77
+ export async function extractRetryAfterFromResponse(resp) {
78
+ if (!resp || typeof resp.headers?.get !== 'function') return 0
79
+
80
+ // 1) Standard Retry-After header (lowercase lookup matches Headers semantics)
81
+ const headerMs = parseRetryAfterMs(resp.headers.get('retry-after'))
82
+ if (headerMs && headerMs > 0) return headerMs
83
+
84
+ // 2) OpenRouter-style message body, e.g.: "Rate limit exceeded, please try again 50680 seconds later."
85
+ try {
86
+ const text = await resp.clone().text()
87
+ if (!text) return 0
88
+ const m = text.match(/try again (\d+)\s*seconds? later/i)
89
+ if (m) {
90
+ const secs = parseInt(m[1], 10)
91
+ if (Number.isFinite(secs) && secs > 0) return secs * 1000
92
+ }
93
+ } catch {
94
+ // Body unreadable โ€” treat as no signal, don't throw.
95
+ }
96
+ return 0
97
+ }
98
+
99
+ // โ”€โ”€โ”€ Pause map API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
100
+
101
+ /**
102
+ * ๐Ÿ“– isProviderQuotaPaused: Return true while the provider's quota pause is active.
103
+ * ๐Ÿ“– Lazily purges expired pauses so the map stays bounded over long sessions.
104
+ *
105
+ * @param {string} providerKey
106
+ * @param {number} [now=Date.now()]
107
+ * @returns {boolean}
108
+ */
109
+ export function isProviderQuotaPaused(providerKey, now = Date.now()) {
110
+ const until = providerQuotaPausedUntil.get(providerKey)
111
+ if (until == null) return false
112
+ if (now >= until) {
113
+ providerQuotaPausedUntil.delete(providerKey)
114
+ return false
115
+ }
116
+ return true
117
+ }
118
+
119
+ /**
120
+ * ๐Ÿ“– pauseProviderQuota: Mark a provider as paused for `ms` milliseconds.
121
+ * ๐Ÿ“– If already paused, keeps the longer of the existing and new windows (never shortens).
122
+ *
123
+ * @param {string} providerKey
124
+ * @param {number} ms โ€” Duration in milliseconds. Non-positive values are ignored.
125
+ * @returns {boolean} true if the pause was applied or extended, false otherwise.
126
+ */
127
+ export function pauseProviderQuota(providerKey, ms, opts = {}) {
128
+ if (!providerKey || typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) return false
129
+ const now = opts.now ?? Date.now()
130
+ const until = now + ms
131
+ const cur = providerQuotaPausedUntil.get(providerKey) ?? 0
132
+ // ๐Ÿ“– Keep the max so a 5s pause arriving after a 14h pause never shortens it.
133
+ if (until > cur) providerQuotaPausedUntil.set(providerKey, until)
134
+ return true
135
+ }
136
+
137
+ /**
138
+ * ๐Ÿ“– providerQuotaPauseRemaining: How many ms remain on the current pause.
139
+ * ๐Ÿ“– Returns 0 when the pause has expired or the provider is not paused.
140
+ *
141
+ * @param {string} providerKey
142
+ * @param {number} [now=Date.now()]
143
+ * @returns {number} milliseconds remaining (always >= 0)
144
+ */
145
+ export function providerQuotaPauseRemaining(providerKey, now = Date.now()) {
146
+ const until = providerQuotaPausedUntil.get(providerKey)
147
+ if (!until) return 0
148
+ const remaining = until - now
149
+ if (remaining <= 0) {
150
+ providerQuotaPausedUntil.delete(providerKey)
151
+ return 0
152
+ }
153
+ return remaining
154
+ }
155
+
156
+ /**
157
+ * ๐Ÿ“– listPausedProviders: Snapshot of currently paused providers + ms remaining.
158
+ * ๐Ÿ“– Intended for diagnostics (TUI footer chip, daemon /stats, error logs).
159
+ *
160
+ * @param {number} [now=Date.now()]
161
+ * @returns {Record<string, number>} {[providerKey]: msRemaining}
162
+ */
163
+ export function listPausedProviders(now = Date.now()) {
164
+ const out = {}
165
+ for (const [providerKey, until] of providerQuotaPausedUntil) {
166
+ const remaining = until - now
167
+ if (remaining > 0) out[providerKey] = remaining
168
+ }
169
+ return out
170
+ }
171
+
172
+ /**
173
+ * ๐Ÿ“– clearProviderQuotaPause: Force-reset the pause for a provider (or all).
174
+ * ๐Ÿ“– Mainly a test/escape hatch โ€” production code should let pauses expire naturally.
175
+ *
176
+ * @param {string} [providerKey] โ€” omit to clear all pauses
177
+ * @returns {void}
178
+ */
179
+ export function clearProviderQuotaPause(providerKey) {
180
+ if (!providerKey) {
181
+ providerQuotaPausedUntil.clear()
182
+ return
183
+ }
184
+ providerQuotaPausedUntil.delete(providerKey)
185
+ }
package/src/tui/app.js CHANGED
@@ -139,6 +139,10 @@ import {
139
139
  pruneStaleEntries as pruneProbeCacheStaleEntries,
140
140
  DEFAULT_PROBE_TTL_MS,
141
141
  } from '../core/probe-cache.js'
142
+ import {
143
+ isProviderQuotaPaused,
144
+ listPausedProviders,
145
+ } from '../core/provider-cooldown.js'
142
146
  import { checkConfigSecurity } from '../core/security.js'
143
147
  import { buildCliHelpText } from './cli-help.js'
144
148
  import { detectActiveTheme, THEME_BG_RGB, getTheme, patchThemeBg } from './theme.js'
@@ -1046,6 +1050,8 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1046
1050
  probeCacheMisses: state.probeCacheMisses || 0,
1047
1051
  probeCacheBrokenHidden: state.probeCacheBrokenHidden || 0,
1048
1052
  showBrokenMode: !!state.showBrokenMode,
1053
+ // ๐Ÿ“– Provider circuit-breaker (issue #146): footer chip for paused providers
1054
+ pausedProviders: state.pausedProviders || {},
1049
1055
  // ๐Ÿ“– Enrichment chips (t4 + t5): benchmark catalog + models.dev provenance
1050
1056
  // ๐Ÿ“– Read fresh from moduleEnrichmentStats on every render so the async
1051
1057
  // ๐Ÿ“– models.dev fetch (background, never blocks the TUI) is reflected as
@@ -1229,6 +1235,13 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1229
1235
  if (!state.config.favorites.includes(favKey)) return
1230
1236
  }
1231
1237
 
1238
+ // ๐Ÿ“– Per-provider circuit-breaker (issue #146): if the provider's quota is
1239
+ // ๐Ÿ“– paused (e.g. openrouter returned 429 "try again 50680s later"), skip
1240
+ // ๐Ÿ“– every model of that provider until the Retry-After window expires.
1241
+ // ๐Ÿ“– Without this, broken/overloaded models are re-pinged every cycle,
1242
+ // ๐Ÿ“– burning the user's daily quota in minutes.
1243
+ if (isProviderQuotaPaused(r.providerKey)) return
1244
+
1232
1245
  // ๐Ÿ“– Probe-cache (t1): skip models that are still fresh + ok in the cache.
1233
1246
  // ๐Ÿ“– Broken models always pass through isCacheFresh() (it returns false for them),
1234
1247
  // ๐Ÿ“– which gives us automatic recovery detection for free.
@@ -1241,6 +1254,11 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1241
1254
  })
1242
1255
  })
1243
1256
 
1257
+ // ๐Ÿ“– Take a snapshot of currently paused providers so the TUI footer can
1258
+ // ๐Ÿ“– surface a "โธ openrouter (14h)" chip while we wait out a Retry-After.
1259
+ // ๐Ÿ“– Updates every cycle so the remaining-time ticks down live.
1260
+ state.pausedProviders = listPausedProviders()
1261
+
1244
1262
  refreshAutoPingMode()
1245
1263
  scheduleNextPing()
1246
1264
  } catch (err) {
@@ -216,6 +216,7 @@ export function renderTable({
216
216
  probeCacheMisses = 0,
217
217
  probeCacheBrokenHidden = 0,
218
218
  showBrokenMode = false,
219
+ pausedProviders = {}, // ๐Ÿ“– issue #146: { openrouter: 50680000 } while a provider is paused
219
220
  enrichmentBenchCount = 0,
220
221
  enrichmentBenchLastUpdated = null,
221
222
  metaSourceCounts = null,
@@ -1235,6 +1236,36 @@ export function renderTable({
1235
1236
  probeCacheLabel = chalk.bgRgb(40, 90, 140).rgb(220, 235, 255).bold(` ${cachedTxt}${brokenTxt} `)
1236
1237
  }
1237
1238
 
1239
+ // ๐Ÿ“– Provider circuit-breaker chip (issue #146): surfaces any provider that the
1240
+ // ๐Ÿ“– health-ping loop has paused because it returned a 429 quota response.
1241
+ // ๐Ÿ“– Format: `โธ openrouter 14h ยท โธ mistral 5m`. The live duration auto-rolls
1242
+ // ๐Ÿ“– to s/m/h/d so users see a real countdown and don't get surprised later
1243
+ // ๐Ÿ“– when their requests stop working ("wait, why is everything 429-ing?").
1244
+ let pausedProvidersLabel = ''
1245
+ if (pausedProviders && typeof pausedProviders === 'object') {
1246
+ const fmtRemaining = (ms) => {
1247
+ if (!Number.isFinite(ms) || ms <= 0) return '0s'
1248
+ const totalSec = Math.round(ms / 1000)
1249
+ const day = Math.floor(totalSec / 86400)
1250
+ const hr = Math.floor((totalSec % 86400) / 3600)
1251
+ const min = Math.floor((totalSec % 3600) / 60)
1252
+ const sec = totalSec % 60
1253
+ if (day > 0) return `${day}d${hr}h`
1254
+ if (hr > 0) return `${hr}h${min}m`
1255
+ if (min > 0) return `${min}m${sec}s`
1256
+ return `${sec}s`
1257
+ }
1258
+ const entries = Object.entries(pausedProviders)
1259
+ .filter(([, ms]) => Number.isFinite(ms) && ms > 0)
1260
+ .sort(([a], [b]) => a.localeCompare(b))
1261
+ if (entries.length > 0) {
1262
+ const txt = entries.map(([k, ms]) => `โธ ${k} ${fmtRemaining(ms)}`).join(' \u00b7 ')
1263
+ // ๐Ÿ“– Warm amber background โ€” distinct from the blue probe-cache chip so the
1264
+ // ๐Ÿ“– user can spot it at a glance and understand "these providers are resting".
1265
+ pausedProvidersLabel = chalk.bgRgb(140, 80, 30).rgb(255, 230, 200).bold(` ${txt} `)
1266
+ }
1267
+ }
1268
+
1238
1269
  // ๐Ÿ“– Enrichment chips (t4 + t5):
1239
1270
  // ๐Ÿ“– `๐Ÿ“Š bench N (date) ยท ๐Ÿ“ก live K / ๐Ÿ“ฆ cached M`
1240
1271
  // ๐Ÿ“– Always shown when any enrichment state exists.
@@ -1277,8 +1308,8 @@ export function renderTable({
1277
1308
  }
1278
1309
  }
1279
1310
 
1280
- // ๐Ÿ“– Line 3: Speed Test + Global Benchmark + Probe + Probe-cache + Enrichment + Quota + Last release
1281
- if (releaseLabel || speedTestLabel || globalBenchmarkLabel || probeLabel || probeCacheLabel || enrichmentLabel || quotaLabel) {
1311
+ // ๐Ÿ“– Line 3: Speed Test + Global Benchmark + Probe + Probe-cache + Paused-providers + Enrichment + Quota + Last release
1312
+ if (releaseLabel || speedTestLabel || globalBenchmarkLabel || probeLabel || probeCacheLabel || pausedProvidersLabel || enrichmentLabel || quotaLabel) {
1282
1313
  const parts = [
1283
1314
  { text: ' ', key: null },
1284
1315
  { text: speedTestLabel, key: 'a' },
@@ -1288,6 +1319,8 @@ export function renderTable({
1288
1319
  { text: probeLabel, key: null },
1289
1320
  { text: probeCacheLabel ? ' ' : '', key: null },
1290
1321
  { text: probeCacheLabel, key: null },
1322
+ { text: pausedProvidersLabel ? ' ' : '', key: null },
1323
+ { text: pausedProvidersLabel, key: null },
1291
1324
  { text: enrichmentLabel ? ' ' : '', key: null },
1292
1325
  { text: enrichmentLabel, key: null },
1293
1326
  { text: quotaLabel ? ' ' : '', key: null },
@@ -297,6 +297,13 @@ export function createTuiState({
297
297
  probeCacheTtlMs: null, // ๐Ÿ“– Set in app.js from cliArgs.probeTtlMs || DEFAULT
298
298
  showBrokenMode: false,
299
299
 
300
+ // ๐Ÿ“– Per-provider quota circuit-breaker (issue #146): snapshot of providers
301
+ // ๐Ÿ“– currently paused because they returned 429 during a health-probe.
302
+ // ๐Ÿ“– Format: { [providerKey]: remainingMs } โ€” refreshed each ping cycle
303
+ // ๐Ÿ“– so the TUI footer can show a live "โธ openrouter (14h)" chip.
304
+ // ๐Ÿ“– Empty when no provider is in pause.
305
+ pausedProviders: {},
306
+
300
307
  // ๐Ÿ“– Runtime telemetry overlay state (t3): Shift+W opens the Runtime Report
301
308
  // ๐Ÿ“– overlay. Data is loaded fresh from ~/.free-coding-models/runtime-telemetry.json
302
309
  // ๐Ÿ“– when the overlay opens โ€” no polling, no daemon dependency.