free-coding-models 0.5.14 β†’ 0.5.16

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 (34) hide show
  1. package/changelog/v0.5.12.md +86 -2
  2. package/changelog/v0.5.13.md +19 -53
  3. package/changelog/v0.5.15.md +17 -0
  4. package/changelog/v0.5.16.md +15 -0
  5. package/package.json +4 -3
  6. package/src/tui/app.js +16 -4
  7. package/src/tui/cli-help.js +87 -0
  8. package/src/tui/key-handler.js +2 -14
  9. package/src/tui/render-table.js +46 -26
  10. package/src/tui/theme.js +70 -0
  11. package/web/README.md +1 -1
  12. package/web/dist/assets/index-avN2GM3H.css +1 -0
  13. package/web/dist/assets/index-vsysCImz.js +41 -0
  14. package/web/dist/index.html +6 -2
  15. package/web/index.html +4 -0
  16. package/web/server.js +249 -15
  17. package/web/src/App.jsx +46 -15
  18. package/web/src/components/dashboard/DetailPanel.jsx +1 -1
  19. package/web/src/components/dashboard/DetailPanel.module.css +5 -0
  20. package/web/src/components/dashboard/ExpandedDetailRow.jsx +464 -0
  21. package/web/src/components/dashboard/ExpandedDetailRow.module.css +347 -0
  22. package/web/src/components/dashboard/FilterBar.jsx +98 -58
  23. package/web/src/components/dashboard/FilterBar.module.css +103 -33
  24. package/web/src/components/dashboard/ModelTable.jsx +49 -29
  25. package/web/src/components/dashboard/ModelTable.module.css +12 -0
  26. package/web/src/components/dashboard/ProviderDropdown.jsx +156 -0
  27. package/web/src/components/dashboard/ProviderDropdown.module.css +248 -0
  28. package/web/src/components/help/HelpView.jsx +13 -0
  29. package/web/src/components/playground/PlaygroundView.jsx +193 -12
  30. package/web/src/components/playground/PlaygroundView.module.css +82 -0
  31. package/web/src/components/router/RouterView.jsx +15 -6
  32. package/web/vite.config.js +1 -1
  33. package/web/dist/assets/index-Bzqz7KbT.js +0 -39
  34. package/web/dist/assets/index-H9JWDRIh.css +0 -1
@@ -118,6 +118,19 @@ const SECTIONS = [
118
118
  { key: 'Same engine', desc: 'All model parsing, ping, and benchmark code is shared with the TUI' },
119
119
  ],
120
120
  },
121
+ {
122
+ id: 'how-router-works',
123
+ title: '🌐 How the FCM Router works',
124
+ items: [
125
+ { key: 'Smart router', desc: 'Point any OpenAI client at http://localhost:19280/v1 with model: "fcm". The daemon picks the healthiest model in the active set and forwards the request with automatic failover.' },
126
+ { key: 'Pre-prompt', desc: 'A first-class system message is injected on every proxied request. The default introduces the assistant as the FCM routing agent. Edit from Settings.' },
127
+ { key: 'Probes', desc: 'Every 10s/30s/120s (eco/balanced/aggressive) the daemon sends a 1-token chat-completion ping to every model in the active set. The probe measures latency + status code, not just URL reachability β€” so a wrong API key is caught and the circuit opens.' },
128
+ { key: 'Circuit breaker', desc: 'Per-model state. Healthy (green) = last probe 2xx, route here. Down (red) = last 3 probes failed, skip until cooldown. Recovering (yellow) = cooldown expired, retrying. Auth error (orange) = 401/403, your key is wrong. Deprecated (gray) = removed from catalog, will be replaced by auto-heal.' },
129
+ { key: 'Failover order', desc: 'Models are tried in priority order. A model in Recovering/Down/Auth error is skipped β€” the request goes to the next healthy one. If ALL fail, you get 503 with the "models_tried" list in the error body.' },
130
+ { key: 'Auto-heal', desc: 'On daemon start, every Auth-error / Deprecated model in the active set is swapped for a working alternative (same provider first, then cross-provider). The first time you add/remove/reorder a model, auto-heal switches off.' },
131
+ { key: 'Rate limits', desc: 'Each provider has its own quota. Common free-tier limits: Groq 14 400 RPD, Mistral 1 RPS, NVIDIA ~40 RPM, OpenRouter 50 RPD. When a provider returns 429, the router fails over. When daily quota is exhausted, the model goes Auth error and auto-heal swaps it out next start.' },
132
+ ],
133
+ },
121
134
  ]
122
135
 
