free-coding-models 0.5.86 → 0.5.87

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.
Files changed (163) hide show
  1. package/README.md +110 -881
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.87.md +48 -0
  4. package/package.json +8 -5
  5. package/sources.js +4 -4
  6. package/src/core/cache.js +3 -1
  7. package/src/core/config.js +43 -5
  8. package/src/core/endpoint-installer.js +23 -12
  9. package/src/core/installed-models-manager.js +43 -20
  10. package/src/core/kilo-config.js +51 -5
  11. package/src/core/kilo.js +5 -3
  12. package/src/core/legacy-proxy-cleanup.js +22 -5
  13. package/src/core/model-merger.js +4 -0
  14. package/src/core/models-drift.js +9 -0
  15. package/src/core/opencode-config.js +57 -5
  16. package/src/core/opencode.js +67 -41
  17. package/src/core/playground.js +7 -0
  18. package/src/core/probe-cache.js +35 -4
  19. package/src/core/provider-key-tester.js +10 -2
  20. package/src/core/provider-metadata.js +10 -0
  21. package/src/core/provider-quota-fetchers.js +14 -7
  22. package/src/core/router-daemon.js +208 -26
  23. package/src/core/runtime-telemetry.js +27 -1
  24. package/src/core/shared-helpers.js +12 -1
  25. package/src/core/shell-env.js +3 -1
  26. package/src/core/telemetry.js +18 -5
  27. package/src/core/tool-launchers.js +9 -6
  28. package/src/core/updater.js +28 -2
  29. package/src/core/utils.js +76 -71
  30. package/src/tui/app.js +38 -14
  31. package/src/tui/command-palette.js +22 -2
  32. package/src/tui/key-handler.js +123 -179
  33. package/src/tui/overlays.js +21 -71
  34. package/src/tui/render-helpers.js +62 -4
  35. package/src/tui/render-table.js +34 -3
  36. package/src/tui/theme.js +36 -5
  37. package/src/tui/tui-filters.js +28 -8
  38. package/src/tui/tui-state.js +22 -6
  39. package/web/dist/assets/index-BpTpPS0g.js +44 -0
  40. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  41. package/web/dist/index.html +5 -9
  42. package/web/index.html +3 -7
  43. package/web/server.js +97 -23
  44. package/web/vite.config.js +8 -0
  45. package/web/app.legacy.js +0 -900
  46. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  47. package/web/dist/assets/index-Dm-Xfby9.js +0 -40
  48. package/web/public/favicon.ico +0 -0
  49. package/web/public/favicons/apple-touch-icon.png +0 -0
  50. package/web/public/favicons/browserconfig.xml +0 -12
  51. package/web/public/favicons/favicon-16x16.png +0 -0
  52. package/web/public/favicons/favicon-192x192.png +0 -0
  53. package/web/public/favicons/favicon-32x32.png +0 -0
  54. package/web/public/favicons/favicon-48x48.png +0 -0
  55. package/web/public/favicons/favicon-512x512.png +0 -0
  56. package/web/public/favicons/favicon-96x96.png +0 -0
  57. package/web/public/favicons/favicon.ico +0 -0
  58. package/web/public/favicons/mstile-150x150.png +0 -0
  59. package/web/public/favicons/mstile-310x150.png +0 -0
  60. package/web/public/favicons/mstile-310x310.png +0 -0
  61. package/web/public/favicons/mstile-512x512.png +0 -0
  62. package/web/public/favicons/mstile-70x70.png +0 -0
  63. package/web/public/favicons/site.webmanifest +0 -25
  64. package/web/src/App.jsx +0 -521
  65. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  66. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  67. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  68. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  69. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  70. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  71. package/web/src/components/atoms/HealthCell.jsx +0 -143
  72. package/web/src/components/atoms/HealthCell.module.css +0 -60
  73. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  74. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  75. package/web/src/components/atoms/MoodCell.jsx +0 -25
  76. package/web/src/components/atoms/MoodCell.module.css +0 -6
  77. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  78. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  79. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  80. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  81. package/web/src/components/atoms/RankCell.jsx +0 -9
  82. package/web/src/components/atoms/RankCell.module.css +0 -9
  83. package/web/src/components/atoms/Sparkline.jsx +0 -63
  84. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  85. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  86. package/web/src/components/atoms/StatusDot.jsx +0 -13
  87. package/web/src/components/atoms/StatusDot.module.css +0 -29
  88. package/web/src/components/atoms/TPSCell.jsx +0 -36
  89. package/web/src/components/atoms/TPSCell.module.css +0 -44
  90. package/web/src/components/atoms/TierBadge.jsx +0 -10
  91. package/web/src/components/atoms/TierBadge.module.css +0 -29
  92. package/web/src/components/atoms/Toast.jsx +0 -25
  93. package/web/src/components/atoms/Toast.module.css +0 -41
  94. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  95. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  96. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  97. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  98. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  99. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  100. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  101. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  102. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  103. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  104. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  105. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  106. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  107. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  108. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  109. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  110. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  111. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  112. package/web/src/components/help/HelpView.jsx +0 -201
  113. package/web/src/components/help/HelpView.module.css +0 -156
  114. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  115. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  116. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  117. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  118. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  119. package/web/src/components/launch/LaunchButton.jsx +0 -30
  120. package/web/src/components/launch/LaunchButton.module.css +0 -35
  121. package/web/src/components/launch/LaunchModal.module.css +0 -125
  122. package/web/src/components/layout/Footer.jsx +0 -19
  123. package/web/src/components/layout/Footer.module.css +0 -10
  124. package/web/src/components/layout/Header.jsx +0 -265
  125. package/web/src/components/layout/Header.module.css +0 -357
  126. package/web/src/components/palette/CommandPalette.jsx +0 -301
  127. package/web/src/components/palette/CommandPalette.module.css +0 -121
  128. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  129. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  130. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  131. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  132. package/web/src/components/recommend/RecommendView.jsx +0 -121
  133. package/web/src/components/recommend/RecommendView.module.css +0 -57
  134. package/web/src/components/router/RouterView.jsx +0 -1212
  135. package/web/src/components/router/RouterView.module.css +0 -1289
  136. package/web/src/components/settings/SettingsView.jsx +0 -555
  137. package/web/src/components/settings/SettingsView.module.css +0 -550
  138. package/web/src/components/tools/ToolPicker.jsx +0 -86
  139. package/web/src/components/tools/ToolPicker.module.css +0 -97
  140. package/web/src/components/update/UpdateChip.jsx +0 -104
  141. package/web/src/components/update/UpdateChip.module.css +0 -146
  142. package/web/src/global.css +0 -298
  143. package/web/src/hooks/urlState.constants.js +0 -28
  144. package/web/src/hooks/useChangelog.js +0 -51
  145. package/web/src/hooks/useColumnSizing.js +0 -211
  146. package/web/src/hooks/useFavorites.js +0 -150
  147. package/web/src/hooks/useFilter.js +0 -310
  148. package/web/src/hooks/useInstalledModels.js +0 -42
  149. package/web/src/hooks/useRecommend.js +0 -67
  150. package/web/src/hooks/useRouterDashboard.js +0 -133
  151. package/web/src/hooks/useSSE.js +0 -73
  152. package/web/src/hooks/useSocket.js +0 -203
  153. package/web/src/hooks/useTheme.js +0 -83
  154. package/web/src/hooks/useTokenUsage.js +0 -103
  155. package/web/src/hooks/useToolMode.js +0 -77
  156. package/web/src/hooks/useUpdateChecker.js +0 -91
  157. package/web/src/hooks/useUrlState.js +0 -170
  158. package/web/src/main.jsx +0 -15
  159. package/web/src/utils/download.js +0 -15
  160. package/web/src/utils/format.js +0 -42
  161. package/web/src/utils/m3.js +0 -57
  162. package/web/src/utils/ranks.js +0 -37
  163. package/web/styles.legacy.css +0 -963
