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.
- package/README.md +10 -4
- package/bin/free-coding-models.js +33 -2
- package/changelog/v0.5.89.md +20 -0
- package/changelog/v0.5.90.md +12 -0
- package/package.json +1 -1
- 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 +1138 -501
- package/src/core/router-v2/anthropic-compat.js +473 -0
- package/src/core/router-v2/bench.js +171 -0
- package/src/core/router-v2/breaker-store.js +265 -0
- package/src/core/router-v2/constants.js +108 -0
- package/src/core/router-v2/decision-trace.js +134 -0
- package/src/core/router-v2/failure-classifier.js +231 -0
- package/src/core/router-v2/request-history.js +137 -0
- package/src/core/router-v2/response-gate.js +175 -0
- package/src/core/router-v2/tui-dashboard.js +632 -0
- package/src/core/schema-normalizer.js +23 -6
- package/src/core/utils.js +12 -0
- package/src/tui/app.js +7 -2
- package/src/tui/cli-help.js +4 -0
- package/src/tui/key-handler.js +117 -2
- package/src/tui/overlays.js +19 -3
- package/src/tui/tui-state.js +22 -0
- package/web/dist/assets/index-CCaxIOti.css +1 -0
- package/web/dist/assets/index-CCkuXrqE.js +48 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +105 -1
- package/web/dist/assets/index-CAzFIt8P.css +0 -1
- 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
|
|
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
|
|
|
@@ -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.
|
|
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
|
-
//
|
|
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
|
}
|