123
136
  export default function HelpView({ onClose }) {
@@ -23,6 +23,8 @@ import {
23
23
  IconAlertTriangle,
24
24
  IconCopy,
25
25
  IconCheck,
26
+ IconPlayerPlay,
27
+ IconLoader,
26
28
  } from '@tabler/icons-react'
27
29
  import styles from './PlaygroundView.module.css'
28
30
 
@@ -89,20 +91,29 @@ function MetaChip({ icon, label, tone }) {
89
91
  )
90
92
  }
91
93
 
92
- function StatusPill({ routerStatus }) {
93
- if (!routerStatus) return null
94
- if (!routerStatus.running) {
94
+ function StatusPill({ routerStatus, daemonRunning }) {
95
+ // πŸ“– Prefer the live daemon state over the stale prop.
96
+ if (daemonRunning === true) {
97
+ return (
98
+ <span className={styles.metaChip}>
99
+ <IconBolt size={11} />
100
+ Router online
101
+ </span>
102
+ )
103
+ }
104
+ if (daemonRunning === false) {
95
105
  return (
96
106
  <span className={`${styles.metaChip} ${styles.error}`}>
97
107
  <IconAlertTriangle size={11} />
98
- Router offline β€” start it to chat
108
+ Router offline
99
109
  </span>
100
110
  )
101
111
  }
112
+ // null = checking...
102
113
  return (
103
114
  <span className={styles.metaChip}>
104
- <IconBolt size={11} />
105
- {routerStatus.activeSet || 'fcm'} Β· port {routerStatus.port || 19280}
115
+ <IconLoader size={11} className={styles.spin} />
116
+ Checking router…
106
117
  </span>
107
118
  )
108
119
  }
@@ -110,7 +121,7 @@ function StatusPill({ routerStatus }) {
110
121
  export default function PlaygroundView({ onClose, onToast, models, routerStatus }) {
111
122
  const [messages, setMessages] = useState([]) // { role, content, meta? }
112
123
  const [input, setInput] = useState('')
113
- const [model, setModel] = useState('fcm')
124
+ const [model, setModel] = useState(null) // null = computing best model
114
125
  const [streamOn, setStreamOn] = useState(true)
115
126
  const [prePromptEnabled, setPrePromptEnabled] = useState(true)
116
127
  const [prePromptText, setPrePromptText] = useState('')
@@ -120,6 +131,16 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
120
131
  const abortRef = useRef(null)
121
132
  const transcriptRef = useRef(null)
122
133
 
134
+ // πŸ“– Daemon state: null = checking, true = running, false = down
135
+ const [daemonRunning, setDaemonRunning] = useState(
136
+ routerStatus?.running === true ? true : null
137
+ )
138
+ const [daemonStarting, setDaemonStarting] = useState(false)
139
+ const [autoStartSec, setAutoStartSec] = useState(5)
140
+ const cooldownRef = useRef(null)
141
+ const daemonCheckRef = useRef(null)
142
+ const autoStartTriggered = useRef(false)
143
+
123
144
  // πŸ“– Fetch the pre-prompt once on mount so the toggle shows the real
124
145
  // πŸ“– value and the indicator matches what the router will inject.
125
146
  useEffect(() => {
@@ -134,6 +155,131 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
134
155
  .catch(() => {})
135
156
  }, [])
136
157
 
158
+ // πŸ“– Smart model selection on mount: pick the best "up" model with
159
+ // πŸ“– an API key, preferring lower latency and higher tier. Falls back
160
+ // πŸ“– to "fcm" (the auto-router) only if the daemon is already running
161
+ // πŸ“– or no working model is found. This ensures the playground works
162
+ // πŸ“– immediately on open, even without the router daemon.
163
+ useEffect(() => {
164
+ if (model !== null) return // already resolved
165
+ const upModels = (Array.isArray(models) ? models : [])
166
+ .filter((m) => m.status === 'up' && m.hasApiKey && !m.isPinging)
167
+
168
+ if (upModels.length > 0) {
169
+ // πŸ“– Sort: best tier first (S+ > S > A+ ...), then lowest avg latency
170
+ const tierOrder = { 'S+': 0, 'S': 1, 'A+': 2, 'A': 3, 'A-': 4, 'B+': 5, 'B': 6, 'C': 7 }
171
+ upModels.sort((a, b) => {
172
+ const ta = tierOrder[a.tier] ?? 99
173
+ const tb = tierOrder[b.tier] ?? 99
174
+ if (ta !== tb) return ta - tb
175
+ const avgA = typeof a.avg === 'number' ? a.avg : 99999
176
+ const avgB = typeof b.avg === 'number' ? b.avg : 99999
177
+ return avgA - avgB
178
+ })
179
+ const best = upModels[0]
180
+ setModel(`${best.providerKey}/${best.modelId}`)
181
+ return
182
+ }
183
+
184
+ // πŸ“– No working model found β€” if daemon is running, use fcm
185
+ if (routerStatus?.running || daemonRunning === true) {
186
+ setModel('fcm')
187
+ return
188
+ }
189
+
190
+ // πŸ“– Nothing works β€” default to fcm anyway (will show daemon start panel)
191
+ setModel('fcm')
192
+ }, [models, model, routerStatus, daemonRunning])
193
+
194
+ // πŸ“– Check daemon status on mount. If the daemon is down AND the user
195
+ // πŸ“– is on the "fcm" auto-router model, start a 5-second countdown to
196
+ // πŸ“– auto-start the daemon. The "Start Router" button is always
197
+ // πŸ“– clickable so the user can trigger it manually at any time.
198
+ useEffect(() => {
199
+ let mounted = true
200
+
201
+ async function checkDaemon() {
202
+ try {
203
+ const resp = await fetch('/api/router/status')
204
+ const data = await resp.json()
205
+ if (!mounted) return
206
+ if (data?.running) {
207
+ setDaemonRunning(true)
208
+ return
209
+ }
210
+ setDaemonRunning(false)
211
+ } catch {
212
+ if (mounted) setDaemonRunning(false)
213
+ }
214
+ }
215
+
216
+ checkDaemon()
217
+
218
+ return () => {
219
+ mounted = false
220
+ if (cooldownRef.current) clearInterval(cooldownRef.current)
221
+ if (daemonCheckRef.current) clearInterval(daemonCheckRef.current)
222
+ }
223
+ }, [])
224
+
225
+ // πŸ“– Auto-start the daemon after 5 seconds if: daemon is down, model
226
+ // πŸ“– is "fcm" (auto-router), and auto-start hasn't been triggered yet.
227
+ // πŸ“– Countdown ticks every second and shows remaining time on the button.
228
+ useEffect(() => {
229
+ if (daemonRunning !== false || model !== 'fcm' || autoStartTriggered.current) return
230
+
231
+ let remaining = 5
232
+ setAutoStartSec(5)
233
+ cooldownRef.current = setInterval(() => {
234
+ remaining -= 1
235
+ setAutoStartSec(remaining)
236
+ if (remaining <= 0) {
237
+ clearInterval(cooldownRef.current)
238
+ autoStartTriggered.current = true
239
+ void startDaemon()
240
+ }
241
+ }, 1000)
242
+
243
+ return () => { if (cooldownRef.current) clearInterval(cooldownRef.current) }
244
+ }, [daemonRunning, model])
245
+
246
+ // πŸ“– Start the router daemon from the playground. After starting,
247
+ // πŸ“– polls every 1s to detect when the daemon is ready, then
248
+ // πŸ“– updates the UI so the user can chat immediately.
249
+ const startDaemon = useCallback(async () => {
250
+ if (daemonStarting) return
251
+ if (cooldownRef.current) { clearInterval(cooldownRef.current); cooldownRef.current = null }
252
+ autoStartTriggered.current = true
253
+ setDaemonStarting(true)
254
+ try {
255
+ await fetch('/api/router/start', { method: 'POST' })
256
+ // πŸ“– Poll until the daemon is confirmed running (max 30s)
257
+ let attempts = 0
258
+ await new Promise((resolve) => {
259
+ daemonCheckRef.current = setInterval(async () => {
260
+ attempts += 1
261
+ try {
262
+ const resp = await fetch('/api/router/status')
263
+ const data = await resp.json()
264
+ if (data?.running) {
265
+ clearInterval(daemonCheckRef.current)
266
+ setDaemonRunning(true)
267
+ setDaemonStarting(false)
268
+ resolve()
269
+ }
270
+ } catch {}
271
+ if (attempts >= 30) {
272
+ clearInterval(daemonCheckRef.current)
273
+ setDaemonStarting(false)
274
+ resolve()
275
+ }
276
+ }, 1000)
277
+ })
278
+ } catch {
279
+ setDaemonStarting(false)
280
+ }
281
+ }, [daemonStarting])
282
+
137
283
  // πŸ“– Auto-scroll the transcript on new content.
138
284
  useEffect(() => {
139
285
  const el = transcriptRef.current
@@ -167,6 +313,11 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
167
313
  const sendMessage = useCallback(async () => {
168
314
  const text = input.trim()
169
315
  if (!text || isLoading) return
316
+ const isFcm = model === 'fcm'
317
+ if (isFcm && daemonRunning !== true) {
318
+ setError('Router daemon is not running. Start it first using the button above or run `free-coding-models --daemon-bg`.')
319
+ return
320
+ }
170
321
  setError(null)
171
322
  setInput('')
172
323
 
@@ -293,7 +444,7 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
293
444
  setIsLoading(false)
294
445
  abortRef.current = null
295
446
  }
296
- }, [input, isLoading, messages, model, streamOn])
447
+ }, [input, isLoading, messages, model, streamOn, daemonRunning])
297
448
 
298
449
  const handleKeyDown = useCallback((e) => {
299
450
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -382,7 +533,7 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
382
533
  />
383
534
  Pre-prompt
384
535
  </label>
385
- <StatusPill routerStatus={routerStatus} />
536
+ <StatusPill routerStatus={routerStatus} daemonRunning={daemonRunning} />
386
537
  </div>
387
538
 
388
539
  {prePromptEnabled && prePromptText && (
@@ -395,7 +546,37 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
395
546
  )}
396
547
 
397
548
  <div className={styles.transcript} ref={transcriptRef}>
398
- {messages.length === 0 ? (
549
+ {daemonRunning === false && !daemonStarting && messages.length === 0 && model === 'fcm' ? (
550
+ <div className={styles.daemonStartPanel}>
551
+ <IconAlertTriangle size={32} style={{ color: '#fbbf24', opacity: 0.8 }} />
552
+ <div className={styles.daemonStartTitle}>Router daemon is not running</div>
553
+ <div className={styles.daemonStartHint}>
554
+ The playground routes your chats through the FCM router daemon. Start it to begin chatting with free coding models, or pick a specific model above.
555
+ </div>
556
+ <button
557
+ className={styles.daemonStartBtn}
558
+ onClick={startDaemon}
559
+ title="Start the router daemon"
560
+ >
561
+ {daemonStarting ? (
562
+ <><IconLoader size={14} className={styles.spin} /> Starting…</>
563
+ ) : (
564
+ <><IconPlayerPlay size={14} /> Start Router{autoStartSec > 0 && !autoStartTriggered.current ? ` (auto in ${autoStartSec}s)` : ''}</>
565
+ )}
566
+ </button>
567
+ <div className={styles.daemonStartAlt}>
568
+ Or run <code>free-coding-models --daemon-bg</code> in your terminal
569
+ </div>
570
+ </div>
571
+ ) : daemonStarting && messages.length === 0 && model === 'fcm' ? (
572
+ <div className={styles.daemonStartPanel}>
573
+ <IconLoader size={32} className={styles.spin} style={{ color: 'var(--accent, #22c55e)' }} />
574
+ <div className={styles.daemonStartTitle}>Starting router daemon…</div>
575
+ <div className={styles.daemonStartHint}>
576
+ The daemon is being launched. This usually takes a few seconds. You'll be able to chat as soon as it's ready.
577
+ </div>
578
+ </div>
579
+ ) : messages.length === 0 ? (
399
580
  <div className={styles.empty}>
400
581
  <IconMessageChatbot size={42} style={{ opacity: 0.5 }} />
401
582
  <div className={styles.emptyTitle}>Try the FCM router in 10 seconds</div>
@@ -508,14 +689,14 @@ export default function PlaygroundView({ onClose, onToast, models, routerStatus
508
689
  value={input}
509
690
  onChange={(e) => setInput(e.target.value)}
510
691
  onKeyDown={handleKeyDown}
511
- disabled={isLoading}
692
+ disabled={isLoading || (model === 'fcm' && daemonRunning !== true)}
512
693
  rows={1}
513
694
  data-testid="playground-input"
514
695
  />
515
696
  <button
516
697
  className={styles.sendBtn}
517
698
  onClick={sendMessage}
518
- disabled={isLoading || !input.trim()}
699
+ disabled={isLoading || !input.trim() || (model === 'fcm' && daemonRunning !== true)}
519
700
  data-testid="playground-send"
520
701
  >
521
702
  <IconSend size={14} />
@@ -411,3 +411,85 @@
411
411
  .stopBtn:hover {
412
412
  background: rgba(248, 113, 113, 0.1);
413
413
  }
414
+
415
+ /* ── Daemon start panel ─────────────────────────────────────────────── */
416
+
417
+ .daemonStartPanel {
418
+ flex: 1;
419
+ display: flex;
420
+ flex-direction: column;
421
+ align-items: center;
422
+ justify-content: center;
423
+ color: var(--text-muted, #888);
424
+ text-align: center;
425
+ padding: 40px 24px;
426
+ gap: 12px;
427
+ }
428
+
429
+ .daemonStartTitle {
430
+ font-size: 16px;
431
+ color: var(--text, #eee);
432
+ font-weight: 600;
433
+ }
434
+
435
+ .daemonStartHint {
436
+ font-size: 12px;
437
+ max-width: 400px;
438
+ line-height: 1.5;
439
+ opacity: 0.7;
440
+ }
441
+
442
+ .daemonStartBtn {
443
+ margin-top: 4px;
444
+ background: var(--accent, #22c55e);
445
+ color: #061a08;
446
+ border: none;
447
+ border-radius: 8px;
448
+ padding: 10px 20px;
449
+ font-size: 14px;
450
+ font-weight: 600;
451
+ cursor: pointer;
452
+ display: flex;
453
+ align-items: center;
454
+ gap: 8px;
455
+ font-family: inherit;
456
+ transition: opacity 0.2s, transform 0.15s, filter 0.12s;
457
+ }
458
+
459
+ .daemonStartBtn:disabled {
460
+ opacity: 0.45;
461
+ cursor: not-allowed;
462
+ filter: grayscale(0.5);
463
+ }
464
+
465
+ .daemonStartBtn:hover:not(:disabled) {
466
+ filter: brightness(1.1);
467
+ transform: scale(1.02);
468
+ }
469
+
470
+ .daemonStartBtn:active:not(:disabled) {
471
+ transform: scale(0.98);
472
+ }
473
+
474
+ .daemonStartAlt {
475
+ font-size: 11px;
476
+ opacity: 0.5;
477
+ margin-top: 4px;
478
+ }
479
+
480
+ .daemonStartAlt code {
481
+ background: var(--bg-secondary, #16161e);
482
+ padding: 2px 6px;
483
+ border-radius: 4px;
484
+ font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
485
+ font-size: 11px;
486
+ }
487
+
488
+ .spin {
489
+ animation: spinAnim 1s linear infinite;
490
+ }
491
+
492
+ @keyframes spinAnim {
493
+ from { transform: rotate(0deg); }
494
+ to { transform: rotate(360deg); }
495
+ }
@@ -30,13 +30,22 @@ function formatNumber(n) {
30
30
  return String(n)
31
31
  }
32
32
 
33
+ // πŸ“– Friendly labels for the circuit breaker states. The raw names
34
+ // πŸ“– (CLOSED/OPEN/HALF_OPEN/AUTH_ERROR) are jargon β€” translate them
35
+ // πŸ“– to words a normal developer can scan in <1 second.
36
+ const CIRCUIT_STATE_LABELS = {
37
+ CLOSED: { label: 'Healthy', cls: 'circuitClosed' },
38
+ OPEN: { label: 'Down', cls: 'circuitOpen' },
39
+ HALF_OPEN: { label: 'Recovering', cls: 'circuitHalfOpen' },
40
+ AUTH_ERROR: { label: 'Auth error', cls: 'circuitAuth' },
41
+ STALE: { label: 'Deprecated', cls: 'circuitUnknown' },
42
+ UNSUPPORTED:{ label: 'Unsupported',cls: 'circuitUnknown' },
43
+ UNKNOWN: { label: 'Unknown', cls: 'circuitUnknown' },
44
+ }
45
+
33
46
  function CircuitBadge({ state }) {
34
- const cls = state === 'CLOSED' ? styles.circuitClosed
35
- : state === 'OPEN' ? styles.circuitOpen
36
- : state === 'HALF_OPEN' ? styles.circuitHalfOpen
37
- : state === 'AUTH_ERROR' ? styles.circuitAuth
38
- : styles.circuitUnknown
39
- return <span className={`${styles.circuitBadge} ${cls}`}>{state?.replace('_', ' ') || '?'}</span>
47
+ const entry = CIRCUIT_STATE_LABELS[state] || CIRCUIT_STATE_LABELS.UNKNOWN
48
+ return <span className={`${styles.circuitBadge} ${styles[entry.cls]}`}>{entry.label}</span>
40
49
  }
41
50
 
42
51
  const SAVE_STATUS_IDLE = { kind: 'idle' }
@@ -18,7 +18,7 @@ export default defineConfig({
18
18
  emptyOutDir: true,
19
19
  },
20
20
  server: {
21
- port: 5173,
21
+ port: 5179,
22
22
  proxy: {
23
23
  '/api': {
24
24
  target: 'http://localhost:3333',