free-coding-models 0.5.64 โ†’ 0.5.66

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.
Files changed (40) hide show
  1. package/README.md +550 -593
  2. package/changelog/v0.5.65.md +35 -0
  3. package/changelog/v0.5.66.md +55 -0
  4. package/package.json +3 -3
  5. package/src/core/ping.js +16 -0
  6. package/src/core/probe-cache.js +54 -5
  7. package/src/core/provider-cooldown.js +185 -0
  8. package/src/tui/app.js +18 -0
  9. package/src/tui/render-table.js +35 -2
  10. package/src/tui/tui-state.js +7 -0
  11. package/web/dist/assets/{index-5S_yM5SF.js โ†’ index-BhjFwk1y.js} +2 -2
  12. package/web/dist/favicon.ico +0 -0
  13. package/web/dist/favicons/apple-touch-icon.png +0 -0
  14. package/web/dist/favicons/favicon-16x16.png +0 -0
  15. package/web/dist/favicons/favicon-192x192.png +0 -0
  16. package/web/dist/favicons/favicon-32x32.png +0 -0
  17. package/web/dist/favicons/favicon-48x48.png +0 -0
  18. package/web/dist/favicons/favicon-512x512.png +0 -0
  19. package/web/dist/favicons/favicon-96x96.png +0 -0
  20. package/web/dist/favicons/favicon.ico +0 -0
  21. package/web/dist/favicons/mstile-150x150.png +0 -0
  22. package/web/dist/favicons/mstile-310x150.png +0 -0
  23. package/web/dist/favicons/mstile-310x310.png +0 -0
  24. package/web/dist/favicons/mstile-512x512.png +0 -0
  25. package/web/dist/favicons/mstile-70x70.png +0 -0
  26. package/web/dist/index.html +1 -1
  27. package/web/public/favicon.ico +0 -0
  28. package/web/public/favicons/apple-touch-icon.png +0 -0
  29. package/web/public/favicons/favicon-16x16.png +0 -0
  30. package/web/public/favicons/favicon-192x192.png +0 -0
  31. package/web/public/favicons/favicon-32x32.png +0 -0
  32. package/web/public/favicons/favicon-48x48.png +0 -0
  33. package/web/public/favicons/favicon-512x512.png +0 -0
  34. package/web/public/favicons/favicon-96x96.png +0 -0
  35. package/web/public/favicons/favicon.ico +0 -0
  36. package/web/public/favicons/mstile-150x150.png +0 -0
  37. package/web/public/favicons/mstile-310x150.png +0 -0
  38. package/web/public/favicons/mstile-310x310.png +0 -0
  39. package/web/public/favicons/mstile-512x512.png +0 -0
  40. package/web/public/favicons/mstile-70x70.png +0 -0
