free-coding-models 0.5.5 → 0.5.8

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 (63) hide show
  1. package/README.md +19 -11
  2. package/bin/free-coding-models.js +6 -0
  3. package/changelog/v0.5.5.md +1 -0
  4. package/changelog/v0.5.6.md +24 -0
  5. package/changelog/v0.5.7.md +20 -0
  6. package/package.json +4 -4
  7. package/src/core/changelog-loader.js +5 -1
  8. package/src/core/endpoint-installer.js +3 -3
  9. package/src/core/router-daemon.js +11 -0
  10. package/src/core/tool-launchers.js +2 -2
  11. package/web/dist/assets/index-DKmmiDip.css +1 -0
  12. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  13. package/web/dist/index.html +2 -2
  14. package/web/server.js +532 -2
  15. package/web/src/App.jsx +235 -73
  16. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  17. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  18. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  19. package/web/src/components/atoms/TierBadge.module.css +3 -3
  20. package/web/src/components/changelog/ChangelogView.jsx +135 -0
  21. package/web/src/components/changelog/ChangelogView.module.css +160 -0
  22. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  23. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  24. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  25. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  26. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  27. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  28. package/web/src/components/help/HelpView.jsx +188 -0
  29. package/web/src/components/help/HelpView.module.css +157 -0
  30. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  31. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  32. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  33. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  34. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  35. package/web/src/components/launch/LaunchButton.jsx +30 -0
  36. package/web/src/components/launch/LaunchButton.module.css +35 -0
  37. package/web/src/components/launch/LaunchModal.module.css +125 -0
  38. package/web/src/components/layout/Header.jsx +78 -14
  39. package/web/src/components/layout/Header.module.css +62 -2
  40. package/web/src/components/palette/CommandPalette.jsx +228 -74
  41. package/web/src/components/recommend/RecommendView.jsx +121 -0
  42. package/web/src/components/recommend/RecommendView.module.css +55 -0
  43. package/web/src/components/router/RouterView.jsx +286 -0
  44. package/web/src/components/router/RouterView.module.css +357 -0
  45. package/web/src/components/settings/SettingsView.jsx +281 -8
  46. package/web/src/components/settings/SettingsView.module.css +174 -0
  47. package/web/src/components/tools/ToolPicker.jsx +86 -0
  48. package/web/src/components/tools/ToolPicker.module.css +84 -0
  49. package/web/src/components/update/UpdateChip.jsx +104 -0
  50. package/web/src/components/update/UpdateChip.module.css +146 -0
  51. package/web/src/global.css +23 -0
  52. package/web/src/hooks/urlState.constants.js +28 -0
  53. package/web/src/hooks/useChangelog.js +51 -0
  54. package/web/src/hooks/useInstalledModels.js +42 -0
  55. package/web/src/hooks/useRecommend.js +67 -0
  56. package/web/src/hooks/useRouterDashboard.js +133 -0
  57. package/web/src/hooks/useTokenUsage.js +103 -0
  58. package/web/src/hooks/useToolMode.js +77 -0
  59. package/web/src/hooks/useUpdateChecker.js +91 -0
  60. package/web/src/hooks/useUrlState.js +123 -62
  61. package/web/src/utils/m3.js +55 -0
  62. package/web/dist/assets/index-uD3faN3G.js +0 -39
  63. package/web/dist/assets/index-uTifVKX1.css +0 -1
@@ -48,8 +48,8 @@
48
48
  <link rel="preconnect" href="https://fonts.googleapis.com">
49
49
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
50
50
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
51
- <script type="module" crossorigin src="/assets/index-uD3faN3G.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-uTifVKX1.css">
51
+ <script type="module" crossorigin src="/assets/index-DzVt42Nf.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-DKmmiDip.css">
53
53
  </head>
54
54
  <body>
55
55
  <div id="root"></div>
package/web/server.js CHANGED
@@ -26,17 +26,37 @@ import { readFileSync, existsSync } from 'node:fs'
26
26
  import { join, dirname, extname } from 'node:path'
27
27
  import { fileURLToPath } from 'node:url'
