free-coding-models 0.5.88 → 0.5.90

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 (36) hide show
  1. package/README.md +10 -4
  2. package/bin/free-coding-models.js +33 -2
  3. package/changelog/v0.5.89.md +20 -0
  4. package/changelog/v0.5.90.md +12 -0
  5. package/package.json +1 -1
  6. package/sources.js +5 -2
  7. package/src/core/benchmark.js +9 -0
  8. package/src/core/cloudflare-account.js +311 -0
  9. package/src/core/endpoint-installer.js +8 -4
  10. package/src/core/opencode.js +9 -6
  11. package/src/core/ping.js +52 -16
  12. package/src/core/provider-key-tester.js +10 -3
  13. package/src/core/provider-metadata.js +1 -1
  14. package/src/core/router-daemon.js +1138 -501
  15. package/src/core/router-v2/anthropic-compat.js +473 -0
  16. package/src/core/router-v2/bench.js +171 -0
  17. package/src/core/router-v2/breaker-store.js +265 -0
  18. package/src/core/router-v2/constants.js +108 -0
  19. package/src/core/router-v2/decision-trace.js +134 -0
  20. package/src/core/router-v2/failure-classifier.js +231 -0
  21. package/src/core/router-v2/request-history.js +137 -0
  22. package/src/core/router-v2/response-gate.js +175 -0
  23. package/src/core/router-v2/tui-dashboard.js +632 -0
  24. package/src/core/schema-normalizer.js +23 -6
  25. package/src/core/utils.js +12 -0
  26. package/src/tui/app.js +7 -2
  27. package/src/tui/cli-help.js +4 -0
  28. package/src/tui/key-handler.js +117 -2
  29. package/src/tui/overlays.js +19 -3
  30. package/src/tui/tui-state.js +22 -0
  31. package/web/dist/assets/index-CCaxIOti.css +1 -0
  32. package/web/dist/assets/index-CCkuXrqE.js +48 -0
  33. package/web/dist/index.html +2 -2
  34. package/web/server.js +105 -1
  35. package/web/dist/assets/index-CAzFIt8P.css +0 -1
  36. package/web/dist/assets/index-DFg1h0Nd.js +0 -44
