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.
- package/README.md +10 -4
- package/bin/free-coding-models.js +33 -2
- package/changelog/v0.5.89.md +20 -0
- package/changelog/v0.5.90.md +12 -0
- package/package.json +1 -1
- package/sources.js +5 -2
- package/src/core/benchmark.js +9 -0
- package/src/core/cloudflare-account.js +311 -0
- package/src/core/endpoint-installer.js +8 -4
- package/src/core/opencode.js +9 -6
- package/src/core/ping.js +52 -16
- package/src/core/provider-key-tester.js +10 -3
- package/src/core/provider-metadata.js +1 -1
- package/src/core/router-daemon.js +1138 -501
- package/src/core/router-v2/anthropic-compat.js +473 -0
- package/src/core/router-v2/bench.js +171 -0
- package/src/core/router-v2/breaker-store.js +265 -0
- package/src/core/router-v2/constants.js +108 -0
- package/src/core/router-v2/decision-trace.js +134 -0
- package/src/core/router-v2/failure-classifier.js +231 -0
- package/src/core/router-v2/request-history.js +137 -0
- package/src/core/router-v2/response-gate.js +175 -0
- package/src/core/router-v2/tui-dashboard.js +632 -0
- package/src/core/schema-normalizer.js +23 -6
- package/src/core/utils.js +12 -0
- package/src/tui/app.js +7 -2
- package/src/tui/cli-help.js +4 -0
- package/src/tui/key-handler.js +117 -2
- package/src/tui/overlays.js +19 -3
- package/src/tui/tui-state.js +22 -0
- package/web/dist/assets/index-CCaxIOti.css +1 -0
- package/web/dist/assets/index-CCkuXrqE.js +48 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +105 -1
- package/web/dist/assets/index-CAzFIt8P.css +0 -1
- 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
|
-
|
|
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
|