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 +4 -4
- package/changelog/v0.5.90.md +12 -0
- package/package.json +2 -2
- package/sources.js +5 -2
- package/src/core/benchmark.js +9 -0
- package/src/core/cloudflare-account.js +311 -0
- package/src/core/endpoint-installer.js +8 -4
- package/src/core/opencode.js +9 -6
- package/src/core/ping.js +52 -16
- package/src/core/provider-key-tester.js +10 -3
- package/src/core/provider-metadata.js +1 -1
- package/src/core/router-daemon.js +9 -1
- package/src/tui/overlays.js +9 -3
- package/web/dist/assets/{index-C5hgQLYN.js → index-CCkuXrqE.js} +3 -3
- package/web/dist/index.html +1 -1
- package/web/server.js +23 -1
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
|
|
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 /
|
|
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 /
|
|
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) |
|
|
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.
|
|
4
|
-
"description": "Find the fastest coding LLM models in seconds
|
|
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
|
-
//
|
|
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
|
package/src/core/benchmark.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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' && !(
|
|
222
|
-
return { supported: false, reason: '
|
|
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
|
}
|
package/src/core/opencode.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
518
|
-
console.log(chalk.dim(' Export CLOUDFLARE_ACCOUNT_ID and retry
|
|
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
|
-
|
|
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
|
|
917
|
-
console.log(chalk.dim(' Export CLOUDFLARE_ACCOUNT_ID and retry
|
|
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
|
|
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
|
-
* - `
|
|
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
|
|
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
|
|
60
|
-
// 📖
|
|
61
|
-
// 📖
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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()
|