free-coding-models 0.3.4 → 0.3.6

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.
@@ -171,6 +171,7 @@ export function createKeyHandler(ctx) {
171
171
  saveAsProfile,
172
172
  setActiveProfile,
173
173
  saveConfig,
174
+ persistApiKeysForProvider,
174
175
  getConfiguredInstallableProviders,
175
176
  getInstallTargetModes,
176
177
  getProviderCatalogModels,
@@ -405,7 +406,7 @@ export function createKeyHandler(ctx) {
405
406
  })
406
407
  setActiveProfile(state.config, name)
407
408
  state.activeProfile = name
408
- saveConfig(state.config)
409
+ saveConfig(state.config, { replaceProfileNames: [name] })
409
410
  }
410
411
  state.profileSaveMode = false
411
412
  state.profileSaveBuffer = ''
@@ -985,14 +986,24 @@ const updateRowIdx = providerKeys.length
985
986
  setTimeout(() => { state.settingsErrorMsg = null }, 3000)
986
987
  return
987
988
  }
989
+ if (!state.config.apiKeys || typeof state.config.apiKeys !== 'object' || Array.isArray(state.config.apiKeys)) {
990
+ state.config.apiKeys = {}
991
+ }
988
992
  if (state.settingsAddKeyMode) {
989
993
  // 📖 Add-key mode: append new key (addApiKey handles duplicates/empty)
990
994
  addApiKey(state.config, pk, newKey)
991
995
  } else {
992
- // 📖 Edit mode: replace the primary key (string-level)
993
- state.config.apiKeys[pk] = newKey
996
+ // 📖 Edit mode: replace only the primary key and keep any extra rotated keys intact.
997
+ const existingKeys = resolveApiKeys(state.config, pk)
998
+ state.config.apiKeys[pk] = existingKeys.length > 1
999
+ ? [newKey, ...existingKeys.slice(1)]
1000
+ : newKey
1001
+ }
1002
+ const saveResult = persistApiKeysForProvider(state.config, pk)
1003
+ if (!saveResult.success) {
1004
+ state.settingsErrorMsg = `⚠️ Failed to persist ${pk} API key: ${saveResult.error || 'Unknown error'}`
1005
+ setTimeout(() => { state.settingsErrorMsg = null }, 4000)
994
1006
  }
995
- saveConfig(state.config)
996
1007
  }
997
1008
  state.settingsEditMode = false
998
1009
  state.settingsAddKeyMode = false
@@ -1103,7 +1114,7 @@ const updateRowIdx = providerKeys.length
1103
1114
  if (state.settingsCursor === widthWarningRowIdx) {
1104
1115
  if (!state.config.settings) state.config.settings = {}
1105
1116
  state.config.settings.disableWidthsWarning = !state.config.settings.disableWidthsWarning
1106
- saveConfig(state.config)
1117
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1107
1118
  return
1108
1119
  }
1109
1120
 
@@ -1156,7 +1167,10 @@ const updateRowIdx = providerKeys.length
1156
1167
  applyTierFilter()
1157
1168
  const visible = state.results.filter(r => !r.hidden)
1158
1169
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1159
- saveConfig(state.config)
1170
+ saveConfig(state.config, {
1171
+ replaceApiKeys: true,
1172
+ replaceFavorites: true,
1173
+ })
1160
1174
  }
1161
1175
  }
1162
1176
  return
@@ -1164,7 +1178,7 @@ const updateRowIdx = providerKeys.length
1164
1178
 
1165
1179
  // 📖 Enter edit mode for the selected provider's key
1166
1180
  const pk = providerKeys[state.settingsCursor]
1167
- state.settingsEditBuffer = state.config.apiKeys?.[pk] ?? ''
1181
+ state.settingsEditBuffer = resolveApiKeys(state.config, pk)[0] ?? ''
1168
1182
  state.settingsEditMode = true
1169
1183
  return
1170
1184
  }
@@ -1176,7 +1190,7 @@ const updateRowIdx = providerKeys.length
1176
1190
  if (state.settingsCursor === widthWarningRowIdx) {
1177
1191
  if (!state.config.settings) state.config.settings = {}
1178
1192
  state.config.settings.disableWidthsWarning = !state.config.settings.disableWidthsWarning
1179
- saveConfig(state.config)
1193
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1180
1194
  return
1181
1195
  }
1182
1196
  // 📖 Profile rows don't respond to Space
@@ -1187,7 +1201,7 @@ const updateRowIdx = providerKeys.length
1187
1201
  if (!state.config.providers) state.config.providers = {}
1188
1202
  if (!state.config.providers[pk]) state.config.providers[pk] = { enabled: true }
1189
1203
  state.config.providers[pk].enabled = !isProviderEnabled(state.config, pk)
