free-coding-models 0.3.54 → 0.3.56

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 (73) hide show
  1. package/CHANGELOG.md +47 -49
  2. package/README.md +236 -160
  3. package/bin/free-coding-models.js +46 -0
  4. package/package.json +2 -2
  5. package/sources.js +133 -309
  6. package/src/analysis.js +23 -10
  7. package/src/app.js +113 -7
  8. package/src/cache.js +1 -1
  9. package/src/cli-help.js +9 -0
  10. package/src/command-palette.js +16 -12
  11. package/src/config.js +199 -32
  12. package/src/endpoint-installer.js +45 -1
  13. package/src/favorites.js +22 -0
  14. package/src/graphify-out/cache/089db1c1def873cf6d112f1590da4490e61e691aff0db41e006aa2fb15ba0656.json +1 -0
  15. package/src/graphify-out/cache/0b510b53cf1a1393fb52b1fc3bbbf88b63938e961ec5b82119a2e9715fee8bd7.json +1 -0
  16. package/src/graphify-out/cache/0ec9a95a326bde58e0316889018b278062d06d494d0f31ba177c9de71e5fed2d.json +1 -0
  17. package/src/graphify-out/cache/1548663a24a68dce740ebab1bd1d3091048c9604e9d067a1650a42a6d82541d4.json +1 -0
  18. package/src/graphify-out/cache/1783af63cb6d0dfb4d469009f71ac83a74ba0b33d48186ff2c6e63f9429e900a.json +1 -0
  19. package/src/graphify-out/cache/1e109f5eb5dc4fd285871c3613e32b6b14a8c225f4080ee34b51c7e1a1764571.json +1 -0
  20. package/src/graphify-out/cache/1eb24dbeb69b46c8bc1caf925df2f2a964af0f33aea143adf8ddf88e017db6ca.json +1 -0
  21. package/src/graphify-out/cache/21e1bcfed11685e8347243f9d8516072dda183266a4bfe22c52fb31753a446c8.json +1 -0
  22. package/src/graphify-out/cache/2327473478b9c4b1940bf7ef66c9ee960b3cba8d5302e56b625df8274246e0b4.json +1 -0
  23. package/src/graphify-out/cache/25955b81fd25454c8fa90fb71a47db8d1215cf621beb8ff3cbd580aaf011b4f3.json +1 -0
  24. package/src/graphify-out/cache/2739677f19c702f88f3de0a0bac475066adbda98709907ad3de967aef689f86d.json +1 -0
  25. package/src/graphify-out/cache/2bba03422f6b3ee7f5b5d29cc90314a064d259e5822a176657bda3e04505cf00.json +1 -0
  26. package/src/graphify-out/cache/2ddf1d2c6d10147b0402446bc71a7988187b79b6210dd7e7250be8c555b9ff35.json +1 -0
  27. package/src/graphify-out/cache/2ee07457a5767c95a57f8e9eb95b28f800044f35666e0715e9d88ad1103a092e.json +1 -0
  28. package/src/graphify-out/cache/2fe9f75dc2951c417f2c8dd22749092cf550dc67599f1c8d1866900dc6e9154e.json +1 -0
  29. package/src/graphify-out/cache/41c4b7c27e7fc3e2948d3a4bf95a72de2ed9a6f0463994babdce8ed2cc84598c.json +1 -0
  30. package/src/graphify-out/cache/5028defd54b7fbd3c7e444973e493de036e097e9b1d2a7cae7f19b88d68aacde.json +1 -0
  31. package/src/graphify-out/cache/5b133aba3fb16410c5b1fdbd1730039fc7fa1ac93abd99d7be08f60da70fc8d4.json +1 -0
  32. package/src/graphify-out/cache/74252e5b0978d85ab3421a3de1a9384aa282ffd2be2cfe7db2530139089f4275.json +1 -0
  33. package/src/graphify-out/cache/7695ebeea056095edd14332963cc43354ef3a097caf46f1e28d0f01369642901.json +1 -0
  34. package/src/graphify-out/cache/777aa7085c395a935c6556bbde182cd871edb61f3a685ed8068ec0c8f6fb0075.json +1 -0
  35. package/src/graphify-out/cache/82a723881980e82273c113def8315533d7da28827e300413d9ad30f27b7407df.json +1 -0
  36. package/src/graphify-out/cache/86b87c9603e6cd188f42c7eed3b86c291d48a781c223a707e74f3e7ed0c02a21.json +1 -0
  37. package/src/graphify-out/cache/890fead9a78cadaed560a2d2453916121fa605c3e43a334910ac4bc951a9ef6d.json +1 -0
  38. package/src/graphify-out/cache/89d3ea66f52783caa775ef9a30923d7d6225e1d8ae9e962f4741b8c7785dab1e.json +1 -0
  39. package/src/graphify-out/cache/8cc82cd9edce41f0e1c092f14a94fd52bf847addf3237b616dc5a9e505bd05bd.json +1 -0
  40. package/src/graphify-out/cache/93ba2e25e3ff7ad525f397902345fbd375df7315de7b402e20cc803c14eccde8.json +1 -0
  41. package/src/graphify-out/cache/99beed29580b9c7bfecfee794cb3d8e535fcf0eb3b92113108f88bdd0a8e79b3.json +1 -0
  42. package/src/graphify-out/cache/aeeb931fa477c65ce2e51d8149957350fa54225c613222bbbe8448998d1afd3d.json +1 -0
  43. package/src/graphify-out/cache/baf91bef5b5ecb2a476433b6cc0c48c563c54ee2d07fc3c192e543685e3e7222.json +1 -0
  44. package/src/graphify-out/cache/bd98b94ac4e9b92b6336d47b26e0366b51a4eaf0711d722f05f98dfae23ab42b.json +1 -0
  45. package/src/graphify-out/cache/bfcb51e9328e9cbfbee4f6fee0f56635d7b03488addc9f6c4e4b190b70a73362.json +1 -0
  46. package/src/graphify-out/cache/c0d3dabeb093aa758c49eadf41b87ecc96a16c1449c2670aaf48cbfc891d8da6.json +1 -0
  47. package/src/graphify-out/cache/c20d6630236f473c1406068c3ae205853e649b216495c93dfec055dd222c55cf.json +1 -0
  48. package/src/graphify-out/cache/c22b9122816bebce0a2f79af41a986559d01e00163dbcd579c5755621b4cb483.json +1 -0
  49. package/src/graphify-out/cache/ca556ec14453ddb8f9e0c5a832dac90d77111b9bad5f8c2d80d272e2e7a06371.json +1 -0
  50. package/src/graphify-out/cache/d6dbc9135dfa35a756b3b09b06700e4bc229fdccba11bb963f2ba44028e0bbae.json +1 -0
  51. package/src/graphify-out/cache/e1cf71276f1779d0fa075f79bd7c8a9fd0b8eef6932ac043137451b7c7fa7cbe.json +1 -0
  52. package/src/graphify-out/cache/e4b3be14494467df2d2ed389bc4f18f099021cb5bc355b901fa88387b2d8b8a2.json +1 -0
  53. package/src/graphify-out/cache/eaea0dded097f6f9553b654220046c6ec0c9be592a5973d906564ee60af34e0d.json +1 -0
  54. package/src/graphify-out/cache/ef07d0cd2675d1f79d2a2fdbf3bc3319687638751e9ce89b0d0d97ed1cd9f7e1.json +1 -0
  55. package/src/graphify-out/cache/f81272d6eb8aaff9e96d5a1d9f06777db70ac3652a646b951ded51f79871d733.json +1 -0
  56. package/src/graphify-out/cache/f9619dd92186f75a6dbda937e0c606647153918524cdb5763f956e6ec2a9e386.json +1 -0
  57. package/src/graphify-out/cache/fd88b1b2ff4bfcae08559d9c2aaeeb9a3f1e2f5cd8928762c311196956c170a5.json +1 -0
  58. package/src/key-handler.js +316 -13
  59. package/src/kilo.js +20 -1
  60. package/src/opencode.js +24 -3
  61. package/src/overlays.js +206 -5
  62. package/src/provider-metadata.js +26 -17
  63. package/src/quota-capabilities.js +6 -10
  64. package/src/render-table.js +37 -4
  65. package/src/router-daemon.js +1986 -0
  66. package/src/router-dashboard.js +893 -0
  67. package/src/sync-set.js +479 -0
  68. package/src/theme.js +4 -0
  69. package/src/tool-launchers.js +1 -0
  70. package/src/tool-metadata.js +6 -2
  71. package/src/utils.js +30 -6
  72. package/web/dist/assets/{index-D2ban2S-.js → index-DNRCaWPi.js} +2 -2
  73. package/web/dist/index.html +1 -1
