dsh-plugin-agentflow 0.0.0-stage → 0.2.1

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/lib/bridge.js ADDED
@@ -0,0 +1,858 @@
1
+ /**
2
+ * llm bridge —— DSH 进程内的本地模型通道。
3
+ *
4
+ * AgentFlow server 通过 HTTP/SSE 调这里,这里只做协议翻译,不碰任何 provider:
5
+ * GET /health 活着吗
6
+ * GET /catalog DSH 的 provider/模型目录(含推理档位)
7
+ * POST /stream ctx.llm.stream 的 SSE 转发
8
+ *
9
+ * 契约是唯一真相源:~/.dsh/agent-flow/docs/bridge-contract.md。
10
+ * 几个刻意的取舍:
11
+ * - 只绑 127.0.0.1,且只认本机 Host(防 DNS rebinding)
12
+ * - token 每次启动随机;发现文件由调用方读,所以端口被占就 listen(0)
13
+ * - 插件在 DSH 主进程里,绝不写 stdout,异常一律 console.warn
14
+ * - 流事件只发 delta/tool/usage/done/error,reasoning-delta 丢弃
15
+ */
16
+
17
+ import http from 'node:http';
18
+ import fs from 'node:fs/promises';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import crypto from 'node:crypto';
22
+
23
+ /** 契约规定的默认端口;被占用时回落到随机端口。 */
24
+ export const DEFAULT_PORT = 8792;
25
+ /** POST /stream 请求体上限。 */
26
+ export const MAX_BODY_BYTES = 1024 * 1024;
27
+ /** 同时进行的流式调用上限。 */
28
+ export const MAX_CONCURRENT_STREAMS = 8;
29
+ /** 单请求超时(10 分钟)。 */
30
+ export const REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
31
+ /** /catalog 缓存时间(契约要求 ≤ 10 秒)。 */
32
+ export const CATALOG_TTL_MS = 10 * 1000;
33
+
34
+ const DEFAULT_DIR = path.join(os.homedir(), '.dsh', 'agent-flow');
35
+ const LOCAL_HOST_RE = /^(?:127\.0\.0\.1|localhost)(?::\d+)?$/i;
36
+
37
+ /** 发现文件路径:<agentflowDir>/data/dsh-bridge.json */
38
+ export function bridgeInfoPath(dir) {
39
+ return path.join(dir, 'data', 'dsh-bridge.json');
40
+ }
41
+
42
+ /**
43
+ * 启动 bridge。同步返回 disposer:服务监听与发现文件写入都是后台完成的,
44
+ * 失败只 warn,不阻塞、不抛错(插件加载比 bridge 可用更重要)。
45
+ *
46
+ * @param {{llm: object}} ctx DSH 插件上下文(用到 ctx.llm)
47
+ * @param {object} [options]
48
+ * @param {string} [options.dir] AgentFlow 目录,默认 ~/.dsh/agent-flow
49
+ * @param {number} [options.port] 首选端口,默认 8792;显式 0 表示直接随机
50
+ * @param {number} [options.requestTimeoutMs] 单请求超时(测试用)
51
+ * @param {number} [options.catalogTtlMs] 目录缓存时长(测试用)
52
+ * @returns {() => Promise<void>} disposer
53
+ */
54
+ export function startBridge(ctx, options = {}) {
55
+ if (!ctx || !ctx.llm) {
56
+ console.warn('[agentflow] 缺少 ctx.llm,llm bridge 未启动');
57
+ return async () => {};
58
+ }
59
+
60
+ const dir = typeof options.dir === 'string' && options.dir ? options.dir : DEFAULT_DIR;
61
+ const preferredPort = Number.isInteger(options.port) && options.port >= 0 ? options.port : DEFAULT_PORT;
62
+ const requestTimeoutMs =
63
+ Number.isFinite(options.requestTimeoutMs) && options.requestTimeoutMs > 0
64
+ ? options.requestTimeoutMs
65
+ : REQUEST_TIMEOUT_MS;
66
+ const catalogTtlMs =
67
+ Number.isFinite(options.catalogTtlMs) && options.catalogTtlMs >= 0 ? options.catalogTtlMs : CATALOG_TTL_MS;
68
+
69
+ const state = {
70
+ ctx,
71
+ dir,
72
+ token: crypto.randomBytes(24).toString('base64url'),
73
+ startedAt: Date.now(),
74
+ infoFile: bridgeInfoPath(dir),
75
+ port: 0,
76
+ requestTimeoutMs,
77
+ catalogTtlMs,
78
+ inflight: 0,
79
+ streams: new Set(),
80
+ catalog: null,
81
+ catalogAt: 0,
82
+ catalogInFlight: null,
83
+ warnedModels: new Set(),
84
+ stopped: false,
85
+ ready: null,
86
+ };
87
+
88
+ const server = http.createServer((req, res) => {
89
+ handleRequest(state, req, res).catch((err) => {
90
+ console.warn('[agentflow] bridge 处理请求失败:' + errorMessage(err));
91
+ if (!res.headersSent) {
92
+ sendJson(res, 500, { error: errorMessage(err) });
93
+ } else {
94
+ try {
95
+ res.end();
96
+ } catch {}
97
+ }
98
+ });
99
+ });
100
+
101
+ // HTTP 解析失败(畸形请求)不能把主进程带崩
102
+ server.on('clientError', (_err, socket) => {
103
+ if (socket && socket.writable) {
104
+ socket.end('HTTP/1.1 400 Bad Request\r\nconnection: close\r\n\r\n');
105
+ }
106
+ });
107
+
108
+ state.ready = (async () => {
109
+ try {
110
+ await listenOnce(server, preferredPort);
111
+ } catch (err) {
112
+ if (preferredPort !== 0 && err && err.code === 'EADDRINUSE') {
113
+ await listenOnce(server, 0);
114
+ } else {
115
+ throw err;
116
+ }
117
+ }
118
+ state.port = server.address().port;
119
+ server.on('error', (err) => {
120
+ if (!state.stopped) console.warn('[agentflow] bridge 服务错误:' + errorMessage(err));
121
+ });
122
+ if (state.stopped) {
123
+ await closeServer(server);
124
+ return;
125
+ }
126
+ await writeInfoFile(state);
127
+ })().catch((err) => {
128
+ console.warn('[agentflow] bridge 启动失败:' + errorMessage(err));
129
+ closeServer(server).catch(() => {});
130
+ });
131
+
132
+ let closePromise = null;
133
+ return function stop() {
134
+ if (closePromise) return closePromise;
135
+ state.stopped = true;
136
+ for (const controller of state.streams) {
137
+ try {
138
+ controller.abort();
139
+ } catch {}
140
+ }
141
+ state.streams.clear();
142
+ closePromise = (async () => {
143
+ try {
144
+ await state.ready;
145
+ } catch {}
146
+ await closeServer(server);
147
+ await removeInfoFile(state);
148
+ })();
149
+ return closePromise;
150
+ };
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // HTTP 路由
155
+ // ---------------------------------------------------------------------------
156
+
157
+ async function handleRequest(state, req, res) {
158
+ if (!isAuthorized(req, state.token)) {
159
+ req.resume();
160
+ sendJson(res, 401, { error: 'unauthorized' });
161
+ return;
162
+ }
163
+ if (!isLocalHost(req.headers.host)) {
164
+ req.resume();
165
+ sendJson(res, 403, { error: 'bad host' });
166
+ return;
167
+ }
168
+
169
+ const pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname;
170
+
171
+ if (req.method === 'GET' && pathname === '/health') {
172
+ handleHealth(state, res);
173
+ return;
174
+ }
175
+ if (req.method === 'GET' && pathname === '/catalog') {
176
+ await handleCatalog(state, res);
177
+ return;
178
+ }
179
+ if (req.method === 'POST' && pathname === '/stream') {
180
+ await handleStream(state, req, res);
181
+ return;
182
+ }
183
+
184
+ req.resume();
185
+ sendJson(res, 404, { error: 'not found' });
186
+ }
187
+
188
+ function handleHealth(state, res) {
189
+ let providers;
190
+ try {
191
+ providers = state.ctx.llm.listProviders();
192
+ } catch (err) {
193
+ sendJson(res, 500, { error: errorMessage(err) });
194
+ return;
195
+ }
196
+ sendJson(res, 200, {
197
+ ok: true,
198
+ pid: process.pid,
199
+ providers: Array.isArray(providers) ? providers.length : 0,
200
+ });
201
+ }
202
+
203
+ async function handleCatalog(state, res) {
204
+ let catalog;
205
+ try {
206
+ catalog = await getCatalog(state);
207
+ } catch (err) {
208
+ sendJson(res, 500, { error: errorMessage(err) });
209
+ return;
210
+ }
211
+ sendJson(res, 200, catalog);
212
+ }
213
+
214
+ /** 目录缓存 + 单飞:同一时刻只允许一轮组装,失败不缓存。 */
215
+ function getCatalog(state) {
216
+ const now = Date.now();
217
+ if (state.catalog && now - state.catalogAt < state.catalogTtlMs) {
218
+ return Promise.resolve(state.catalog);
219
+ }
220
+ if (state.catalogInFlight) return state.catalogInFlight;
221
+
222
+ state.catalogInFlight = buildCatalog(state)
223
+ .then((catalog) => {
224
+ state.catalog = catalog;
225
+ state.catalogAt = Date.now();
226
+ return catalog;
227
+ })
228
+ .finally(() => {
229
+ state.catalogInFlight = null;
230
+ });
231
+ return state.catalogInFlight;
232
+ }
233
+
234
+ async function buildCatalog(state) {
235
+ const providers = state.ctx.llm.listProviders();
236
+ if (!Array.isArray(providers)) throw new Error('/catalog:listProviders 没有返回数组');
237
+ const entries = await Promise.all(providers.map((provider) => buildProviderEntry(state, provider)));
238
+ return { providers: entries };
239
+ }
240
+
241
+ /**
242
+ * 单条 provider 失败只写该条的 error;模型级 resolveModelInfo 失败则保留模型、
243
+ * 档位给空(抓错,不抛),避免一个坏模型拖垮整个目录。
244
+ */
245
+ async function buildProviderEntry(state, provider) {
246
+ const id = stringOf(provider && provider.id);
247
+ const entry = {
248
+ id,
249
+ label: stringOf(provider && provider.name) || id,
250
+ models: [],
251
+ error: null,
252
+ };
253
+ try {
254
+ const models = await state.ctx.llm.listModels(id);
255
+ for (const model of Array.isArray(models) ? models : []) {
256
+ entry.models.push(await describeModel(state, id, model));
257
+ }
258
+ } catch (err) {
259
+ entry.error = errorMessage(err);
260
+ }
261
+ return entry;
262
+ }
263
+
264
+ async function describeModel(state, provider, model) {
265
+ const id = stringOf(model && model.id).trim();
266
+ const entry = { id, name: stringOf(model && model.name) || id, levels: [], defaultLevel: '' };
267
+ if (!id) return entry;
268
+
269
+ try {
270
+ const info = await state.ctx.llm.resolveModelInfo(provider, id);
271
+ const reasoning = info && info.reasoning;
272
+ const efforts = reasoning && Array.isArray(reasoning.efforts) ? reasoning.efforts : [];
273
+ if (efforts.length) {
274
+ entry.levels = efforts.map((effort) => stringOf(effort && effort.id)).filter(Boolean);
275
+ const preferred = stringOf(reasoning && reasoning.defaultEffort);
276
+ entry.defaultLevel = preferred && entry.levels.includes(preferred) ? preferred : entry.levels[0] || '';
277
+ }
278
+ } catch (err) {
279
+ const key = provider + '\u0000' + id;
280
+ if (!state.warnedModels.has(key)) {
281
+ state.warnedModels.add(key);
282
+ console.warn(`[agentflow] bridge 解析模型信息失败(${provider}/${id}):${errorMessage(err)}`);
283
+ }
284
+ }
285
+ return entry;
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // POST /stream
290
+ // ---------------------------------------------------------------------------
291
+
292
+ async function handleStream(state, req, res) {
293
+ const declared = Number(req.headers['content-length']);
294
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
295
+ req.resume();
296
+ sendJson(res, 413, { error: 'body too large' });
297
+ return;
298
+ }
299
+ if (state.inflight >= MAX_CONCURRENT_STREAMS) {
300
+ req.resume();
301
+ sendJson(res, 429, { error: 'bridge busy' });
302
+ return;
303
+ }
304
+
305
+ state.inflight += 1;
306
+ try {
307
+ let raw;
308
+ try {
309
+ raw = await readBody(req, MAX_BODY_BYTES);
310
+ } catch (err) {
311
+ if (err instanceof BodyTooLargeError) {
312
+ req.resume();
313
+ sendJson(res, 413, { error: 'body too large' });
314
+ } else if (!res.headersSent) {
315
+ sendJson(res, 400, { error: errorMessage(err) });
316
+ }
317
+ return;
318
+ }
319
+
320
+ let body;
321
+ try {
322
+ body = JSON.parse(raw || '{}');
323
+ } catch {
324
+ sendJson(res, 400, { error: 'bad json' });
325
+ return;
326
+ }
327
+
328
+ let options;
329
+ try {
330
+ options = normalizeStreamRequest(body);
331
+ } catch (err) {
332
+ if (err instanceof BridgeRequestError) {
333
+ sendJson(res, 400, { error: err.message });
334
+ return;
335
+ }
336
+ throw err;
337
+ }
338
+
339
+ await streamResponse(state, res, options);
340
+ } finally {
341
+ state.inflight -= 1;
342
+ }
343
+ }
344
+
345
+ /**
346
+ * 把 ctx.llm.stream 的 StreamChunk 翻成契约里的五种事件。
347
+ * 结束必须先发 done 或 error,再关流;客户端断开要 abort 底层调用。
348
+ */
349
+ async function streamResponse(state, res, requestOptions) {
350
+ const controller = new AbortController();
351
+ const toolSlots = new Map();
352
+ let clientGone = false;
353
+ let timedOut = false;
354
+ let terminal = false;
355
+ let headersSent = false;
356
+
357
+ state.streams.add(controller);
358
+ const onClose = () => {
359
+ if (!res.writableEnded) {
360
+ clientGone = true;
361
+ try {
362
+ controller.abort();
363
+ } catch {}
364
+ }
365
+ };
366
+ res.on('close', onClose);
367
+
368
+ const timer = setTimeout(() => {
369
+ timedOut = true;
370
+ try {
371
+ controller.abort();
372
+ } catch {}
373
+ }, state.requestTimeoutMs);
374
+ if (typeof timer.unref === 'function') timer.unref();
375
+
376
+ const send = (event) => {
377
+ if (res.writableEnded || res.destroyed) return;
378
+ if (!headersSent) {
379
+ headersSent = true;
380
+ res.writeHead(200, {
381
+ 'content-type': 'text/event-stream; charset=utf-8',
382
+ 'cache-control': 'no-cache, no-transform',
383
+ connection: 'keep-alive',
384
+ });
385
+ }
386
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
387
+ };
388
+
389
+ const finishWith = (event) => {
390
+ if (terminal) return;
391
+ terminal = true;
392
+ if (!clientGone) send(event);
393
+ };
394
+
395
+ try {
396
+ const stream = state.ctx.llm.stream({ ...requestOptions, signal: controller.signal });
397
+ for await (const chunk of stream) {
398
+ if (terminal) break;
399
+ if (!chunk || typeof chunk !== 'object') continue;
400
+
401
+ switch (chunk.type) {
402
+ case 'text-delta':
403
+ if (typeof chunk.text === 'string' && chunk.text) {
404
+ send({ type: 'delta', text: chunk.text });
405
+ }
406
+ break;
407
+ case 'tool-call-delta':
408
+ accumulateToolDelta(toolSlots, chunk);
409
+ break;
410
+ case 'block-end': {
411
+ const toolEvent = toolEventFromBlock(toolSlots, chunk);
412
+ if (toolEvent) send(toolEvent);
413
+ break;
414
+ }
415
+ case 'usage':
416
+ send(usageEvent(chunk.usage));
417
+ break;
418
+ case 'finish':
419
+ finishWith(finishEvent(chunk.reason, state, timedOut));
420
+ break;
421
+ default:
422
+ // block-start / reasoning-delta / 未来新增的块类型:都不进物料正文
423
+ break;
424
+ }
425
+ }
426
+
427
+ if (!terminal) {
428
+ if (timedOut) {
429
+ finishWith({ type: 'error', message: timeoutMessage(state.requestTimeoutMs), code: 'BRIDGE_TIMEOUT' });
430
+ } else if (clientGone) {
431
+ finishWith({ type: 'error', message: '客户端已断开', code: 'ABORTED' });
432
+ } else {
433
+ finishWith({
434
+ type: 'error',
435
+ message: 'bridge 流提前结束(没有收到 finish)',
436
+ code: 'STREAM_INCOMPLETE',
437
+ });
438
+ }
439
+ }
440
+ } catch (err) {
441
+ if (timedOut) {
442
+ finishWith({ type: 'error', message: timeoutMessage(state.requestTimeoutMs), code: 'BRIDGE_TIMEOUT' });
443
+ } else {
444
+ const code = stringOf(err && err.code);
445
+ finishWith({
446
+ type: 'error',
447
+ message: errorMessage(err),
448
+ ...(code ? { code } : {}),
449
+ });
450
+ }
451
+ } finally {
452
+ clearTimeout(timer);
453
+ res.off('close', onClose);
454
+ state.streams.delete(controller);
455
+ try {
456
+ res.end();
457
+ } catch {}
458
+ }
459
+ }
460
+
461
+ function accumulateToolDelta(slots, chunk) {
462
+ const index = Number.isInteger(chunk.index) ? chunk.index : 0;
463
+ const slot = slots.get(index) || { id: '', name: '', arguments: '' };
464
+ if (typeof chunk.id === 'string' && chunk.id) slot.id = chunk.id;
465
+ // 名字是整体下发(不是增量),后面再出现就覆盖
466
+ if (typeof chunk.name === 'string' && chunk.name) slot.name = chunk.name;
467
+ if (typeof chunk.argumentsDelta === 'string') slot.arguments += chunk.argumentsDelta;
468
+ slots.set(index, slot);
469
+ }
470
+
471
+ /** block-end 携带组装好的 block;accumulator 兜底,防止 adapter 漏字段。 */
472
+ function toolEventFromBlock(slots, chunk) {
473
+ const block = chunk.block;
474
+ if (!block || block.type !== 'tool-call') return null;
475
+
476
+ const index = Number.isInteger(chunk.index) ? chunk.index : 0;
477
+ const slot = slots.get(index);
478
+ slots.delete(index);
479
+
480
+ const id = stringOf(block.id) || stringOf(slot && slot.id) || `call-${index}`;
481
+ const name = stringOf(block.name) || stringOf(slot && slot.name) || '';
482
+ const args =
483
+ typeof block.arguments === 'string' ? block.arguments : stringOf(slot && slot.arguments) || '{}';
484
+
485
+ return { type: 'tool', id, name, arguments: args };
486
+ }
487
+
488
+ function usageEvent(usage) {
489
+ const u = usage && typeof usage === 'object' ? usage : {};
490
+ const count = (value) => {
491
+ const n = Number(value);
492
+ return Number.isFinite(n) && n > 0 ? n : 0;
493
+ };
494
+ // DSH 的 inputTokens 不含缓存命中(三项互斥),promptTokens 取三者之和,
495
+ // 与 provider 的 prompt_tokens 口径一致。
496
+ return {
497
+ type: 'usage',
498
+ promptTokens: count(u.inputTokens) + count(u.cacheReadTokens) + count(u.cacheWriteTokens),
499
+ completionTokens: count(u.outputTokens),
500
+ };
501
+ }
502
+
503
+ function finishEvent(reason, state, timedOut) {
504
+ const kind = stringOf(reason && reason.kind) || 'stop';
505
+ const failure = reason && reason.failure;
506
+
507
+ if (kind === 'aborted') {
508
+ if (timedOut) {
509
+ return { type: 'error', message: timeoutMessage(state.requestTimeoutMs), code: 'BRIDGE_TIMEOUT' };
510
+ }
511
+ const code = stringOf(failure && failure.code);
512
+ return {
513
+ type: 'error',
514
+ message: stringOf(failure && failure.message) || '模型调用已中止',
515
+ ...(code ? { code } : {}),
516
+ };
517
+ }
518
+
519
+ if (kind === 'error') {
520
+ const code = stringOf(failure && failure.code);
521
+ return {
522
+ type: 'error',
523
+ message: stringOf(failure && failure.message) || '模型调用失败',
524
+ ...(code ? { code } : {}),
525
+ };
526
+ }
527
+
528
+ // stop / tool-calls / max-tokens(截断也是正常收尾)/ 未来新增的成功原因
529
+ return { type: 'done' };
530
+ }
531
+
532
+ function timeoutMessage(ms) {
533
+ if (ms >= 60_000 && ms % 60_000 === 0) return `bridge 请求超时(${ms / 60_000} 分钟)`;
534
+ return `bridge 请求超时(${ms}ms)`;
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // 请求体翻译:AgentFlow 的宽松结构 → DSH 的 RequestMessage / GenerateOptions
539
+ // ---------------------------------------------------------------------------
540
+
541
+ class BridgeRequestError extends Error {}
542
+ class BodyTooLargeError extends Error {}
543
+
544
+ function normalizeStreamRequest(body) {
545
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
546
+ throw new BridgeRequestError('请求体必须是 JSON 对象');
547
+ }
548
+
549
+ const provider = requiredString(body.provider, 'provider');
550
+ const model = requiredString(body.model, 'model');
551
+ const options = {
552
+ provider,
553
+ model,
554
+ messages: normalizeMessages(body.messages, provider, model),
555
+ };
556
+
557
+ if (body.reasoningEffort != null && body.reasoningEffort !== '') {
558
+ if (typeof body.reasoningEffort !== 'string') {
559
+ throw new BridgeRequestError('reasoningEffort 必须是字符串');
560
+ }
561
+ options.reasoningEffort = body.reasoningEffort;
562
+ }
563
+
564
+ if (body.system != null && body.system !== '') {
565
+ if (typeof body.system !== 'string') {
566
+ throw new BridgeRequestError('system 必须是字符串');
567
+ }
568
+ options.system = body.system;
569
+ }
570
+
571
+ const tools = normalizeTools(body.tools);
572
+ if (tools) options.tools = tools;
573
+
574
+ return options;
575
+ }
576
+
577
+ function requiredString(value, field) {
578
+ const text = stringOf(value).trim();
579
+ if (!text) throw new BridgeRequestError(`缺少 ${field}`);
580
+ return text;
581
+ }
582
+
583
+ function normalizeMessages(messages, provider, model) {
584
+ if (!Array.isArray(messages) || messages.length === 0) {
585
+ throw new BridgeRequestError('messages 必须是非空数组');
586
+ }
587
+ return messages.map((message, index) => normalizeMessage(message, index, provider, model));
588
+ }
589
+
590
+ function normalizeMessage(message, index, provider, model) {
591
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
592
+ throw new BridgeRequestError(`messages[${index}] 不是对象`);
593
+ }
594
+ const role = stringOf(message.role);
595
+
596
+ if (role === 'user') {
597
+ // RequestUserInput:不带 id/source,内容必须是块数组
598
+ return { role, content: normalizeContent(role, message.content, index) };
599
+ }
600
+
601
+ if (role === 'system') {
602
+ return {
603
+ id: `af-${index}`,
604
+ role,
605
+ content: normalizeContent(role, message.content, index),
606
+ source: { kind: 'system-prompt' },
607
+ };
608
+ }
609
+
610
+ if (role === 'assistant') {
611
+ return {
612
+ id: `af-${index}`,
613
+ role,
614
+ content: normalizeContent(role, message.content, index),
615
+ source: { kind: 'model', provider, model },
616
+ };
617
+ }
618
+
619
+ if (role === 'tool') {
620
+ const toolCallId = stringOf(message.toolCallId) || stringOf(message.tool_call_id);
621
+ if (!toolCallId) {
622
+ throw new BridgeRequestError(`messages[${index}](tool)缺少 toolCallId`);
623
+ }
624
+ return {
625
+ id: `af-${index}`,
626
+ role,
627
+ toolCallId,
628
+ content: normalizeContent(role, message.content, index),
629
+ source: { kind: 'tool', callId: toolCallId },
630
+ ...(message.isError === true ? { isError: true } : {}),
631
+ };
632
+ }
633
+
634
+ throw new BridgeRequestError(`messages[${index}] 的 role「${role || '(空)'}」不受支持`);
635
+ }
636
+
637
+ function normalizeContent(role, content, messageIndex) {
638
+ if (content == null) return [];
639
+ if (typeof content === 'string') {
640
+ return content === '' ? [] : [{ type: 'text', text: content }];
641
+ }
642
+ if (!Array.isArray(content)) {
643
+ throw new BridgeRequestError(`messages[${messageIndex}] 的 content 必须是字符串或数组`);
644
+ }
645
+
646
+ const blocks = [];
647
+ content.forEach((block, blockIndex) => {
648
+ if (typeof block === 'string') {
649
+ blocks.push({ type: 'text', text: block });
650
+ return;
651
+ }
652
+ if (!block || typeof block !== 'object') {
653
+ throw new BridgeRequestError(`messages[${messageIndex}].content[${blockIndex}] 不是对象`);
654
+ }
655
+ if (block.type === 'text') {
656
+ blocks.push({ type: 'text', text: stringOf(block.text) });
657
+ return;
658
+ }
659
+ if (role === 'assistant' && block.type === 'tool-call') {
660
+ blocks.push(normalizeToolCallBlock(block, messageIndex, blockIndex));
661
+ return;
662
+ }
663
+ throw new BridgeRequestError(
664
+ `messages[${messageIndex}].content[${blockIndex}] 的块类型「${stringOf(block.type) || '(空)'}」不受支持`,
665
+ );
666
+ });
667
+ return blocks;
668
+ }
669
+
670
+ function normalizeToolCallBlock(block, messageIndex, blockIndex) {
671
+ const name = stringOf(block.name);
672
+ if (!name) {
673
+ throw new BridgeRequestError(`messages[${messageIndex}].content[${blockIndex}] 的 tool-call 缺少 name`);
674
+ }
675
+ let args;
676
+ if (typeof block.arguments === 'string') {
677
+ args = block.arguments;
678
+ } else if (block.arguments && typeof block.arguments === 'object') {
679
+ args = JSON.stringify(block.arguments);
680
+ } else {
681
+ args = '{}';
682
+ }
683
+ return {
684
+ type: 'tool-call',
685
+ id: stringOf(block.id) || `call-${messageIndex}-${blockIndex}`,
686
+ name,
687
+ arguments: args,
688
+ };
689
+ }
690
+
691
+ function normalizeTools(tools) {
692
+ if (tools == null) return undefined;
693
+ if (!Array.isArray(tools)) throw new BridgeRequestError('tools 必须是数组');
694
+
695
+ const normalized = [];
696
+ tools.forEach((tool, index) => {
697
+ if (!tool || typeof tool !== 'object') {
698
+ throw new BridgeRequestError(`tools[${index}] 不是对象`);
699
+ }
700
+ const name = stringOf(tool.name).trim();
701
+ if (!name) throw new BridgeRequestError(`tools[${index}] 缺少 name`);
702
+ normalized.push({
703
+ name,
704
+ description: stringOf(tool.description),
705
+ parameters:
706
+ tool.parameters && typeof tool.parameters === 'object'
707
+ ? tool.parameters
708
+ : { type: 'object', properties: {} },
709
+ });
710
+ });
711
+ return normalized.length ? normalized : undefined;
712
+ }
713
+
714
+ // ---------------------------------------------------------------------------
715
+ // IO / 小工具
716
+ // ---------------------------------------------------------------------------
717
+
718
+ function sendJson(res, status, body) {
719
+ if (res.headersSent) return;
720
+ const text = JSON.stringify(body);
721
+ try {
722
+ res.writeHead(status, {
723
+ 'content-type': 'application/json; charset=utf-8',
724
+ 'content-length': Buffer.byteLength(text),
725
+ 'cache-control': 'no-store',
726
+ });
727
+ res.end(text);
728
+ } catch {}
729
+ }
730
+
731
+ function isAuthorized(req, token) {
732
+ const header = req.headers.authorization;
733
+ if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
734
+ const provided = Buffer.from(header.slice('Bearer '.length), 'utf8');
735
+ const expected = Buffer.from(token, 'utf8');
736
+ return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
737
+ }
738
+
739
+ function isLocalHost(host) {
740
+ return typeof host === 'string' && LOCAL_HOST_RE.test(host);
741
+ }
742
+
743
+ function readBody(req, limit) {
744
+ return new Promise((resolve, reject) => {
745
+ let size = 0;
746
+ let settled = false;
747
+ const chunks = [];
748
+
749
+ const cleanup = () => {
750
+ req.off('data', onData);
751
+ req.off('end', onEnd);
752
+ req.off('error', onError);
753
+ req.off('aborted', onAborted);
754
+ };
755
+ const fail = (err) => {
756
+ if (settled) return;
757
+ settled = true;
758
+ cleanup();
759
+ reject(err);
760
+ };
761
+ const onData = (chunk) => {
762
+ size += chunk.length;
763
+ if (size > limit) {
764
+ fail(new BodyTooLargeError('body too large'));
765
+ return;
766
+ }
767
+ chunks.push(chunk);
768
+ };
769
+ const onEnd = () => {
770
+ if (settled) return;
771
+ settled = true;
772
+ cleanup();
773
+ resolve(Buffer.concat(chunks).toString('utf8'));
774
+ };
775
+ const onError = (err) => fail(err);
776
+ const onAborted = () => fail(new Error('请求被中断'));
777
+
778
+ req.on('data', onData);
779
+ req.on('end', onEnd);
780
+ req.on('error', onError);
781
+ req.on('aborted', onAborted);
782
+ });
783
+ }
784
+
785
+ function listenOnce(server, port) {
786
+ return new Promise((resolve, reject) => {
787
+ const onListening = () => {
788
+ server.off('error', onError);
789
+ resolve();
790
+ };
791
+ const onError = (err) => {
792
+ server.off('listening', onListening);
793
+ reject(err);
794
+ };
795
+ server.once('listening', onListening);
796
+ server.once('error', onError);
797
+ try {
798
+ server.listen(port, '127.0.0.1');
799
+ } catch (err) {
800
+ server.off('listening', onListening);
801
+ server.off('error', onError);
802
+ reject(err);
803
+ }
804
+ });
805
+ }
806
+
807
+ function closeServer(server) {
808
+ return new Promise((resolve) => {
809
+ if (!server.listening) {
810
+ resolve();
811
+ return;
812
+ }
813
+ server.close(() => resolve());
814
+ // keep-alive 的空闲连接会拖住 close,直接收掉
815
+ try {
816
+ server.closeAllConnections?.();
817
+ } catch {}
818
+ });
819
+ }
820
+
821
+ async function writeInfoFile(state) {
822
+ await fs.mkdir(path.dirname(state.infoFile), { recursive: true });
823
+ const payload = JSON.stringify({
824
+ version: 1,
825
+ pid: process.pid,
826
+ port: state.port,
827
+ token: state.token,
828
+ startedAt: state.startedAt,
829
+ });
830
+ // 先写临时文件再 rename,避免调用方读到半个 JSON
831
+ const tmp = `${state.infoFile}.${process.pid}.tmp`;
832
+ await fs.writeFile(tmp, payload, { mode: 0o600 });
833
+ try {
834
+ await fs.chmod(tmp, 0o600);
835
+ } catch {}
836
+ await fs.rename(tmp, state.infoFile);
837
+ }
838
+
839
+ async function removeInfoFile(state) {
840
+ try {
841
+ const raw = await fs.readFile(state.infoFile, 'utf8');
842
+ const info = JSON.parse(raw);
843
+ // 只删自己写的那份:多实例/热重载时不误伤别人
844
+ if (info && info.pid === process.pid && info.token === state.token) {
845
+ await fs.unlink(state.infoFile);
846
+ }
847
+ } catch {}
848
+ }
849
+
850
+ function stringOf(value) {
851
+ return typeof value === 'string' ? value : '';
852
+ }
853
+
854
+ function errorMessage(err) {
855
+ if (err && typeof err.message === 'string' && err.message) return err.message;
856
+ const text = String(err);
857
+ return text && text !== '[object Object]' ? text : '未知错误';
858
+ }