1190
- saveConfig(state.config)
1204
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1191
1205
  return
1192
1206
  }
1193
1207
 
@@ -1218,7 +1232,7 @@ const updateRowIdx = providerKeys.length
1218
1232
  setActiveProfile(state.config, null)
1219
1233
  state.activeProfile = null
1220
1234
  }
1221
- saveConfig(state.config)
1235
+ saveConfig(state.config, { removedProfileNames: [profileName] })
1222
1236
  // 📖 Re-clamp cursor after deletion (profile list just got shorter)
1223
1237
  const newProfiles = listProfiles(state.config)
1224
1238
  const newMaxRowIdx = newProfiles.length > 0 ? profileStartIdx + newProfiles.length - 1 : changelogViewRowIdx
@@ -1285,7 +1299,11 @@ const updateRowIdx = providerKeys.length
1285
1299
  const pk = providerKeys[state.settingsCursor]
1286
1300
  const removed = removeApiKey(state.config, pk) // removes last key; collapses array-of-1 to string
1287
1301
  if (removed) {
1288
- saveConfig(state.config)
1302
+ const saveResult = persistApiKeysForProvider(state.config, pk)
1303
+ if (!saveResult.success) {
1304
+ state.settingsSyncStatus = { type: 'error', msg: `❌ Failed to save API key changes: ${saveResult.error || 'Unknown error'}` }
1305
+ return
1306
+ }
1289
1307
  const remaining = resolveApiKeys(state.config, pk).length
1290
1308
  const msg = remaining > 0
1291
1309
  ? `✅ Removed one key for ${pk} (${remaining} remaining)`
@@ -1325,7 +1343,7 @@ const updateRowIdx = providerKeys.length
1325
1343
  }
1326
1344
  if (!state.config.settings) state.config.settings = {}
1327
1345
  state.config.settings.proxy = { ...proxySettings, preferredPort: parsed }
1328
- saveConfig(state.config)
1346
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1329
1347
  state.settingsProxyPortEditMode = false
1330
1348
  state.settingsProxyPortBuffer = ''
1331
1349
  state.proxyDaemonMessage = { type: 'success', msg: `✅ Preferred port saved: ${parsed === 0 ? 'auto' : parsed}`, ts: Date.now() }
@@ -1366,7 +1384,7 @@ const updateRowIdx = providerKeys.length
1366
1384
  if (state.proxyDaemonCursor === ROW_PROXY_ENABLED) {
1367
1385
  if (!state.config.settings) state.config.settings = {}
1368
1386
  state.config.settings.proxy = { ...proxySettings, enabled: !proxySettings.enabled }
1369
- saveConfig(state.config)
1387
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1370
1388
  state.proxyDaemonMessage = { type: 'success', msg: `✅ Proxy mode ${state.config.settings.proxy.enabled ? 'enabled' : 'disabled'}`, ts: Date.now() }
1371
1389
  return
1372
1390
  }
@@ -1377,7 +1395,7 @@ const updateRowIdx = providerKeys.length
1377
1395
  }
1378
1396
  if (!state.config.settings) state.config.settings = {}
1379
1397
  state.config.settings.proxy = { ...proxySettings, syncToOpenCode: !proxySettings.syncToOpenCode }
1380
- saveConfig(state.config)
1398
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1381
1399
  const { getToolMeta } = await import('./tool-metadata.js')
1382
1400
  const toolLabel = getToolMeta(currentProxyTool).label
1383
1401
  state.proxyDaemonMessage = { type: 'success', msg: `✅ Auto-sync to ${toolLabel} ${state.config.settings.proxy.syncToOpenCode ? 'enabled' : 'disabled'}`, ts: Date.now() }
@@ -1393,7 +1411,7 @@ const updateRowIdx = providerKeys.length
1393
1411
  if (state.proxyDaemonCursor === ROW_PROXY_ENABLED) {
1394
1412
  if (!state.config.settings) state.config.settings = {}
1395
1413
  state.config.settings.proxy = { ...proxySettings, enabled: !proxySettings.enabled }
1396
- saveConfig(state.config)
1414
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1397
1415
  state.proxyDaemonMessage = { type: 'success', msg: `✅ Proxy mode ${state.config.settings.proxy.enabled ? 'enabled' : 'disabled'}`, ts: Date.now() }
1398
1416
  return
1399
1417
  }
@@ -1406,7 +1424,7 @@ const updateRowIdx = providerKeys.length
1406
1424
  }
1407
1425
  if (!state.config.settings) state.config.settings = {}
1408
1426
  state.config.settings.proxy = { ...proxySettings, syncToOpenCode: !proxySettings.syncToOpenCode }
1409
- saveConfig(state.config)
1427
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1410
1428
  const { getToolMeta } = await import('./tool-metadata.js')
