free-coding-models 0.5.78 → 0.5.79

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,12 @@
1
+ # Changelog v0.5.79 - 2026-08-23
2
+
3
+ ### Fixed
4
+ - **Fast-coding pool malformed tool_calls hang (fixes #124).** Some providers returned `finish_reason: tool_calls` without a `tool_calls` array, causing pi and other OpenAI clients to wait forever. Router now normalizes such responses to `finish_reason: stop` in `src/core/router-daemon.js` (`normalizeToolCallsResponse`) before forwarding, making the response spec-compliant even when upstream is quirky. Thanks @stgreenb.
5
+
6
+ - **Docker mandatory update EACCES crash (fixes #109).** Inside Docker the app lives at `/app` and runs as non-root `fcm`, so `npm i -g` always hit EACCES on `/usr/local/lib/node_modules` and showed "Mandatory update failed" then exited. Added `isRunningInDocker()` in `src/core/updater.js` (checks `/.dockerenv` and `/app` prefix) to skip mandatory startup updates in containers; rebuild the image to update. Thanks @karneaud.
7
+
8
+ ### Changed
9
+ - Closed older user feature requests with concise responses: #51 Nix module (PR welcome), #30 oh-my-opencode (wrapper externe, pas de duplication), #27 Claude Code integration (now via router `fcm`). Only vava's own issues (75, 21, 18) remain open.
10
+
11
+ ### Tests
12
+ - 813/813 passing. Router now re-stringifies normalized JSON only when needed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.78",
3
+ "version": "0.5.79",
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",
@@ -118,6 +118,32 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
118
118
  const CLI_ENTRY_PATH = join(__dirname, '..', '..', 'bin', 'free-coding-models.js')
119
119
  const LOCAL_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')).version
120
120
  const MAX_BODY_BYTES = 10 * 1024 * 1024
121
+ /**
122
+ * 📖 normalizeToolCallsResponse — fix malformed tool_calls from upstream.
123
+ * Some providers return finish_reason: "tool_calls" but message has no tool_calls array,
124
+ * which hangs OpenAI-compatible clients (pi agent waits for tool_calls that never arrives).
125
+ * Normalize to finish_reason: "stop" when tool_calls is missing/empty per OpenAI spec.
126
+ * This is defensive: it makes the router spec-compliant regardless of upstream quirks.
127
+ * @param {object} data - parsed JSON response object
128
+ * @returns {boolean} true if mutated
129
+ */
130
+ function normalizeToolCallsResponse(data) {
131
+ if (!data || typeof data !== 'object' || !Array.isArray(data.choices)) return false
132
+ let mutated = false
133
+ for (const choice of data.choices) {
134
+ if (!choice || typeof choice !== 'object') continue
135
+ if (choice.finish_reason !== 'tool_calls') continue
136
+ const msg = choice.message
137
+ if (!msg || typeof msg !== 'object') continue
138
+ const tc = msg.tool_calls
139
+ if (!Array.isArray(tc) || tc.length === 0) {
140
+ choice.finish_reason = 'stop'
141
+ mutated = true
142
+ }
143
+ }
144
+ return mutated
145
+ }
146
+
121
147
  const MAX_REQUEST_LOG = 200
122
148
  const MAX_SSE_CLIENTS = 10
123
149
  const MAX_CONCURRENT_REQUESTS = 50
@@ -2226,13 +2252,20 @@ class RouterRuntime {
2226
2252
  failover: attemptIndex > 0,
2227
2253
  })
2228
2254
  this.logger.info(`Routed to ${key} - ${latencyMs}ms`, { request_id: requestId, status: response.status })
2255
+ // 📖 Fix #124: normalize malformed tool_calls (finish_reason tool_calls without tool_calls array)
2256
+ let responseText = text
2257
+ try {
2258
+ if (normalizeToolCallsResponse(parsed.value)) {
2259
+ responseText = JSON.stringify(parsed.value)
2260
+ }
2261
+ } catch {}
2229
2262
  if (!res.writableEnded) {
2230
2263
  res.writeHead(response.status, {
2231
2264
  ...headerEntries(response.headers),
2232
2265
  'x-fcm-router-model': key,
2233
2266
  'x-request-id': requestId,
2234
2267
  })
2235
- res.end(text)
2268
+ res.end(responseText)
2236
2269
  }
2237
2270
  return { done: true }
2238
2271
  }
@@ -110,6 +110,25 @@ export function isPackageDevMode() {
110
110
  return process.env.FCM_DEV === '1' || existsSync(join(PACKAGE_ROOT, '.git'))
111
111
  }
112
112
 
113
+ /**
114
+ * 📖 isRunningInDocker: detect Docker/container environment where global npm writes fail.
115
+ * In Docker the app lives at /app (not /usr/local/lib/node_modules) and runs as non-root `fcm`.
116
+ * Mandatory updates would always EACCES, so we skip them gracefully.
117
+ * @returns {boolean}
118
+ */
119
+ export function isRunningInDocker() {
120
+ // 📖 Standard Docker marker file, plus explicit env override for testing
121
+ if (process.env.FCM_DOCKER === '1' || process.env.DOCKER_CONTAINER === '1') return true
122
+ try {
123
+ if (existsSync('/.dockerenv')) return true
124
+ } catch {}
125
+ // 📖 Fallback: check if package is at /app (Docker COPY) vs global prefix
126
+ try {
127
+ if (String(PACKAGE_ROOT) === '/app' || String(PACKAGE_ROOT).startsWith('/app/')) return true
128
+ } catch {}
129
+ return false
130
+ }
131
+
113
132
  /**
114
133
  * 📖 getUpdateInstallFailureCount: sanitized persistent failure counter.
115
134
  * @param {object} config
@@ -265,6 +284,11 @@ export async function enforceMandatoryStartupUpdate(config, options = {}) {
265
284
  }
266
285
 
267
286
  if (devMode) return base
287
+ if (isRunningInDocker()) {
288
+ // 📖 In Docker, global npm install as non-root always EACCES. Skip mandatory update
289
+ // 📖 and let the container run with the baked-in version. User can rebuild image for updates.
290
+ return base
291
+ }
268
292
 
269
293
  const { latestVersion, error } = await checkForUpdateDetailed()
270
294
  base.checked = true