28
28
  import { exec } from 'node:child_process'
29
+ import { createRequire } from 'node:module'
29
30
  import { Server } from 'socket.io'
30
31
 
32
+ // 📖 Resolve the local package version for /api/version — same trick the TUI uses.
33
+ const require = createRequire(import.meta.url)
34
+ const { version: LOCAL_VERSION } = require('../package.json')
35
+
31
36
  import { sources, MODELS } from '../sources.js'
32
37
  import { loadConfig, getApiKey, saveConfig, isProviderEnabled } from '../src/core/config.js'
33
38
  import { ensureFavoritesConfig } from '../src/core/favorites.js'
34
39
  import { ping } from '../src/core/ping.js'
40
+ import { loadChangelog } from '../src/core/changelog-loader.js'
41
+ import { checkForUpdateDetailed, checkForUpdate, runUpdate, fetchLastReleaseDate } from '../src/core/updater.js'
42
+ import { syncShellEnv, ensureShellRcSource, removeShellEnv } from '../src/core/shell-env.js'
43
+ import { cleanupLegacyProxyArtifacts } from '../src/core/legacy-proxy-cleanup.js'
35
44
  import {
36
45
  getAvg, getVerdict, getUptime, getP95, getJitter,
37
46
  getStabilityScore,
38
47
  } from '../src/core/utils.js'
39
48
  import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from '../src/core/benchmark.js'
49
+ import { getInstallTargetModes, installProviderEndpoints, getConfiguredInstallableProviders, getProviderCatalogModels } from '../src/core/endpoint-installer.js'
50
+ import { isModelCompatibleWithTool } from '../src/core/tool-metadata.js'
51
+ import { sendUsageTelemetry } from '../src/core/telemetry.js'
52
+ import { getRouterDaemonStatus, startRouterDaemonBackground, stopRouterDaemon, ROUTER_TOKENS_PATH } from '../src/core/router-daemon.js'
53
+ import { scanAllToolConfigs, softDeleteModel } from '../src/core/installed-models-manager.js'
54
+ import {
55
+ TASK_TYPES,
56
+ PRIORITY_TYPES,
57
+ CONTEXT_BUDGETS,
58
+ getTopRecommendations,
59
+ } from '../src/core/utils.js'
40
60
  import {
41
61
  PING_MODE_INTERVALS,
42
62
  PING_MODE_CYCLE,
@@ -502,6 +522,108 @@ function runWithConcurrency(tasks, concurrency) {
502
522
  })
503
523
  }
504
524
 
