free-coding-models 0.5.88 โ†’ 0.5.90

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 (36) hide show
  1. package/README.md +10 -4
  2. package/bin/free-coding-models.js +33 -2
  3. package/changelog/v0.5.89.md +20 -0
  4. package/changelog/v0.5.90.md +12 -0
  5. package/package.json +1 -1
  6. package/sources.js +5 -2
  7. package/src/core/benchmark.js +9 -0
  8. package/src/core/cloudflare-account.js +311 -0
  9. package/src/core/endpoint-installer.js +8 -4
  10. package/src/core/opencode.js +9 -6
  11. package/src/core/ping.js +52 -16
  12. package/src/core/provider-key-tester.js +10 -3
  13. package/src/core/provider-metadata.js +1 -1
  14. package/src/core/router-daemon.js +1138 -501
  15. package/src/core/router-v2/anthropic-compat.js +473 -0
  16. package/src/core/router-v2/bench.js +171 -0
  17. package/src/core/router-v2/breaker-store.js +265 -0
  18. package/src/core/router-v2/constants.js +108 -0
  19. package/src/core/router-v2/decision-trace.js +134 -0
  20. package/src/core/router-v2/failure-classifier.js +231 -0
  21. package/src/core/router-v2/request-history.js +137 -0
  22. package/src/core/router-v2/response-gate.js +175 -0
  23. package/src/core/router-v2/tui-dashboard.js +632 -0
  24. package/src/core/schema-normalizer.js +23 -6
  25. package/src/core/utils.js +12 -0
  26. package/src/tui/app.js +7 -2
  27. package/src/tui/cli-help.js +4 -0
  28. package/src/tui/key-handler.js +117 -2
  29. package/src/tui/overlays.js +19 -3
  30. package/src/tui/tui-state.js +22 -0
  31. package/web/dist/assets/index-CCaxIOti.css +1 -0
  32. package/web/dist/assets/index-CCkuXrqE.js +48 -0
  33. package/web/dist/index.html +2 -2
  34. package/web/server.js +105 -1
  35. package/web/dist/assets/index-CAzFIt8P.css +0 -1
  36. package/web/dist/assets/index-DFg1h0Nd.js +0 -44
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center">
8
8
  <strong>Find the fastest free coding model in seconds.</strong><br>
9
- Live latency, stability and verdicts for 229 models from 24 free AI providers, then install the one you pick straight into your favorite coding tool.<br><br>
9
+ Live latency, stability and verdicts for 228 models from 24 free AI providers, then install the one you pick straight into your favorite coding tool.<br><br>
10
10
  <strong>Works with:</strong> OpenCode CLI / Desktop / WebUI, OpenClaw, Crush, Goose, Aider, Kilo CLI, Qwen Code, OpenHands, Amp, Hermes, Continue, Cline, Xcode, Pi, ZCode, ForgeCode, Copilot, jcode, Caveman Code and more.
11
11
  </p>
12
12
 
@@ -35,7 +35,7 @@ free-coding-models
35
35
 
36
36
  ## ๐Ÿ’ก Why this tool?
37
37
 
38
- There is a large catalog of free and free-limited coding models (**24 providers / 229 live models**, generated from [`sources.js`](./sources.js)). Which one is fastest *right now*? Which one is actually stable, versus just lucky on the last ping?
38
+ There is a large catalog of free and free-limited coding models (**24 providers / 228 live models**, generated from [`sources.js`](./sources.js)). Which one is fastest *right now*? Which one is actually stable, versus just lucky on the last ping?
39
39
 
40
40
  `free-coding-models` (FCM) answers that by pinging every model in parallel, showing live latency, and computing a **live Stability Score (0-100)** combining p95 latency, jitter, spike rate and uptime. Average latency alone is misleading: a model that randomly spikes to 6 seconds is not reliable.
41
41
 
@@ -87,7 +87,7 @@ free-coding-models --fiable # print the single most reliable model
87
87
 
88
88
  ## ๐ŸŸข Providers
89
89
 
90
- **24 active providers / 229 live models**, sorted by live model count. Top 8:
90
+ **24 active providers / 228 live models**, sorted by live model count. Top 8:
91
91
 
92
92
  | Provider | Models | Best tier | Env var |
93
93
  |----------|--------|-----------|---------|