@@ -0,0 +1,632 @@
1
+ /**
2
+ * @file router-v2/tui-dashboard.js
3
+ * @description TUI client + renderer for the Router v2 (BETA) dashboard overlay.
4
+ *
5
+ * @details
6
+ * 📖 Open with Shift+V. This overlay talks to the v2 daemon (own port
7
+ * range, own state files) and renders what v1's dashboard could not:
8
+ * - model states including DEGRADED (amber: failing, not yet tripped) and
9
+ * QUOTA_PAUSED (with expiry),
10
+ * - the per-request fallback chain (which model was tried, what it returned
11
+ * with, what was skipped and why) straight from the persisted history,
12
+ * - a live "test via router" action that sends ONE real chat completion
13
+ * through the daemon with a pinned model (`fcm:@provider/model`), so the
14
+ * test exercises normalization + pre-prompt + content gate + failover,
15
+ * unlike the direct-to-provider benchmarks.
16
+ *
17
+ * 📖 Same defensive posture as the v1 dashboard: the daemon may be stopped,
18
+ * stale or mid-restart; every fetch failure degrades to a status label,
19
+ * never a thrown exception into the render loop.
20
+ *
21
+ * @functions
22
+ * → openRouterV2DashboardOverlay(state) - Open + start polling/SSE
23
+ * → closeRouterV2DashboardOverlay(state) - Close + stop I/O
24
+ * → refreshRouterV2Snapshot(state, opts) - Fetch /health + /stats + /history
25
+ * → startRouterV2Polling / startRouterV2EventStream / stopRouterV2DashboardClient
26
+ * → testModelViaRouterV2(state, modelKeyStr) - One pinned-model test
27
+ * → testAllVisibleViaRouterV2(state) - Test every visible model (pooled)
28
+ * → renderRouterV2Dashboard(state) - Full-screen overlay renderer
29
+ *
30
+ * @exports ROUTER_V2_DASHBOARD_POLL_INTERVAL_MS, openRouterV2DashboardOverlay
31
+ * @exports closeRouterV2DashboardOverlay, refreshRouterV2Snapshot
32
+ * @exports startRouterV2Polling, startRouterV2EventStream, stopRouterV2DashboardClient
33
+ * @exports testModelViaRouterV2, testAllVisibleViaRouterV2, renderRouterV2Dashboard
34
+ * @exports setRouterV2Notice, cycleRouterV2ProbeMode
35
+ *
36
+ * @see ../tui/key-handler.js - Shift+V / Ctrl+T / Ctrl+Shift+T bindings
37
+ * @see ./daemon.js - v2 daemon endpoints consumed by this screen
38
+ */
39
+
40
+ import chalk from 'chalk'
41
+ import { existsSync, readFileSync } from 'node:fs'
42
+ import { homedir } from 'node:os'
43
+ import { join } from 'node:path'
44
+ import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from '../../tui/render-helpers.js'
45
+ import { themeColors } from '../../tui/theme.js'
46
+ import { formatTokenTotalCompact } from '../token-usage-reader.js'
47
+ import { parseFcmModel } from './constants.js'
48
+ import { discoverRouterV2Port, testModelViaRouter, testSetViaRouter } from './bench.js'
49
+ // 📖 After the merge, the v2 engine lives in the MAIN router daemon: all
50
+ // discovery and lifecycle calls target the historical daemon paths/ports.
51
+ import { getRouterPidPath, getRouterPortPath, getRouterPortRange } from '../router-daemon.js'
52
+
53
+ export const ROUTER_V2_DASHBOARD_POLL_INTERVAL_MS = 2000
54
+ export const ROUTER_V2_DASHBOARD_FETCH_TIMEOUT_MS = 1500
55
+ export const ROUTER_V2_PROBE_MODE_CYCLE = ['eco', 'balanced', 'aggressive']
56
+ export const ROUTER_V2_TEST_CONCURRENCY = 3
57
+
58
+ function isRecord(value) {
59
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
60
+ }
61
+
62
+ function toFiniteNumber(value, fallback = null) {
63
+ const n = Number(value)
64
+ return Number.isFinite(n) ? n : fallback
65
+ }
66
+
67
+ function makeTimeoutController(ms) {
68
+ const controller = new AbortController()
69
+ const timer = setTimeout(() => controller.abort(), ms)
70
+ if (typeof timer.unref === 'function') timer.unref()
71
+ return { controller, cleanup: () => clearTimeout(timer) }
72
+ }
73
+
74
+ async function fetchJsonV2(url, options = {}) {
75
+ const { controller, cleanup } = makeTimeoutController(options.timeoutMs || ROUTER_V2_DASHBOARD_FETCH_TIMEOUT_MS)
76
+ try {
77
+ const response = await fetch(url, { ...options, signal: controller.signal })
78
+ const text = await response.text()
79
+ let data = null
80
+ try {
81
+ data = JSON.parse(text)
82
+ } catch {
83
+ return { ok: false, status: response.status, data: null, error: 'Malformed JSON from daemon' }
84
+ }
85
+ if (!response.ok) return { ok: false, status: response.status, data, error: `HTTP ${response.status}` }
86
+ return { ok: true, status: response.status, data, error: null }
87
+ } catch (error) {
88
+ return { ok: false, status: 0, data: null, error: error?.name === 'AbortError' ? 'timeout' : (error?.message || String(error)) }
89
+ } finally {
90
+ cleanup()
91
+ }
92
+ }
93
+
94
+ function readV2DaemonFiles() {
95
+ const pidPath = getRouterPidPath()
96
+ const portPath = getRouterPortPath()
97
+ return {
98
+ hasPidFile: existsSync(pidPath),
99
+ hasPortFile: existsSync(portPath),
100
+ pid: readNumberFileSafe(pidPath),
101
+ recordedPort: readNumberFileSafe(portPath),
102
+ }
103
+ }
104
+
105
+ function readNumberFileSafe(path) {
106
+ try {
107
+ const value = Number.parseInt(readFileSync(path, 'utf8').trim(), 10)
108
+ return Number.isFinite(value) ? value : null
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ async function discoverRouterV2Dashboard(state, fetchFn = globalThis.fetch) {
115
+ const recordedPort = readNumberFileSafe(getRouterPortPath())
116
+ const candidates = []
117
+ if (recordedPort) candidates.push(recordedPort)
118
+ const { defaultPort, maxPort } = getRouterPortRange()
119
+ for (let port = defaultPort; port <= maxPort; port += 1) {
120
+ if (!candidates.includes(port)) candidates.push(port)
121
+ }
122
+ for (const port of candidates) {
123
+ try {
124
+ const { controller, cleanup } = makeTimeoutController(ROUTER_V2_DASHBOARD_FETCH_TIMEOUT_MS)
125
+ try {
126
+ const response = await fetchFn(`http://127.0.0.1:${port}/health`, { signal: controller.signal })
127
+ if (response.ok) {
128
+ const health = await response.json()
129
+ return { baseUrl: `http://127.0.0.1:${port}`, port, health, error: null }
130
+ }
131
+ } finally {
132
+ cleanup()
133
+ }
134
+ } catch (error) {
135
+ if (state.terminalCols === -1) return { baseUrl: null, port, health: null, error: error?.message }
136
+ }
137
+ }
138
+ const files = readV2DaemonFiles()
139
+ const stalePid = files.pid && !isAlive(files.pid) ? files.pid : null
140
+ void homedir
141
+ return { baseUrl: null, port: files.recordedPort || defaultPort, health: null, error: null, stalePid, files }
142
+ }
143
+
144
+ function isAlive(pid) {
145
+ if (!Number.isInteger(pid) || pid <= 0) return false
146
+ try {
147
+ process.kill(pid, 0)
148
+ return true
149
+ } catch {
150
+ return false
151
+ }
152
+ }
153
+
154
+ export function setRouterV2Notice(state, type, message, ttlMs = 3500) {
155
+ state.routerV2Notice = { type, message, at: Date.now() }
156
+ if (state.routerV2NoticeTimer) clearTimeout(state.routerV2NoticeTimer)
157
+ state.routerV2NoticeTimer = setTimeout(() => {
158
+ state.routerV2Notice = null
159
+ }, ttlMs)
160
+ if (typeof state.routerV2NoticeTimer.unref === 'function') state.routerV2NoticeTimer.unref()
161
+ }
162
+
163
+ export async function refreshRouterV2Snapshot(state, options = {}) {
164
+ const fetchFn = options.fetchFn || globalThis.fetch
165
+ if (!state.routerV2DashboardOpen && !options.force) return null
166
+ if (!state.routerV2Status || state.routerV2Status === 'idle') {
167
+ state.routerV2Status = 'loading'
168
+ }
169
+
170
+ let discovery
171
+ try {
172
+ discovery = await discoverRouterV2Dashboard(state, fetchFn)
173
+ } catch (err) {
174
+ state.routerV2Status = 'unreachable'
175
+ state.routerV2Error = err?.message || 'Discovery failed unexpectedly'
176
+ return null
177
+ }
178
+ if (!discovery.baseUrl) {
179
+ state.routerV2BaseUrl = null
180
+ state.routerV2Port = discovery.port
181
+ state.routerV2Health = discovery.health || null
182
+ state.routerV2Stats = null
183
+ state.routerV2History = null
184
+ const files = discovery.files || readV2DaemonFiles()
185
+ state.routerV2Status = discovery.stalePid
186
+ ? 'stale'
187
+ : files.hasPidFile || files.hasPortFile
188
+ ? 'unreachable'
189
+ : 'stopped'
190
+ state.routerV2Error = discovery.error
191
+ stopRouterV2EventStream(state)
192
+ return null
193
+ }
194
+
195
+ state.routerV2BaseUrl = discovery.baseUrl
196
+ state.routerV2Port = discovery.port
197
+ state.routerV2Health = discovery.health
198
+ const [stats, history] = await Promise.all([
199
+ fetchJsonV2(`${discovery.baseUrl}/stats`, { fetchFn }),
200
+ fetchJsonV2(`${discovery.baseUrl}/api/router-v2/history?limit=15`, { fetchFn }),
201
+ ])
202
+ state.routerV2Stats = stats.ok ? stats.data : null
203
+ state.routerV2History = history.ok ? history.data : null
204
+ state.routerV2Status = stats.ok ? 'ready' : 'partial'
205
+ state.routerV2Error = stats.ok ? null : stats.error
206
+ startRouterV2EventStream(state, { fetchFn })
207
+ return state.routerV2Stats
208
+ }
209
+
210
+ export function startRouterV2Polling(state, options = {}) {
211
+ if (state.routerV2PollTimer) return
212
+ const fetchFn = options.fetchFn || globalThis.fetch
213
+ void refreshRouterV2Snapshot(state, { fetchFn, force: true })
214
+ state.routerV2PollTimer = setInterval(() => {
215
+ void refreshRouterV2Snapshot(state, { fetchFn, force: true })
216
+ }, ROUTER_V2_DASHBOARD_POLL_INTERVAL_MS)
217
+ state.routerV2PollTimer.unref?.()
218
+ }
219
+
220
+ export function stopRouterV2EventStream(state) {
221
+ if (state.routerV2EventAbort) {
222
+ try { state.routerV2EventAbort.abort() } catch {}
223
+ }
224
+ state.routerV2EventAbort = null
225
+ }
226
+
227
+ export function stopRouterV2DashboardClient(state) {
228
+ if (state.routerV2PollTimer) clearInterval(state.routerV2PollTimer)
229
+ state.routerV2PollTimer = null
230
+ stopRouterV2EventStream(state)
231
+ if (state.routerV2NoticeTimer) clearTimeout(state.routerV2NoticeTimer)
232
+ state.routerV2NoticeTimer = null
233
+ }
234
+
235
+ export function startRouterV2EventStream(state, options = {}) {
236
+ const fetchFn = options.fetchFn || globalThis.fetch
237
+ if (!state.routerV2DashboardOpen) return
238
+ if (!state.routerV2BaseUrl || typeof fetchFn !== 'function') return
239
+ if (state.routerV2EventAbort) return
240
+
241
+ const controller = new AbortController()
242
+ state.routerV2EventAbort = controller
243
+ void (async () => {
244
+ try {
245
+ const response = await fetchFn(`${state.routerV2BaseUrl}/api/router-v2/events`, {
246
+ headers: { accept: 'text/event-stream' },
247
+ signal: controller.signal,
248
+ })
249
+ if (!response.ok) return
250
+ if (!response.body || typeof response.body.getReader !== 'function') return
251
+ const reader = response.body.getReader()
252
+ const decoder = new TextDecoder()
253
+ let buffer = ''
254
+ while (!controller.signal.aborted) {
255
+ const chunk = await reader.read()
256
+ if (chunk.done) break
257
+ buffer += decoder.decode(chunk.value, { stream: true })
258
+ let frameEnd = buffer.indexOf('\n\n')
259
+ while (frameEnd >= 0) {
260
+ const frame = buffer.slice(0, frameEnd)
261
+ buffer = buffer.slice(frameEnd + 2)
262
+ applyRouterV2SseEvent(state, frame)
263
+ frameEnd = buffer.indexOf('\n\n')
264
+ }
265
+ }
266
+ } catch {
267
+ // 📖 Polling keeps the overlay functional when SSE is unavailable.
268
+ } finally {
269
+ if (state.routerV2EventAbort === controller) state.routerV2EventAbort = null
270
+ }
271
+ })()
272
+ }
273
+
274
+ function applyRouterV2SseEvent(state, frame) {
275
+ let event = 'message'
276
+ let data = ''
277
+ for (const line of frame.split('\n')) {
278
+ if (line.startsWith('event:')) event = line.slice(6).trim()
279
+ else if (line.startsWith('data:')) data += line.slice(5).trim()
280
+ }
281
+ if (!data) return
282
+ let payload
283
+ try {
284
+ payload = JSON.parse(data)
285
+ } catch {
286
+ return
287
+ }
288
+ // 📖 Lightweight touch: SSE events just nudge the snapshot refresh clock so
289
+ // the next poll tick lands sooner and the overlay feels live.
290
+ state.routerV2LastEventAt = Date.now()
291
+ if (event === 'request' && isRecord(payload) && state.routerV2Stats) {
292
+ state.routerV2Stats.requestsRouted = toFiniteNumber(payload.requestsRouted, state.routerV2Stats.requestsRouted)
293
+ }
294
+ }
295
+
296
+ export function openRouterV2DashboardOverlay(state) {
297
+ state.routerV2DashboardOpen = true
298
+ state.routerV2ScrollOffset = 0
299
+ state.routerV2CursorIndex = 0
300
+ state.routerV2Status = state.routerV2Status || 'loading'
301
+ startRouterV2Polling(state)
302
+ }
303
+
304
+ export function closeRouterV2DashboardOverlay(state) {
305
+ state.routerV2DashboardOpen = false
306
+ state.routerV2ScrollOffset = 0
307
+ state.routerV2CursorIndex = 0
308
+ stopRouterV2DashboardClient(state)
309
+ }
310
+
311
+ export async function cycleRouterV2ProbeMode(state, options = {}) {
312
+ const baseUrl = state.routerV2BaseUrl
313
+ if (!baseUrl) {
314
+ setRouterV2Notice(state, 'error', 'Router v2 daemon is not reachable.')
315
+ return
316
+ }
317
+ const current = state.routerV2Stats?.probeMode || state.routerV2Health?.probeMode || 'balanced'
318
+ const idx = ROUTER_V2_PROBE_MODE_CYCLE.indexOf(current)
319
+ const next = ROUTER_V2_PROBE_MODE_CYCLE[(idx + 1) % ROUTER_V2_PROBE_MODE_CYCLE.length]
320
+ const response = await fetchJsonV2(`${baseUrl}/daemon/probe-mode`, {
321
+ method: 'POST',
322
+ headers: { 'content-type': 'application/json' },
323
+ body: JSON.stringify({ probeMode: next }),
324
+ ...(options.fetchFn ? { fetchFn: options.fetchFn } : {}),
325
+ })
326
+ if (!response.ok) {
327
+ setRouterV2Notice(state, 'error', `Probe mode change failed: ${response.error || 'unknown error'}`)
328
+ return
329
+ }
330
+ setRouterV2Notice(state, 'success', `Health check speed: ${next}`)
331
+ void refreshRouterV2Snapshot(state, { force: true })
332
+ }
333
+
334
+ /**
335
+ * 📖 Test ONE model through the running v2 daemon (pinned model, full chain).
336
+ * `modelKeyStr` is `provider/modelId` as shown in the main table.
337
+ */
338
+ export async function testModelViaRouterV2(state, modelKeyStr, options = {}) {
339
+ const parsed = parseFcmModel(`fcm:@${modelKeyStr}`)
340
+ if (parsed.kind !== 'pinned') {
341
+ setRouterV2Notice(state, 'error', `Cannot parse model: ${modelKeyStr}`)
342
+ return { ok: false, error: 'parse_failed' }
343
+ }
344
+ if (!state.routerV2TestRunning) state.routerV2TestRunning = new Set()
345
+ if (!state.routerV2TestResults) state.routerV2TestResults = new Map()
346
+ if (state.routerV2TestRunning.has(modelKeyStr)) return { ok: false, error: 'already_running' }
347
+ state.routerV2TestRunning.add(modelKeyStr)
348
+ try {
349
+ const fetchFn = options.fetchFn || globalThis.fetch
350
+ let port = state.routerV2Port
351
+ if (!port || !state.routerV2BaseUrl) {
352
+ port = await discoverRouterV2Port()
353
+ if (!port) {
354
+ setRouterV2Notice(state, 'error', 'Router v2 daemon is not running. Open Shift+V and start it first.')
355
+ return { ok: false, error: 'daemon_not_running' }
356
+ }
357
+ state.routerV2Port = port
358
+ state.routerV2BaseUrl = `http://127.0.0.1:${port}`
359
+ }
360
+ const result = await testModelViaRouter({ port, provider: parsed.pinned.provider, model: parsed.pinned.model })
361
+ state.routerV2TestResults.set(modelKeyStr, { ...result, at: Date.now() })
362
+ if (result.ok) {
363
+ setRouterV2Notice(state, 'success', `${modelKeyStr} OK via router - ${result.latencyMs}ms`)
364
+ } else {
365
+ setRouterV2Notice(state, 'error', `${modelKeyStr} FAILED via router: ${result.error}`)
366
+ }
367
+ void refreshRouterV2Snapshot(state, { fetchFn, force: true })
368
+ return result
369
+ } finally {
370
+ state.routerV2TestRunning.delete(modelKeyStr)
371
+ }
372
+ }
373
+
374
+ /**
375
+ * 📖 Test every VISIBLE model through the router with a small worker pool.
376
+ * Long-running by design: results stream into the overlay as they land.
377
+ */
378
+ export async function testAllVisibleViaRouterV2(state) {
379
+ const visible = Array.isArray(state.visibleSorted) ? state.visibleSorted : []
380
+ const models = visible
381
+ .filter((row) => row && row.providerKey && row.modelId && row.hasApiKey !== false)
382
+ .map((row) => ({ provider: row.providerKey, model: row.modelId }))
383
+ if (models.length === 0) {
384
+ setRouterV2Notice(state, 'error', 'No configured models visible to test.')
385
+ return []
386
+ }
387
+ let port = state.routerV2Port
388
+ if (!port) {
389
+ port = await discoverRouterV2Port()
390
+ if (!port) {
391
+ setRouterV2Notice(state, 'error', 'Router v2 daemon is not running. Open Shift+V and start it first.')
392
+ return []
393
+ }
394
+ state.routerV2Port = port
395
+ state.routerV2BaseUrl = `http://127.0.0.1:${port}`
396
+ }
397
+ if (!state.routerV2TestRunning) state.routerV2TestRunning = new Set()
398
+ if (!state.routerV2TestResults) state.routerV2TestResults = new Map()
399
+ state.routerV2BatchTest = { running: true, total: models.length, completed: 0 }
400
+ try {
401
+ const results = await testSetViaRouter({
402
+ port,
403
+ models,
404
+ concurrency: ROUTER_V2_TEST_CONCURRENCY,
405
+ onResult: (record) => {
406
+ state.routerV2TestResults.set(record.key, { ...record, at: Date.now() })
407
+ if (state.routerV2BatchTest) state.routerV2BatchTest.completed += 1
408
+ },
409
+ })
410
+ const passed = results.filter((r) => r.ok).length
411
+ setRouterV2Notice(state, passed === results.length ? 'success' : 'warning', `Router test done: ${passed}/${results.length} models serve real content through v2.`)
412
+ void refreshRouterV2Snapshot(state, { force: true })
413
+ return results
414
+ } finally {
415
+ state.routerV2BatchTest = null
416
+ }
417
+ }
418
+
419
+ // ─── Rendering ──────────────────────────────────────────────────────────────
420
+
421
+ function formatDurationV2(seconds) {
422
+ if (!Number.isFinite(seconds) || seconds < 0) return '-'
423
+ if (seconds < 60) return `${seconds}s`
424
+ const minutes = Math.floor(seconds / 60)
425
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
426
+ const hours = Math.floor(minutes / 60)
427
+ return `${hours}h ${minutes % 60}m`
428
+ }
429
+
430
+ function stateBadgeV2(modelState) {
431
+ switch (modelState) {
432
+ case 'CLOSED': return { text: '✅ UP', color: themeColors.success }
433
+ case 'DEGRADED': return { text: '🟠 DEGRADED', color: themeColors.warningBold }
434
+ case 'HALF_OPEN': return { text: '🔁 PROBING', color: themeColors.warning }
435
+ case 'OPEN': return { text: '⛔ OPEN', color: themeColors.error }
436
+ case 'AUTH_ERROR': return { text: '🔐 AUTH FAIL', color: themeColors.errorBold }
437
+ case 'QUOTA_PAUSED': return { text: '🔥 QUOTA', color: themeColors.warningBold }
438
+ case 'STALE': return { text: '👻 STALE', color: themeColors.dim }
439
+ case 'UNSUPPORTED': return { text: '🚫 UNSUPPORTED', color: themeColors.dim }
440
+ default: return { text: '⏳ PENDING', color: themeColors.dim }
441
+ }
442
+ }
443
+
444
+ function attemptChainLabel(entry) {
445
+ const attempts = Array.isArray(entry.attempts) ? entry.attempts : []
446
+ if (attempts.length === 0) return entry.served_model || '-'
447
+ return attempts
448
+ .map((a) => {
449
+ const name = typeof a.model === 'string' ? a.model.split('/').slice(1).join('/') || a.model : '?'
450
+ if (a.error) return `${name}:${a.error}`
451
+ return `${name}:${a.status ?? '?'}`
452
+ })
453
+ .join(' -> ')
454
+ }
455
+
456
+ export function renderRouterV2Dashboard(state, deps = {}) {
457
+ const EL = '\x1b[K'
458
+ const lines = []
459
+ const status = state.routerV2Status || 'idle'
460
+ const width = Math.max(80, state.terminalCols || 80)
461
+ const separator = themeColors.dim('-'.repeat(Math.max(20, width - 6)))
462
+ const stats = isRecord(state.routerV2Stats) ? state.routerV2Stats : null
463
+
464
+ const LOADING_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
465
+ const loadingGlyph = LOADING_FRAMES[(state.frame || 0) % LOADING_FRAMES.length]
466
+ const isRunning = status === 'ready' || status === 'partial'
467
+ const isLoading = status === 'loading'
468
+
469
+ const bannerWidth = Math.max(40, width - 6)
470
+ let bannerText, bannerBgRgb
471
+ if (isRunning) {
472
+ bannerText = ' ROUTER V2 RUNNING (BUILT-IN) '
473
+ bannerBgRgb = [22, 120, 60]
474
+ } else if (isLoading) {
475
+ bannerText = ` ROUTER V2 STARTING (BUILT-IN) ${loadingGlyph} `
476
+ bannerBgRgb = [180, 100, 0]
477
+ } else {
478
+ bannerText = ' ROUTER V2 STOPPED (BUILT-IN) '
479
+ bannerBgRgb = [160, 30, 30]
480
+ }
481
+ const padTotal = Math.max(0, bannerWidth - displayWidth(bannerText))
482
+ const padLeft = Math.floor(padTotal / 2)
483
+ const bannerLine = ' '.repeat(padLeft) + bannerText + ' '.repeat(padTotal - padLeft)
484
+ const paintBanner = chalk.bgRgb(...bannerBgRgb).rgb(255, 255, 255).bold
485
+
486
+ lines.push('')
487
+ lines.push(` ${paintBanner(bannerLine)}`)
488
+
489
+ // ── Quick Setup ─────────────────────────────────────────────────────────────
490
+ const { defaultPort } = getRouterPortRange()
491
+ const port = state.routerV2Port || defaultPort
492
+ lines.push(` ${themeColors.textBold('Quick Setup')} ${themeColors.dim('(beta)')} ${themeColors.dim('- point your coding tool at v2')}`)
493
+ lines.push(` ${themeColors.dim('URL')} ${themeColors.infoBold(`http://localhost:${port}/v1`)} ${themeColors.dim('Anthropic:')} ${themeColors.infoBold(`http://localhost:${port}`)} ${themeColors.dim('(POST /v1/messages)')}`)
494
+ lines.push(` ${themeColors.dim('Model')} ${themeColors.infoBold('fcm')} ${themeColors.dim('or pin one:')} ${themeColors.infoBold('fcm:@provider/model')}`)
495
+ lines.push(` ${themeColors.dim('API Key')} ${themeColors.infoBold('fcm-local')}`)
496
+ if (isRunning) {
497
+ lines.push(` ${themeColors.dim('Uptime')} ${themeColors.success(formatDurationV2(toFiniteNumber(stats?.uptimeSeconds, 0)))} ${themeColors.dim('Routed:')} ${themeColors.info(String(toFiniteNumber(stats?.requestsRouted, 0)))} ${themeColors.dim('Failover rate:')} ${themeColors.info(`${Math.round(toFiniteNumber(stats?.history?.failover_rate, 0) * 100)}%`)}`)
498
+ }
499
+ lines.push(` ${separator}`)
500
+
501
+ // ── Fallback chain with live breaker states ─────────────────────────────────
502
+ lines.push(` ${themeColors.textBold('Fallback Chain')} ${themeColors.dim('- routing order for the next request')}`)
503
+ const routingOrder = Array.isArray(stats?.routingOrder) ? stats.routingOrder : []
504
+ const models = Array.isArray(stats?.models) ? stats.models : []
505
+ const healthByKey = new Map(models.map((m) => [m.key, m]))
506
+ const testResults = state.routerV2TestResults instanceof Map ? state.routerV2TestResults : new Map()
507
+ const cursor = state.routerV2CursorIndex ?? 0
508
+
509
+ if (!isRunning) {
510
+ lines.push(` ${themeColors.dim('Start the daemon to see the live chain.')}`)
511
+ } else if (routingOrder.length === 0) {
512
+ lines.push(` ${themeColors.warning('No routeable candidates right now (keys missing or all models failing).')}`)
513
+ } else {
514
+ lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay('MODEL', 44))} ${themeColors.dim(padEndDisplay('STATE', 15))} ${themeColors.dim(padEndDisplay('UPTIME', 7))} ${themeColors.dim(padEndDisplay('V2 TEST', 12))} ${themeColors.dim('LAST ERROR')}`)
515
+ const maxRows = Math.max(1, routingOrder.length)
516
+ routingOrder.forEach((entry, i) => {
517
+ const health = healthByKey.get(entry.key)
518
+ const badge = stateBadgeV2(health?.state || entry.state || 'UNKNOWN')
519
+ const uptime = health?.uptime != null ? `${Math.round(health.uptime * 100)}%` : '-'
520
+ const test = testResults.get(entry.key)
521
+ let testLabel = themeColors.dim(padEndDisplay('- space t', 12))
522
+ if (state.routerV2TestRunning?.has(entry.key)) testLabel = themeColors.warning(padEndDisplay('⏳ testing', 12))
523
+ else if (test?.ok === true) testLabel = themeColors.success(padEndDisplay(`✅ ${test.latencyMs}ms`, 12))
524
+ else if (test?.ok === false) testLabel = themeColors.error(padEndDisplay(`❌ ${String(test.error || 'fail').slice(0, 6)}`, 12))
525
+ const lastError = health?.last_error ? compactTextV2(health.last_error, 24) : themeColors.dim('-')
526
+ const isCursorRow = i === cursor
527
+ const nextMarker = i === 0 ? themeColors.successBold('▶') : themeColors.dim(' ')
528
+ const rowText = ` ${nextMarker} ${padEndDisplay(String(entry.priority || i + 1), 4)} ${padEndDisplay(entry.key, 44)} ${padEndDisplay(badge.text, 15)} ${themeColors.dim(padEndDisplay(uptime, 7))} ${testLabel} ${lastError}`
529
+ lines.push(isCursorRow
530
+ ? themeColors.bgCursor(rowText + ' '.repeat(Math.max(0, width - displayWidth(rowText) - 3)))
531
+ : rowText)
532
+ })
533
+ void maxRows
534
+ }
535
+
536
+ // ── Recent requests WITH fallback chains (the v1 gap) ───────────────────────
537
+ lines.push('')
538
+ lines.push(` ${themeColors.textBold('Request Chains')} ${themeColors.dim('- every attempt, skips and the winner')}`)
539
+ const historyEntries = Array.isArray(state.routerV2History?.entries) ? state.routerV2History.entries : []
540
+ if (!isRunning) {
541
+ lines.push(` ${themeColors.dim('No history (daemon stopped).')}`)
542
+ } else if (historyEntries.length === 0) {
543
+ lines.push(` ${themeColors.dim('No requests routed yet')}`)
544
+ } else {
545
+ for (const entry of historyEntries.slice(0, 6)) {
546
+ const atMs = Date.parse(entry.at)
547
+ const time = Number.isFinite(atMs) ? new Date(atMs).toLocaleTimeString() : '-'
548
+ const outcome = entry.outcome === 'served'
549
+ ? themeColors.success('served')
550
+ : entry.outcome === 'client_aborted'
551
+ ? themeColors.dim('aborted')
552
+ : themeColors.error(entry.outcome || 'failed')
553
+ const chain = attemptChainLabel(entry)
554
+ const skips = Array.isArray(entry.skipped) && entry.skipped.length > 0
555
+ ? ` ${themeColors.dim(`[skips: ${entry.skipped.map((s) => s.reason).join(', ')}]`)}`
556
+ : ''
557
+ const lastResort = entry.last_resort_used ? ` ${themeColors.warningBold('[last-resort]')}` : ''
558
+ const shortId = typeof entry.request_id === 'string' ? entry.request_id.slice(-4) : '----'
559
+ lines.push(` ${themeColors.dim(`[${shortId}]`)} ${themeColors.dim(time)} ${outcome}`)
560
+ lines.push(` ${themeColors.dim(chain)}${skips}${lastResort}`)
561
+ }
562
+ }
563
+
564
+ // ── Quota pauses + counts ───────────────────────────────────────────────────
565
+ const stateCounts = stats?.modelStates
566
+ if (isRecord(stateCounts)) {
567
+ lines.push('')
568
+ const chips = [
569
+ `✅ ${stateCounts.CLOSED ?? 0}`,
570
+ `🟠 degraded ${stateCounts.DEGRADED ?? 0}`,
571
+ `⛔ open ${stateCounts.OPEN ?? 0}`,
572
+ `🔐 auth ${stateCounts.AUTH_ERROR ?? 0}`,
573
+ `🔥 quota ${stateCounts.QUOTA_PAUSED ?? 0}`,
574
+ ]
575
+ lines.push(` ${themeColors.textBold('Models:')} ${chips.map((c) => themeColors.dim(c)).join(' ')}`)
576
+ const pauses = Array.isArray(stats?.quotaPauses) ? stats.quotaPauses : []
577
+ if (pauses.length > 0) {
578
+ for (const pause of pauses.slice(0, 3)) {
579
+ lines.push(` ${themeColors.warning(`🔥 ${pause.model} paused until ${String(pause.until || '').slice(11, 19)}`)}`)
580
+ }
581
+ }
582
+ }
583
+
584
+ // ── Batch test progress ─────────────────────────────────────────────────────
585
+ if (state.routerV2BatchTest?.running) {
586
+ lines.push('')
587
+ lines.push(` ${themeColors.warning(`⏳ Testing ${state.routerV2BatchTest.completed}/${state.routerV2BatchTest.total} visible models through the router...`)}`)
588
+ }
589
+
590
+ // ── Buttons ─────────────────────────────────────────────────────────────────
591
+ lines.push('')
592
+ const isStopped = !isRunning && !isLoading
593
+ const cursorBase = Math.max(1, routingOrder.length)
594
+ const startBtnCursor = cursorBase
595
+ const startBtnText = isStopped ? '▶ Start Router Daemon (v2 engine)' : '⏹ Stop Router Daemon (v2 engine)'
596
+ const startBtnRow = ` [ ${startBtnText} ]`
597
+ lines.push(cursor === startBtnCursor
598
+ ? themeColors.bgCursor(startBtnRow + ' '.repeat(Math.max(0, width - displayWidth(startBtnRow) - 3)))
599
+ : startBtnRow)
600
+
601
+ // ── Notice / errors ─────────────────────────────────────────────────────────
602
+ const notice = state.routerV2Notice
603
+ if (notice?.message) {
604
+ lines.push('')
605
+ const color = notice.type === 'error' ? themeColors.errorBold : notice.type === 'success' ? themeColors.successBold : themeColors.warningBold
606
+ lines.push(` ${color(notice.message)}`)
607
+ } else if (state.routerV2Error && isStopped) {
608
+ lines.push('')
609
+ lines.push(` ${themeColors.dim('Press')} ${themeColors.hotkey('S')} ${themeColors.dim('to start it now.')}`)
610
+ } else if (state.routerV2Error) {
611
+ lines.push('')
612
+ lines.push(` ${themeColors.warning(state.routerV2Error)}`)
613
+ }
614
+
615
+ // ── Footer ──────────────────────────────────────────────────────────────────
616
+ lines.push('')
617
+ lines.push(` ${separator}`)
618
+ const probeMode = stats?.probeMode || 'balanced'
619
+ lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('T')} ${themeColors.dim('Test via router')} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? 'Start' : 'Stop')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Probes: ${probeMode}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear history')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
620
+ lines.push(` ${themeColors.dim('BETA: the v2 engine now powers the main router daemon. Ctrl+T tests the selected table model, Ctrl+Shift+T tests all visible models through the router.')}`)
621
+
622
+ const { visible, offset } = sliceOverlayLines(lines, state.routerV2ScrollOffset || 0, state.terminalRows || 24)
623
+ state.routerV2ScrollOffset = offset
624
+ const tinted = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols || 80)
625
+ return tinted.map((line) => line + EL).join('\n')
626
+ }
627
+
628
+ function compactTextV2(value, width) {
629
+ const text = String(value ?? '')
630
+ if (displayWidth(text) <= width) return themeColors.dim(text)
631
+ return themeColors.dim(`${text.slice(0, Math.max(1, width - 1))}…`)
632
+ }
@@ -82,27 +82,44 @@ function stripUnsupportedParams(body) {
82
82
  // 📖 requires a `tool` role message to be the result of a specific assistant
83
83
  // 📖 tool call — but some clients (ZCode, Claude Code) drop the assistant
84
84
  // 📖 tool_calls entry while keeping the tool result, which GLM rejects with 422.
85
+ //
86
+ // 📖 The matching window is the assistant message's WHOLE tool_calls batch:
87
+ // 📖 an assistant turn that fired N parallel tool calls is followed by N tool
88
+ // 📖 result messages, and only the first one has the assistant as its direct
89
+ // 📖 predecessor. Matching against a pending-id set (instead of the immediate
90
+ // 📖 previous message) keeps results 2..N, which used to be dropped as
91
+ // 📖 "orphans" and made providers reject the truncated conversation with 400.
85
92
  function dropOrphanToolMessages(body) {
86
93
  if (!Array.isArray(body.messages)) return body
87
94
  const filtered = []
95
+ let pendingToolCallIds = new Set()
88
96
  for (const msg of body.messages) {
89
97
  if (!msg || typeof msg !== 'object') continue
98
+ if (msg.role === 'assistant') {
99
+ filtered.push(msg)
100
+ pendingToolCallIds = new Set(
101
+ Array.isArray(msg.tool_calls)
102
+ ? msg.tool_calls.map((tc) => (tc && typeof tc.id === 'string' ? tc.id : null)).filter(Boolean)
103
+ : [],
104
+ )
105
+ continue
106
+ }
90
107
  if (msg.role === 'tool') {
91
108
  const toolCallId = msg.tool_call_id
92
109
  if (typeof toolCallId !== 'string' || toolCallId.length === 0) {
93
110
  // 📖 tool message without a tool_call_id is fundamentally invalid
94
111
  continue
95
112
  }
96
- const prev = filtered[filtered.length - 1]
97
- const hasMatch = prev
98
- && prev.role === 'assistant'
99
- && Array.isArray(prev.tool_calls)
100
- && prev.tool_calls.some((tc) => tc && tc.id === toolCallId)
101
- if (!hasMatch) {
113
+ if (!pendingToolCallIds.has(toolCallId)) {
102
114
  // 📖 Skip the orphan — better to drop than to 422
103
115
  continue
104
116
  }
117
+ pendingToolCallIds.delete(toolCallId)
118
+ filtered.push(msg)
119
+ continue
105
120
  }
121
+ // 📖 A user or system message closes the pending tool-call window.
122
+ pendingToolCallIds = new Set()
106
123
  filtered.push(msg)
107
124
  }
108
125
  // 📖 If filtering changed anything, materialize a new body object