525
+ const TOOL_MODE_ORDER = getInstallTargetModes()
526
+ const TOOL_MODES = new Set(TOOL_MODE_ORDER)
527
+ const DAEMON_PROXY_TIMEOUT_MS = 5000
528
+
529
+ // ─── Router daemon proxy helper ────────────────────────────────────────────
530
+ async function proxyToDaemon(path, options = {}) {
531
+ const port = await readDaemonPort()
532
+ if (!port) return null
533
+ try {
534
+ const url = `http://127.0.0.1:${port}${path}`
535
+ const resp = await fetch(url, { ...options, signal: AbortSignal.timeout(DAEMON_PROXY_TIMEOUT_MS) })
536
+ return { ok: resp.ok, status: resp.status, data: await resp.json().catch(() => null) }
537
+ } catch { return null }
538
+ }
539
+
540
+ function readTokenFile() {
541
+ try {
542
+ if (!existsSync(ROUTER_TOKENS_PATH)) return null
543
+ return JSON.parse(readFileSync(ROUTER_TOKENS_PATH, 'utf8'))
544
+ } catch { return null }
545
+ }
546
+
547
+ function normalizeToolMode(mode) {
548
+ return typeof mode === 'string' && TOOL_MODES.has(mode) ? mode : 'opencode'
549
+ }
550
+
551
+ function getPreferredToolMode() {
552
+ return normalizeToolMode(config.settings?.preferredToolMode)
553
+ }
554
+
555
+ function persistPreferredToolMode(mode) {
556
+ const normalized = normalizeToolMode(mode)
557
+ if (!config.settings || typeof config.settings !== 'object') config.settings = {}
558
+ config.settings.preferredToolMode = normalized
559
+ const saveResult = saveConfig(config)
560
+ return { mode: normalized, saveResult }
561
+ }
562
+
563
+ function getEndpointModel(providerKey, modelId) {
564
+ const result = getResult(providerKey, modelId)
565
+ if (!result) return null
566
+ return {
567
+ providerKey: result.providerKey,
568
+ modelId: result.modelId,
569
+ label: result.label,
570
+ tier: result.tier,
571
+ sweScore: result.sweScore,
572
+ ctx: result.ctx,
573
+ status: result.status,
574
+ }
575
+ }
576
+
577
+ async function readDaemonPort() {
578
+ try {
579
+ const raw = readFileSync(`${process.env.HOME}/.free-coding-models-daemon.port`, 'utf8').trim()
580
+ if (/^\d+$/.test(raw)) return Number(raw)
581
+ } catch {}
582
+ return null
583
+ }
584
+
585
+ async function syncFavoritesToRouter(selected) {
586
+ if (config?.router?.enabled !== true) return
587
+ const selKey = `${selected.providerKey}/${selected.modelId}`
588
+ const favorites = Array.isArray(config.favorites) ? config.favorites : []
589
+ const chain = [selKey, ...favorites.filter((entry) => entry !== selKey)]
590
+ const models = chain.map((entry, index) => {
591
+ const slashIdx = entry.indexOf('/')
592
+ const provider = slashIdx >= 0 ? entry.slice(0, slashIdx) : '?'
593
+ const model = slashIdx >= 0 ? entry.slice(slashIdx + 1) : entry
594
+ return { provider, model, priority: index + 1 }
595
+ })
596
+ try {
597
+ const port = await readDaemonPort()
598
+ if (!port) return
599
+ const baseUrl = `http://127.0.0.1:${port}`
600
+ const setPayload = { name: 'fast-coding', models, created: new Date().toISOString() }
601
+ await fetch(`${baseUrl}/sets/fast-coding`, {
602
+ method: 'PUT',
603
+ headers: { 'Content-Type': 'application/json' },
604
+ body: JSON.stringify(setPayload),
605
+ })
606
+ await fetch(`${baseUrl}/sets/fast-coding/activate`, { method: 'POST' })
607
+ } catch {}
608
+ }
609
+
610
+ async function installEndpointForMode(mode, model) {
611
+ return installProviderEndpoints(config, model.providerKey, mode, {
612
+ scope: 'selected',
613
+ modelIds: [model.modelId],
614
+ })
615
+ }
616
+
617
+ function buildRecommendReason(result, answers) {
618
+ const bits = []
619
+ if (result.tier) bits.push(`${result.tier} tier`)
620
+ if (result.sweScore && result.sweScore !== '—') bits.push(`${result.sweScore} SWE`)
621
+ if (result.ctx) bits.push(`${result.ctx} context`)
622
+ if (result.status === 'up') bits.push('currently up')
623
+ const priority = PRIORITY_TYPES[answers.priority]?.label || 'balanced'
624
+ return `${bits.join(' · ') || 'Strong catalog fit'} for ${priority.toLowerCase()} priority.`
625
+ }
626
+
505
627
  async function handleRequest(req, res) {
506
628
  res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
507
629
  res.setHeader('Access-Control-Allow-Origin', '*')
@@ -516,7 +638,34 @@ async function handleRequest(req, res) {
516
638
 
517
639
  const url = new URL(req.url, `http://${req.headers.host || `localhost:${DEFAULT_WEB_PORT}`}`)
518
640
 
519
- const keyMatch = url.pathname.match(/^\/api\/key\/(.+)$/)
641
+ // 📖 M2: /api/key/:provider/test — matched here (above the switch) so the
642
+ // 📖 M2 path doesn't conflict with the single-segment key reveal below.
643
+ // 📖 Mirrors the TUI Settings `T` key behavior: parallel auth probe + chat
644
+ // 📖 ping through the existing ping() helper.
645
+ const keyTestMatch = url.pathname.match(/^\/api\/key\/([^/]+)\/test$/)
646
+ if (keyTestMatch) {
647
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
648
+ const providerKey = decodeURIComponent(keyTestMatch[1])
649
+ if (!sources[providerKey]) { sendJson(res, 404, { error: 'Unknown provider' }); return }
650
+ const apiKey = getApiKey(config, providerKey)
651
+ if (!apiKey) { sendJson(res, 200, { outcome: 'missing_key', detail: `${providerKey} has no saved API key.` }); return }
652
+ try {
653
+ const result = await ping(apiKey, '', providerKey, sources[providerKey].url, { silent: true })
654
+ if (result?.code === 200 || result?.code === '200') { sendJson(res, 200, { outcome: 'ok', code: 200 }); return }
655
+ if (result?.code === 401 || result?.code === '401' || result?.code === 403 || result?.code === '403') {
656
+ sendJson(res, 200, { outcome: 'auth_error', code: result.code }); return
657
+ }
658
+ sendJson(res, 200, { outcome: 'fail', code: result?.code ?? 'ERR', detail: 'Probe did not return a 2xx' })
659
+ } catch (err) {
660
+ sendJson(res, 200, { outcome: 'fail', detail: err.message || 'Probe failed' })
661
+ }
662
+ return
663
+ }
664
+
665
+ // 📖 Single-provider key reveal endpoint. The M2 /api/key/:provider/test
666
+ // 📖 route is matched above (above the switch) and uses a stricter regex
667
+ // 📖 (one segment, not two) so the two routes coexist cleanly.
668
+ const keyMatch = url.pathname.match(/^\/api\/key\/([^/]+)$/)
520
669
  if (keyMatch) {
521
670
  const providerKey = decodeURIComponent(keyMatch[1])
522
671
  if (!sources[providerKey]) {
@@ -592,6 +741,110 @@ async function handleRequest(req, res) {
592
741
  sendJson(res, 200, getConfigPayload())
593
742
  return
594
743
 
744
+ // ── M3: shared tool mode — same preferredToolMode setting as the TUI Z cycle ──
745
+ case '/api/tool-mode': {
746
+ if (req.method === 'GET') {
747
+ sendJson(res, 200, { mode: getPreferredToolMode(), tools: TOOL_MODE_ORDER })
748
+ return
749
+ }
750
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
751
+ const body = await readJsonBody(req)
752
+ if (!TOOL_MODES.has(body?.mode)) { sendJson(res, 422, { error: 'Invalid tool mode' }); return }
753
+ const { mode, saveResult } = persistPreferredToolMode(body.mode)
754
+ if (!saveResult.success) { sendJson(res, 500, { error: saveResult.error || 'Failed to save tool mode' }); return }
755
+ noteUserActivity()
756
+ sendJson(res, 200, { mode })
757
+ return
758
+ }
759
+
760
+ // ── M3: install selected model endpoint into a tool config, no process spawn ──
761
+ case '/api/install-endpoint':
762
+ case '/api/launch': {
763
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
764
+ const body = await readJsonBody(req)
765
+ const mode = normalizeToolMode(body?.toolMode || body?.mode || getPreferredToolMode())
766
+ const model = getEndpointModel(body?.providerKey, body?.modelId)
767
+ if (!model) { sendJson(res, 404, { error: 'Model not found' }); return }
768
+ if (!isModelCompatibleWithTool(model.providerKey, mode)) {
769
+ sendJson(res, 422, { error: 'Model is incompatible with selected tool', code: 'incompatible_model', mode, model })
770
+ return
771
+ }
772
+
773
+ const { saveResult } = persistPreferredToolMode(mode)
774
+ if (!saveResult.success) { sendJson(res, 500, { error: saveResult.error || 'Failed to persist tool mode' }); return }
775
+ noteUserActivity()
776
+ try {
777
+ const installResult = await installEndpointForMode(mode, model)
778
+ void syncFavoritesToRouter(model)
779
+ void sendUsageTelemetry(config, { noTelemetry: false }, {
780
+ event: 'app_action',
781
+ mode,
782
+ properties: {
783
+ source: 'web',
784
+ action_type: 'install_endpoint',
785
+ tool_mode: mode,
786
+ provider: model.providerKey,
787
+ model_id: model.modelId,
788
+ model_label: model.label,
789
+ model_tier: model.tier,
790
+ },
791
+ })
792
+ sendJson(res, 200, { configured: true, mode, model, installResult })
793
+ } catch (err) {
794
+ sendJson(res, 422, {
795
+ error: err?.message || 'Failed to install endpoint',
796
+ code: 'endpoint_install_failed',
797
+ mode,
798
+ model,
799
+ })
800
+ }
801
+ return
802
+ }
803
+
804
+ // ── M3: Smart Recommend — wraps the same core scoring engine as the TUI ──
805
+ case '/api/recommend': {
806
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
807
+ const body = await readJsonBody(req)
808
+ const answers = body?.answers || {}
809
+ const taskType = answers.taskType
810
+ const priority = answers.priority
811
+ const contextBudget = answers.contextBudget
812
+ if (!TASK_TYPES[taskType] || !PRIORITY_TYPES[priority] || !CONTEXT_BUDGETS[contextBudget]) {
813
+ sendJson(res, 422, { error: 'Invalid recommendation answers' })
814
+ return
815
+ }
816
+ const top3 = getTopRecommendations(results, taskType, priority, contextBudget, 3)
817
+ .map(({ result, score }) => ({
818
+ result: serializeModel(result),
819
+ score,
820
+ reason: buildRecommendReason(result, answers),
821
+ }))
822
+ void sendUsageTelemetry(config, { noTelemetry: false }, {
823
+ event: 'app_action',
824
+ mode: getPreferredToolMode(),
825
+ properties: { source: 'web', action_type: 'smart_recommend', taskType, priority, contextBudget },
826
+ })
827
+ sendJson(res, 200, { top3, answers: { taskType, priority, contextBudget } })
828
+ return
829
+ }
830
+
831
+ // ── M3: web telemetry mirror — never blocks UX and never exposes secrets ──
832
+ case '/api/telemetry/event': {
833
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
834
+ const body = await readJsonBody(req)
835
+ const event = typeof body?.event === 'string' && body.event.trim() ? body.event.trim() : 'app_action'
836
+ const properties = body?.properties && typeof body.properties === 'object' && !Array.isArray(body.properties)
837
+ ? body.properties
838
+ : {}
839
+ void sendUsageTelemetry(config, { noTelemetry: false }, {
840
+ event,
841
+ mode: getPreferredToolMode(),
842
+ properties: { ...properties, source: 'web' },
843
+ })
844
+ sendJson(res, 200, { ok: true })
845
+ return
846
+ }
847
+
595
848
  case '/api/events':
596
849
  res.writeHead(200, {
597
850
  'Content-Type': 'text/event-stream',
@@ -755,7 +1008,284 @@ async function handleRequest(req, res) {
755
1008
  return
756
1009
  }
757
1010
 
758
- default:
1011
+ // ── M2: /api/version — local vs latest + lastRelease date ─────────────
1012
+ case '/api/version': {
1013
+ try {
1014
+ const { latestVersion, error } = await checkForUpdateDetailed()
1015
+ const lastReleaseDate = await fetchLastReleaseDate()
1016
+ sendJson(res, 200, {
1017
+ local: LOCAL_VERSION,
1018
+ latest: latestVersion,
1019
+ lastReleaseDate,
1020
+ error: error || null,
1021
+ })
1022
+ } catch (err) {
1023
+ sendJson(res, 200, { local: LOCAL_VERSION, latest: null, lastReleaseDate: null, error: err.message || 'update check failed' })
1024
+ }
1025
+ return
1026
+ }
1027
+
1028
+ // ── M2: /api/update/check — force a fresh registry check ──────────────
1029
+ case '/api/update/check': {
1030
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1031
+ const { latestVersion, error } = await checkForUpdateDetailed()
1032
+ sendJson(res, 200, { latest: latestVersion, error: error || null })
1033
+ return
1034
+ }
1035
+
1036
+ // ── M2: /api/update/run — spawn the package manager upgrade ──────────
1037
+ case '/api/update/run': {
1038
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1039
+ const body = await readJsonBody(req)
1040
+ const target = typeof body?.version === 'string' && body.version ? body.version : null
1041
+ // 📖 Mirrors the TUI's `Shift+U` behavior: install + tell the user to
1042
+ // 📖 restart the server. We don't kill the in-process server from
1043
+ // 📖 here because that would interrupt every connected client.
1044
+ if (target) {
1045
+ runUpdate(target)
1046
+ sendJson(res, 200, { started: true, version: target, message: 'Update initiated — restart the dashboard to apply.' })
1047
+ } else {
1048
+ const { latestVersion } = await checkForUpdateDetailed()
1049
+ if (!latestVersion) { sendJson(res, 404, { error: 'No update available' }); return }
1050
+ runUpdate(latestVersion)
1051
+ sendJson(res, 200, { started: true, version: latestVersion, message: 'Update initiated — restart the dashboard to apply.' })
1052
+ }
1053
+ return
1054
+ }
1055
+
1056
+ // ── M2: /api/changelog — parsed changelog directory ─────────────────
1057
+ case '/api/changelog': {
1058
+ sendJson(res, 200, loadChangelog())
1059
+ return
1060
+ }
1061
+
1062
+ // ── M2: /api/settings/feature — single-feature toggle endpoint ───────
1063
+ case '/api/settings/feature': {
1064
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1065
+ const body = await readJsonBody(req)
1066
+ if (!body || typeof body !== 'object' || !body.feature) {
1067
+ sendJson(res, 400, { error: 'Missing "feature" key' }); return
1068
+ }
1069
+ if (!config.settings || typeof config.settings !== 'object') config.settings = {}
1070
+ const before = config.settings[body.feature]
1071
+ // 📖 Boolean features are toggled unless the caller passes an explicit value.
1072
+ if (body.value !== undefined) {
1073
+ // 📖 Explicit value (string / boolean / number) always wins. Used for
1074
+ // 📖 things like theme='auto'|'dark'|'light' where a string payload
1075
+ // 📖 is the right shape.
1076
+ config.settings[body.feature] = body.value
1077
+ } else if (typeof before === 'boolean') {
1078
+ // 📖 No explicit value → toggle for boolean features
1079
+ // 📖 (e.g. favoritesPinnedAndSticky).
1080
+ config.settings[body.feature] = !before
1081
+ } else {
1082
+ config.settings[body.feature] = true
1083
+ }
1084
+ const result = saveConfig(config)
1085
+ if (!result.success) { sendJson(res, 500, { error: result.error || 'Save failed' }); return }
1086
+ sendJson(res, 200, { success: true, feature: body.feature, value: config.settings[body.feature] })
1087
+ return
1088
+ }
1089
+
1090
+ // ── M2: /api/shell-env/toggle — flip shell env export for the user ──
1091
+ case '/api/shell-env/toggle': {
1092
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1093
+ const body = await readJsonBody(req)
1094
+ const enable = typeof body?.enabled === 'boolean' ? body.enabled : undefined
1095
+ if (!config.settings || typeof config.settings !== 'object') config.settings = {}
1096
+ if (enable === undefined) {
1097
+ config.settings.shellEnvEnabled = !config.settings.shellEnvEnabled
1098
+ } else {
1099
+ config.settings.shellEnvEnabled = enable
1100
+ }
1101
+ if (config.settings.shellEnvEnabled) {
1102
+ syncShellEnv(config)
1103
+ ensureShellRcSource()
1104
+ } else {
1105
+ removeShellEnv()
1106
+ }
1107
+ saveConfig(config)
1108
+ sendJson(res, 200, { success: true, enabled: config.settings.shellEnvEnabled })
1109
+ return
1110
+ }
1111
+
1112
+ // ── M2: /api/legacy-cleanup — run the discontinued-proxy cleanup ─────
1113
+ case '/api/legacy-cleanup': {
1114
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1115
+ const summary = cleanupLegacyProxyArtifacts()
1116
+ sendJson(res, 200, summary)
1117
+ return
1118
+ }
1119
+
1120
+ // ── M4: Router dashboard endpoints ────────────────────────────────────
1121
+ case '/api/router/status': {
1122
+ try {
1123
+ const status = await getRouterDaemonStatus()
1124
+ sendJson(res, 200, status)
1125
+ } catch (err) {
1126
+ sendJson(res, 200, { ok: false, running: false, error: err.message })
1127
+ }
1128
+ return
1129
+ }
1130
+
1131
+ case '/api/router/stats': {
1132
+ const proxy = await proxyToDaemon('/stats')
1133
+ if (proxy?.ok) { sendJson(res, 200, proxy.data); return }
1134
+ sendJson(res, 200, { ok: false, running: false, error: 'Daemon not reachable' })
1135
+ return
1136
+ }
1137
+
1138
+ case '/api/router/tokens': {
1139
+ // 📖 Try daemon first (live data), fall back to reading the token file
1140
+ const proxy = await proxyToDaemon('/stats/tokens')
1141
+ if (proxy?.ok) { sendJson(res, 200, proxy.data); return }
1142
+ const fileData = readTokenFile()
1143
+ if (fileData) { sendJson(res, 200, fileData); return }
1144
+ sendJson(res, 200, { daily: {}, all_time: { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, requests: 0 } })
1145
+ return
1146
+ }
1147
+
1148
+ case '/api/router/start': {
1149
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1150
+ try {
1151
+ const result = await startRouterDaemonBackground()
1152
+ noteUserActivity()
1153
+ sendJson(res, 200, result)
1154
+ } catch (err) {
1155
+ sendJson(res, 500, { ok: false, error: err.message })
1156
+ }
1157
+ return
1158
+ }
1159
+
1160
+ case '/api/router/stop': {
1161
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1162
+ try {
1163
+ const result = await stopRouterDaemon()
1164
+ sendJson(res, 200, result)
1165
+ } catch (err) {
1166
+ sendJson(res, 500, { ok: false, error: err.message })
1167
+ }
1168
+ return
1169
+ }
1170
+
1171
+ case '/api/router/sets': {
1172
+ // 📖 Proxy set operations to the daemon
1173
+ if (req.method === 'GET') {
1174
+ const proxy = await proxyToDaemon('/sets')
1175
+ if (proxy?.ok) { sendJson(res, 200, proxy.data); return }
1176
+ // 📖 Fallback: read sets from config directly
1177
+ const routerConfig = config.router || {}
1178
+ sendJson(res, 200, { activeSet: routerConfig.activeSet || 'fast-coding', sets: routerConfig.sets || {} })
1179
+ return
1180
+ }
1181
+ if (req.method === 'POST') {
1182
+ const proxy = await proxyToDaemon('/sets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(await readJsonBody(req)) })
1183
+ if (proxy?.ok) { sendJson(res, 200, proxy.data); return }
1184
+ if (proxy?.status === 201) { sendJson(res, 201, proxy.data); return }
1185
+ sendJson(res, proxy?.status || 502, proxy?.data || { error: 'Daemon not reachable' })
1186
+ return
1187
+ }
1188
+ res.writeHead(405); res.end('Method Not Allowed')
1189
+ return
1190
+ }
1191
+
1192
+ case '/api/router/probe-mode': {
1193
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1194
+ const body = await readJsonBody(req)
1195
+ const proxy = await proxyToDaemon('/daemon/probe-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
1196
+ if (proxy?.ok) { sendJson(res, 200, proxy.data); return }
1197
+ sendJson(res, proxy?.status || 502, proxy?.data || { error: 'Daemon not reachable' })
1198
+ return
1199
+ }
1200
+
1201
+ case '/api/router/quick-setup': {
1202
+ // 📖 Return router connection info for quick clipboard copy
1203
+ const port = await readDaemonPort()
1204
+ const routerConfig = config.router || {}
1205
+ const activeSetName = routerConfig.activeSet || 'fast-coding'
1206
+ const baseUrl = port ? `http://127.0.0.1:${port}/v1` : null
1207
+ sendJson(res, 200, {
1208
+ running: !!port,
1209
+ port: port || null,
1210
+ baseUrl,
1211
+ model: 'fcm',
1212
+ activeSet: activeSetName,
1213
+ apiKey: 'not-needed',
1214
+ })
1215
+ return
1216
+ }
1217
+
1218
+ // ── M4: Installed Models — scan tool configs + soft-delete ────────────
1219
+ case '/api/installed-models': {
1220
+ if (req.method !== 'GET') { res.writeHead(405); res.end('Method Not Allowed'); return }
1221
+ const results = scanAllToolConfigs()
1222
+ sendJson(res, 200, { results })
1223
+ return
1224
+ }
1225
+
1226
+ // ── M4: Install Endpoints wizard — full provider install into tool ────
1227
+ case '/api/install-endpoints/providers': {
1228
+ if (req.method !== 'GET') { res.writeHead(405); res.end('Method Not Allowed'); return }
1229
+ const providers = getConfiguredInstallableProviders(config)
1230
+ sendJson(res, 200, { providers })
1231
+ return
1232
+ }
1233
+
1234
+ case '/api/install-endpoints/catalog': {
1235
+ const catProvider = url.searchParams.get('provider')
1236
+ if (!catProvider) { sendJson(res, 400, { error: 'Missing ?provider= parameter' }); return }
1237
+ const models = getProviderCatalogModels(catProvider)
1238
+ sendJson(res, 200, { provider: catProvider, models })
1239
+ return
1240
+ }
1241
+
1242
+ case '/api/install-endpoints/wizard': {
1243
+ if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1244
+ const body = await readJsonBody(req)
1245
+ const wizProvider = body?.providerKey
1246
+ const wizTool = body?.toolMode
1247
+ const wizScope = body?.scope || 'all'
1248
+ const wizModelIds = body?.modelIds || []
1249
+ if (!wizProvider || !wizTool) {
1250
+ sendJson(res, 400, { error: 'Missing providerKey or toolMode' }); return
1251
+ }
1252
+ noteUserActivity()
1253
+ try {
1254
+ const installResult = installProviderEndpoints(config, wizProvider, wizTool, {
1255
+ scope: wizScope,
1256
+ modelIds: wizModelIds,
1257
+ })
1258
+ void sendUsageTelemetry(config, { noTelemetry: false }, {
1259
+ event: 'app_action',
1260
+ mode: wizTool,
1261
+ properties: {
1262
+ source: 'web',
1263
+ action_type: 'install_endpoints_wizard',
1264
+ provider: wizProvider,
1265
+ tool_mode: wizTool,
1266
+ scope: wizScope,
1267
+ model_count: installResult.modelCount || 0,
1268
+ },
1269
+ })
1270
+ sendJson(res, 200, { success: true, ...installResult })
1271
+ } catch (err) {
1272
+ sendJson(res, 422, { error: err.message, code: 'install_failed' })
1273
+ }
1274
+ return
1275
+ }
1276
+
1277
+ // 📖 M4: soft-delete an installed model (pattern match — must be before default:)
1278
+ // 📖 Path: /api/installed-models/:tool/:model/disable
1279
+ default: {
1280
+ const disableMatch = url.pathname.match(/^\/api\/installed-models\/([^/]+)\/([^/]+)\/disable$/)
1281
+ if (disableMatch && req.method === 'POST') {
1282
+ const toolMode = decodeURIComponent(disableMatch[1])
1283
+ const modelId = decodeURIComponent(disableMatch[2])
1284
+ const result = softDeleteModel(toolMode, modelId)
1285
+ sendJson(res, result.success ? 200 : 422, result)
1286
+ return
1287
+ }
1288
+ }
759
1289
  // 📖 Serve Vite's /assets/* bundle, and our static favicon set that
760
1290
  // 📖 Vite copies verbatim from web/public/ into web/dist/. The legacy
761
1291
  // 📖 /favicon.ico lives at web/public/favicon.ico (root of public/).