free-coding-models 0.5.88 → 0.5.89

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.
@@ -0,0 +1,473 @@
1
+ /**
2
+ * @file anthropic-compat.js
3
+ * @description Anthropic Messages API compatibility layer for Router v2.
4
+ *
5
+ * @details
6
+ * 📖 v1 only spoke OpenAI `/v1/chat/completions`, so any coding agent that
7
+ * talks the Anthropic `/v1/messages` protocol (Claude Code style clients)
8
+ * could not use the router at all. v2 accepts `/v1/messages` and translates:
9
+ *
10
+ * - Request: Anthropic Messages body → OpenAI chat-completions body
11
+ * (system string/blocks, content blocks, tool_use / tool_result blocks,
12
+ * tools with input_schema, stop_sequences, tool_choice).
13
+ * - Response: OpenAI chat-completion payload → Anthropic message
14
+ * (content blocks, stop_reason mapping, usage input/output tokens).
15
+ * - Stream: upstream OpenAI SSE → Anthropic SSE event sequence
16
+ * (message_start, content_block_start/delta/stop, message_delta,
17
+ * message_stop), including incremental tool_use input_json deltas.
18
+ *
19
+ * 📖 Unsupported today (returned as clear request errors, never silent
20
+ * garbage): image blocks, document blocks, thinking blocks, server-side
21
+ * tools (web_search etc.), and `metadata.user_id` passthrough.
22
+ *
23
+ * @functions
24
+ * → translateAnthropicToOpenAI(body) - Request translation
25
+ * → translateOpenAIToAnthropicResponse(payload, opts) - Response translation
26
+ * → createAnthropicStreamTransformer(opts) - Incremental SSE transformer
27
+ * → anthropicErrorPayload(type, message) - Anthropic-style error envelope
28
+ * → anthropicErrorTypeForStatus(status) - Map HTTP status to Anthropic error type
29
+ *
30
+ * @exports translateAnthropicToOpenAI, translateOpenAIToAnthropicResponse
31
+ * @exports createAnthropicStreamTransformer, anthropicErrorPayload
32
+ * @exports anthropicErrorTypeForStatus
33
+ */
34
+
35
+ /**
36
+ * 📖 Translate an Anthropic `/v1/messages` request body into an OpenAI
37
+ * `/v1/chat/completions` body. Pure: never mutates the input.
38
+ *
39
+ * @param {object} body - Anthropic request body
40
+ * @returns {{ ok: true, body: object, warnings: string[] }
41
+ * | { ok: false, error: string }}
42
+ */
43
+ export function translateAnthropicToOpenAI(body) {
44
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
45
+ return { ok: false, error: 'Request body must be a JSON object' }
46
+ }
47
+ const warnings = []
48
+ const messages = []
49
+
50
+ // 📖 `system` can be a string or an array of {type:"text",text} blocks.
51
+ const systemText = normalizeSystem(body.system)
52
+ if (systemText) messages.push({ role: 'system', content: systemText })
53
+
54
+ const incoming = Array.isArray(body.messages) ? body.messages : []
55
+ for (const message of incoming) {
56
+ const role = message?.role === 'assistant' ? 'assistant' : 'user'
57
+ if (typeof message?.content === 'string') {
58
+ messages.push({ role, content: message.content })
59
+ continue
60
+ }
61
+ if (!Array.isArray(message?.content)) {
62
+ return { ok: false, error: `Message content must be a string or a blocks array (role: ${role})` }
63
+ }
64
+ // 📖 tool_result blocks (user role) become OpenAI `role:"tool"` messages;
65
+ // any sibling text blocks ride along as a trailing user message.
66
+ const toolMessages = []
67
+ const textParts = []
68
+ const assistantToolCalls = []
69
+ for (const block of message.content) {
70
+ if (!block || typeof block !== 'object') continue
71
+ switch (block.type) {
72
+ case 'text':
73
+ textParts.push(typeof block.text === 'string' ? block.text : '')
74
+ break
75
+ case 'tool_use':
76
+ assistantToolCalls.push({
77
+ id: typeof block.id === 'string' ? block.id : `call_${assistantToolCalls.length}`,
78
+ type: 'function',
79
+ function: {
80
+ name: String(block.name || ''),
81
+ arguments: safeJsonStringify(block.input ?? {}),
82
+ },
83
+ })
84
+ break
85
+ case 'tool_result': {
86
+ const content = normalizeToolResultContent(block.content)
87
+ toolMessages.push({
88
+ role: 'tool',
89
+ tool_call_id: typeof block.tool_use_id === 'string' ? block.tool_use_id : '',
90
+ content,
91
+ })
92
+ break
93
+ }
94
+ case 'image':
95
+ case 'document':
96
+ warnings.push(`content block type "${block.type}" is not supported and was dropped`)
97
+ break
98
+ case 'thinking':
99
+ // 📖 Interleaved thinking blocks from a previous assistant turn are
100
+ // provider-internal state; silently dropping them is correct here.
101
+ break
102
+ default:
103
+ warnings.push(`unknown content block type "${block.type || 'unknown'}" was dropped`)
104
+ }
105
+ }
106
+ if (role === 'assistant' && assistantToolCalls.length > 0) {
107
+ messages.push({
108
+ role: 'assistant',
109
+ content: textParts.join('\n').trim() || null,
110
+ tool_calls: assistantToolCalls,
111
+ })
112
+ } else {
113
+ if (toolMessages.length > 0) messages.push(...toolMessages)
114
+ const text = textParts.join('\n').trim()
115
+ if (text) messages.push({ role, content: text })
116
+ else if (toolMessages.length === 0 && message.content.length > 0) {
117
+ // 📖 Blocks existed but produced nothing translatable: keep an empty
118
+ // user turn so ordering with the next tool_result stays valid.
119
+ messages.push({ role, content: '' })
120
+ }
121
+ }
122
+ }
123
+
124
+ const out = {
125
+ model: typeof body.model === 'string' ? body.model : 'fcm',
126
+ messages,
127
+ max_tokens: Number.isFinite(body.max_tokens) ? body.max_tokens : 4096,
128
+ }
129
+ if (Number.isFinite(body.temperature)) out.temperature = body.temperature
130
+ if (Number.isFinite(body.top_p)) out.top_p = body.top_p
131
+ if (Array.isArray(body.stop_sequences) && body.stop_sequences.length > 0) out.stop = body.stop_sequences
132
+ if (body.stream === true) out.stream = true
133
+
134
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
135
+ const tools = []
136
+ for (const tool of body.tools) {
137
+ if (!tool || typeof tool !== 'object' || !tool.name) continue
138
+ // 📖 Skip Anthropic server-side tools (web_search etc.): they have no
139
+ // input_schema and cannot run on an OpenAI-compatible upstream.
140
+ if (tool.input_schema === undefined && tool.type && tool.type !== 'custom') {
141
+ warnings.push(`server-side tool "${tool.name}" is not supported and was dropped`)
142
+ continue
143
+ }
144
+ tools.push({
145
+ type: 'function',
146
+ function: {
147
+ name: tool.name,
148
+ description: typeof tool.description === 'string' ? tool.description : '',
149
+ parameters: tool.input_schema && typeof tool.input_schema === 'object' ? tool.input_schema : { type: 'object', properties: {} },
150
+ },
151
+ })
152
+ }
153
+ if (tools.length > 0) out.tools = tools
154
+ }
155
+
156
+ if (body.tool_choice && typeof body.tool_choice === 'object') {
157
+ if (body.tool_choice.type === 'any') out.tool_choice = 'required'
158
+ else if (body.tool_choice.type === 'auto') out.tool_choice = 'auto'
159
+ else if (body.tool_choice.type === 'tool' && body.tool_choice.name) {
160
+ out.tool_choice = { type: 'function', function: { name: body.tool_choice.name } }
161
+ }
162
+ }
163
+
164
+ return { ok: true, body: out, warnings }
165
+ }
166
+
167
+ /**
168
+ * 📖 Translate an OpenAI chat-completion payload into an Anthropic message.
169
+ * @param {object} payload - parsed OpenAI response
170
+ * @param {{ model: string }} opts - the model name to advertise downstream
171
+ * @returns {{ ok: true, body: object } | { ok: false, error: string }}
172
+ */
173
+ export function translateOpenAIToAnthropicResponse(payload, { model }) {
174
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.choices) || payload.choices.length === 0) {
175
+ return { ok: false, error: 'Upstream returned no choices' }
176
+ }
177
+ const choice = payload.choices[0] || {}
178
+ const message = choice.message || {}
179
+ const content = []
180
+ const text = typeof message.content === 'string' && message.content.length > 0 ? message.content : null
181
+ if (text) content.push({ type: 'text', text })
182
+ if (Array.isArray(message.tool_calls)) {
183
+ for (const call of message.tool_calls) {
184
+ if (!call || typeof call !== 'object') continue
185
+ const fn = call.function || {}
186
+ content.push({
187
+ type: 'tool_use',
188
+ id: typeof call.id === 'string' ? call.id : `toolu_${content.length}`,
189
+ name: String(fn.name || ''),
190
+ input: safeJsonParse(fn.arguments, {}),
191
+ })
192
+ }
193
+ }
194
+ if (content.length === 0) content.push({ type: 'text', text: '' })
195
+
196
+ const usage = payload.usage || {}
197
+ return {
198
+ ok: true,
199
+ body: {
200
+ id: typeof payload.id === 'string' && payload.id ? `msg_${payload.id}` : `msg_${Date.now().toString(36)}`,
201
+ type: 'message',
202
+ role: 'assistant',
203
+ model: typeof model === 'string' ? model : 'fcm',
204
+ content,
205
+ stop_reason: mapStopReason(choice.finish_reason),
206
+ stop_sequence: null,
207
+ usage: {
208
+ input_tokens: Number(usage.prompt_tokens ?? 0) || 0,
209
+ output_tokens: Number(usage.completion_tokens ?? 0) || 0,
210
+ },
211
+ },
212
+ }
213
+ }
214
+
215
+ /**
216
+ * 📖 Incremental transformer: feed upstream OpenAI SSE text, get Anthropic
217
+ * SSE event text back. Handles chunks split across arbitrary boundaries via
218
+ * an internal line buffer, and tracks tool-call argument deltas so partial
219
+ * JSON streams as `input_json_delta` blocks.
220
+ *
221
+ * @param {{ model: string }} opts
222
+ * @returns {{ write(chunk: string): string, end(): string, outputTokens(): number }}
223
+ */
224
+ export function createAnthropicStreamTransformer({ model } = {}) {
225
+ let lineBuffer = ''
226
+ let blockIndex = 0
227
+ let textBlockOpened = false
228
+ let stopReason = null
229
+ let outputTokens = 0
230
+ let started = false
231
+ let finished = false
232
+ // 📖 tool_calls arrive as parallel deltas keyed by index in the OpenAI
233
+ // stream: { index, id?, function: { name?, arguments? } }
234
+ const toolBlocks = new Map() // openaiIndex → { anthropicIndex, id, name, opened }
235
+ const openBlocks = new Set() // anthropic indexes that had a _start emitted
236
+
237
+ const event = (name, data) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`
238
+
239
+ const ensureStart = () => {
240
+ if (started) return ''
241
+ started = true
242
+ return event('message_start', {
243
+ type: 'message_start',
244
+ message: {
245
+ id: `msg_${Date.now().toString(36)}`,
246
+ type: 'message',
247
+ role: 'assistant',
248
+ model: model || 'fcm',
249
+ content: [],
250
+ stop_reason: null,
251
+ stop_sequence: null,
252
+ usage: { input_tokens: 0, output_tokens: 0 },
253
+ },
254
+ })
255
+ }
256
+
257
+ const openTextBlock = () => {
258
+ if (textBlockOpened) return ''
259
+ textBlockOpened = true
260
+ openBlocks.add(blockIndex)
261
+ const out = event('content_block_start', {
262
+ type: 'content_block_start',
263
+ index: blockIndex,
264
+ content_block: { type: 'text', text: '' },
265
+ })
266
+ blockIndex += 1
267
+ return out
268
+ }
269
+
270
+ const openToolBlock = (tool) => {
271
+ openBlocks.add(tool.anthropicIndex)
272
+ return event('content_block_start', {
273
+ type: 'content_block_start',
274
+ index: tool.anthropicIndex,
275
+ content_block: { type: 'tool_use', id: tool.id, name: tool.name, input: {} },
276
+ })
277
+ }
278
+
279
+ const handlePayload = (payload) => {
280
+ let out = ''
281
+ if (!payload || typeof payload !== 'object') return out
282
+ if (payload.error) {
283
+ // 📖 Mid-stream upstream error after the gate let content through:
284
+ // surface it as an Anthropic error event and finish the message.
285
+ const err = payload.error
286
+ out += event('error', {
287
+ type: 'error',
288
+ error: {
289
+ type: 'api_error',
290
+ message: String(err?.message || err?.code || 'upstream stream error').slice(0, 300),
291
+ },
292
+ })
293
+ return out
294
+ }
295
+ const choice = Array.isArray(payload.choices) ? payload.choices[0] : null
296
+ if (!choice) return out
297
+ const delta = choice.delta || {}
298
+ if (typeof delta.content === 'string' && delta.content.length > 0) {
299
+ out += ensureStart()
300
+ out += openTextBlock()
301
+ outputTokens += Math.max(1, Math.ceil(delta.content.length / 4))
302
+ out += event('content_block_delta', {
303
+ type: 'content_block_delta',
304
+ index: 0,
305
+ delta: { type: 'text_delta', text: delta.content },
306
+ })
307
+ }
308
+ if (Array.isArray(delta.tool_calls)) {
309
+ for (const call of delta.tool_calls) {
310
+ const idx = Number.isFinite(call?.index) ? call.index : 0
311
+ let tool = toolBlocks.get(idx)
312
+ if (!tool) {
313
+ tool = {
314
+ anthropicIndex: blockIndex,
315
+ id: typeof call?.id === 'string' && call.id ? call.id : `toolu_${idx}_${Date.now().toString(36)}`,
316
+ name: String(call?.function?.name || ''),
317
+ opened: false,
318
+ }
319
+ blockIndex += 1
320
+ toolBlocks.set(idx, tool)
321
+ }
322
+ out += ensureStart()
323
+ if (!tool.opened) {
324
+ tool.opened = true
325
+ out += openToolBlock(tool)
326
+ }
327
+ const args = typeof call?.function?.arguments === 'string' ? call.function.arguments : ''
328
+ if (args.length > 0) {
329
+ outputTokens += Math.max(1, Math.ceil(args.length / 4))
330
+ out += event('content_block_delta', {
331
+ type: 'content_block_delta',
332
+ index: tool.anthropicIndex,
333
+ delta: { type: 'input_json_delta', partial_json: args },
334
+ })
335
+ }
336
+ }
337
+ }
338
+ if (choice.finish_reason) stopReason = mapStopReason(choice.finish_reason)
339
+ return out
340
+ }
341
+
342
+ return {
343
+ write(chunk) {
344
+ if (finished) return ''
345
+ if (typeof chunk !== 'string' || chunk.length === 0) return ''
346
+ const data = lineBuffer + chunk
347
+ const lines = data.split('\n')
348
+ lineBuffer = lines.pop() ?? ''
349
+ let out = ''
350
+ for (const line of lines) {
351
+ const trimmed = line.trim()
352
+ if (!trimmed || trimmed.startsWith(':')) continue
353
+ if (trimmed.startsWith('data:')) {
354
+ const raw = trimmed.slice(5).trim()
355
+ if (!raw || raw === '[DONE]') continue
356
+ try {
357
+ out += handlePayload(JSON.parse(raw))
358
+ } catch {
359
+ // 📖 Non-JSON data frame: ignore (some providers send keepalives).
360
+ }
361
+ }
362
+ }
363
+ return out
364
+ },
365
+ end() {
366
+ if (finished) return ''
367
+ finished = true
368
+ let out = ensureStart()
369
+ // 📖 A stream with no content still produces a valid empty message.
370
+ if (!textBlockOpened && toolBlocks.size === 0) {
371
+ out += openTextBlock()
372
+ }
373
+ for (const tool of toolBlocks.values()) {
374
+ if (tool.opened) {
375
+ out += event('content_block_stop', { type: 'content_block_stop', index: tool.anthropicIndex })
376
+ }
377
+ }
378
+ if (textBlockOpened) {
379
+ out += event('content_block_stop', { type: 'content_block_stop', index: 0 })
380
+ }
381
+ out += event('message_delta', {
382
+ type: 'message_delta',
383
+ delta: { stop_reason: stopReason || 'end_turn', stop_sequence: null },
384
+ usage: { output_tokens: outputTokens },
385
+ })
386
+ out += event('message_stop', { type: 'message_stop' })
387
+ return out
388
+ },
389
+ outputTokens() {
390
+ return outputTokens
391
+ },
392
+ }
393
+ }
394
+
395
+ /**
396
+ * 📖 Anthropic-style error envelope for /v1/messages endpoints.
397
+ * @param {string} type - one of the Anthropic error types
398
+ * @param {string} message
399
+ */
400
+ export function anthropicErrorPayload(type, message) {
401
+ return { type: 'error', error: { type, message } }
402
+ }
403
+
404
+ /**
405
+ * 📖 Map an HTTP status to the closest Anthropic error type.
406
+ * @param {number} status
407
+ * @returns {string}
408
+ */
409
+ export function anthropicErrorTypeForStatus(status) {
410
+ if (status === 400) return 'invalid_request_error'
411
+ if (status === 401 || status === 403) return 'authentication_error'
412
+ if (status === 404) return 'not_found_error'
413
+ if (status === 413) return 'request_too_large'
414
+ if (status === 429) return 'rate_limit_error'
415
+ if (status === 529) return 'overloaded_error'
416
+ if (status >= 500) return 'api_error'
417
+ return 'api_error'
418
+ }
419
+
420
+ function mapStopReason(finishReason) {
421
+ switch (finishReason) {
422
+ case 'tool_calls':
423
+ case 'function_call':
424
+ return 'tool_use'
425
+ case 'length':
426
+ return 'max_tokens'
427
+ case 'content_filter':
428
+ return 'refusal'
429
+ case 'stop':
430
+ default:
431
+ return 'end_turn'
432
+ }
433
+ }
434
+
435
+ function normalizeSystem(system) {
436
+ if (typeof system === 'string') return system.trim() || null
437
+ if (Array.isArray(system)) {
438
+ const text = system
439
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
440
+ .map((block) => block.text)
441
+ .join('\n')
442
+ .trim()
443
+ return text || null
444
+ }
445
+ return null
446
+ }
447
+
448
+ function normalizeToolResultContent(content) {
449
+ if (typeof content === 'string') return content
450
+ if (Array.isArray(content)) {
451
+ return content
452
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
453
+ .map((block) => block.text)
454
+ .join('\n')
455
+ }
456
+ return ''
457
+ }
458
+
459
+ function safeJsonStringify(value) {
460
+ try {
461
+ return JSON.stringify(value ?? {})
462
+ } catch {
463
+ return '{}'
464
+ }
465
+ }
466
+
467
+ function safeJsonParse(raw, fallback) {
468
+ try {
469
+ return JSON.parse(raw)
470
+ } catch {
471
+ return fallback
472
+ }
473
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @file bench.js
3
+ * @description Test-via-router client for Router v2 ("AI Speed Test through the router").
4
+ *
5
+ * @details
6
+ * 📖 v1's Ctrl+A / Ctrl+U benchmarks called providers DIRECTLY from the
7
+ * TUI: they bypassed schema normalization, the pre-prompt, the response
8
+ * gate and the whole failover engine, so "the model passed the test" said
9
+ * nothing about what happens when the router actually serves it (and vice
10
+ * versa). A model could pass the benchmark while failing through the router.
11
+ *
12
+ * 📖 v2 reverses the flow: tests go THROUGH the daemon using the pinned
13
+ * model syntax (`model: "fcm:@provider/modelId"`), so every test exercises
14
+ * the exact same chain production traffic uses. Results are computed from
15
+ * the response's decision headers, so "ok" means: the router routed to the
16
+ * pinned model, the response passed the content gate, and real text came
17
+ * back. One prompt, one call, no retries, 20s budget.
18
+ *
19
+ * @functions
20
+ * → testModelViaRouter(opts) - One pinned-model test call through the daemon
21
+ * → testSetViaRouter(opts) - Test every model of a set with a small pool
22
+ * → discoverRouterV2Port() - Find the running v2 daemon (port file + scan)
23
+ *
24
+ * @exports testModelViaRouter, testSetViaRouter, discoverRouterV2Port, ROUTER_V2_TEST_PROMPT
25
+ */
26
+
27
+ import { existsSync, readFileSync } from 'node:fs'
28
+ import { homedir } from 'node:os'
29
+ import { join } from 'node:path'
30
+ import { getRouterPortPath, getRouterPortRange } from '../router-daemon.js'
31
+
32
+ export const ROUTER_V2_TEST_PROMPT = 'Why is the sky blue? Answer in one short sentence.'
33
+ export const ROUTER_V2_TEST_TIMEOUT_MS = 20_000
34
+
35
+ function extractHeader(headers, name) {
36
+ if (!headers) return null
37
+ if (typeof headers.get === 'function') return headers.get(name)
38
+ return headers[name] || null
39
+ }
40
+
41
+ /**
42
+ * 📖 Run one real chat completion through the v2 daemon, pinned to a model.
43
+ *
44
+ * @param {{ port: number, provider: string, model: string,
45
+ * timeoutMs?: number, token?: string, setName?: string }} opts
46
+ * @returns {Promise<{ ok: boolean, latencyMs: number, code: number|string,
47
+ * error: string|null, servedModel: string|null,
48
+ * attempts: string|null, preview: string|null }>}
49
+ */
50
+ export async function testModelViaRouter({ port, provider, model, timeoutMs = ROUTER_V2_TEST_TIMEOUT_MS, token = null, setName = null }) {
51
+ const started = Date.now()
52
+ const controller = new AbortController()
53
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
54
+ try {
55
+ const modelSpec = setName ? `fcm:@${provider}/${model}` : `fcm:@${provider}/${model}`
56
+ const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
57
+ method: 'POST',
58
+ headers: {
59
+ 'Content-Type': 'application/json',
60
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
61
+ },
62
+ body: JSON.stringify({
63
+ model: modelSpec,
64
+ messages: [{ role: 'user', content: ROUTER_V2_TEST_PROMPT }],
65
+ max_tokens: 80,
66
+ temperature: 0,
67
+ stream: false,
68
+ }),
69
+ signal: controller.signal,
70
+ })
71
+ const latencyMs = Date.now() - started
72
+ const servedModel = extractHeader(response.headers, 'x-fcm-v2-model')
73
+ const attempts = extractHeader(response.headers, 'x-fcm-v2-decision')
74
+ const text = await response.text()
75
+
76
+ if (!response.ok) {
77
+ let message = `HTTP ${response.status}`
78
+ try {
79
+ const parsed = JSON.parse(text)
80
+ message = parsed?.error?.message || message
81
+ } catch {}
82
+ return { ok: false, latencyMs, code: response.status, error: message, servedModel, attempts, preview: null }
83
+ }
84
+
85
+ let payload = null
86
+ try {
87
+ payload = JSON.parse(text)
88
+ } catch {
89
+ return { ok: false, latencyMs, code: 200, error: 'invalid_json from router', servedModel, attempts, preview: null }
90
+ }
91
+ if (payload?.error) {
92
+ return { ok: false, latencyMs, code: 200, error: String(payload.error?.message || 'upstream error'), servedModel, attempts, preview: null }
93
+ }
94
+ const content = payload?.choices?.[0]?.message?.content
95
+ if (typeof content !== 'string' || content.trim().length === 0) {
96
+ return { ok: false, latencyMs, code: 200, error: 'empty_content (gate should have failed over)', servedModel, attempts, preview: null }
97
+ }
98
+ return { ok: true, latencyMs, code: 200, error: null, servedModel, attempts, preview: content.trim().slice(0, 120) }
99
+ } catch (error) {
100
+ const aborted = error?.name === 'AbortError'
101
+ return {
102
+ ok: false,
103
+ latencyMs: Date.now() - started,
104
+ code: aborted ? 'TIMEOUT' : 'ERR',
105
+ error: aborted ? `timeout after ${timeoutMs}ms` : (error?.message || 'test failed'),
106
+ servedModel: null,
107
+ attempts: null,
108
+ preview: null,
109
+ }
110
+ } finally {
111
+ clearTimeout(timer)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 📖 Test several models through the router with a small worker pool.
117
+ * @param {{ port: number, models: Array<{provider: string, model: string}>,
118
+ * concurrency?: number, timeoutMs?: number, token?: string,
119
+ * onResult?: (result) => void }} opts
120
+ * @returns {Promise<Array<{ key: string } & Awaited<ReturnType<typeof testModelViaRouter>>>}
121
+ */
122
+ export async function testSetViaRouter({ port, models, concurrency = 3, timeoutMs, token, onResult } = {}) {
123
+ const queue = [...(Array.isArray(models) ? models : [])]
124
+ const results = []
125
+ const workers = new Array(Math.max(1, Math.min(concurrency, 8))).fill(null).map(async () => {
126
+ while (queue.length > 0) {
127
+ const next = queue.shift()
128
+ if (!next) break
129
+ const result = await testModelViaRouter({ port, provider: next.provider, model: next.model, timeoutMs, token })
130
+ const record = { key: `${next.provider}/${next.model}`, ...result }
131
+ results.push(record)
132
+ if (typeof onResult === 'function') {
133
+ try { onResult(record) } catch {}
134
+ }
135
+ }
136
+ })
137
+ await Promise.all(workers)
138
+ return results
139
+ }
140
+
141
+ /**
142
+ * 📖 Find a running v2 daemon: try the recorded port file first, then scan
143
+ * the effective port range. Returns null when nothing answers /health.
144
+ * @returns {Promise<number|null>}
145
+ */
146
+ export async function discoverRouterV2Port() {
147
+ const candidates = []
148
+ try {
149
+ const portPath = getRouterPortPath()
150
+ if (existsSync(portPath)) {
151
+ const parsed = Number.parseInt(readFileSync(portPath, 'utf8').trim(), 10)
152
+ if (Number.isFinite(parsed)) candidates.push(parsed)
153
+ }
154
+ } catch {}
155
+ const { defaultPort, maxPort } = getRouterPortRange()
156
+ for (let port = defaultPort; port <= maxPort; port += 1) {
157
+ if (!candidates.includes(port)) candidates.push(port)
158
+ }
159
+ for (const port of candidates) {
160
+ try {
161
+ const response = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(700) })
162
+ if (response.ok) return port
163
+ } catch {}
164
+ }
165
+ return null
166
+ }
167
+
168
+ // 📖 homedir import is used by tests that override HOME before requiring this
169
+ // module; keep the reference meaningful.
170
+ void homedir
171
+ void join