free-coding-models 0.5.89 → 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.
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
 
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.89",
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.90",
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",
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
  }
package/src/core/ping.js CHANGED
@@ -8,17 +8,20 @@
8
8
  * endpoint quota polling with caching.
9
9
  *
10
10
  * 🎯 Key features:
11
- * - Provider-specific request building (handles Replicate, Cloudflare, OpenRouter)
11
+ * - Provider-specific request building (handles Replicate, Cloudflare, OpenRouter,
12
+ * OpenCode Zen mandatory session header)
12
13
  * - Async ping with timeout and abort controller
13
14
  * - Quota extraction from rate limit headers (multiple variants supported)
14
15
  * - Cached provider quota polling with TTL and error backoff
15
- * - Cloudflare account ID resolution from environment
16
+ * - Cloudflare account-id resolution from env var, stored config or zero-setup
17
+ * auto-discovery via the /accounts endpoint (see ./cloudflare-account.js)
16
18
  * - Per-provider circuit-breaker on 429 (issue #146): pauses ALL models of a provider
17
19
  * when the provider returns a quota-exhausted response, so the ping loop stops
18
20
  * hammering the user's daily quota while the provider's retry window is active.
19
21
  *
20
22
  * → Functions:
21
- * - `resolveCloudflareUrl`: Resolve {account_id} placeholder from CLOUDFLARE_ACCOUNT_ID env var
23
+ * - `getProviderSessionHeaders`: Extra mandatory headers per provider (e.g. OpenCode Zen session id)
24
+ * - `resolveCloudflareUrl`: Resolve {account_id} placeholders (env > cache > stored config, issue #181)
22
25
  * - `buildChatCompletionPingBody`: Build minimal chat-completion probe payloads with thinking disabled
23
26
  * - `markDisabledThinkingUnsupported`: Cache strict providers that reject the optional thinking control
24
27
  * - `shouldUseDisabledThinkingForProvider`: Decide whether a provider should receive disabled-thinking probes
@@ -38,13 +41,21 @@
38
41
  *
39
42
  * ⚙️ Configuration:
40
43
  * - PING_TIMEOUT: Timeout in ms for ping requests (default: 15000)
41
- * - CLOUDFLARE_ACCOUNT_ID: Env var for Cloudflare Workers AI account ID
44
+ * - CLOUDFLARE_ACCOUNT_ID: Env var for the Cloudflare Workers AI account id
45
+ * (fallbacks: stored settings.cloudflareAccountId, then auto-discovery via
46
+ * the /accounts endpoint, handled in ./cloudflare-account.js)
42
47
  *
43
48
  * @see {@link ../src/provider-quota-fetchers.js} Quota fetching implementation
44
49
  * @see {@link ../src/quota-capabilities.js} Quota telemetry + Usage behavior detection
45
50
  */
46
51
 
52
+ import { randomUUID } from 'node:crypto'
47
53
  import { PING_TIMEOUT } from './constants.js'
54
+ import {
55
+ applyCloudflareAccountId,
56
+ getCloudflareAccountIdSync,
57
+ ensureCloudflareAccountId,
58
+ } from './cloudflare-account.js'
48
59
  import { fetchProviderQuota as _fetchProviderQuotaFromModule, extractQuota as _extractQuotaFromModule, processResponseHeaders as _processResponseHeadersFromModule } from './provider-quota-fetchers.js'
49
60
  import { supportsUsagePercent } from './quota-capabilities.js'
50
61
  import {
@@ -56,17 +67,15 @@ const DISABLED_THINKING_RETRY_STATUSES = new Set([400, 422])
56
67
  const disabledThinkingUnsupportedProviders = new Set()
57
68
 
58
69
  // 📖 resolveCloudflareUrl: Cloudflare's OpenAI-compatible endpoint is account-scoped.
59
- // 📖 We resolve the placeholder from the CLOUDFLARE_ACCOUNT_ID env var so provider
60
- // 📖 setup can stay simple in config. Supports both the `{account_id}` placeholder
61
- // 📖 and the explicit `{$CLOUDFLARE_ACCOUNT_ID}` form used in sources.js.
62
- export function resolveCloudflareUrl(url) {
63
- const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
64
- const hasPlaceholder = url.includes('{$CLOUDFLARE_ACCOUNT_ID}') || url.includes('{account_id}')
65
- if (!hasPlaceholder) return url
66
- const replacement = accountId ? encodeURIComponent(accountId) : 'missing-account-id'
67
- return url
68
- .replace(/\{\$CLOUDFLARE_ACCOUNT_ID\}/g, replacement)
69
- .replace(/\{account_id\}/g, replacement)
70
+ // 📖 We resolve the placeholder from the CLOUDFLARE_ACCOUNT_ID env var, the
71
+ // 📖 in-process cache or the stored config settings (issue #181), so provider
72
+ // 📖 setup can stay simple. Supports both the `{account_id}` placeholder and
73
+ // 📖 the explicit `{$CLOUDFLARE_ACCOUNT_ID}` form used in sources.js.
74
+ // 📖 When nothing resolves, the historical 'missing-account-id' segment keeps
75
+ // 📖 verdicts stable. Async paths should call ensureCloudflareAccountId() first
76
+ // 📖 so zero-setup auto-discovery can populate the cache (see ping()).
77
+ export function resolveCloudflareUrl(url, options = {}) {
78
+ return applyCloudflareAccountId(url, getCloudflareAccountIdSync(options))
70
79
  }
71
80
 
72
81
  // 📖 buildChatCompletionPingBody: Use the smallest useful chat-completion probe.
@@ -97,6 +106,25 @@ export function shouldUseDisabledThinkingForProvider(providerKey) {
97
106
  return !disabledThinkingUnsupportedProviders.has(providerKey)
98
107
  }
99
108
 
109
+ // 📖 getProviderSessionHeaders: extra mandatory headers a provider requires beyond
110
+ // 📖 the standard Content-Type/Authorization pair, centralised so probes (ping.js),
111
+ // 📖 the router daemon and the web dashboard all send identical headers instead of
112
+ // 📖 duplicating per-provider knowledge (issue #181).
113
+ // 📖 OpenCode Zen rejects every request without an `x-opencode-session` header with
114
+ // 📖 HTTP 400 MissingSessionID ("OpenCode's free tier can only be used in OpenCode").
115
+ // 📖 The Zen docs describe a stable per-conversation session id, so we generate ONE
116
+ // 📖 uuid per process at module load: every request this process sends shares the
117
+ // 📖 same identity, mirroring how the real OpenCode client behaves and keeping the
118
+ // 📖 value stable across repeated probes.
119
+ const OPENCODE_ZEN_SESSION_ID = randomUUID()
120
+
121
+ export function getProviderSessionHeaders(providerKey) {
122
+ if (providerKey === 'opencode-zen') {
123
+ return { 'x-opencode-session': OPENCODE_ZEN_SESSION_ID }
124
+ }
125
+ return {}
126
+ }
127
+
100
128
  // 📖 buildPingRequest: Build provider-specific ping request.
101
129
  // 📖 Handles Replicate's /v1/predictions format, Cloudflare's account_id in URL,
102
130
  // 📖 and standard OpenAI-compliant chat completions with provider-specific headers.
@@ -128,7 +156,7 @@ export function buildPingRequest(apiKey, modelId, providerKey, url, options = {}
128
156
  }
129
157
  }
130
158
 
131
- const headers = { 'Content-Type': 'application/json' }
159
+ const headers = { 'Content-Type': 'application/json', ...getProviderSessionHeaders(providerKey) }
132
160
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`
133
161
  if (providerKey === 'openrouter') {
134
162
  // 📖 OpenRouter recommends optional app identification headers.
@@ -178,6 +206,14 @@ async function isDisabledThinkingRejected(resp, req) {
178
206
  // 📖 A 401 response still tells us the server is UP and gives us real latency.
179
207
  // 📖 Returns { code, ms, quotaPercent }
180
208
  export async function ping(apiKey, modelId, providerKey, url) {
209
+ // 📖 Cloudflare zero-setup (issue #181): before building the request, give the
210
+ // 📖 account-id resolver a chance to auto-discover the id from the stored API
211
+ // 📖 key. Done BEFORE the timer starts so a one-time discovery round-trip never
212
+ // 📖 pollutes the measured latency or races PING_TIMEOUT. No-op when the id is
213
+ // 📖 already known from env/cache/config.
214
+ if (providerKey === 'cloudflare') {
215
+ await ensureCloudflareAccountId()
216
+ }
181
217
  const ctrl = new AbortController()
182
218
  const timer = setTimeout(() => ctrl.abort(), PING_TIMEOUT)
183
219
  const t0 = performance.now()