@@ -0,0 +1,35 @@
1
+ # Changelog v0.5.65 - 2026-07-29
2
+
3
+ ### Fixed
4
+
5
+ - ๐Ÿ›ก๏ธ **#146 โ€” OpenRouter / rate-limited providers no longer burned the user's daily quota.** Health-probe pings are now throttled at two orthogonal levels so a single overloaded model can no longer wipe out an OpenRouter (or similar) daily request limit. Issue submitted by `@ia-S-on`.
6
+
7
+ - **Per-provider quota circuit-breaker** โ€” New module `src/core/provider-cooldown.js` exposes an in-memory pause map. When a probe returns HTTP `429`, FCM parses the `Retry-After` header (and falls back to the OpenRouter-style body `"Rate limit exceeded, please try again N seconds later."`), then pauses **every model of that provider** until the window expires. Probe pressure drops from ~30 req/min/provider to ~0 req/min/provider for the entire duration. Affected code: `src/core/ping.js` (trigger on `resp.status === 429`), `src/tui/app.js` (skip paused providers in `runPingCycle`).
8
+ - **Per-model broken cooldown with exponential backoff** โ€” `src/core/probe-cache.js` now caches `broken` entries too. `isCacheFresh()` and `getModelsDueForProbe()` honour a new `brokenCooldownMs(failureCount)` ladder (30 s โ†’ 1 m โ†’ 2 m โ†’ 5 m, then plateau at 5 min). `recordProbeResults()` tracks `consecutiveFailures` (increments on `broken`, resets to `0` on `ok`). This caps worst-case probe pressure on a permanently-broken model at **~0.2 req/min** instead of the previous **~30 req/min**.
9
+ - **Live footer chip** โ€” When one or more providers are paused, the TUI footer now shows e.g. `โธ openrouter 14h ยท โธ ovhcloud 34s` (warm amber background, live countdown that auto-rolls to s/m/h/d). New state field `state.pausedProviders` (defaults to `{}`) + render-table chip fed by `pausedProviders` option. Behaviour verified on first run โ€” OVHcloud was correctly paused for 34 s on a real 429 during smoke testing.
10
+
11
+ ### Added
12
+
13
+ - ๐Ÿ“– **README health-check warning** โ€” New section `โš ๏ธ Health checks consume provider quota` between the providers table and the Tier scale. Explains the 1k/day OpenRouter cap, links the v0.5.65 fix points, and offers the immediate workaround (remove the provider's key from Settings โ€” anonymous probes still work for liveness checks).
14
+ - ๐Ÿงช **18 new unit tests** in `test/test.js` covering both layers (`Issue #146: provider quota circuit-breaker` + `Issue #146: per-model broken-cooldown backoff`). Three pre-existing tests in `test/probe-cache.test.js` updated to reflect the new `broken`-is-fresh-during-cooldown contract.
15
+
16
+ ### Changed
17
+
18
+ - ๐Ÿ“– `pnpm test` suite: **797 / 797 passing** (was 779 / 779 in 0.5.64; net +18 new tests + 3 updated).
19
+ - ๐Ÿ“– `node --check` clean on every touched file; TUI smoke-tested in tmux (no runtime regression).
20
+ - ๐Ÿ“– Footer's existing probe-cache chip is unchanged in behaviour but gains the sibling paused-providers chip immediately to its right.
21
+
22
+ ### Migration / workaround
23
+
24
+ > **If you were already hitting the 429 from issue #146 before upgrading**, simply upgrading to v0.5.65 fixes the runaway probe loop โ€” no manual step required. The provider will be auto-paused on its next probe.
25
+ >
26
+ > **If you want zero authenticated probes for a particular provider** (e.g. you only use OpenRouter for ad-hoc calls and want FCM to leave the key alone), leave the key empty in Settings โ€” anonymous probes still tell us whether each model is up and will not burn your personal daily quota.
27
+
28
+ ### Files
29
+
30
+ - **New**: `src/core/provider-cooldown.js` (โ‰ˆ220 lines).
31
+ - **Modified**: `src/core/ping.js` (import + 429 trigger), `src/core/probe-cache.js` (constants, `brokenCooldownMs`, `isCacheFresh`, `getModelsDueForProbe`, `recordProbeResults`), `src/tui/app.js` (import + `runPingCycle` skip + paused snapshot), `src/tui/tui-state.js` (`pausedProviders: {}` on state), `src/tui/render-table.js` (new chip + thread option through), `README.md` (warning section), `test/test.js` (18 new tests), `test/probe-cache.test.js` (3 updated), `package.json` (`0.5.64` โ†’ `0.5.65`), this changelog.
32
+
33
+ ### Breaking changes
34
+
35
+ None. All changes are additive and behaviour-preserving for users who were not already rate-limited.
@@ -0,0 +1,55 @@
1
+ # Changelog v0.5.66 - 2026-07-29
2
+
3
+ ### Added
4
+
5
+ - ๐Ÿ“š **Project-wide documentation overhaul** โ€” The README, the `docs/` repo, and the marketing site (`website/`) were rewritten end-to-end by a pro doc pass. ~2,000 insertions / ~1,600 deletions across **29 files**. Public behaviour is unchanged; this is a content-only release.
6
+
7
+ - **Reordered README around the product priority** โ€” TUI โ†’ Web โ†’ Agent Extensions โ†’ Smart Model Router (last). The router and the extensions moved out of the middle of the doc and into dedicated sections at the end, so the TUI and the Web Dashboard (the two core surfaces) are now the centerpiece of the reading flow.
8
+
9
+ - **Expanded Quick Start** โ€” grew from a 3-step outline to a 6-step user-centric walkthrough (Install โ†’ Grab a key โ†’ Launch & paste โ†’ Pick & launch โ†’ The 30-second cool tour โ†’ Go further). Each step links to the relevant deep section. Includes a `headless` sidebar (`--json`, `--fiable`) and a `pre-target a tool from the command line` example block.
10
+
11
+ - **๐ŸŸข Free AI Providers** โ€” kept the 20-provider table near the top, but now sits after Quick Start with a clean `โš ๏ธ Health checks consume provider quota` note and a collapsible `๐Ÿงน Providers removed from the catalog` details block.
12
+
13
+ - **๐ŸŽ›๏ธ TUI section** โ€” now opens with a `First-run flow` (3 steps), then 7 user-framed workflows (`"Give me the fastest model that actually works"`, `"Pick a model & launch"`, `"Benchmark before I commit"`, `"Smart Recommend"`, `"Keep my go-to models pinned"`, `"Switch tools without restarting"`, `"My terminal theme fights the TUI colors"`), followed by the complete keyboard reference and a mouse reference.
14
+
15
+ - **๐ŸŒ Web Dashboard** โ€” feature table condensed, `web` (port 3333) vs `--daemon` (port 19280) port distinction made explicit, Docker quick-start + image tags + env vars + compose + troubleshooting all in one place.
16
+
17
+ - **๐Ÿ”Œ 19 per-tool integration pages** โ€” replaced the previous 3 generic integration pages (`opencode`, `pi-extension`, `openclaw`) with a dedicated page for every supported target: OpenCode, Pi, OpenClaw, Crush, Goose, Aider, Kilo, Hermes, Continue, Cline, Amp, Qwen Code, ForgeCode, ZCode, jcode, Caveman Code, Copilot CLI, OpenHands, Xcode Intelligence. Each page documents the launch flag, the exact config file FCM writes (path + JSON example verified against `src/core/tool-launchers.js`), the install/bootstrap behaviour, and (for OpenCode + Pi) the in-agent plugin commands.
18
+
19
+ - **OpenCode + Pi merged into one page per tool** โ€” `integrations/opencode.mdx` and `integrations/pi.mdx` each combine the FCM launch flow (CLI flag, config, install) with the in-agent plugin (`/fcm` commands, progress display, architecture). The old `pi-extension` slug redirects to `pi` so any external link still resolves.
20
+
21
+ - **๐Ÿ“– 5 video wrappers added across the docs and the landing** โ€” `<Video name="โ€ฆ" />` placeholders for the 5 planned screen recordings (`tui-first-launch`, `tui-pick-and-launch`, `tui-speed-test`, `web-url-deep-linking`, `router-playground`). On the doc pages a `<Callout>` immediately below each wrapper contains the recording script (shot list, export spec, file location) and a `{/* AGENT TODO: โ€ฆ */}` comment that tells the next agent exactly which block to delete once the `.mp4` is committed. On the landing page the wrappers are bare โ€” no Callout, because the landing is public.
22
+
23
+ - **๐Ÿง  Persistent site component changes** โ€” a new `<Video />` component (`website/src/components/Video.tsx`) renders an autoplay / muted / loop / playsinline `<video>` in a styled figure. The Markdown renderer now wraps every MDX `<table>` in a `.table-scroll` container so wide tables scroll *inside* the page instead of pushing the viewport wider, and the first column gets a `min-width: 8rem` so label columns (e.g. the "Area" column in the Web Dashboard features table) stay readable. The "On this page" right-side table-of-contents was removed from every docs page; the content was already in flow and the column was redundant.
24
+
25
+ - **Sidebar "View more" toggle on long nav groups** โ€” `Agent Integrations` now lists the 9 most-used tools in the user's preferred order (OpenCode, Pi, Hermes, OpenClaw, Qwen Code, Cline, Goose, Kilo, jcode) and hides the remaining 10 behind a "+ View 10 moreโ€ฆ" toggle that expands to "- View less". The active page is always shown even when the group is collapsed, so you never lose your place in the nav.
26
+
27
+ - **Repository docs cleaned up** โ€” `docs/config.md` no longer lists the deprecated providers that were removed from the active catalog (huggingface, replicate, deepinfra, siliconflow, together, perplexity); the env-var table is now aligned with the 20 active providers. `docs/development.md` is updated from the stale `160 models across 20 providers` figure to the real `222 models`, and the file-path table is rewritten from the obsolete `src/*.js` layout to the current `src/core/`, `src/tui/`, `src/data/` structure. `docs/integrations.md` now lists **19 tools + the FCM router** in the tool โ†’ config mapping.
28
+
29
+ - **Marketing site MDX content realigned** โ€” the entire `website/src/content/docs/` tree was rewritten to match the README. Architectural inaccuracies (e.g. `Gemini CLI` referenced as a provider, fabricated REST endpoints like `/api/best`, half-true circuit-breaker states) were corrected against the source code and the README. The `cli-tui.mdx` keybinding table was rebuilt from the README so it is now 100% accurate (fixing the historical bugs where `N` was mislabelled as "Sort by Context" and `Y` was mislabelled as "Sort by Tier"). The docs nav sidebar was reordered so the surfaces (TUI, Web, Docker) come first, the agent integrations follow, and the Smart Router is the last item in "Core concepts & advanced" โ€” matching the README's reading order.
30
+
31
+ - **Landing-page video integration** โ€” the static `LogoMark` + `MorphingText` content in the Hero card was replaced with the `<Video name="tui-first-launch">` wrapper so the first thing a visitor sees becomes a live demo as soon as the recording lands. The router demo is embedded in the FailoverSection, and the remaining three demos live in a new "See it live" grid at the bottom of the SurfacesSection.
32
+
33
+ ### Changed
34
+
35
+ - **README "double title" bug** โ€” every docs page used to render the title twice (once in the route header, once in the leading `# Title` of the MDX body). All 18 MDX files had their leading `# <Title>` line removed; the route header is now the single source of truth for the page title.
36
+ - **Markdown renderer table overflow** โ€” the previous `.prose table { white-space: nowrap }` rule forced every cell onto a single line, which exploded the page width whenever a cell contained a long description (e.g. the "Highlights" column in the Web Dashboard features table). Tables now wrap their content (`overflow-wrap: anywhere`) and live inside a horizontal-scroll wrapper as a fallback so even pathological tables scroll *inside* the page instead of stretching the viewport.
37
+ - **`README.md` now contains 5 video references** โ€” the same 5 recordings are available on the landing and the docs; all three surfaces reference the same files in `website/public/videos/`. Mutualised, no duplication.
38
+ - **Removed `pi-extension.mdx`** โ€” superseded by the merged `integrations/pi.mdx`. A redirect from the old slug lives in `docs/$.tsx` so any external link still resolves.
39
+
40
+ ### Migration / workaround
41
+
42
+ > **No code or behaviour changes.** This release is documentation-only. No new flags, no new endpoints, no breaking changes. Upgrade is safe and invisible to runtime users.
43
+ >
44
+ > **The 5 videos are not yet recorded.** The wrappers currently render empty `<video>` frames everywhere. Drop the recorded `.mp4` files at `website/public/videos/<name>.mp4` and the surrounding `<Callout>` recording scripts in the docs remove themselves via the `{/* AGENT TODO: โ€ฆ */}` comment the next time an agent touches them. Public behaviour is unchanged.
45
+
46
+ ### Files
47
+
48
+ - **Modified**: `README.md` (full rewrite, ~1,000 lines), `docs/config.md`, `docs/development.md`, `docs/integrations.md`, 16 docs MDX files under `website/src/content/docs/`, `website/src/components/MdxComponents.tsx` (registers `Video`, `table`), `website/src/components/DocsSidebar.tsx` (refactored with `NavGroup` + `View more` toggle), `website/src/routes/index.tsx` (landing videos + import cleanup), `website/src/routes/docs/$.tsx` (TOC removed, `pi-extension` redirect), `website/src/styles.css` (table wrapping + scroll wrapper, `.table-scroll` activated), `website/src/content/nav.ts` (reordered + `primaryCount`), `package.json` (`0.5.65` โ†’ `0.5.66`), this changelog.
49
+ - **New**: `website/src/components/Video.tsx` (~35 lines).
50
+ - **New folder**: `website/public/videos/` (target for the 5 upcoming recordings).
51
+ - **Deleted**: `website/src/content/docs/integrations/pi-extension.mdx` (replaced by `integrations/pi.mdx`; redirect handles old links).
52
+
53
+ ### Breaking changes
54
+
55
+ None. This is a documentation-only release. No CLI flags added, no flags removed, no endpoints changed, no behaviour changed.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.64",
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.",
3
+ "version": "0.5.66",
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",
7
7
  "nim",
@@ -89,4 +89,4 @@
89
89
  "vite": "^8.0.16",
90
90
  "vite-plus": "^0.2.6"
91
91
  }