1411
1429
  const toolLabel = getToolMeta(currentProxyTool).label
1412
1430
  state.proxyDaemonMessage = { type: 'success', msg: `✅ Auto-sync to ${toolLabel} ${state.config.settings.proxy.syncToOpenCode ? 'enabled' : 'disabled'}`, ts: Date.now() }
@@ -1456,7 +1474,7 @@ const updateRowIdx = providerKeys.length
1456
1474
  if (!state.config.settings.proxy.preferredPort || state.config.settings.proxy.preferredPort === 0) {
1457
1475
  state.config.settings.proxy.preferredPort = 18045
1458
1476
  }
1459
- saveConfig(state.config)
1477
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1460
1478
  const result = installDaemon()
1461
1479
  if (result.success) {
1462
1480
  state.proxyDaemonMessage = { type: 'success', msg: '✅ FCM Proxy V2 background service installed and started!', ts: Date.now() }
@@ -1470,7 +1488,7 @@ const updateRowIdx = providerKeys.length
1470
1488
  // 📖 Uninstall daemon
1471
1489
  const result = uninstallDaemon()
1472
1490
  state.config.settings.proxy.daemonEnabled = false
1473
- saveConfig(state.config)
1491
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1474
1492
  if (result.success) {
1475
1493
  state.proxyDaemonMessage = { type: 'success', msg: '✅ FCM Proxy V2 background service uninstalled.', ts: Date.now() }
1476
1494
  state.daemonStatus = 'not-installed'
@@ -1629,7 +1647,7 @@ const updateRowIdx = providerKeys.length
1629
1647
  })
1630
1648
  setActiveProfile(state.config, 'default')
1631
1649
  state.activeProfile = 'default'
1632
- saveConfig(state.config)
1650
+ saveConfig(state.config, { replaceProfileNames: ['default'] })
1633
1651
  } else {
1634
1652
  // 📖 Cycle to next profile (or back to null = raw config)
1635
1653
  const currentIdx = state.activeProfile ? profiles.indexOf(state.activeProfile) : -1
@@ -1662,7 +1680,10 @@ const updateRowIdx = providerKeys.length
1662
1680
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1663
1681
  state.cursor = 0
1664
1682
  state.scrollOffset = 0
1665
- saveConfig(state.config)
1683
+ saveConfig(state.config, {
1684
+ replaceApiKeys: true,
1685
+ replaceFavorites: true,
1686
+ })
1666
1687
  }
1667
1688
  }
1668
1689
  }
@@ -1693,7 +1714,7 @@ const updateRowIdx = providerKeys.length
1693
1714
  profile.settings.sortColumn = state.config.settings.sortColumn
1694
1715
  profile.settings.sortAsc = state.config.settings.sortAsc
1695
1716
  }
1696
- saveConfig(state.config)
1717
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1697
1718
  }
1698
1719
 
1699
1720
  // 📖 Shift+R: reset all UI view settings to defaults (tier, sort, provider) and clear persisted config
@@ -1717,7 +1738,7 @@ const updateRowIdx = providerKeys.length
1717
1738
  delete profile.settings.sortAsc
1718
1739
  }
1719
1740
  }
1720
- saveConfig(state.config)
1741
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1721
1742
  applyTierFilter()
1722
1743
  const visible = state.results.filter(r => !r.hidden)
1723
1744
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
@@ -1824,7 +1845,7 @@ const updateRowIdx = providerKeys.length
1824
1845
  if (!profile.settings || typeof profile.settings !== 'object') profile.settings = {}
1825
1846
  profile.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
1826
1847
  }
1827
- saveConfig(state.config)
1848
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1828
1849
  applyTierFilter()
1829
1850
  const visible = state.results.filter(r => !r.hidden)
1830
1851
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
@@ -1891,7 +1912,7 @@ const updateRowIdx = providerKeys.length
1891
1912
  if (!profile.settings || typeof profile.settings !== 'object') profile.settings = {}
1892
1913
  profile.settings.preferredToolMode = state.mode
1893
1914
  }
1894
- saveConfig(state.config)
1915
+ saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
1895
1916
  return
1896
1917
  }
1897
1918
 
@@ -108,6 +108,21 @@ function sendJson(res, statusCode, body) {
108
108
  res.end(json)
109
109
  }
110
110
 
