free-coding-models 0.5.77 → 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,17 @@
1
+ # Changelog v0.5.78 - 2026-08-23
2
+
3
+ ### Fixed
4
+ - **Router forwards `prompt_cache_key` to NVIDIA -> HTTP 400 (fixes #160).** Clients using OpenAI JS SDK (pi agent) set `prompt_cache_key` to session UUID for prompt caching; NVIDIA rejects it with `Unsupported parameter(s): prompt_cache_key`. Added `prompt_cache_key` to `STRIP_PARAMS` in `src/core/schema-normalizer.js` and added `nvidia: normalizeNvidia` (strip + orphan tool drop) to `PROVIDER_NORMALIZERS` so `normalizeRequestBody(body, 'nvidia')` now strips the hint before forwarding. Verified with direct replay against `integrate.api.nvidia.com` and `pnpm test` 813/813. Thanks @pelvity.
5
+
6
+ - **Config `pingInterval` not respected at startup (fixes #155).** TUI always started in speed mode (2s) ignoring `settings.pingInterval` and `--ping-interval`. Now `src/tui/tui-state.js` `createTuiState()` derives `initialInterval` from `config.settings.pingInterval` (already merged from CLI in `src/tui/app.js:283`) and maps to mode via `intervalToPingMode` (30000 -> slow, 10000 -> normal, 4000 -> forced, <=3000 -> speed). Sets `pingModeSource` to `config` vs `startup` and only sets `speedModeUntil` when initial mode is speed. Users with 30s interval no longer burn quota on startup burst. Thanks @BetterToAutomateTheWorld.
7
+
8
+ - **jcode configuration option for CLI and web (fixes #145).** CLI Z cycle already had `jcode` but launch hung (stdin left in raw mode, REPL appeared frozen) and web had no jcode option at all.
9
+ - CLI hang: `src/tui/key-handler.js` `launchSelectedModel` now calls `stopUi({ resetRawMode: true })` (was `stopUi()` with raw mode still enabled) and `src/core/tool-launchers.js` `spawnCommand` defensively resets `stdin.setRawMode(false)` before handing TTY to child.
10
+ - Web: Added `jcode` to `INSTALL_TARGET_MODES` in `src/core/endpoint-installer.js` (now 15 targets, `jcode` after `pi` to match `TOOL_MODE_ORDER` order) with env-based installer (`~/.fcm-jcode-env`), mirrored in `web/src/utils/m3.js` `INSTALL_ENDPOINT_TOOL_MODES`, and fixed `web/src/hooks/useToolMode.js` cycle bug (`TOOL_MODE_ORDER` -> `INSTALL_ENDPOINT_TOOL_MODES`). Web `/api/tool-mode` now accepts `jcode` without 422 and dropdown/cycle shows it. Thanks @plahakps.
11
+
12
+ ### Changed
13
+ - `src/core/schema-normalizer.js` JSDoc now exports `normalizeNvidia` and clarifies per-provider normalization.
14
+ - `web/src/utils/m3.js` and `src/core/endpoint-installer.js` tool order now matches CLI's `TOOL_MODE_ORDER` (opencode -> pi -> jcode -> opencode-desktop ...).
15
+
16
+ ### Tests
17
+ - 813/813 passing. Updated `test/test.js` endpoint install target expectation to include `jcode`.
@@ -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.77",
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",
@@ -53,8 +53,8 @@ import { ensureDir, readJson as sharedReadJson } from './shared-helpers.js'
53
53
  // 📖 zai and opencode-zen ARE OpenAI-compatible and CAN be installed into any tool.
54
54
  const DIRECT_INSTALL_UNSUPPORTED_PROVIDERS = new Set(['replicate'])
55
55
  // 📖 Install Endpoints only lists tools whose persisted config shape is actually supported here.
56
- // 📖 Launch-only tools stay out: the Web dashboard configures endpoints, it never starts CLIs.
57
- const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'opencode-web', 'openclaw', 'crush', 'goose', 'pi', 'aider', 'qwen', 'openhands', 'amp', 'forgecode', 'fcm_router', 'zcode']
56
+ // 📖 jcode is CLI-only and uses an env-file helper (same as openhands) selectable in both CLI and Web.
57
+ const INSTALL_TARGET_MODES = ['opencode', 'pi', 'jcode', 'opencode-desktop', 'opencode-web', 'openclaw', 'crush', 'goose', 'aider', 'qwen', 'openhands', 'amp', 'forgecode', 'fcm_router', 'zcode']
58
58
 
59
59
  function getDefaultPaths() {
60
60
  const home = homedir()
@@ -839,6 +839,8 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
839
839
  installResult = installIntoAmp(providerKey, models, apiKey, paths)
840
840
  } else if (canonicalToolMode === 'qwen') {
841
841
  installResult = installIntoQwen(providerKey, models, apiKey, paths)
842
+ } else if (canonicalToolMode === 'jcode') {
843
+ installResult = installIntoEnvBasedTool(providerKey, models, apiKey, canonicalToolMode, paths)
842
844
  } else if (canonicalToolMode === 'openhands') {
843
845
  installResult = installIntoEnvBasedTool(providerKey, models, apiKey, canonicalToolMode, paths)
844
846
  } else if (canonicalToolMode === 'fcm_router') {
@@ -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
  }
@@ -30,8 +30,9 @@
30
30
  * → `clampTemperature` — clamps `temperature` to the provider's accepted range
31
31
  * → `normalizeZai` — for `zai` (GLM) provider
32
32
  * → `normalizeMistral` — for `mistral` and `codestral` providers
33
+ * → `normalizeNvidia` — for `nvidia` provider (strips unsupported params like prompt_cache_key)
33
34
  *
34
- * @exports normalizeRequestBody, normalizeZai, normalizeMistral, PROVIDER_NORMALIZERS
35
+ * @exports normalizeRequestBody, normalizeZai, normalizeMistral, normalizeNvidia, PROVIDER_NORMALIZERS
35
36
  *
36
37
  * @see src/core/router-daemon.js — calls `normalizeRequestBody` before forwarding upstream
37
38
  */
@@ -48,6 +49,7 @@ const STRIP_PARAMS = [
48
49
  'echo', // not in OpenAI spec
49
50
  'user', // PII risk; not used by FCM
50
51
  'metadata', // not always supported
52
+ 'prompt_cache_key', // OpenAI prompt-caching hint; NVIDIA rejects with 400
51
53
  'store', // GLM rejects
52
54
  ]
53
55
 
@@ -146,6 +148,20 @@ export function normalizeMistral(body) {
146
148
  return result
147
149
  }
148
150
 
151
+ /**
152
+ * 📖 `normalizeNvidia` — transforms a request body for the `nvidia` provider.
153
+ *
154
+ * 1. Strips parameters NVIDIA rejects with 400 (e.g. prompt_cache_key)
155
+ * 2. Removes orphan `tool` messages that lack a matching assistant tool_call
156
+ * 3. Strips `stream_options` when not streaming
157
+ */
158
+ export function normalizeNvidia(body) {
159
+ if (!body || typeof body !== 'object') return body
160
+ let result = stripUnsupportedParams(body)
161
+ result = dropOrphanToolMessages(result)
162
+ return result
163
+ }
164
+
149
165
  // 📖 Map of provider key → normalizer function. Providers that don't need
150
166
  // 📖 any tweak (most OpenAI-compat ones like Groq, Cerebras, etc.) are not
151
167
  // 📖 listed and pass through `normalizeRequestBody` unchanged.
@@ -153,6 +169,7 @@ export const PROVIDER_NORMALIZERS = {
153
169
  zai: normalizeZai,
154
170
  mistral: normalizeMistral,
155
171
  codestral: normalizeMistral,
172
+ nvidia: normalizeNvidia,
156
173
  }
157
174
 
158
175
  /**
@@ -209,6 +209,12 @@ const JCODE_NATIVE_PROVIDERS = {
209
209
  }
210
210
 
211
211
  function spawnCommand(command, args, env) {
212
+ // 📖 Defensive: ensure stdin is back in cooked mode before handing the TTY to
213
+ // 📖 an interactive child (jcode REPL etc). The TUI leaves raw mode enabled;
214
+ // 📖 if the caller forgot to reset it the child will appear to hang.
215
+ try {
216
+ if (process.stdin.isTTY && process.stdin.isRaw) process.stdin.setRawMode(false)
217
+ } catch {}
212
218
  return new Promise((resolve, reject) => {
213
219
  const child = spawn(command, args, {
214
220
  stdio: 'inherit',
@@ -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
@@ -278,9 +278,7 @@ export function createKeyHandler(ctx) {
278
278
  userSelected = { modelId: selected.modelId, label: selected.label, tier: selected.tier, providerKey: selected.providerKey, ctx: selected.ctx }
279
279
 
280
280
  if (!uiAlreadyStopped) {
281
- readline.emitKeypressEvents(process.stdin)
282
- if (process.stdin.isTTY) process.stdin.setRawMode(true)
283
- stopUi()
281
+ stopUi({ resetRawMode: true })
284
282
  }
285
283
 
286
284
  // 📖 If router is enabled, push [selected, ...favorites] to daemon as the active set
@@ -89,6 +89,13 @@ export function createTuiState({
89
89
 
90
90
  // 📖 Dynamic normal interval from config (default 10s)
91
91
  const normalInterval = config.settings?.pingInterval ?? PING_MODE_INTERVALS.normal
92
+ // 📖 Respect config.settings.pingInterval at startup (issue #155).
93
+ // 📖 CLI override is already merged into config.settings.pingInterval in app.js.
94
+ const configuredInterval = config.settings?.pingInterval
95
+ const hasConfiguredInterval =
96
+ typeof configuredInterval === 'number' && Number.isFinite(configuredInterval) && configuredInterval > 0
97
+ const initialInterval = hasConfiguredInterval ? configuredInterval : PING_MODE_INTERVALS.speed
98
+ const initialMode = intervalToPingMode(initialInterval)
92
99
 
93
100
  return {
94
101
  // 📖 Core data: model results (mutated in-place by ping loop)
@@ -107,14 +114,14 @@ export function createTuiState({
107
114
  sortDirection: (config.settings?.sortAsc ?? true) ? 'asc' : 'desc',
108
115
 
109
116
  // 📖 Ping cadence — drives the interval between background ping cycles
110
- pingInterval: PING_MODE_INTERVALS.speed,
111
- pingMode: 'speed',
112
- pingModeSource: 'startup',
117
+ pingInterval: initialInterval,
118
+ pingMode: initialMode,
119
+ pingModeSource: hasConfiguredInterval ? 'config' : 'startup',
113
120
  pingModeIntervals: {
114
121
  ...PING_MODE_INTERVALS,
115
122
  normal: normalInterval,
116
123
  },
117
- speedModeUntil: now + SPEED_MODE_DURATION_MS,
124
+ speedModeUntil: initialMode === 'speed' ? now + SPEED_MODE_DURATION_MS : null,
118
125
  lastPingTime: now,
119
126
  lastUserActivityAt: now,
120
127
  resumeSpeedOnActivity: false,