@@ -96,7 +96,7 @@ free-coding-models --fiable # print the single most reliable model
96
96
  | [OpenRouter](https://openrouter.ai/keys) | 19 | S+ | `OPENROUTER_API_KEY` |
97
97
  | [Cloudflare AI](https://dash.cloudflare.com) | 15 | S | `CLOUDFLARE_API_TOKEN` |
98
98
  | [Kilo](https://kilo.ai) | 14 | S+ | `KILO_API_KEY` |
99
- | [NVIDIA NIM](https://build.nvidia.com) | 14 | S+ | `NVIDIA_API_KEY` |
99
+ | [NVIDIA NIM](https://build.nvidia.com) | 13 | S+ | `NVIDIA_API_KEY` |
100
100
  | [Pollinations AI](https://enter.pollinations.ai) | 13 | S+ | `POLLINATIONS_API_KEY` |
101
101
  | [OVHcloud AI](https://endpoints.ai.cloud.ovh.net) | 12 | S+ | `OVH_AI_ENDPOINTS_ACCESS_TOKEN` |
102
102
 
@@ -151,6 +151,12 @@ free-coding-models --daemon-stop # stop
151
151
 
152
152
  > ๐Ÿ“– **Full guide (probes, circuit breaker, failover, Playground, `--sync-set`, REST API):** [`docs/router.md`](./docs/router.md)
153
153
 
154
+ ### ๐Ÿงช Router v2 engine (built in)
155
+
156
+ The router daemon now runs the hardened v2 engine internally: content-validated failover (a 200 with empty or garbage output is a failure and fails over), quota-aware routing with `Retry-After` pauses, persisted circuit breakers with a DEGRADED warning state, per-request decision traces, an Anthropic `/v1/messages` endpoint, and "test via router" actions that exercise the real routing chain. Same command, same port, same endpoints - existing setups upgrade by updating the package.
157
+
158
+ Open the TUI with `Shift+V` (or the "Router v2" entry in the Web Dashboard) to see the live fallback chain, per-request attempt chains and run pinned-model tests (`Ctrl+T`). Docs: [`docs/router-v2.md`](./docs/router-v2.md).
159
+
154
160
  ---
155
161
 
156
162
  ## ๐Ÿ“– CLI Flags
@@ -118,7 +118,7 @@ async function main() {
118
118
  ensureFavoritesConfig(config);
119
119
 
120
120
  const isDevMode = isPackageDevMode();
121
- const shouldEnforceUpdate = !cliArgs.daemonStopMode && !cliArgs.daemonStatusMode;
121
+ const shouldEnforceUpdate = !cliArgs.daemonStopMode && !cliArgs.daemonStatusMode && !cliArgs.routerV2StopMode && !cliArgs.routerV2StatusMode;
122
122
  const startupUpdate = shouldEnforceUpdate
123
123
  ? await enforceMandatoryStartupUpdate(config, {
124
124
  saveConfig,
@@ -168,7 +168,11 @@ async function main() {
168
168
  && !cliArgs.daemonMode
169
169
  && !cliArgs.daemonBackgroundMode
170
170
  && !cliArgs.daemonStopMode
171
- && !cliArgs.daemonStatusMode;
171
+ && !cliArgs.daemonStatusMode
172
+ && !cliArgs.routerV2Mode
173
+ && !cliArgs.routerV2BackgroundMode
174
+ && !cliArgs.routerV2StopMode
175
+ && !cliArgs.routerV2StatusMode;
172
176
  try {
173
177
  await checkConfigSecurity({ autoFix: cliArgs.fixPermissionsMode, promptAllowed });
174
178
  } catch {
@@ -210,6 +214,33 @@ async function main() {
210
214
  process.exit(result.ok ? 0 : 1);
211
215
  }
212
216
 
217
+ // ๐Ÿ“– Router v2 lifecycle flags are now ALIASES of the main daemon: the v2
218
+ // engine (content-validated failover, persisted breakers, decision traces,
219
+ // Anthropic /v1/messages) is merged into the regular router daemon on the
220
+ // historical port. The flags keep working for existing scripts.
221
+ if (cliArgs.routerV2Mode || cliArgs.routerV2BackgroundMode || cliArgs.routerV2StopMode || cliArgs.routerV2StatusMode) {
222
+ const {
223
+ getRouterDaemonStatus,
224
+ runRouterDaemon,
225
+ startRouterDaemonBackground,
226
+ stopRouterDaemon,
227
+ } = await import('../src/core/router-daemon.js');
228
+
229
+ if (cliArgs.routerV2Mode) {
230
+ await runRouterDaemon();
231
+ return;
232
+ }
233
+
234
+ const result = cliArgs.routerV2BackgroundMode
235
+ ? await startRouterDaemonBackground()
236
+ : cliArgs.routerV2StopMode
237
+ ? await stopRouterDaemon()
238
+ : await getRouterDaemonStatus();
239
+
240
+ console.log(JSON.stringify(result, null, 2));
241
+ process.exit(result.ok ? 0 : 1);
242
+ }
243
+
213
244
  // ๐Ÿ“– --sync-set [name] โ€” auto-discover, probe, and populate a router set
214
245
  if (cliArgs.syncSetMode) {
215
246
  const { syncSet } = await import('../src/core/sync-set.js');
@@ -0,0 +1,20 @@
1
+ # Changelog v0.5.89 - 2026-09-06
2
+
3
+ ### Added
4
+ - ๐Ÿงช **Smart Model Router v2 engine (built in)**: the regular router daemon (`--daemon-bg`, historical port) now runs the hardened v2 engine - same command, same port, same endpoints, existing setups upgrade transparently. A hardened parallel beta ran on port 19380 during development; it is retired by this release. Open the TUI overlay with `Shift+V`, or use the "Router v2" page in the Web Dashboard (BETA badge on both surfaces).
5
+ - ๐Ÿ›ก๏ธ **Content-validated failover**: a 200 only counts as a success when the answer holds real content. Empty `choices`, embedded `error` objects or content-less replies now fail over to the next model, in both streaming and non-streaming. This closes the biggest v1 blind spot where free providers serving empty 200s were reported as successes.
6
+ - โฑ๏ธ **Body-read timeout**: a provider that sends headers but trickles (or never sends) the body can no longer hang your coding tool; the read is raced against `router.failover.bodyReadTimeoutMs` (default 30s) and fails over.
7
+ - ๐Ÿ”ฅ **Quota-aware routing**: models answering 429 are paused for their `Retry-After` window and skipped; when every model is paused the client gets a proper `429` + `Retry-After` instead of a generic 503.
8
+ - โš–๏ธ **Blame attribution**: client-caused 4xx (bad payload, unsupported fields) fail over but never damage healthy models' circuit breakers, and client disconnects never mark failures.
9
+ - ๐Ÿ’พ **Persisted circuit breakers**: breaker state survives daemon restarts, with a new amber `DEGRADED` warning state (60% of the failure threshold) and escalating cooldowns for repeatedly-tripping models.
10
+ - ๐Ÿ” **Decision traces**: every response carries `x-fcm-v2-model`, `x-fcm-v2-attempts` and `x-fcm-v2-decision` headers, and every request chain (attempts, statuses, skip reasons) is persisted locally and rendered in the TUI overlay and the web page. Routing metadata only: no prompts, no keys.
11
+ - ๐Ÿค– **Anthropic `/v1/messages` support** on the v2 port: request, response and SSE stream translation (including `tool_use` / `input_json_delta`), so Anthropic-protocol agents can use the router directly.
12
+ - ๐Ÿ“Œ **Pinned-model requests**: `model: "fcm:@provider/modelId"` routes to one exact model through the full chain with failover disabled. New `Ctrl+T` (test selected model) and `Ctrl+Shift+T` (test all visible models) TUI hotkeys use it to test the real routing path, unlike the direct-to-provider `Ctrl+A` / `Ctrl+U` benchmarks.
13
+ - ๐Ÿš‘ **Last-resort model**: optional `router.failover.lastResortModel` config gives a designated model one final shot when the whole set fails.
14
+ - โšก **Fast first boot**: v2 listens before its first probe pass, removing the old first-boot stall where the router port stayed dark for up to ~36 seconds.
15
+
16
+ ### Security
17
+ - ๐Ÿ”’ Client `x-api-key` headers are now stripped before proxying to upstream providers on v2, so the local router token can never leak to a provider.
18
+
19
+ ### Fixed
20
+ - ๐Ÿ› Ghost "active requests" no longer accumulate in v2 stats when a request is rejected before routing (v1 leaks one entry per rejected request until restart).
@@ -0,0 +1,12 @@
1
+ # Changelog v0.5.90 - 2026-09-09
2
+
3
+ ### Fixed
4
+
5
+ - **OpenCode Zen: every model probed `down` (HTTP 400) since 2026-09-07** (issue #181). Zen now gates its free tier behind an `x-opencode-session` header and answered `400 MissingSessionID` without it. We now send a stable per-process session uuid on every path that reaches the Zen gateway: TUI/CLI probes, router health probes and forwarded traffic, web dashboard benchmark + proxy routes, and key-tester model discovery. Verified live: `400 MissingSessionID` without the header, `200` with a real completion once it is present.
6
+ - **Cloudflare Workers AI: every model probed `down` (HTTP 404)** (issue #181). The Workers AI endpoint is account-scoped, and we were failing that twice: two web dashboard paths (benchmark SSE, playground direct route) posted the raw catalog URL with the literal `{$CLOUDFLARE_ACCOUNT_ID}` placeholder, and every other path silently depended on the `CLOUDFLARE_ACCOUNT_ID` env var being visible in the probing process, which detached daemons and Docker never have. New `src/core/cloudflare-account.js` centralizes resolution: env var first, then stored settings (`settings.cloudflareAccountId`), then zero-setup auto-discovery from your API key via `GET /accounts` (cached in-process and persisted, so it runs once). The provider now works with just an API key, no env var needed. Key-tester live model discovery resolves the URL too.
7
+ - **NVIDIA: removed MiniMax M3** - the model answered `410 Gone` with an explicit end-of-life timestamp of 2026-09-09. All 13 remaining NIM models were re-verified against the live API (9 x `200` today, 4 temporarily capacity-starved but alive).
8
+
9
+ ### Changed
10
+
11
+ - **NVIDIA NIM catalog audit documented in `sources.js`** (issue #181, part 2). The issue flagged 67 NVIDIA models missing from our catalog; a live audit of all 80 ids in NIM's public `/v1/models` list shows that catalog is stale: every one of the 33 chat-capable candidates we do not track answers `404 Function not found` to a real chat completion (several were already removed on 2026-08-30 for exactly that signature). The catalog stays at the 13 live-verified models, and a dated audit comment now documents this so the ghost ids are not re-probed blindly.
12
+ - **Docs**: `docs/providers.md` now notes the Zen session-header requirement and the Cloudflare account-id resolution order (env var > stored settings > auto-discovery). README model counts updated (229 -> 228 live models).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.88",
3
+ "version": "0.5.90",
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/sources.js CHANGED
@@ -40,6 +40,10 @@
40
40
 
41
41
  // ๐Ÿ“– NIM source - https://build.nvidia.com
42
42
  export const nvidiaNim = [
43
+ // Audit (2026-09-09): GET /v1/models lists 80 ids but most are ghost catalog entries that
44
+ // answer 404 "Function not found" to a 1-token chat completion on the free integrate API.
45
+ // All 46 chat-capable candidates were probed live: only the models kept below respond, so
46
+ // ghost ids stay out even though the public catalog lists them (issue #181).
43
47
  // โ”€โ”€ S+ tier โ€” SWE-bench Verified โ‰ฅ70% โ”€โ”€
44
48
  // Removed (2026-08-23): z-ai/glm-5.2 (GLM 5.1) โ€” no longer in integrate.api.nvidia.com/v1/models (102 models live)
45
49
  // Removed (2026-09-05): moonshotai/kimi-k2.6 (Kimi K2.6) - Model page returns 404 and model is absent from the NVIDIA model catalog; could not verify existence
@@ -55,8 +59,7 @@ export const nvidiaNim = [
55
59
  // Removed (2026-07-27): meta/llama-4-maverick-17b-128e-instruct (Llama 4 Maverick) โ€” EOL 2026-07-27 (HTTP 410 Gone)
56
60
  // Removed (2026-08-23): mistralai/mistral-medium-3.5-128b (Mistral Medium 3.5) โ€” no longer in integrate.api.nvidia.com/v1/models (still on Mistral LP directly)
57
61
  // Removed (2026-07-27): mistralai/mistral-small-4-119b-2603 (Mistral Small 4) โ€” EOL 2026-07-27 (HTTP 410 Gone)
58
- // โš ๏ธ DEPRECATED - NVIDIA shutdown 2026-09-08
59
- ['minimaxai/minimax-m3', 'MiniMax M3', 'S+', '78.4%', '1M'],
62
+ // Removed (2026-09-09): minimaxai/minimax-m3 (MiniMax M3) - 410 Gone per live chat probe: reached end of life 2026-09-09T09:00:00Z (shutdown was announced in-file on 2026-09-08)
60
63
  ['moonshotai/kimi-k3', 'Kimi K3', 'S', '-', '1M'], // Added (2026-09-02) โ€” new in NIM catalog
61
64
  ['mistralai/mistral-nemotron', 'Mistral Nemotron', 'S', '-', '128k'], // Fixed ID (2026-07-27): nvidia/mistral-nemotron โ†’ mistralai/mistral-nemotron
62
65
  // Removed (2026-07-27): deepseek-ai/deepseek-v3.2 (DeepSeek V3.2) โ€” HTTP 404
@@ -25,12 +25,14 @@
25
25
  *
26
26
  * ๐Ÿ“ฆ Dependencies:
27
27
  * - ./ping.js: buildPingRequest, resolveCloudflareUrl
28
+ * - ./cloudflare-account.js: ensureCloudflareAccountId (Cloudflare account discovery)
28
29
  *
29
30
  * @see {@link ./ping.js} Provider-specific request building
30
31
  * @see {@link ./render-table.js} AI Latency + TPS column rendering
31
32
  */
32
33
 
33
34
  import { buildPingRequest, resolveCloudflareUrl } from './ping.js'
35
+ import { ensureCloudflareAccountId } from './cloudflare-account.js'
34
36
 
35
37
  // ๐Ÿ“– BENCHMARK_PROMPT: A deterministic one-paragraph task that any model can answer.
36
38
  // ๐Ÿ“– The longer target gives latency + TPS measurements enough generated tokens to be reliable.
@@ -258,6 +260,13 @@ async function benchmarkSingleAttempt({ apiKey, modelId, providerKey, url, timeo
258
260
  // ๐Ÿ“– { ok: false, code, totalMs, error, retries }
259
261
  export async function benchmarkModel({ apiKey, modelId, providerKey, url, timeoutMs = BENCHMARK_TIMEOUT_MS, maxRetries = BENCHMARK_MAX_RETRIES, retryDelayMs = BENCHMARK_RETRY_DELAY_MS }) {
260
262
 
263
+ // ๐Ÿ“– Cloudflare zero-setup (issue #181): let the account-id resolver run its
264
+ // ๐Ÿ“– one-time auto-discovery before any attempt so buildBenchmarkRequest's
265
+ // ๐Ÿ“– sync URL substitution sees a real account id instead of 'missing-account-id'.
266
+ if (providerKey === 'cloudflare') {
267
+ await ensureCloudflareAccountId()
268
+ }
269
+
261
270
  let lastResult = null
262
271
 
263
272
  for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -0,0 +1,311 @@
1
+ /**
2
+ * @file cloudflare-account.js
3
+ * @description Central Cloudflare account-id resolution for the account-scoped Workers AI endpoint.
4
+ *
5
+ * @details
6
+ * WHY: Cloudflare's OpenAI-compatible endpoint is per-account
7
+ * (`https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1/chat/completions`).
8
+ * The catalog URL in sources.js therefore carries a `{$CLOUDFLARE_ACCOUNT_ID}`
9
+ * placeholder, and every request path must replace it with a real account id
10
+ * before hitting the API. A literal or missing id makes Cloudflare's router
11
+ * return 404 for every model, 100% of the time (issue #181).
12
+ *
13
+ * Resolution order (first hit wins):
14
+ * 1. `CLOUDFLARE_ACCOUNT_ID` env var
15
+ * 2. In-process cache (a previously resolved or discovered id)
16
+ * 3. Stored config `settings.cloudflareAccountId` (~/.free-coding-models.json)
17
+ * 4. Auto-discovery: GET /client/v4/accounts with the stored Cloudflare API key
18
+ * (env CLOUDFLARE_API_TOKEN/CLOUDFLARE_API_KEY or config apiKeys.cloudflare);
19
+ * the first account's id is cached in-process AND persisted to the config
20
+ * settings so discovery only ever happens once. Best-effort: on failure the
21
+ * old 'missing-account-id' behavior is kept and discovery backs off for
22
+ * DISCOVERY_RETRY_COOLDOWN_MS so probes never hammer the endpoint.
23
+ *
24
+ * The pure decision helpers (env / settings / discovery-payload / URL substitution)
25
+ * are exported separately so tests can cover every branch hermetically with
26
+ * injected dependencies, no network and no real config file.
27
+ *
28
+ * ๐Ÿ“– Note: `settings.cloudflareAccountId` is intentionally NOT added to
29
+ * normalizeSettingsSection() in config.js: unknown settings keys pass through the
30
+ * spread, so a stale process that loaded its config before discovery never clobbers
31
+ * the persisted id on its next saveConfig() (it does not carry the key at all).
32
+ *
33
+ * @functions
34
+ * โ†’ `pickAccountIdFromEnv(env)` - pure: trimmed CLOUDFLARE_ACCOUNT_ID or null
35
+ * โ†’ `pickAccountIdFromSettings(settings)` - pure: stored settings.cloudflareAccountId or null
36
+ * โ†’ `pickAccountIdFromDiscoveryResponse(payload)` - pure: first account id from a /accounts JSON body
37
+ * โ†’ `applyCloudflareAccountId(url, accountId)` - pure: substitute the URL placeholders (encode + safe fallback)
38
+ * โ†’ `getCloudflareAccountIdSync(deps)` - sync env > cache > stored-config resolution
39
+ * โ†’ `ensureCloudflareAccountId(deps)` - async: sync resolution, then best-effort discovery (cached + persisted)
40
+ * โ†’ `resolveCloudflareUrlAsync(url, deps)` - async: resolve the URL after guaranteeing an account id
41
+ * โ†’ `resetCloudflareAccountStateForTests()` - clear in-process caches between tests
42
+ *
43
+ * @exports CLOUDFLARE_ACCOUNTS_URL, pickAccountIdFromEnv, pickAccountIdFromSettings,
44
+ * pickAccountIdFromDiscoveryResponse, applyCloudflareAccountId,
45
+ * getCloudflareAccountIdSync, ensureCloudflareAccountId, resolveCloudflareUrlAsync,
46
+ * resetCloudflareAccountStateForTests
47
+ *
48
+ * @see src/core/ping.js - resolveCloudflareUrl (sync wrapper) + ping() probe path
49
+ * @see web/server.js - benchmark SSE + playground direct-route request paths
50
+ * @see issue #181 - why every cloudflare model probed as 404
51
+ */
52
+
53
+ import { existsSync, readFileSync } from 'node:fs'
54
+ import { CONFIG_PATH, saveConfig } from './config.js'
55
+
56
+ // ๐Ÿ“– Cloudflare account-list endpoint: a plain GET with the API key as Bearer
57
+ // ๐Ÿ“– returns the accounts the token can access; the first id is what probes need.
58
+ export const CLOUDFLARE_ACCOUNTS_URL = 'https://api.cloudflare.com/client/v4/accounts'
59
+
60
+ // ๐Ÿ“– Fallback segment kept identical to ping.js's historical behavior so verdicts,
61
+ // ๐Ÿ“– docs and error messages stay stable when no account id can be resolved.
62
+ const MISSING_ACCOUNT_ID = 'missing-account-id'
63
+
64
+ // ๐Ÿ“– Discovery is best-effort: short timeout so probes never stall on it, and a
65
+ // ๐Ÿ“– cooldown after failures so a broken key does not turn every ping into a
66
+ // ๐Ÿ“– /accounts round-trip.
67
+ const DISCOVERY_TIMEOUT_MS = 8000
68
+ const DISCOVERY_RETRY_COOLDOWN_MS = 5 * 60 * 1000
69
+
70
+ // ๐Ÿ“– In-process state: a resolved id is stable for the lifetime of the process,
71
+ // ๐Ÿ“– so cache aggressively and dedupe concurrent discoveries.
72
+ let cachedAccountId = null
73
+ let diskSettingsLoaded = false
74
+ let diskAccountId = null
75
+ let discoveryInFlight = null
76
+ // ๐Ÿ“– null = no failure yet; a timestamp (per the injectable clock) once a
77
+ // ๐Ÿ“– discovery attempt failed, so back-off works with any clock source.
78
+ let lastDiscoveryFailureAt = null
79
+
80
+ /**
81
+ * ๐Ÿ“– Pure: read the account id from an env-style object. Trimmed, or null.
82
+ * @param {NodeJS.ProcessEnv | Record<string, string|undefined>} [env]
83
+ * @returns {string|null}
84
+ */
85
+ export function pickAccountIdFromEnv(env = process.env) {
86
+ const raw = typeof env.CLOUDFLARE_ACCOUNT_ID === 'string' ? env.CLOUDFLARE_ACCOUNT_ID.trim() : ''
87
+ return raw || null
88
+ }
89
+
90
+ /**
91
+ * ๐Ÿ“– Pure: read the account id from a stored settings section. Trimmed, or null.
92
+ * @param {Record<string, unknown>|undefined} settings
93
+ * @returns {string|null}
94
+ */
95
+ export function pickAccountIdFromSettings(settings) {
96
+ const raw = settings && typeof settings.cloudflareAccountId === 'string'
97
+ ? settings.cloudflareAccountId.trim()
98
+ : ''
99
+ return raw || null
100
+ }
101
+
102
+ /**
103
+ * ๐Ÿ“– Pure: extract the first usable account id from a /accounts JSON response.
104
+ * ๐Ÿ“– Cloudflare answers { success: true, result: [{ id: '...' }, ...] } on success
105
+ * ๐Ÿ“– and { success: false, errors: [...] } on auth failures; both must be safe.
106
+ * @param {unknown} payload
107
+ * @returns {string|null}
108
+ */
109
+ export function pickAccountIdFromDiscoveryResponse(payload) {
110
+ if (!payload || typeof payload !== 'object' || payload.success !== true) return null
111
+ const accounts = Array.isArray(payload.result) ? payload.result : []
112
+ for (const account of accounts) {
113
+ const id = account && typeof account.id === 'string' ? account.id.trim() : ''
114
+ if (id) return id
115
+ }
116
+ return null
117
+ }
118
+
119
+ /**
120
+ * ๐Ÿ“– Pure: substitute the account-id placeholders in a catalog URL.
121
+ * ๐Ÿ“– Encodes the id so odd characters can never break the path, falls back to
122
+ * ๐Ÿ“– the historical 'missing-account-id' segment, and leaves URLs without a
123
+ * ๐Ÿ“– placeholder completely untouched.
124
+ * @param {string} url
125
+ * @param {string|null} accountId
126
+ * @returns {string}
127
+ */
128
+ export function applyCloudflareAccountId(url, accountId) {
129
+ const hasPlaceholder = url.includes('{$CLOUDFLARE_ACCOUNT_ID}') || url.includes('{account_id}')
130
+ if (!hasPlaceholder) return url
131
+ const replacement = accountId ? encodeURIComponent(accountId) : MISSING_ACCOUNT_ID
132
+ return url
133
+ .replace(/\{\$CLOUDFLARE_ACCOUNT_ID\}/g, replacement)
134
+ .replace(/\{account_id\}/g, replacement)
135
+ }
136
+
137
+ // ๐Ÿ“– One-shot read of the stored settings.cloudflareAccountId from the config
138
+ // ๐Ÿ“– file. Deliberately a raw readFileSync (no loadConfig side effects like
139
+ // ๐Ÿ“– auto-repair or console noise) because this runs on every probe path.
140
+ function readDiskSettings() {
141
+ if (diskSettingsLoaded) return diskAccountId
142
+ diskSettingsLoaded = true
143
+ try {
144
+ if (existsSync(CONFIG_PATH)) {
145
+ const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
146
+ diskAccountId = pickAccountIdFromSettings(parsed?.settings)
147
+ }
148
+ } catch {
149
+ // ๐Ÿ“– Corrupt or unreadable config - behave as if nothing is stored.
150
+ }
151
+ return diskAccountId
152
+ }
153
+
154
+ /**
155
+ * ๐Ÿ“– Sync resolution: env var, then in-process cache, then stored config.
156
+ * ๐Ÿ“– Used by every synchronous URL builder (ping request builder, benchmarks,
157
+ * ๐Ÿ“– router daemon, tool launchers, endpoint installer).
158
+ * @param {{ env?: NodeJS.ProcessEnv|Record<string, string|undefined>, settingsProvider?: () => string|null }} [deps]
159
+ * @returns {string|null}
160
+ */
161
+ export function getCloudflareAccountIdSync({ env = process.env, settingsProvider = readDiskSettings } = {}) {
162
+ const fromEnv = pickAccountIdFromEnv(env)
163
+ if (fromEnv) {
164
+ cachedAccountId = fromEnv
165
+ return fromEnv
166
+ }
167
+ if (cachedAccountId) return cachedAccountId
168
+ const stored = settingsProvider()
169
+ if (stored) {
170
+ cachedAccountId = stored
171
+ return stored
172
+ }
173
+ return null
174
+ }
175
+
176
+ /**
177
+ * ๐Ÿ“– Best-effort persistence of a discovered id into the config settings so
178
+ * ๐Ÿ“– discovery only happens once per machine, not once per process.
179
+ * ๐Ÿ“– Reads a fresh snapshot right before saving to minimize clobber risk.
180
+ * @param {string} accountId
181
+ * @returns {void}
182
+ */
183
+ function persistAccountIdToDisk(accountId) {
184
+ try {
185
+ if (!existsSync(CONFIG_PATH)) return
186
+ const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
187
+ if (!parsed || typeof parsed !== 'object') return
188
+ if (!parsed.settings || typeof parsed.settings !== 'object') parsed.settings = {}
189
+ if (parsed.settings.cloudflareAccountId === accountId) return
190
+ parsed.settings.cloudflareAccountId = accountId
191
+ saveConfig(parsed)
192
+ } catch {
193
+ // ๐Ÿ“– Persistence is an optimization; resolution still works in-process.
194
+ }
195
+ }
196
+
197
+ // ๐Ÿ“– Discovery API key: same candidates as config.js's ENV_VARS.cloudflare,
198
+ // ๐Ÿ“– falling back to the stored config key (string or multi-key array).
199
+ function readDiscoveryApiKey({ env, storedApiKeyProvider }) {
200
+ for (const candidate of ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_API_KEY']) {
201
+ const raw = typeof env[candidate] === 'string' ? env[candidate].trim() : ''
202
+ if (raw) return raw
203
+ }
204
+ return storedApiKeyProvider() || null
205
+ }
206
+
207
+ function readStoredApiKeyFromDisk() {
208
+ try {
209
+ if (!existsSync(CONFIG_PATH)) return null
210
+ const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
211
+ const stored = parsed?.apiKeys?.cloudflare
212
+ if (Array.isArray(stored)) {
213
+ const first = stored.find(k => typeof k === 'string' && k.length > 0)
214
+ return first ?? null
215
+ }
216
+ return typeof stored === 'string' && stored ? stored : null
217
+ } catch {
218
+ return null
219
+ }
220
+ }
221
+
222
+ /**
223
+ * ๐Ÿ“– Async orchestrator: resolve via env/cache/config, then discover.
224
+ * ๐Ÿ“– Inject every dependency in tests - never let tests touch the network.
225
+ * @param {{
226
+ * env?: NodeJS.ProcessEnv|Record<string, string|undefined>,
227
+ * settingsProvider?: () => string|null,
228
+ * apiKey?: string|null,
229
+ * storedApiKeyProvider?: () => string|null,
230
+ * fetchImpl?: typeof fetch,
231
+ * persist?: (accountId: string) => void,
232
+ * now?: () => number,
233
+ * }} [deps]
234
+ * @returns {Promise<string|null>} the resolved id, or null (caller keeps 'missing-account-id')
235
+ */
236
+ export async function ensureCloudflareAccountId({
237
+ env = process.env,
238
+ settingsProvider = readDiskSettings,
239
+ apiKey = undefined,
240
+ storedApiKeyProvider = readStoredApiKeyFromDisk,
241
+ fetchImpl = globalThis.fetch,
242
+ persist = persistAccountIdToDisk,
243
+ now = Date.now,
244
+ } = {}) {
245
+ const existing = getCloudflareAccountIdSync({ env, settingsProvider })
246
+ if (existing) return existing
247
+
248
+ const key = apiKey !== undefined ? apiKey : readDiscoveryApiKey({ env, storedApiKeyProvider })
249
+ // ๐Ÿ“– No key means discovery cannot authenticate: stay in fail-safe mode
250
+ // ๐Ÿ“– instead of firing an always-401 request on every probe.
251
+ if (!key) return null
252
+
253
+ // ๐Ÿ“– After a failure, back off so probe loops never hammer /accounts.
254
+ if (lastDiscoveryFailureAt !== null && now() - lastDiscoveryFailureAt < DISCOVERY_RETRY_COOLDOWN_MS) return null
255
+
256
+ // ๐Ÿ“– Dedupe concurrent probes: share one in-flight discovery.
257
+ if (discoveryInFlight) return discoveryInFlight
258
+
259
+ discoveryInFlight = (async () => {
260
+ try {
261
+ const resp = await fetchImpl(CLOUDFLARE_ACCOUNTS_URL, {
262
+ method: 'GET',
263
+ headers: { Authorization: `Bearer ${key}` },
264
+ signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
265
+ })
266
+ let payload = null
267
+ try { payload = await resp.json() } catch { /* non-JSON error body */ }
268
+ const id = resp.ok ? pickAccountIdFromDiscoveryResponse(payload) : null
269
+ if (id) {
270
+ cachedAccountId = id
271
+ persist(id)
272
+ return id
273
+ }
274
+ lastDiscoveryFailureAt = now()
275
+ return null
276
+ } catch {
277
+ // ๐Ÿ“– Network error, timeout or abort: fail safe, back off.
278
+ lastDiscoveryFailureAt = now()
279
+ return null
280
+ } finally {
281
+ discoveryInFlight = null
282
+ }
283
+ })()
284
+ return discoveryInFlight
285
+ }
286
+
287
+ /**
288
+ * ๐Ÿ“– Async URL resolution for request paths that are already async: guarantee
289
+ * ๐Ÿ“– an account id (running discovery if needed) before substituting. Every
290
+ * ๐Ÿ“– caller that POSTs to the Cloudflare endpoint should go through this or
291
+ * ๐Ÿ“– through ping's sync resolveCloudflareUrl after ensure has run.
292
+ * @param {string} url
293
+ * @param {Parameters<typeof ensureCloudflareAccountId>[0]} [deps]
294
+ * @returns {Promise<string>}
295
+ */
296
+ export async function resolveCloudflareUrlAsync(url, deps = {}) {
297
+ const accountId = await ensureCloudflareAccountId(deps)
298
+ return applyCloudflareAccountId(url, accountId)
299
+ }
300
+
301
+ /**
302
+ * ๐Ÿ“– Test-only: clear all in-process state so tests stay hermetic.
303
+ * @returns {void}
304
+ */
305
+ export function resetCloudflareAccountStateForTests() {
306
+ cachedAccountId = null
307
+ diskSettingsLoaded = false
308
+ diskAccountId = null
309
+ discoveryInFlight = null
310
+ lastDiscoveryFailureAt = null
311
+ }
@@ -45,6 +45,7 @@ import { homedir } from 'node:os'
45
45
  import { dirname, join } from 'node:path'
46
46
  import { MODELS, sources } from '../../sources.js'
47
47
  import { getApiKey, saveConfig } from './config.js'
48
+ import { getCloudflareAccountIdSync } from './cloudflare-account.js'
48
49
  import { ENV_VAR_NAMES, PROVIDER_METADATA } from './provider-metadata.js'
49
50
  import { getToolMeta } from './tool-metadata.js'
50
51
  import { ensureDir, readJson as sharedReadJson, shellSingleQuote, atomicWriteJson } from './shared-helpers.js'
@@ -180,7 +181,9 @@ function resolveProviderBaseUrl(providerKey) {
180
181
  if (!providerUrl) return null
181
182
 
182
183
  if (providerKey === 'cloudflare') {
183
- const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
184
+ // ๐Ÿ“– Shared account-id resolution (issue #181): env var, in-process cache or
185
+ // ๐Ÿ“– stored config settings, so installs work without exporting the env var.
186
+ const accountId = getCloudflareAccountIdSync() || ''
184
187
  if (!accountId) return null
185
188
  return providerUrl
186
189
  .replace(/\{\$CLOUDFLARE_ACCOUNT_ID\}/g, encodeURIComponent(accountId))
@@ -201,7 +204,8 @@ function resolveGooseBaseUrl(providerKey) {
201
204
  const providerUrl = sources[providerKey]?.url
202
205
  if (!providerUrl) return null
203
206
  if (providerKey === 'cloudflare') {
204
- const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
207
+ // ๐Ÿ“– Shared account-id resolution (issue #181): env var, cache or stored config.
208
+ const accountId = getCloudflareAccountIdSync() || ''
205
209
  if (!accountId) return null
206
210
  return providerUrl
207
211
  .replace(/\{\$CLOUDFLARE_ACCOUNT_ID\}/g, encodeURIComponent(accountId))
@@ -218,8 +222,8 @@ function getDirectInstallSupport(providerKey) {
218
222
  if (DIRECT_INSTALL_UNSUPPORTED_PROVIDERS.has(providerKey)) {
219
223
  return { supported: false, reason: 'This provider still needs a dedicated runtime bridge' }
220
224
  }
221
- if (providerKey === 'cloudflare' && !(process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()) {
222
- return { supported: false, reason: 'CLOUDFLARE_ACCOUNT_ID is required for direct installs' }
225
+ if (providerKey === 'cloudflare' && !getCloudflareAccountIdSync()) {
226
+ return { supported: false, reason: 'Cloudflare account id required: set CLOUDFLARE_ACCOUNT_ID (or let auto-discovery find it from your API key)' }
223
227
  }
224
228
  return { supported: true, reason: null }
225
229
  }
@@ -32,6 +32,7 @@ import { PROVIDER_COLOR } from '../tui/render-table.js'
32
32
  import { sources } from '../../sources.js'
33
33
  import { loadOpenCodeConfig, saveOpenCodeConfig } from './opencode-config.js'
34
34
  import { getApiKey } from './config.js'
35
+ import { getCloudflareAccountIdSync } from './cloudflare-account.js'
35
36
  import { ENV_VAR_NAMES, OPENCODE_MODEL_MAP, isWindows, isMac, isLinux } from './provider-metadata.js'
36
37
  import { resolveToolBinaryPath } from './tool-bootstrap.js'
37
38
 
@@ -512,10 +513,11 @@ export async function startOpenCode(model, fcmConfig) {
512
513
  models: {}
513
514
  }
514
515
  } else if (providerKey === 'cloudflare') {
515
- const cloudflareAccountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
516
+ // ๐Ÿ“– Shared account-id resolution (issue #181): env var, cache or stored config.
517
+ const cloudflareAccountId = getCloudflareAccountIdSync() || ''
516
518
  if (!cloudflareAccountId) {
517
- console.log(chalk.yellow(' Cloudflare Workers AI requires CLOUDFLARE_ACCOUNT_ID for OpenCode integration.'))
518
- console.log(chalk.dim(' Export CLOUDFLARE_ACCOUNT_ID and retry this selection.'))
519
+ console.log(chalk.yellow(' Cloudflare Workers AI needs an account id for OpenCode integration.'))
520
+ console.log(chalk.dim(' Export CLOUDFLARE_ACCOUNT_ID and retry (or ping a Cloudflare model once so the account id is auto-discovered from your API key).'))
519
521
  console.log()
520
522
  return
521
523
  }
@@ -911,10 +913,11 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
911
913
  models: {}
912
914
  }
913
915
  } else if (providerKey === 'cloudflare') {
914
- const cloudflareAccountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
916
+ // ๐Ÿ“– Shared account-id resolution (issue #181): env var, cache or stored config.
917
+ const cloudflareAccountId = getCloudflareAccountIdSync() || ''
915
918
  if (!cloudflareAccountId) {
916
- console.log(chalk.yellow(' Cloudflare Workers AI requires CLOUDFLARE_ACCOUNT_ID for OpenCode integration.'))
917
- console.log(chalk.dim(' Export CLOUDFLARE_ACCOUNT_ID and retry this selection.'))
919
+ console.log(chalk.yellow(' Cloudflare Workers AI needs an account id for OpenCode integration.'))
920
+ console.log(chalk.dim(' Export CLOUDFLARE_ACCOUNT_ID and retry (or ping a Cloudflare model once so the account id is auto-discovered from your API key).'))
918
921
  console.log()
919
922
  return
920
923
  }