@@ -33,7 +33,10 @@
33
33
 
34
34
  import { loadChangelog } from './changelog-loader.js'
35
35
  import { getToolMeta, isModelCompatibleWithTool, getCompatibleTools, findSimilarCompatibleModels } from './tool-metadata.js'
36
- import { loadConfig, replaceConfigContents } from './config.js'
36
+ import { loadConfig, saveConfig, replaceConfigContents } from './config.js'
37
+ import { join, dirname } from 'node:path'
38
+ import { fileURLToPath } from 'node:url'
39
+ import { spawn } from 'node:child_process'
37
40
  import { cleanupLegacyProxyArtifacts } from './legacy-proxy-cleanup.js'
38
41
  import { getLastLayout, COLUMN_SORT_MAP } from './render-table.js'
39
42
  import { cycleThemeSetting, detectActiveTheme } from './theme.js'
@@ -42,12 +45,23 @@ import { buildCommandPaletteTree, flattenCommandTree, filterCommandPaletteEntrie
42
45
  import { WIDTH_WARNING_MIN_COLS, VERDICT_CYCLE, HEALTH_CYCLE } from './constants.js'
43
46
  import { scanAllToolConfigs, softDeleteModel } from './installed-models-manager.js'
44
47
  import { startExternalTool } from './tool-launchers.js'
48
+ import {
49
+ clearRouterDashboardRequestLog,
50
+ closeRouterDashboardOverlay,
51
+ cycleRouterDashboardActiveSet,
52
+ cycleRouterDashboardProbeMode,
53
+ openRouterDashboardOverlay,
54
+ restartRouterDashboardDaemon,
55
+ toggleRouterDashboardProbePause,
56
+ } from './router-dashboard.js'
45
57
 
46
58
  // 📖 Some providers need an explicit probe model because the first catalog entry
47
59
  // 📖 is not guaranteed to be accepted by their chat endpoint.
48
60
  const PROVIDER_TEST_MODEL_OVERRIDES = {
49
- sambanova: ['DeepSeek-V3-0324'],
50
- nvidia: ['deepseek-ai/deepseek-v3.1-terminus', 'openai/gpt-oss-120b'],
61
+ sambanova: ['MiniMax-M2.5', 'DeepSeek-V3.1', 'DeepSeek-V3.2'],
62
+ nvidia: ['deepseek-ai/deepseek-v4-flash', 'openai/gpt-oss-120b'],
63
+ 'github-models': ['openai/gpt-4.1-mini'],
64
+ mistral: ['mistral-small-latest', 'devstral-small-latest'],
51
65
  }
52
66
 
53
67
  // 📖 Settings key tests retry retryable failures across several models so a
@@ -67,6 +81,7 @@ const PROVIDER_AUTH_ENDPOINTS = {
67
81
  cerebras: { url: 'https://api.cerebras.ai/v1/models', method: 'GET' },
68
82
  sambanova: { url: 'https://api.sambanova.ai/v1/models', method: 'GET' },
69
83
  openrouter: { url: 'https://openrouter.ai/api/v1/models', method: 'GET' },
84
+ mistral: { url: 'https://api.mistral.ai/v1/models', method: 'GET' },
70
85
  huggingface: { url: 'https://router.huggingface.co/v1/models', method: 'GET' },
71
86
  deepinfra: { url: 'https://api.deepinfra.com/v1/models', method: 'GET' },
72
87
  fireworks: { url: 'https://api.fireworks.ai/v1/models', method: 'GET' },
@@ -79,6 +94,7 @@ const PROVIDER_AUTH_ENDPOINTS = {
79
94
  ovhcloud: { url: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models', method: 'GET' },
80
95
  qwen: { url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models', method: 'GET' },
81
96
  iflow: { url: 'https://apis.iflow.cn/v1/models', method: 'GET' },
97
+ 'github-models': null, // 📖 GitHub Models catalog is public; use chat ping to validate the token.
82
98
  replicate: null, // 📖 Replicate has no /models endpoint; use chat completions ping
83
99
  cloudflare: null, // 📖 Workers AI has no auth-check endpoint; use ping only
84
100
  zai: null, // 📖 ZAI undocumented; use ping only
@@ -307,7 +323,10 @@ export function createKeyHandler(ctx) {
307
323
  }
308
324
 
309
325
  function shouldCheckMissingTool(mode) {
310
- return mode !== 'opencode-desktop'
326
+ // 📖 opencode-desktop doesn't have a binary check (it uses 'open -a').
327
+ // 📖 opencode-web, opencode, and kilo manage their own ENOENT errors in spawn handlers.
328
+ // 📖 xcode uses 'open -a Xcode' which doesn't need a binary path resolution.
329
+ return !['opencode-desktop', 'opencode-web', 'opencode', 'kilo', 'xcode'].includes(mode)
311
330
  }
312
331
 
313
332
  function getModelTelemetryFamily(providerKey) {
@@ -360,6 +379,40 @@ export function createKeyHandler(ctx) {
360
379
  })
361
380
  }
362
381
 
382
+ async function syncFavoritesToRouter(selected) {
383
+ if (state.config?.router?.enabled !== true) return
384
+ const favorites = state.config.favorites || []
385
+ const selKey = toFavoriteKey(selected.providerKey, selected.modelId)
386
+ const chain = [selKey, ...favorites.filter((f) => f !== selKey)]
387
+ const models = chain.map((f, i) => {
388
+ const slashIdx = f.indexOf('/')
389
+ const provider = slashIdx >= 0 ? f.slice(0, slashIdx) : '?'
390
+ const model = slashIdx >= 0 ? f.slice(slashIdx + 1) : f
391
+ return { provider, model, priority: i + 1 }
392
+ })
393
+ try {
394
+ const port = await readDaemonPort()
395
+ if (!port) return
396
+ const baseUrl = `http://127.0.0.1:${port}`
397
+ const setPayload = { name: 'fast-coding', models, created: new Date().toISOString() }
398
+ await globalThis.fetch(`${baseUrl}/sets/fast-coding`, {
399
+ method: 'PUT',
400
+ headers: { 'Content-Type': 'application/json' },
401
+ body: JSON.stringify(setPayload),
402
+ })
403
+ await globalThis.fetch(`${baseUrl}/sets/fast-coding/activate`, { method: 'POST' })
404
+ } catch {}
405
+ }
406
+
407
+ async function readDaemonPort() {
408
+ try {
409
+ const { readFileSync } = await import('node:fs')
410
+ const raw = readFileSync(`${process.env.HOME}/.free-coding-models-daemon.port`, 'utf8').trim()
411
+ if (/^\d+$/.test(raw)) return Number(raw)
412
+ } catch {}
413
+ return null
414
+ }
415
+
363
416
  async function launchSelectedModel(selected, options = {}) {
364
417
  const { uiAlreadyStopped = false } = options
365
418
  userSelected = { modelId: selected.modelId, label: selected.label, tier: selected.tier, providerKey: selected.providerKey }
@@ -370,7 +423,9 @@ export function createKeyHandler(ctx) {
370
423
  stopUi()
371
424
  }
372
425
 
373
- // 📖 Show selection status before handing control to the target tool.
426
+ // 📖 If router is enabled, push [selected, ...favorites] to daemon as the active set
427
+ await syncFavoritesToRouter(selected)
428
+
374
429
  if (selected.status === 'timeout') {
375
430
  console.log(chalk.yellow(` ⚠ Selected: ${selected.label} (currently timing out)`))
376
431
  } else if (selected.status === 'down') {
@@ -925,6 +980,51 @@ export function createKeyHandler(ctx) {
925
980
  }
926
981
  }
927
982
 
983
+
984
+ // 📖 Token Usage screen — Shift+T from main table. Fetches daily token history
985
+ // 📖 from the daemon and renders a 7-day chart plus today/all-time breakdowns.
986
+ async function openTokenUsageOverlay() {
987
+ state.tokenUsageOpen = true
988
+ state.tokenUsageScrollOffset = 0
989
+ state.tokenUsageError = null
990
+ state.tokenUsageData = null
991
+ // 📖 Discover daemon port
992
+ let port = 19280
993
+ try {
994
+ const { readFileSync: rfs } = await import('node:fs')
995
+ const portPath = `${process.env.HOME}/.free-coding-models-daemon.port`
996
+ const savedPort = rfs(portPath, 'utf8').trim()
997
+ if (/^\d+$/.test(savedPort)) port = Number(savedPort)
998
+ } catch {}
999
+ state.tokenUsageLastFetchAt = Date.now()
1000
+ try {
1001
+ const controller = new AbortController()
1002
+ const timer = setTimeout(() => controller.abort(), 2000)
1003
+ const res = await globalThis.fetch(`http://127.0.0.1:${port}/stats/tokens`, { signal: controller.signal })
1004
+ clearTimeout(timer)
1005
+ if (!res.ok) {
1006
+ state.tokenUsageError = `Daemon returned HTTP ${res.status} — is the router running?`
1007
+ return
1008
+ }
1009
+ // 📖 Guard: res.json() can throw on malformed response body
1010
+ const text = await res.text()
1011
+ try {
1012
+ state.tokenUsageData = JSON.parse(text)
1013
+ } catch {
1014
+ state.tokenUsageError = 'Daemon returned invalid JSON — try restarting the daemon'
1015
+ }
1016
+ } catch (err) {
1017
+ state.tokenUsageError = err?.name === 'AbortError' ? 'Request timed out — is the router daemon running?' : (err?.message || 'Failed to fetch token stats')
1018
+ }
1019
+ }
1020
+
1021
+ function closeTokenUsageOverlay() {
1022
+ state.tokenUsageOpen = false
1023
+ state.tokenUsageScrollOffset = 0
1024
+ state.tokenUsageError = null
1025
+ }
1026
+
1027
+
928
1028
  function cycleToolMode() {
929
1029
  const modeOrder = getToolModeOrder()
930
1030
  const currentIndex = modeOrder.indexOf(state.mode)
@@ -1013,6 +1113,7 @@ export function createKeyHandler(ctx) {
1013
1113
  || state.installEndpointsOpen
1014
1114
  || state.toolInstallPromptOpen
1015
1115
  || state.installedModelsOpen
1116
+ || state.routerDashboardOpen
1016
1117
  || state.recommendOpen
1017
1118
  || state.feedbackOpen
1018
1119
  || state.helpVisible
@@ -1204,6 +1305,8 @@ export function createKeyHandler(ctx) {
1204
1305
  case 'open-changelog': return openChangelogOverlay()
1205
1306
  case 'open-feedback': return openFeedbackOverlay()
1206
1307
  case 'open-recommend': return openRecommendOverlay()
1308
+ case 'open-router-dashboard': return openRouterDashboardOverlay(state)
1309
+ case 'open-token-usage': return openTokenUsageOverlay()
1207
1310
  case 'open-install-endpoints': return openInstallEndpointsOverlay()
1208
1311
  case 'open-installed-models': return openInstalledModelsOverlay()
1209
1312
  case 'action-cycle-theme': return cycleGlobalTheme()
@@ -1337,6 +1440,59 @@ export function createKeyHandler(ctx) {
1337
1440
 
1338
1441
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1339
1442
 
1443
+ // 📖 Router Dashboard captures local dashboard controls while open so keys
1444
+ // 📖 like S/I/R/C do not leak through to sort/filter actions in the table.
1445
+ if (state.routerDashboardOpen) {
1446
+ if (key.ctrl && key.name === 'c') { exit(0); return }
1447
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
1448
+
1449
+ if (key.name === 'escape') {
1450
+ closeRouterDashboardOverlay(state)
1451
+ return
1452
+ }
1453
+ if (key.name === 'up' || key.name === 'k') {
1454
+ state.routerDashboardScrollOffset = Math.max(0, (state.routerDashboardScrollOffset || 0) - 1)
1455
+ return
1456
+ }
1457
+ if (key.name === 'down' || key.name === 'j') {
1458
+ state.routerDashboardScrollOffset = (state.routerDashboardScrollOffset || 0) + 1
1459
+ return
1460
+ }
1461
+ if (key.name === 'pageup') {
1462
+ state.routerDashboardScrollOffset = Math.max(0, (state.routerDashboardScrollOffset || 0) - pageStep)
1463
+ return
1464
+ }
1465
+ if (key.name === 'pagedown') {
1466
+ state.routerDashboardScrollOffset = (state.routerDashboardScrollOffset || 0) + pageStep
1467
+ return
1468
+ }
1469
+ if (key.name === 'home') {
1470
+ state.routerDashboardScrollOffset = 0
1471
+ return
1472
+ }
1473
+ if (key.name === 's') {
1474
+ try { await cycleRouterDashboardActiveSet(state) } catch {}
1475
+ return
1476
+ }
1477
+ if (key.name === 'i') {
1478
+ try { await cycleRouterDashboardProbeMode(state) } catch {}
1479
+ return
1480
+ }
1481
+ if (key.name === 'r') {
1482
+ restartRouterDashboardDaemon(state)
1483
+ return
1484
+ }
1485
+ if (key.name === 'c') {
1486
+ clearRouterDashboardRequestLog(state)
1487
+ return
1488
+ }
1489
+ if (key.name === 'p') {
1490
+ toggleRouterDashboardProbePause(state)
1491
+ return
1492
+ }
1493
+ return
1494
+ }
1495
+
1340
1496
  // 📖 Install Endpoints overlay: provider → tool → connection → scope → optional model subset.
1341
1497
  if (state.installEndpointsOpen) {
1342
1498
  if (key.ctrl && key.name === 'c') { exit(0); return }
@@ -1817,6 +1973,116 @@ export function createKeyHandler(ctx) {
1817
1973
  return
1818
1974
  }
1819
1975
 
1976
+ // 📖 Token Usage overlay: Shift+T shows token history chart and today/all-time breakdowns.
1977
+ if (state.tokenUsageOpen) {
1978
+ if (key.ctrl && key.name === 'c') { exit(0); return }
1979
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
1980
+ if (key.name === 'escape') {
1981
+ closeTokenUsageOverlay()
1982
+ return
1983
+ }
1984
+ if (key.name === 'up' || key.name === 'k') {
1985
+ state.tokenUsageScrollOffset = Math.max(0, state.tokenUsageScrollOffset - 1)
1986
+ return
1987
+ }
1988
+ if (key.name === 'down' || key.name === 'j') {
1989
+ state.tokenUsageScrollOffset += 1
1990
+ return
1991
+ }
1992
+ if (key.name === 'pageup') {
1993
+ state.tokenUsageScrollOffset = Math.max(0, state.tokenUsageScrollOffset - pageStep)
1994
+ return
1995
+ }
1996
+ if (key.name === 'pagedown') {
1997
+ state.tokenUsageScrollOffset += pageStep
1998
+ return
1999
+ }
2000
+ if (key.name === 'home') {
2001
+ state.tokenUsageScrollOffset = 0
2002
+ return
2003
+ }
2004
+ if (key.name === 'end') {
2005
+ state.tokenUsageScrollOffset = Number.MAX_SAFE_INTEGER
2006
+ return
2007
+ }
2008
+ return
2009
+ }
2010
+
2011
+ // 📖 Router Onboarding overlay: shown on first launch. Y=yes enable, N=not now, Esc=cancel.
2012
+ if (state.routerOnboardingOpen) {
2013
+ if (key.ctrl && key.name === 'c') { exit(0); return }
2014
+ if (state.routerOnboardingPhase === 'loading' || state.routerOnboardingPhase === 'success' || state.routerOnboardingPhase === 'error') {
2015
+ if (key.name === 'escape' || key.name === 'return') {
2016
+ state.routerOnboardingOpen = false
2017
+ // 📖 Mark onboarding as seen (don't show again)
2018
+ if (state.config?.router) {
2019
+ state.config.router.onboardingSeen = true
2020
+ }
2021
+ return
2022
+ }
2023
+ return
2024
+ }
2025
+ if (key.name === 'escape' || key.name === 'n') {
2026
+ state.routerOnboardingOpen = false
2027
+ // 📖 Mark as seen and disabled
2028
+ if (state.config?.router) {
2029
+ state.config.router.onboardingSeen = true
2030
+ state.config.router.enabled = false
2031
+ }
2032
+ return
2033
+ }
2034
+ if (key.name === 'up' || key.name === 'k') {
2035
+ state.routerOnboardingCursor = 0
2036
+ return
2037
+ }
2038
+ if (key.name === 'down' || key.name === 'j') {
2039
+ state.routerOnboardingCursor = 1
2040
+ return
2041
+ }
2042
+ if (key.name === 'return' || key.name === 'y') {
2043
+ const shouldEnable = key.name === 'return' ? true : (state.routerOnboardingCursor === 0)
2044
+ if (!shouldEnable) {
2045
+ state.routerOnboardingOpen = false
2046
+ if (state.config?.router) {
2047
+ state.config.router.onboardingSeen = true
2048
+ state.config.router.enabled = false
2049
+ }
2050
+ return
2051
+ }
2052
+ // 📖 Enable router: start daemon in background and mark onboarding seen
2053
+ state.routerOnboardingPhase = 'loading'
2054
+ state.routerOnboardingError = null
2055
+ void (async () => {
2056
+ try {
2057
+ const binPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'free-coding-models.js')
2058
+ const child = spawn('node', [binPath, '--daemon-bg'], {
2059
+ detached: true,
2060
+ stdio: 'ignore',
2061
+ })
2062
+ child.unref()
2063
+ await new Promise((r) => setTimeout(r, 2000))
2064
+ if (state.routerOnboardingPhase === 'loading') {
2065
+ state.routerOnboardingPhase = 'success'
2066
+ if (state.config?.router) {
2067
+ state.config.router.enabled = true
2068
+ state.config.router.onboardingSeen = true
2069
+ saveConfig(state.config)
2070
+ }
2071
+ trackTelemetryEvent('app_router_install', { router_version: '0.4.0' })
2072
+ await new Promise((r) => setTimeout(r, 1500))
2073
+ state.routerOnboardingOpen = false
2074
+ openRouterDashboardOverlay(state)
2075
+ }
2076
+ } catch (err) {
2077
+ state.routerOnboardingPhase = 'error'
2078
+ state.routerOnboardingError = err?.message || 'Failed to start router'
2079
+ }
2080
+ })()
2081
+ return
2082
+ }
2083
+ return
2084
+ }
2085
+
1820
2086
  // 📖 Changelog overlay: two-phase (index + details) with keyboard navigation
1821
2087
  if (state.changelogOpen) {
1822
2088
  const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
@@ -2363,9 +2629,15 @@ export function createKeyHandler(ctx) {
2363
2629
 
2364
2630
  // 📖 Profile system removed - API keys now persist permanently across all sessions
2365
2631
 
2366
- // 📖 Shift+R: reset all UI view settings to defaults (tier, sort, provider) and clear persisted config
2367
- if (key.name === 'r' && key.shift) {
2368
- resetViewSettings()
2632
+ // 📖 Shift+R: open the Smart Model Router dashboard from the main table.
2633
+ if (key.name === 'r' && key.shift && !key.ctrl && !key.meta) {
2634
+ openRouterDashboardOverlay(state)
2635
+ return
2636
+ }
2637
+
2638
+ // 📖 Shift+T: open the Token Usage screen.
2639
+ if (key.name === 't' && key.shift && !key.ctrl && !key.meta) {
2640
+ openTokenUsageOverlay()
2369
2641
  return
2370
2642
  }
2371
2643
 
@@ -2380,7 +2652,7 @@ export function createKeyHandler(ctx) {
2380
2652
  // 📖 T is reserved for tier filter cycling. Y toggles favorites display mode.
2381
2653
  // 📖 X clears the active custom text filter.
2382
2654
  // 📖 D is now reserved for provider filter cycling
2383
- // 📖 Shift+R is reserved for reset view settings
2655
+ // 📖 Shift+R is reserved for the Router Dashboard; reset view remains in Ctrl+P.
2384
2656
  const sortKeys = {
2385
2657
  'r': 'rank', 'o': 'origin', 'm': 'model',
2386
2658
  'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime'
@@ -2499,6 +2771,28 @@ export function createKeyHandler(ctx) {
2499
2771
  return
2500
2772
  }
2501
2773
 
2774
+ if (key.shift && key.name === 'up') {
2775
+ const selected = state.visibleSorted?.[state.cursor]
2776
+ if (selected?.isFavorite) {
2777
+ reorderFavorite(state.config, selected.providerKey, selected.modelId, 'up')
2778
+ syncFavoriteFlags(state.results, state.config)
2779
+ applyTierFilter()
2780
+ refreshVisibleSorted({ resetCursor: false })
2781
+ }
2782
+ return
2783
+ }
2784
+
2785
+ if (key.shift && key.name === 'down') {
2786
+ const selected = state.visibleSorted?.[state.cursor]
2787
+ if (selected?.isFavorite) {
2788
+ reorderFavorite(state.config, selected.providerKey, selected.modelId, 'down')
2789
+ syncFavoriteFlags(state.results, state.config)
2790
+ applyTierFilter()
2791
+ refreshVisibleSorted({ resetCursor: false })
2792
+ }
2793
+ return
2794
+ }
2795
+
2502
2796
  if (key.name === 'up' || key.name === 'k') {
2503
2797
  // 📖 Main list wrap navigation: top -> bottom on Up / K (vim-style).
2504
2798
  const count = state.visibleSorted.length
@@ -2524,12 +2818,9 @@ export function createKeyHandler(ctx) {
2524
2818
  }
2525
2819
 
2526
2820
  if (key.name === 'return') { // Enter
2527
- // 📖 Use the cached visible+sorted array — guaranteed to match what's on screen
2528
2821
  const selected = state.visibleSorted[state.cursor]
2529
- if (!selected) return // 📖 Guard: empty visible list (all filtered out)
2822
+ if (!selected) return
2530
2823
 
2531
- // 📖 Incompatibility intercept — if the model can't run on the active tool,
2532
- // 📖 show the fallback overlay instead of launching. Lets user switch tool or pick similar model.
2533
2824
  if (!isModelCompatibleWithTool(selected.providerKey, state.mode)) {
2534
2825
  const compatTools = getCompatibleTools(selected.providerKey)
2535
2826
  const similarModels = findSimilarCompatibleModels(
@@ -2760,6 +3051,11 @@ export function createMouseEventHandler(ctx) {
2760
3051
  }
2761
3052
  return
2762
3053
  }
3054
+ if (state.routerDashboardOpen) {
3055
+ const step = evt.type === 'scroll-up' ? -3 : 3
3056
+ state.routerDashboardScrollOffset = Math.max(0, (state.routerDashboardScrollOffset || 0) + step)
3057
+ return
3058
+ }
2763
3059
 
2764
3060
  // 📖 Main table scroll: move cursor up/down with wrap-around
2765
3061
  const count = state.visibleSorted.length
@@ -2840,6 +3136,11 @@ export function createMouseEventHandler(ctx) {
2840
3136
  return
2841
3137
  }
2842
3138
 
3139
+ if (state.routerDashboardOpen) {
3140
+ closeRouterDashboardOverlay(state)
3141
+ return
3142
+ }
3143
+
2843
3144
  if (state.incompatibleFallbackOpen) {
2844
3145
  // 📖 Incompatible fallback: click closes
2845
3146
  state.incompatibleFallbackOpen = false
@@ -3001,6 +3302,8 @@ export function createMouseEventHandler(ctx) {
3001
3302
  // 📖 Most are single-character keys; special cases like ctrl+p need special handling.
3002
3303
  if (zone.key === 'ctrl+p') {
3003
3304
  process.stdin.emit('keypress', '\x10', { name: 'p', ctrl: true, meta: false, shift: false })
3305
+ } else if (zone.key === 'shift+r') {
3306
+ process.stdin.emit('keypress', 'R', { name: 'r', ctrl: false, meta: false, shift: true })
3004
3307
  } else {
3005
3308
  process.stdin.emit('keypress', zone.key, { name: zone.key, ctrl: false, meta: false, shift: false })
3006
3309
  }
package/src/kilo.js CHANGED
@@ -9,6 +9,7 @@ import { loadKiloConfig, saveKiloConfig, getKiloConfigPath } from './kilo-config
9
9
  import { getApiKey } from './config.js'
10
10
  import { ENV_VAR_NAMES, OPENCODE_MODEL_MAP } from './provider-metadata.js'
11
11
  import { resolveToolBinaryPath } from './tool-bootstrap.js'
12
+ import { sources } from '../sources.js'
12
13
 
13
14
  // 📖 Map source model IDs to Kilo built-in IDs (same as OpenCode).
14
15
  function getKiloModelId(providerKey, modelId) {
@@ -17,6 +18,21 @@ function getKiloModelId(providerKey, modelId) {
17
18
  return OPENCODE_MODEL_MAP[providerKey]?.[modelId] || modelId
18
19
  }
19
20
 
21
+ function buildOpenAiCompatibleProviderConfig(providerKey) {
22
+ const source = sources[providerKey]
23
+ const envVarName = ENV_VAR_NAMES[providerKey]
24
+ if (!source?.url || !envVarName) return null
25
+ const baseURL = source.url
26
+ .replace(/\/chat\/completions$/i, '')
27
+ .replace(/\/responses$/i, '')
28
+ return {
29
+ npm: '@ai-sdk/openai-compatible',
30
+ name: source.name || providerKey,
31
+ options: { baseURL, apiKey: `{env:${envVarName}}` },
32
+ models: {},
33
+ }
34
+ }
35
+
20
36
  // 📖 spawnKilo: Resolve API keys + spawn kilo CLI with correct env.
21
37
  async function spawnKilo(args, providerKey, fcmConfig) {
22
38
  const envVarName = ENV_VAR_NAMES[providerKey]
@@ -120,7 +136,7 @@ export async function startKilo(model, fcmConfig) {
120
136
  config.provider.codestral = {
121
137
  npm: '@ai-sdk/openai-compatible',
122
138
  name: 'Mistral Codestral',
123
- options: { baseURL: 'https://api.mistral.ai/v1', apiKey: '{env:CODESTRAL_API_KEY}' },
139
+ options: { baseURL: 'https://api.mistral.ai/v1', apiKey: '{env:MISTRAL_API_KEY}' },
124
140
  models: {}
125
141
  }
126
142
  } else if (providerKey === 'hyperbolic') {
@@ -179,6 +195,9 @@ export async function startKilo(model, fcmConfig) {
179
195
  options: { baseURL: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1', apiKey: '{env:OVH_AI_ENDPOINTS_ACCESS_TOKEN}' },
180
196
  models: {}
181
197
  }
198
+ } else {
199
+ const providerConfig = buildOpenAiCompatibleProviderConfig(providerKey)
200
+ if (providerConfig) config.provider[providerKey] = providerConfig
182
201
  }
183
202
  }
184
203
 
package/src/opencode.js CHANGED
@@ -98,6 +98,21 @@ function getOpenCodeModelId(providerKey, modelId) {
98
98
  return OPENCODE_MODEL_MAP[providerKey]?.[modelId] || modelId
99
99
  }
100
100
 
101
+ function buildOpenAiCompatibleProviderConfig(providerKey) {
102
+ const source = sources[providerKey]
103
+ const envVarName = ENV_VAR_NAMES[providerKey]
104
+ if (!source?.url || !envVarName) return null
105
+ const baseURL = source.url
106
+ .replace(/\/chat\/completions$/i, '')
107
+ .replace(/\/responses$/i, '')
108
+ return {
109
+ npm: '@ai-sdk/openai-compatible',
110
+ name: source.name || providerKey,
111
+ options: { baseURL, apiKey: `{env:${envVarName}}` },
112
+ models: {},
113
+ }
114
+ }
115
+
101
116
  // ─── ZAI proxy bridge ─────────────────────────────────────────────────────────
102
117
 
103
118
  // 📖 createZaiProxy: Localhost reverse proxy bridging ZAI's non-standard API paths
@@ -203,7 +218,7 @@ async function spawnOpenCode(args, providerKey, fcmConfig, existingZaiProxy = nu
203
218
  if (zaiProxy) zaiProxy.close()
204
219
  if (err.code === 'ENOENT') {
205
220
  console.error(chalk.red('\n X Could not find "opencode" -- is it installed and in your PATH?'))
206
- console.error(chalk.dim(' Install: npm i -g opencode or see https://opencode.ai'))
221
+ console.error(chalk.dim(' Install: npm i -g opencode-ai or see https://opencode.ai'))
207
222
  resolve(1)
208
223
  } else {
209
224
  reject(err)
@@ -434,7 +449,7 @@ export async function startOpenCode(model, fcmConfig) {
434
449
  config.provider.codestral = {
435
450
  npm: '@ai-sdk/openai-compatible',
436
451
  name: 'Mistral Codestral',
437
- options: { baseURL: 'https://api.mistral.ai/v1', apiKey: '{env:CODESTRAL_API_KEY}' },
452
+ options: { baseURL: 'https://api.mistral.ai/v1', apiKey: '{env:MISTRAL_API_KEY}' },
438
453
  models: {}
439
454
  }
440
455
  } else if (providerKey === 'hyperbolic') {
@@ -514,6 +529,9 @@ export async function startOpenCode(model, fcmConfig) {
514
529
  options: { baseURL: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1', apiKey: '{env:OVH_AI_ENDPOINTS_ACCESS_TOKEN}' },
515
530
  models: {}
516
531
  }
532
+ } else {
533
+ const providerConfig = buildOpenAiCompatibleProviderConfig(providerKey)
534
+ if (providerConfig) config.provider[providerKey] = providerConfig
517
535
  }
518
536
  }
519
537
 
@@ -799,7 +817,7 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
799
817
  config.provider.codestral = {
800
818
  npm: '@ai-sdk/openai-compatible',
801
819
  name: 'Mistral Codestral',
802
- options: { baseURL: 'https://api.mistral.ai/v1', apiKey: '{env:CODESTRAL_API_KEY}' },
820
+ options: { baseURL: 'https://api.mistral.ai/v1', apiKey: '{env:MISTRAL_API_KEY}' },
803
821
  models: {}
804
822
  }
805
823
  } else if (providerKey === 'hyperbolic') {
@@ -879,6 +897,9 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
879
897
  options: { baseURL: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1', apiKey: '{env:OVH_AI_ENDPOINTS_ACCESS_TOKEN}' },
880
898
  models: {}
881
899
  }
900
+ } else {
901
+ const providerConfig = buildOpenAiCompatibleProviderConfig(providerKey)
902
+ if (providerConfig) config.provider[providerKey] = providerConfig
882
903
  }
883
904
  }
884
905