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,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
|