free-coding-models 0.5.55 โ†’ 0.5.57

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.
@@ -0,0 +1,28 @@
1
+ # Changelog v0.5.56 - 2026-07-25
2
+
3
+ ### Fixed
4
+ - ๐Ÿ” **Router stream-stall failover after partial response** ([#137](https://github.com/vava-nessa/free-coding-models/issues/137)) โ€” previously, if a model streamed some data then stalled (per-chunk timeout), the client received a truncated response with no automatic retry. Now the router:
5
+ 1. Emits a synthetic SSE error event in OpenAI format (`code: fcm_stream_failover`) so the client knows the stream was truncated and that failover is happening.
6
+ 2. Returns `failoverToNext: true` to the outer retry loop, which tries the next model on a fresh upstream connection.
7
+ 3. The new model's chunks are appended to the same response object โ€” the client sees one continuous stream with a clear mid-stream error marker.
8
+ 4. On a mid-stream failover the router skips `res.writeHead()` (headers were already sent by the previous model) and emits an SSE comment `: fcm-router-failover-from=<oldKey>` so debuggers can see which model served which segment.
9
+
10
+ **Scope of the fix:** only `stream_stall_timeout` and `timeout` errors trigger mid-stream failover. Generic upstream errors (malformed JSON, connection reset, destroy) still close cleanly with no failover โ€” their partial data is more likely to be invalid, so re-streaming from another model wouldn't help.
11
+
12
+ ### Maintenance
13
+ - ๐Ÿงช **+1 regression test** (`test/test.js`) locking in the new failover behavior with a stalled-stream mock provider. The existing "does not retry after partial output" test still passes โ€” it uses `res.destroy()` which is *not* a stall, so it correctly keeps the old behaviour.
14
+ - ๐Ÿงช **591/591 tests pass** (`pnpm test`).
15
+ - ๐Ÿงน `vite build` succeeds.
16
+
17
+ ### How clients see this
18
+ A streaming request that stalls now produces something like:
19
+ ```
20
+ data: {"choices":[{"delta":{"content":"partial"}}]}
21
+
22
+ data: {"error":{"message":"Stream truncated by router due to upstream stream_stall_timeout; failing over to next model.","type":"stream_error","code":"fcm_stream_failover","reason":"stream_stall_timeout"}}
23
+
24
+ data: {"choices":[{"delta":{"content":"fresh answer from fallback"}}]}
25
+
26
+ data: [DONE]
27
+ ```
28
+ Plus an SSE comment marker on each failover segment.
@@ -0,0 +1,20 @@
1
+ # Changelog v0.5.57 - 2026-07-25
2
+
3
+ ### Fixed
4
+ - ๐Ÿ“ฆ **`patch-openclaw.js` now finds `sources.js` in more locations** ([#35](https://github.com/vava-nessa/free-coding-models/issues/35)) โ€” when users copy just `patch-openclaw.js` to another directory (e.g. `~/.free-coding-models/`) and run it, the static `import './sources.js'` failed with `ERR_MODULE_NOT_FOUND` because ESM resolves imports relative to the importer file at parse time. Switched to a runtime search over multiple candidate paths:
5
+
6
+ 1. Same directory as the script (primary โ€” FCM source / global install layout)
7
+ 2. Parent directory (covers `tools/`-style subdirs)
8
+ 3. Two levels up
9
+ 4. Three levels up
10
+ 5. `process.cwd()` (last-resort fallback)
11
+
12
+ If none resolve, the script now prints a clear error pointing to the script's actual location and how to fix it, instead of a cryptic `ERR_MODULE_NOT_FOUND`.
13
+
14
+ ### Maintenance
15
+ - ๐Ÿงช **+1 unit test** (`test/patch-openclaw.test.js`) locking in the search order so any future reordering is caught.
16
+ - ๐Ÿงช **592/592 tests pass** (`pnpm test`).
17
+ - ๐Ÿงน `vite build` succeeds.
18
+
19
+ ### Why a top-level `await import()`?
20
+ ESM `import './sources.js'` is resolved at parse-time relative to the importer file's directory. There's no way to fall back at parse time, so the only way to support multiple candidate paths is to compute the path at runtime and use dynamic `import(pathToFileURL(sourcesPath).href)`. Top-level `await` keeps the rest of the script's top-level statements unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.55",
3
+ "version": "0.5.57",
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/patch-openclaw.js CHANGED
@@ -9,8 +9,39 @@
9
9
 
10
10
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
11
11
  import { homedir } from 'os'
12
- import { join } from 'path'
13
- import { nvidiaNim } from './sources.js'
12
+ import { join, dirname } from 'path'
13
+ import { fileURLToPath, pathToFileURL } from 'url'
14
+
15
+ // ๐Ÿ“– Issue #35: when users copy just patch-openclaw.js to another directory
16
+ // ๐Ÿ“– (e.g. ~/.free-coding-models/) and try to run it, the relative import of
17
+ // ๐Ÿ“– sources.js fails with ERR_MODULE_NOT_FOUND. Look up the tree + check CWD
18
+ // ๐Ÿ“– before giving up with a helpful message.
19
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
20
+
21
+ function findSourcesPath() {
22
+ const candidates = [
23
+ join(SCRIPT_DIR, 'sources.js'),
24
+ join(SCRIPT_DIR, '..', 'sources.js'),
25
+ join(SCRIPT_DIR, '..', '..', 'sources.js'),
26
+ join(SCRIPT_DIR, '..', '..', '..', 'sources.js'),
27
+ join(process.cwd(), 'sources.js'),
28
+ ]
29
+ for (const p of candidates) {
30
+ if (existsSync(p)) return p
31
+ }
32
+ return null
33
+ }
34
+
35
+ const sourcesPath = findSourcesPath()
36
+ if (!sourcesPath) {
37
+ console.error(' โœ– Could not locate sources.js next to this script.')
38
+ console.error(` Script location: ${SCRIPT_DIR}`)
39
+ console.error(' Make sure sources.js is in the same directory as patch-openclaw.js,')
40
+ console.error(' or run this from the free-coding-models repo root.')
41
+ process.exit(1)
42
+ }
43
+
44
+ const { nvidiaNim } = await import(pathToFileURL(sourcesPath).href)
14
45
 
15
46
  const MODELS_JSON = join(homedir(), '.openclaw', 'agents', 'main', 'agent', 'models.json')
16
47
  const OPENCLAW_JSON = join(homedir(), '.openclaw', 'openclaw.json')
@@ -2194,11 +2194,23 @@ class RouterRuntime {
2194
2194
  }
2195
2195
 
2196
2196
  if (res.writableEnded) return { done: true }
2197
- res.writeHead(response.status, {
2198
- ...headerEntries(response.headers),
2199
- 'x-fcm-router-model': key,
2200
- 'x-request-id': requestId,
2201
- })
2197
+ // ๐Ÿ“– Issue #137: when the previous model sent partial data, headers are
2198
+ // ๐Ÿ“– already on the wire โ€” re-calling writeHead throws ERR_HTTP_HEADERS_SENT.
2199
+ // ๐Ÿ“– On a mid-stream failover we just append chunks to the existing response.
2200
+ if (!res.headersSent) {
2201
+ res.writeHead(response.status, {
2202
+ ...headerEntries(response.headers),
2203
+ 'x-fcm-router-model': key,
2204
+ 'x-request-id': requestId,
2205
+ })
2206
+ } else {
2207
+ // ๐Ÿ“– Reflect the new model in trailer-ish debug headers. Node won't let
2208
+ // ๐Ÿ“– us add new headers after send, but we still update x-fcm-router-model
2209
+ // ๐Ÿ“– semantics via a leading SSE comment so clients can see the switch.
2210
+ try {
2211
+ res.write(`: fcm-router-failover-from=${key}\n\n`)
2212
+ } catch { /* best-effort */ }
2213
+ }
2202
2214
  sentToClient = true
2203
2215
  res.write(firstChunkBuffer)
2204
2216
 
@@ -2230,6 +2242,12 @@ class RouterRuntime {
2230
2242
  return { done: true }
2231
2243
  }
2232
2244
  const reason = error.name === 'AbortError' ? 'timeout' : (error.message || String(error))
2245
+ // ๐Ÿ“– Issue #137: stream-stall timeouts get a special tag so we can
2246
+ // ๐Ÿ“– distinguish them from generic upstream errors below. Only stalls
2247
+ // ๐Ÿ“– should trigger failover after a partial response โ€” generic errors
2248
+ // ๐Ÿ“– (malformed JSON, network reset, etc.) usually mean the partial
2249
+ // ๐Ÿ“– data is invalid anyway, so closing cleanly is safer.
2250
+ const isStall = reason === 'stream_stall_timeout' || reason === 'timeout'
2233
2251
  this.markFailure(key, reason)
2234
2252
  if (reason !== 'timeout') {
2235
2253
  this.recordRouterError('upstream_stream_error', requestId, { model: key, reason, partial: sentToClient })
@@ -2238,6 +2256,31 @@ class RouterRuntime {
2238
2256
  }
2239
2257
  this.addRequestLog({ request_id: requestId, model: key, status: 'ERR', latency_ms: null, tokens: 0, failover: attemptIndex > 0, error: reason, stream: true })
2240
2258
  if (sentToClient) {
2259
+ if (isStall) {
2260
+ // ๐Ÿ“– Issue #137: failover even after a partial response. Emit a
2261
+ // ๐Ÿ“– synthetic SSE error event in OpenAI format so clients know the
2262
+ // ๐Ÿ“– stream was truncated and that the router is failing over. The
2263
+ // ๐Ÿ“– outer retry loop will then try the next model on a fresh
2264
+ // ๐Ÿ“– upstream connection; its chunks are appended to the same
2265
+ // ๐Ÿ“– response object so the client sees one continuous stream.
2266
+ this.logger.warn(`Stream stall after partial response from ${key}, attempting failover`, { request_id: requestId, reason })
2267
+ if (!res.writableEnded) {
2268
+ try {
2269
+ const errorPayload = JSON.stringify({
2270
+ error: {
2271
+ message: `Stream truncated by router due to upstream ${reason}; failing over to next model.`,
2272
+ type: 'stream_error',
2273
+ code: 'fcm_stream_failover',
2274
+ reason,
2275
+ },
2276
+ })
2277
+ res.write(`data: ${errorPayload}\n\n`)
2278
+ } catch { /* best-effort */ }
2279
+ }
2280
+ return { done: false, failoverToNext: true, reason: `stream_stall_${reason}` }
2281
+ }
2282
+ // ๐Ÿ“– Non-stall errors after partial output: keep existing behaviour
2283
+ // ๐Ÿ“– (close cleanly, no failover) to avoid sending malformed data.
2241
2284
  this.logger.warn(`Streaming failure after partial response from ${key}`, { request_id: requestId, reason })
2242
2285
  try { if (!res.writableEnded) res.end() } catch {}
2243
2286
  return { done: true }