111
+ /**
112
+ * 📖 Match routes on the URL pathname only so Claude Code's `?beta=true`
113
+ * 📖 Anthropic requests resolve exactly like FastAPI routes do in free-claude-code.
114
+ *
115
+ * @param {http.IncomingMessage} req
116
+ * @returns {string}
117
+ */
118
+ function getRequestPathname(req) {
119
+ try {
120
+ return new URL(req.url || '/', 'http://127.0.0.1').pathname || '/'
121
+ } catch {
122
+ return req.url || '/'
123
+ }
124
+ }
125
+
111
126
  function normalizeRequestedModel(modelId) {
112
127
  if (typeof modelId !== 'string') return null
113
128
  const trimmed = modelId.trim()
@@ -303,14 +318,16 @@ export class ProxyServer {
303
318
  // ── Request routing ────────────────────────────────────────────────────────
304
319
 
305
320
  _handleRequest(req, res) {
321
+ const pathname = getRequestPathname(req)
322
+
306
323
  // 📖 Root endpoint is unauthenticated so a browser hit on http://127.0.0.1:{port}/
307
324
  // 📖 gives a useful status payload instead of a misleading Unauthorized error.
308
- if (req.method === 'GET' && req.url === '/') {
325
+ if (req.method === 'GET' && pathname === '/') {
309
326
  return this._handleRoot(res)
310
327
  }
311
328
 
312
329
  // 📖 Health endpoint is unauthenticated so external monitors can probe it
313
- if (req.method === 'GET' && req.url === '/v1/health') {
330
+ if (req.method === 'GET' && pathname === '/v1/health') {
314
331
  return this._handleHealth(res)
315
332
  }
316
333
 
@@ -319,11 +336,11 @@ export class ProxyServer {
319
336
  return sendJson(res, 401, { error: 'Unauthorized' })
320
337
  }
321
338
 
322
- if (req.method === 'GET' && req.url === '/v1/models') {
339
+ if (req.method === 'GET' && pathname === '/v1/models') {
323
340
  this._handleModels(res)
324
- } else if (req.method === 'GET' && req.url === '/v1/stats') {
341
+ } else if (req.method === 'GET' && pathname === '/v1/stats') {
325
342
  this._handleStats(res)
326
- } else if (req.method === 'POST' && req.url === '/v1/chat/completions') {
343
+ } else if (req.method === 'POST' && pathname === '/v1/chat/completions') {
327
344
  this._handleChatCompletions(req, res).catch(err => {
328
345
  console.error('[proxy] Internal error:', err)
329
346
  // 📖 Return 413 for body-too-large, generic 500 for everything else — never leak stack traces
@@ -331,7 +348,7 @@ export class ProxyServer {
331
348
  const msg = err.statusCode === 413 ? 'Request body too large' : 'Internal server error'
332
349
  sendJson(res, status, { error: msg })
333
350
  })
334
- } else if (req.method === 'POST' && req.url === '/v1/messages') {
351
+ } else if (req.method === 'POST' && pathname === '/v1/messages') {
335
352
  // 📖 Anthropic Messages API translation — enables Claude Code compatibility
336
353
  this._handleAnthropicMessages(req, res, authContext).catch(err => {
337
354
  console.error('[proxy] Internal error:', err)
@@ -339,26 +356,26 @@ export class ProxyServer {
339
356
  const msg = err.statusCode === 413 ? 'Request body too large' : 'Internal server error'
340
357
  sendJson(res, status, { error: msg })
341
358
  })
342
- } else if (req.method === 'POST' && req.url === '/v1/messages/count_tokens') {
359
+ } else if (req.method === 'POST' && pathname === '/v1/messages/count_tokens') {
343
360
  this._handleAnthropicCountTokens(req, res).catch(err => {
344
361
  console.error('[proxy] Internal error:', err)
345
362
  const status = err.statusCode === 413 ? 413 : 500
346
363
  const msg = err.statusCode === 413 ? 'Request body too large' : 'Internal server error'
347
364
  sendJson(res, status, { error: msg })
348
365
  })
349
- } else if (req.method === 'POST' && req.url === '/v1/responses') {
366
+ } else if (req.method === 'POST' && pathname === '/v1/responses') {
350
367
  this._handleResponses(req, res).catch(err => {
351
368
  console.error('[proxy] Internal error:', err)
352
369
  const status = err.statusCode === 413 ? 413 : 500
353
370
  const msg = err.statusCode === 413 ? 'Request body too large' : 'Internal server error'
354
371
  sendJson(res, status, { error: msg })
355
372
  })
356
- } else if (req.method === 'POST' && req.url === '/v1/completions') {
373
+ } else if (req.method === 'POST' && pathname === '/v1/completions') {
357
374
  // These legacy/alternative OpenAI endpoints are not supported by the proxy.
358
375
  // Return 501 (not 404) so callers get a clear signal instead of silently failing.
359
376
  sendJson(res, 501, {
360
377
  error: 'Not Implemented',
361
- message: `${req.url} is not supported by this proxy. Use POST /v1/chat/completions instead.`,
378
+ message: `${pathname} is not supported by this proxy. Use POST /v1/chat/completions instead.`,
362
379
  })
363
380
  } else {
364
381
  sendJson(res, 404, { error: 'Not found' })