@@ -160,6 +160,10 @@ export async function overlayModelsDevMetadata(mergedModels, opts = {}) {
160
160
  }
161
161
  }
162
162
  if (!bestMatch && m.slug) bestMatch = lookup(m.slug, m.label, index)
163
+ // 📖 Skip substring matches entirely: a label-only match can bind the WRONG
164
+ // 📖 catalog model and overwrite the curated ctx window with its value
165
+ // 📖 (detectDrift ignores substring matches for the same reason).
166
+ if (bestMatch && bestMatch.matchKind === 'substring') bestMatch = null
163
167
  if (!bestMatch) {
164
168
  if (opts.mutate) {
165
169
  Object.assign(m, { modelsDevMeta: null, metaSource: 'sources.js' })
@@ -131,8 +131,10 @@ export function detectDrift(models, catalog, opts = {}) {
131
131
  for (const entry of models) {
132
132
  // 📖 Accept both tuple form and object form
133
133
  let modelId, label, ctx, reasoning, vision, thinking, maxTokens
134
+ let isTupleForm = false
134
135
  if (Array.isArray(entry)) {
135
136
  [modelId, label, , , ctx] = entry
137
+ isTupleForm = true
136
138
  // 📖 The 6th+ elements of the sources.js tuple are providerKey + metadata;
137
139
  // 📖 for drift we only need the first 5.
138
140
  } else if (entry && typeof entry === 'object') {
@@ -162,6 +164,13 @@ export function detectDrift(models, catalog, opts = {}) {
162
164
  matchKind,
163
165
  })
164
166
  }
167
+
168
+ // 📖 Tuple-form entries carry no capability flags or maxTokens (arity 5), so
169
+ // 📖 flagMatch(undefined, true) used to report a bogus "add" for every capable
170
+ // 📖 model and bury the real ctx drift under noise. Only object-form entries
171
+ // 📖 (which can actually hold these fields) are compared.
172
+ if (isTupleForm) continue
173
+
165
174
  // maxTokens
166
175
  const maxCmp = numMatch(maxTokens, norm.maxOutputTokens)
167
176
  if (maxCmp.status === 'drift' || maxCmp.status === 'add') {
@@ -8,37 +8,89 @@
8
8
  * 📖 This module deliberately stays tiny so OpenCode launch code is not
9
9
  * 📖 coupled to old bridge-specific sync behavior anymore.
10
10
  *
11
+ * 📖 Data-loss guard: an existing opencode.json that fails to parse returns
12
+ * 📖 null from `loadOpenCodeConfig`, and `saveOpenCodeConfig` refuses to write
13
+ * 📖 over an unreadable file. Saving from the empty `{}` base would otherwise
14
+ * 📖 silently replace the user's whole config with a near-empty one.
15
+ *
11
16
  * @functions
12
- * → `loadOpenCodeConfig` read `~/.config/opencode/opencode.json` safely
13
- * → `saveOpenCodeConfig` write `opencode.json` with a simple backup
17
+ * → `loadOpenCodeConfig` - read `~/.config/opencode/opencode.json` safely (null when unreadable)
18
+ * → `saveOpenCodeConfig` - write `opencode.json` atomically with a simple backup
14
19
  * → `restoreOpenCodeBackup` — restore the last `.bak` copy if needed
15
20
  *
16
21
  * @exports loadOpenCodeConfig, saveOpenCodeConfig, restoreOpenCodeBackup
17
22
  */
18
23
 
19
- import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs'
24
+ import { readFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs'
20
25
  import { join } from 'node:path'
21
26
  import { homedir } from 'node:os'
27
+ import { atomicWriteJson } from './shared-helpers.js'
22
28
 
23
29
  const OPENCODE_CONFIG_DIR = join(homedir(), '.config', 'opencode')
24
30
  const OPENCODE_CONFIG_PATH = join(OPENCODE_CONFIG_DIR, 'opencode.json')
25
31
  const OPENCODE_BACKUP_PATH = join(OPENCODE_CONFIG_DIR, 'opencode.json.bak')
26
32
 
33
+ const UNREADABLE_CONFIG_HINT = ` ⚠ ${OPENCODE_CONFIG_PATH} contains invalid JSON and was NOT modified. Fix or remove the file, then retry.`
34
+
35
+ /**
36
+ * 📖 True when the file exists, is non-empty, and does not parse as JSON.
37
+ * 📖 Missing and empty files are considered readable (nothing to lose).
38
+ */
39
+ function isExistingFileUnreadable(configPath) {
40
+ if (!existsSync(configPath)) return false
41
+ try {
42
+ const raw = readFileSync(configPath, 'utf8')
43
+ if (!raw.trim()) return false
44
+ JSON.parse(raw)
45
+ return false
46
+ } catch {
47
+ return true
48
+ }
49
+ }
50
+
27
51
  export function loadOpenCodeConfig() {
28
52
  try {
29
53
  if (existsSync(OPENCODE_CONFIG_PATH)) {
30
- return JSON.parse(readFileSync(OPENCODE_CONFIG_PATH, 'utf8'))
54
+ const raw = readFileSync(OPENCODE_CONFIG_PATH, 'utf8')
55
+ // 📖 Missing file and empty file behave as before: fresh empty base.
56
+ if (!raw.trim()) return {}
57
+ try {
58
+ return JSON.parse(raw)
59
+ } catch {
60
+ // 📖 Existing non-empty file with broken JSON: signal "unreadable" so
61
+ // 📖 callers never build a save on an empty base and wipe the file.
62
+ return null
63
+ }
31
64
  }
32
65
  } catch {}
33
66
  return {}
34
67
  }
35
68
 
69
+ /**
70
+ * 📖 Save opencode.json atomically (tmp + rename) with a one-shot backup.
71
+ * 📖 Returns false and aborts (with a clear message) instead of overwriting
72
+ * 📖 when the config is null or the on-disk file is unreadable.
73
+ * @param {object|null} config
74
+ * @returns {boolean} true when written
75
+ */
36
76
  export function saveOpenCodeConfig(config) {
77
+ if (config === null || config === undefined) {
78
+ console.error(UNREADABLE_CONFIG_HINT)
79
+ return false
80
+ }
81
+ // 📖 Belt-and-suspenders for callers that normalized null to {}: never let a
82
+ // 📖 stale in-memory copy clobber a file we cannot read.
83
+ if (isExistingFileUnreadable(OPENCODE_CONFIG_PATH)) {
84
+ console.error(UNREADABLE_CONFIG_HINT)
85
+ return false
86
+ }
37
87
  mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true })
38
88
  if (existsSync(OPENCODE_CONFIG_PATH)) {
39
89
  copyFileSync(OPENCODE_CONFIG_PATH, OPENCODE_BACKUP_PATH)
40
90
  }
41
- writeFileSync(OPENCODE_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n')
91
+ // 📖 Atomic write: a crash mid-write must never truncate the shared config.
92
+ atomicWriteJson(OPENCODE_CONFIG_PATH, config, 0o600)
93
+ return true
42
94
  }
43
95
 
44
96
  export function restoreOpenCodeBackup() {
@@ -14,7 +14,7 @@
14
14
  * - Auto-pick tmux port for OpenCode sub-agents
15
15
  *
16
16
  * → Functions:
17
- * - `setOpenCodeModelData` Keep shared merged model references available
17
+ * - `setOpenCodeModelData` - No-op kept for call-site stability (dead refs removed)
18
18
  * - `startOpenCode` — Launch OpenCode CLI with selected model
19
19
  * - `startOpenCodeDesktop` — Set model and open Desktop app
20
20
  *
@@ -41,15 +41,10 @@ const OPENCODE_CONFIG = join(homedir(), '.config', 'opencode', 'opencode.json')
41
41
  const OPENCODE_PORT_RANGE_START = 4096
42
42
  const OPENCODE_PORT_RANGE_END = 5096
43
43
 
44
- // 📖 Keep merged model references available for future OpenCode-related features.
45
- let mergedModelsRef = []
46
- let mergedModelByLabelRef = new Map()
47
-
48
- // 📖 setOpenCodeModelData: Provide mergedModels + mergedModelByLabel to this module.
49
- export function setOpenCodeModelData(mergedModels, mergedModelByLabel) {
50
- mergedModelsRef = Array.isArray(mergedModels) ? mergedModels : []
51
- mergedModelByLabelRef = mergedModelByLabel instanceof Map ? mergedModelByLabel : new Map()
52
- }
44
+ // 📖 setOpenCodeModelData: kept as a stable no-op export. The merged model refs it
45
+ // 📖 used to store were written but never read anywhere (verified by grep), so the
46
+ // 📖 dead module state was removed; src/tui/app.js still calls this on startup.
47
+ export function setOpenCodeModelData() {}
53
48
 
54
49
  // 📖 isTcpPortAvailable: checks if a local TCP port is free for OpenCode.
55
50
  // 📖 Used to avoid tmux sub-agent port conflicts when multiple projects run in parallel.
@@ -147,7 +142,19 @@ async function createZaiProxy(apiKey) {
147
142
  res.writeHead(proxyRes.statusCode, proxyRes.headers)
148
143
  proxyRes.pipe(res)
149
144
  })
150
- proxyReq.on('error', () => { res.writeHead(502); res.end() })
145
+ proxyReq.on('error', () => {
146
+ // 📖 Headers may already be flushed to the client mid-stream; writeHead after
147
+ // 📖 that throws ERR_HTTP_HEADERS_SENT and masks the real proxy error.
148
+ if (!res.headersSent) res.writeHead(502)
149
+ res.end()
150
+ })
151
+ // 📖 Hang guard: an upstream that never responds used to stall the client
152
+ // 📖 forever. Destroy the socket and answer 502 when headers are not sent yet.
153
+ proxyReq.setTimeout(15000, () => {
154
+ proxyReq.destroy(new Error('ZAI proxy upstream timeout'))
155
+ if (!res.headersSent) res.writeHead(502)
156
+ res.end()
157
+ })
151
158
  req.pipe(proxyReq)
152
159
  })
153
160
  await new Promise(r => server.listen(0, '127.0.0.1', r))
@@ -196,7 +203,9 @@ async function spawnOpenCode(args, providerKey, fcmConfig, existingZaiProxy = nu
196
203
  const { spawn } = await import('child_process')
197
204
  const child = spawn(resolveToolBinaryPath('opencode') || 'opencode', finalArgs, {
198
205
  stdio: 'inherit',
199
- shell: true,
206
+ // 📖 Windows keeps a shell because npm-installed CLIs resolve to .cmd shims;
207
+ // 📖 everywhere else the resolved absolute binary path is spawned directly.
208
+ shell: isWindows,
200
209
  detached: false,
201
210
  env: childEnv
202
211
  })
@@ -207,7 +216,7 @@ async function spawnOpenCode(args, providerKey, fcmConfig, existingZaiProxy = nu
207
216
  // 📖 ZAI cleanup: remove the ephemeral proxy provider from opencode.json
208
217
  if (providerKey === 'zai') {
209
218
  try {
210
- const cfg = loadOpenCodeConfig()
219
+ const cfg = loadOpenCodeConfig() || {}
211
220
  if (cfg.provider?.zai) delete cfg.provider.zai
212
221
  if (typeof cfg.model === 'string' && cfg.model.startsWith('zai/')) delete cfg.model
213
222
  saveOpenCodeConfig(cfg)
@@ -236,7 +245,7 @@ export async function startOpenCode(model, fcmConfig) {
236
245
  const modelRef = `${providerKey}/${ocModelId}`
237
246
 
238
247
  if (providerKey === 'nvidia') {
239
- const config = loadOpenCodeConfig()
248
+ const config = loadOpenCodeConfig() || {}
240
249
  const backupPath = `${getOpenCodeConfigPath()}.backup-${Date.now()}`
241
250
 
242
251
  if (existsSync(getOpenCodeConfigPath())) {
@@ -271,7 +280,7 @@ export async function startOpenCode(model, fcmConfig) {
271
280
 
272
281
  saveOpenCodeConfig(config)
273
282
 
274
- const savedConfig = loadOpenCodeConfig()
283
+ const savedConfig = loadOpenCodeConfig() || {}
275
284
  console.log(chalk.dim(` Config saved to: ${getOpenCodeConfigPath()}`))
276
285
  console.log(chalk.dim(` Default model in config: ${savedConfig.model || 'NOT SET'}`))
277
286
  console.log()
@@ -312,7 +321,7 @@ export async function startOpenCode(model, fcmConfig) {
312
321
  console.log(chalk.dim(` Model: ${modelRef}`))
313
322
  console.log()
314
323
 
315
- const config = loadOpenCodeConfig()
324
+ const config = loadOpenCodeConfig() || {}
316
325
  const backupPath = `${getOpenCodeConfigPath()}.backup-${Date.now()}`
317
326
 
318
327
  if (existsSync(getOpenCodeConfigPath())) {
@@ -335,7 +344,7 @@ export async function startOpenCode(model, fcmConfig) {
335
344
 
336
345
  saveOpenCodeConfig(config)
337
346
 
338
- const savedConfig = loadOpenCodeConfig()
347
+ const savedConfig = loadOpenCodeConfig() || {}
339
348
  console.log(chalk.dim(` Config saved to: ${getOpenCodeConfigPath()}`))
340
349
  console.log(chalk.dim(` Default model in config: ${savedConfig.model || 'NOT SET'}`))
341
350
  console.log()
@@ -361,7 +370,7 @@ export async function startOpenCode(model, fcmConfig) {
361
370
  console.log(chalk.dim(` Model: ${zenModelRef}`))
362
371
  console.log()
363
372
 
364
- const config = loadOpenCodeConfig()
373
+ const config = loadOpenCodeConfig() || {}
365
374
  const backupPath = `${getOpenCodeConfigPath()}.backup-${Date.now()}`
366
375
 
367
376
  if (existsSync(getOpenCodeConfigPath())) {
@@ -372,7 +381,7 @@ export async function startOpenCode(model, fcmConfig) {
372
381
  config.model = zenModelRef
373
382
  saveOpenCodeConfig(config)
374
383
 
375
- const savedConfig = loadOpenCodeConfig()
384
+ const savedConfig = loadOpenCodeConfig() || {}
376
385
  console.log(chalk.dim(` Config saved to: ${getOpenCodeConfigPath()}`))
377
386
  console.log(chalk.dim(` Default model in config: ${savedConfig.model || 'NOT SET'}`))
378
387
  console.log()
@@ -392,7 +401,7 @@ export async function startOpenCode(model, fcmConfig) {
392
401
  console.log(chalk.dim(` Model: ${modelRef}`))
393
402
  console.log()
394
403
 
395
- const config = loadOpenCodeConfig()
404
+ const config = loadOpenCodeConfig() || {}
396
405
  const backupPath = `${getOpenCodeConfigPath()}.backup-${Date.now()}`
397
406
 
398
407
  if (existsSync(getOpenCodeConfigPath())) {
@@ -559,7 +568,7 @@ export async function startOpenCode(model, fcmConfig) {
559
568
  config.model = modelRef
560
569
  saveOpenCodeConfig(config)
561
570
 
562
- const savedConfig = loadOpenCodeConfig()
571
+ const savedConfig = loadOpenCodeConfig() || {}
563
572
  console.log(chalk.dim(` Config saved to: ${getOpenCodeConfigPath()}`))
564
573
  console.log(chalk.dim(` Default model in config: ${savedConfig.model || 'NOT SET'}`))
565
574
  console.log()
@@ -587,7 +596,7 @@ export async function startOpenCodeWeb(model, fcmConfig) {
587
596
  console.log(chalk.dim(` Model: ${modelRef}`))
588
597
  console.log()
589
598
 
590
- const config = loadOpenCodeConfig()
599
+ const config = loadOpenCodeConfig() || {}
591
600
  const backupPath = `${getOpenCodeConfigPath()}.backup-${Date.now()}`
592
601
 
593
602
  if (existsSync(getOpenCodeConfigPath())) {
@@ -639,33 +648,50 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
639
648
  const ocModelId = getOpenCodeModelId(providerKey, model.modelId)
640
649
  const modelRef = `${providerKey}/${ocModelId}`
641
650
 
651
+ const printDesktopLaunchError = () => {
652
+ console.error(chalk.red(' Could not open OpenCode Desktop'))
653
+ if (isWindows) {
654
+ console.error(chalk.dim(' Make sure OpenCode is installed from https://opencode.ai'))
655
+ } else if (isLinux) {
656
+ console.error(chalk.dim(' Install via: snap install opencode OR flatpak install ai.opencode.OpenCode'))
657
+ console.error(chalk.dim(' Or download from https://opencode.ai'))
658
+ } else {
659
+ console.error(chalk.dim(' Is it installed at /Applications/OpenCode.app?'))
660
+ }
661
+ }
662
+
642
663
  const launchDesktop = async () => {
664
+ // 📖 Linux: try each launcher directly (shell:false) so modelRef is never
665
+ // 📖 interpolated into a shell command string. The final candidate opens the
666
+ // 📖 .desktop entry without a model argument.
667
+ if (isLinux) {
668
+ const { execFile } = await import('child_process')
669
+ const tryExecFile = (cmd, args) => new Promise((resolve) => {
670
+ execFile(cmd, args, (err) => resolve(!err))
671
+ })
672
+ const launched =
673
+ (await tryExecFile('opencode-desktop', ['--model', modelRef])) ||
674
+ (await tryExecFile('flatpak', ['run', 'ai.opencode.OpenCode', '--model', modelRef])) ||
675
+ (await tryExecFile('snap', ['run', 'opencode', '--model', modelRef])) ||
676
+ (await tryExecFile('xdg-open', ['/usr/share/applications/opencode.desktop']))
677
+ if (!launched) printDesktopLaunchError()
678
+ return
679
+ }
680
+
643
681
  const { exec } = await import('child_process')
644
682
  let command
645
683
  if (isMac) {
646
684
  command = 'open -a OpenCode'
647
685
  } else if (isWindows) {
648
686
  command = 'start "" "%LOCALAPPDATA%\\Programs\\OpenCode\\OpenCode.exe" 2>nul || start "" "%PROGRAMFILES%\\OpenCode\\OpenCode.exe" 2>nul || start OpenCode'
649
- } else if (isLinux) {
650
- command = `opencode-desktop --model ${modelRef} 2>/dev/null || flatpak run ai.opencode.OpenCode --model ${modelRef} 2>/dev/null || snap run opencode --model ${modelRef} 2>/dev/null || xdg-open /usr/share/applications/opencode.desktop 2>/dev/null || echo "OpenCode not found"`
651
687
  }
652
688
  exec(command, (err) => {
653
- if (err) {
654
- console.error(chalk.red(' Could not open OpenCode Desktop'))
655
- if (isWindows) {
656
- console.error(chalk.dim(' Make sure OpenCode is installed from https://opencode.ai'))
657
- } else if (isLinux) {
658
- console.error(chalk.dim(' Install via: snap install opencode OR flatpak install ai.opencode.OpenCode'))
659
- console.error(chalk.dim(' Or download from https://opencode.ai'))
660
- } else {
661
- console.error(chalk.dim(' Is it installed at /Applications/OpenCode.app?'))
662
- }
663
- }
689
+ if (err) printDesktopLaunchError()
664
690
  })
665
691
  }
666
692
 
667
693
  if (providerKey === 'nvidia') {
668
- const config = loadOpenCodeConfig()
694
+ const config = loadOpenCodeConfig() || {}
669
695
  const backupPath = `${getOpenCodeConfigPath()}.backup-${Date.now()}`
670
696
 
671
697
  if (existsSync(getOpenCodeConfigPath())) {
@@ -700,7 +726,7 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
700
726
 
701
727
  saveOpenCodeConfig(config)
702
728
 
703
- const savedConfig = loadOpenCodeConfig()
729
+ const savedConfig = loadOpenCodeConfig() || {}
704
730
  console.log(chalk.dim(` Config saved to: ${getOpenCodeConfigPath()}`))
705
731
  console.log(chalk.dim(` Default model in config: ${savedConfig.model || 'NOT SET'}`))
706
732
  console.log()
@@ -741,7 +767,7 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
741
767
  console.log(chalk.dim(` Model: ${zenModelRef}`))
742
768
  console.log()
743
769
 
744
- const config = loadOpenCodeConfig()
770
+ const config = loadOpenCodeConfig() || {}
745
771
  const backupPath = `${getOpenCodeConfigPath()}.backup-${Date.now()}`
746
772
 
747
773
  if (existsSync(getOpenCodeConfigPath())) {
@@ -752,7 +778,7 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
752
778
  config.model = zenModelRef
753
779
  saveOpenCodeConfig(config)
754
780
 
755
- const savedConfig = loadOpenCodeConfig()
781
+ const savedConfig = loadOpenCodeConfig() || {}
756
782
  console.log(chalk.dim(` Config saved to: ${getOpenCodeConfigPath()}`))
757
783
  console.log(chalk.dim(` Default model in config: ${savedConfig.model || 'NOT SET'}`))
758
784
  console.log()
@@ -774,7 +800,7 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
774
800
  console.log(chalk.dim(` Model: ${modelRef}`))
775
801
  console.log()
776
802
 
777
- const config = loadOpenCodeConfig()
803
+ const config = loadOpenCodeConfig() || {}
778
804
  const backupPath = `${getOpenCodeConfigPath()}.backup-${Date.now()}`
779
805
 
780
806
  if (existsSync(getOpenCodeConfigPath())) {
@@ -941,7 +967,7 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
941
967
  config.model = modelRef
942
968
  saveOpenCodeConfig(config)
943
969
 
944
- const savedConfig = loadOpenCodeConfig()
970
+ const savedConfig = loadOpenCodeConfig() || {}
945
971
  console.log(chalk.dim(` Config saved to: ${getOpenCodeConfigPath()}`))
946
972
  console.log(chalk.dim(` Default model in config: ${savedConfig.model || 'NOT SET'}`))
947
973
  console.log()
@@ -190,6 +190,11 @@ export async function playgroundSubmit(state, deps = {}) {
190
190
  const url = `http://127.0.0.1:${port}/v1/chat/completions`
191
191
  const fetchFn = deps.fetchFn || globalThis.fetch
192
192
 
193
+ // 📖 Non-streaming responses have no per-chunk timer, so give the whole
194
+ // 📖 request the same 90s ceiling. The streaming branch switches to its own
195
+ // 📖 per-chunk timer inside readSseStream.
196
+ let requestTimer = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
197
+
193
198
  try {
194
199
  const resp = await fetchFn(url, {
195
200
  method: 'POST',
@@ -205,6 +210,7 @@ export async function playgroundSubmit(state, deps = {}) {
205
210
  return
206
211
  }
207
212
  if (PLAYGROUND_OVERLAY_STATE.streamOn) {
213
+ clearTimeout(requestTimer)
208
214
  await readSseStream(resp, fetchFn, controller)
209
215
  } else {
210
216
  const json = await resp.json().catch(() => null)
@@ -231,6 +237,7 @@ export async function playgroundSubmit(state, deps = {}) {
231
237
  finalizeAssistantMeta({ error: PLAYGROUND_OVERLAY_STATE.lastError, aborted: true })
232
238
  }
233
239
  } finally {
240
+ clearTimeout(requestTimer)
234
241
  PLAYGROUND_OVERLAY_STATE.busy = false
235
242
  PLAYGROUND_OVERLAY_STATE.abortController = null
236
243
  PLAYGROUND_OVERLAY_STATE.statusMessage = null
@@ -104,6 +104,14 @@ const CACHE_DIRNAME = 'free-coding-models'
104
104
  let _cache = null
105
105
  let _cacheLoadedFrom = null // path we last loaded from (for write-back)
106
106
 
107
+ /**
108
+ * 📖 Composite `provider::model` keys pruned this session. flushCache() re-reads
109
+ * 📖 the disk file before writing, and the read-merge-write pass would otherwise
110
+ * 📖 resurrect exactly the entries pruneStaleEntries() just deleted (the disk file
111
+ * 📖 still contains them until our write lands).
112
+ */
113
+ const _sessionPrunedKeys = new Set()
114
+
107
115
  // ─── Path resolution ──────────────────────────────────────────────────────────
108
116
 
109
117
  /**
@@ -180,16 +188,24 @@ export function loadCache({ path: cachePath } = {}) {
180
188
  * 📖 from incoming do NOT delete fields from base. Used by flushCache to merge
181
189
  * 📖 our in-memory mirror with whatever the on-disk file now contains (covers
182
190
  * 📖 the daemon + CLI running concurrently case).
191
+ *
192
+ * 📖 `excludeKeys` (optional Set of `provider::model`) drops matching base
193
+ * 📖 entries: keys pruned this session must stay pruned instead of coming back
194
+ * 📖 from the stale disk snapshot.
183
195
  */
184
- function mergeCache(base, incoming) {
196
+ function mergeCache(base, incoming, excludeKeys = null) {
185
197
  if (!base || typeof base !== 'object') return incoming
186
198
  if (!incoming || typeof incoming !== 'object') return base
187
199
  const out = { ...incoming, providers: { ...(incoming.providers || {}) } }
188
200
  for (const [providerKey, providerBucket] of Object.entries(base.providers || {})) {
189
201
  const incomingBucket = out.providers[providerKey] || { models: {} }
190
- out.providers[providerKey] = {
191
- models: { ...(providerBucket?.models || {}), ...(incomingBucket.models || {}) },
202
+ const mergedModels = { ...(providerBucket?.models || {}), ...(incomingBucket.models || {}) }
203
+ if (excludeKeys && excludeKeys.size > 0) {
204
+ for (const modelId of Object.keys(mergedModels)) {
205
+ if (excludeKeys.has(`${providerKey}::${modelId}`)) delete mergedModels[modelId]
206
+ }
192
207
  }
208
+ out.providers[providerKey] = { models: mergedModels }
193
209
  }
194
210
  return out
195
211
  }
@@ -220,12 +236,15 @@ export function flushCache({ path: cachePath, cache } = {}) {
220
236
  onDisk = null
221
237
  }
222
238
 
223
- const merged = onDisk ? mergeCache(onDisk, localData) : localData
239
+ const merged = onDisk ? mergeCache(onDisk, localData, _sessionPrunedKeys) : localData
224
240
 
225
241
  try {
226
242
  atomicWriteJson(target, merged, 0o600)
227
243
  _cacheLoadedFrom = target
228
244
  _cache = merged
245
+ // 📖 Deletions are now persisted: clear the set so entries re-added later by
246
+ // 📖 another process (e.g. the model returned to the catalog) still merge in.
247
+ _sessionPrunedKeys.clear()
229
248
  return true
230
249
  } catch {
231
250
  return false
@@ -244,6 +263,8 @@ export function clearCache({ path: cachePath } = {}) {
244
263
  const target = cachePath ?? getProbeCachePath()
245
264
  _cache = null
246
265
  _cacheLoadedFrom = null
266
+ // 📖 The file is gone, so tracked deletions have nothing left to override.
267
+ _sessionPrunedKeys.clear()
247
268
  try {
248
269
  fs.unlinkSync(target)
249
270
  return true
@@ -406,6 +427,11 @@ export function recordProbeResults(providerKey, results, opts = {}) {
406
427
  probeVersion: CURRENT_PROBE_VERSION,
407
428
  consecutiveFailures,
408
429
  }
430
+ // 📖 A live re-probe of a pruned model means it is catalogued again: stop
431
+ // 📖 excluding it from disk merges.
432
+ if (!opts || !Object.prototype.hasOwnProperty.call(opts, 'cache')) {
433
+ _sessionPrunedKeys.delete(`${providerKey}::${r.modelId}`)
434
+ }
409
435
  written++
410
436
  } catch {
411
437
  dropped++
@@ -553,6 +579,11 @@ export function pruneStaleEntries(providerKey, liveModelIds, opts = {}) {
553
579
  for (const id of Object.keys(bucket)) {
554
580
  if (!live.has(id)) {
555
581
  delete bucket[id]
582
+ // 📖 Remember the deletion so flushCache's read-merge-write does not
583
+ // 📖 resurrect this entry from the still-stale disk file.
584
+ if (!opts || !Object.prototype.hasOwnProperty.call(opts, 'cache')) {
585
+ _sessionPrunedKeys.add(`${providerKey}::${id}`)
586
+ }
556
587
  pruned++
557
588
  }
558
589
  }
@@ -305,7 +305,11 @@ export async function runProviderKeyTest(apiKey, providerKey, source, options =
305
305
  headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
306
306
  headers['X-Title'] = 'free-coding-models'
307
307
  }
308
- const modelsResp = await fetch(modelsUrl, { headers })
308
+ const modelsResp = await fetch(modelsUrl, {
309
+ headers,
310
+ // 📖 Same 8s ceiling as the fast-path probes so discovery can never hang.
311
+ signal: AbortSignal.timeout(8000),
312
+ })
309
313
  if (modelsResp.ok) {
310
314
  const data = await modelsResp.json()
311
315
  discoveredModelIds.push(...parseProviderModelIds(data))
@@ -316,7 +320,11 @@ export async function runProviderKeyTest(apiKey, providerKey, source, options =
316
320
  discoveryNote = `Live model discovery returned HTTP ${modelsResp.status}; falling back to the repo catalog.`
317
321
  }
318
322
  } catch (err) {
319
- discoveryNote = `Live model discovery failed (${err?.name || 'error'}); falling back to the repo catalog.`
323
+ // 📖 AbortSignal.timeout rejects with TimeoutError; treat it like the fast-path timeout.
324
+ const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError'
325
+ discoveryNote = timedOut
326
+ ? 'Live model discovery timed out; falling back to the repo catalog.'
327
+ : `Live model discovery failed (${err?.name || 'error'}); falling back to the repo catalog.`
320
328
  }
321
329
  }
322
330
 
@@ -67,6 +67,16 @@ export const ENV_VAR_NAMES = {
67
67
  requesty: 'REQUESTY_API_KEY',
68
68
  orcarouter: 'ORCAROUTER_API_KEY',
69
69
  'vercel-gateway': 'VERCEL_AI_GATEWAY_API_KEY',
70
+ // 📖 Providers whose OpenCode/Kilo config writers reference {env:VAR} - names
71
+ // 📖 must match those tables exactly (see opencode.js / kilo.js).
72
+ huggingface: 'HUGGINGFACE_API_KEY',
73
+ deepinfra: 'DEEPINFRA_API_KEY',
74
+ fireworks: 'FIREWORKS_API_KEY',
75
+ together: 'TOGETHER_API_KEY',
76
+ hyperbolic: 'HYPERBOLIC_API_KEY',
77
+ perplexity: 'PERPLEXITY_API_KEY',
78
+ chutes: 'CHUTES_API_KEY',
79
+ iflow: 'IFLOW_API_KEY',
70
80
  }
71
81
 
72
82
  // 📖 OPENCODE_MODEL_MAP: sparse table of model IDs that differ between sources.js and OpenCode's
@@ -134,10 +134,11 @@ export function parseSiliconFlowResponse(responseData) {
134
134
  * Create an in-memory cache entry.
135
135
  * @param {number|null} value
136
136
  * @param {number} expiresAt - Date.now() timestamp
137
- * @returns {{ value: number|null, expiresAt: number }}
137
+ * @param {number} [resolvedAt=Date.now()] - when the value was actually fetched
138
+ * @returns {{ value: number|null, expiresAt: number, resolvedAt: number }}
138
139
  */
139
- function makeCacheEntry(value, expiresAt) {
140
- return { value, expiresAt }
140
+ function makeCacheEntry(value, expiresAt, resolvedAt = Date.now()) {
141
+ return { value, expiresAt, resolvedAt }
141
142
  }
142
143
 
143
144
  // ─── Endpoint definitions ─────────────────────────────────────────────────────
@@ -320,7 +321,8 @@ export async function fetchProviderQuota(providerKey, apiKey, options = {}) {
320
321
  const pendingPromise = doFetch()
321
322
  .then((value) => {
322
323
  const finalValue = (typeof value === 'number' && Number.isFinite(value)) ? value : null
323
- _defaultCache.set(cacheKey, makeCacheEntry(finalValue, Date.now() + cacheTtlMs))
324
+ const resolvedAt = Date.now()
325
+ _defaultCache.set(cacheKey, makeCacheEntry(finalValue, resolvedAt + cacheTtlMs, resolvedAt))
324
326
  return finalValue
325
327
  })
326
328
  .catch(() => {
@@ -483,12 +485,17 @@ function getActiveSnapshot(providerKey, now = Date.now()) {
483
485
  if (typeof entry.expiresAt !== 'number' || entry.expiresAt <= now) continue
484
486
  const value = entry.value
485
487
  if (typeof value !== 'number' || !Number.isFinite(value)) continue
488
+ // 📖 lastUpdated must be when the value was actually fetched, not "now": a
489
+ // 📖 TTL-cached value can be up to 60s old, and stamping it with now made it
490
+ // 📖 always beat real passive header snapshots in getQuota's freshest-wins
491
+ // 📖 pick while fabricating a fresh remaining/limit.
492
+ const resolvedAt = typeof entry.resolvedAt === 'number'
493
+ ? entry.resolvedAt
494
+ : entry.expiresAt - DEFAULT_CACHE_TTL_MS
486
495
  return makeSnapshot(
487
496
  { remaining: value, limit: 100, percent: value, windowType: 'unknown', source: 'active_fetcher' },
488
497
  'endpoint',
489
- // 📖 active fetcher stores wall-clock ms at fetch time; expose it so
490
- // 📖 getQuota's freshest-wins logic works on real timestamps.
491
- now,
498
+ resolvedAt,
492
499
  )
493
500
  }
494
501
  return null