92
- }
92
+ }
package/src/core/ping.js CHANGED
@@ -13,6 +13,9 @@
13
13
  * - Quota extraction from rate limit headers (multiple variants supported)
14
14
  * - Cached provider quota polling with TTL and error backoff
15
15
  * - Cloudflare account ID resolution from environment
16
+ * - Per-provider circuit-breaker on 429 (issue #146): pauses ALL models of a provider
17
+ * when the provider returns a quota-exhausted response, so the ping loop stops
18
+ * hammering the user's daily quota while the provider's retry window is active.
16
19
  *
17
20
  * โ†’ Functions:
18
21
  * - `resolveCloudflareUrl`: Resolve {account_id} placeholder from CLOUDFLARE_ACCOUNT_ID env var
@@ -31,6 +34,7 @@
31
34
  * - ../src/constants.js: PING_TIMEOUT
32
35
  * - ../src/provider-quota-fetchers.js: _fetchProviderQuotaFromModule (quota fetching with cache)
33
36
  * - ../src/quota-capabilities.js: supportsUsagePercent
37
+ * - ./provider-cooldown.js: pauseProviderQuota, extractRetryAfterFromResponse (issue #146)
34
38
  *
35
39
  * โš™๏ธ Configuration:
36
40
  * - PING_TIMEOUT: Timeout in ms for ping requests (default: 15000)
@@ -43,6 +47,10 @@
43
47
  import { PING_TIMEOUT } from './constants.js'
44
48
  import { fetchProviderQuota as _fetchProviderQuotaFromModule, extractQuota as _extractQuotaFromModule, processResponseHeaders as _processResponseHeadersFromModule } from './provider-quota-fetchers.js'
45
49
  import { supportsUsagePercent } from './quota-capabilities.js'
50
+ import {
51
+ extractRetryAfterFromResponse,
52
+ pauseProviderQuota,
53
+ } from './provider-cooldown.js'
46
54
 
47
55
  const DISABLED_THINKING_RETRY_STATUSES = new Set([400, 422])
48
56
  const disabledThinkingUnsupportedProviders = new Set()
@@ -171,6 +179,14 @@ export async function ping(apiKey, modelId, providerKey, url) {
171
179
  req = buildPingRequest(apiKey, modelId, providerKey, url, { disableThinking: false })
172
180
  resp = await sendPingFetch(req, ctrl.signal)
173
181
  }
182
+ // ๐Ÿ“– Provider circuit-breaker (issue #146): openrouter + similar gateways rate-limit
183
+ // ๐Ÿ“– at the provider/key level, not per-model. A 429 means the WHOLE key is paused,
184
+ // ๐Ÿ“– often for hours โ€” re-pinging every model every 2-30s would burn the quota in minutes.
185
+ // ๐Ÿ“– Honour Retry-After header + the OpenRouter body "try again N seconds later".
186
+ if (resp.status === 429) {
187
+ const retryMs = await extractRetryAfterFromResponse(resp)
188
+ if (retryMs > 0) pauseProviderQuota(providerKey, retryMs)
189
+ }
174
190
  // ๐Ÿ“– Normalize all HTTP 2xx statuses to "200" so existing verdict/avg logic still works.
175
191
  const code = resp.status >= 200 && resp.status < 300 ? '200' : String(resp.status)
176
192
  // ๐Ÿ“– Passive quota tracker (t2): parse the response headers via the shared module.
@@ -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.