base.js
26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
/**
* LLMBase —— 适配器三段式的「公共一处」。
*
* 公共能力全在这里、且只在这里(生命周期编排):
* - attempt 生命周期(acquire→fetch→消费流→release)、每 attempt 独立超时与组合 signal
* - SSE 帧切分外移至 sse.js、mime 魔数外移至 media.js,本文件一行 import 接入、不含其逻辑
* - 增量回调编排(text→on_content、reasoning→on_thinking、tool_call→on_tool_call)
* - usage 汇总与 reasoning/text 累积
* - 重试退避(瞬态错误重试,经 @drama/service-call 纯函数判定)
* - trace(经注入的 tracePort,缺省 no-op;本包不写盘、不读全局上下文)
* - 多模态消息中间态构建(images/videos → 统一中间态,交由子类落各厂商形态)
*
* 子类(providers/llm.<apiType>.js)只覆写两件事:
* - async chat(req, opts):拼本厂商 body/headers,调 this.request(...)
* - messageToValue(rawFrame):把本厂商 SSE 帧解析成统一 { type, ... }
*
* 边界纪律:构造里不建连接、不 mkdir、不 appendFile;apiKey/baseUrl 不静态持有,
* 每次 request 从注入的 keyProvider.acquire() 取、finally release()。
*/
import { LLMError } from './errors.js'
import { mapHttpStatus, mapThrownError, isAbortError, mapChatFinishReason } from './map-error.js'
import { iterateSse } from './sse.js'
import { sniffMime, parseDataUrl } from './media.js'
import { normalizeResponseFormat } from './response-format.js'
import {
DEFAULT_RETRY_POLICY,
shouldRetry,
backoffDelayMs,
isRetryableReason,
} from '@drama/service-call'
/** 外部 abort reason 常量(与 map-error.js 的 'aborted' 对齐,消除魔法字符串)。 */
const ABORTED_REASON = 'aborted'
export const EMPTY_USAGE = {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
cached_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
}
/**
* @typedef {import('./map-error.js').LLMErrorReason} LLMErrorReason
* @typedef {import('@drama/service-call').RetryPolicy} RetryPolicy
*/
/**
* 多模态归一后的单一形态:恰含 (mime+data) 或 url 其一。
* @typedef {object} MediaPart
* @property {'image'|'video'} kind
* @property {string} [mime] 本地字节的 mime(sniff/dataUrl 得),与 data 同时存在。
* @property {string} [data] base64 负载。
* @property {string} [url] 远端直传 URL(无本地 mime)。
*/
/**
* 统一中间帧(messageToValue 的产物)。messageToValue 可返回单个 ParsedValue、其数组、或 null;
* 同帧多产物(如 Gemini 同帧 text+usage、OpenAI 同帧多路 tool_call 分片)以数组返回,base 展开消费。
* @typedef {object} ParsedValue
* @property {'text'|'reasoning'|'tool_call'|'tool_call_delta'|'usage'|'metadata'|'finish'} type
* @property {string} [text] type==='text'|'reasoning' 时的增量。
* @property {Array<object>} [tool_calls] type==='tool_call' 时的完整(中性)调用列表。
* @property {number} [index] type==='tool_call_delta' 时的分片归属序号(同一 index 聚合为一个调用)。
* @property {string} [id] type==='tool_call_delta' 时的调用 id(首帧给出)。
* @property {string} [name] type==='tool_call_delta' 时的工具名(首帧给出)。
* @property {string} [args_fragment] type==='tool_call_delta' 时的 arguments JSON 分片(跨帧拼接)。
* @property {Partial<import('./index.js').ChatUsage>} [usage]
* @property {string} [provider_request_id]
* @property {string} [service_tier]
* @property {string|null} [finish_reason]
*/
/**
* request() 返回的中性聚合结果(不含任何厂商专有字段)。
* @typedef {object} StreamResult
* @property {string} content
* @property {string} [reasoning]
* @property {Array<object>} [tool_calls]
* @property {import('./index.js').ChatUsage} usage
* @property {string|null} provider_request_id
* @property {string|null} service_tier
* @property {string|null} [finish_reason]
* @property {{ ttft_ms:number|null, duration_ms:number }} [timings]
* 计时(仅成功路径有):ttft_ms 为首个 content/reasoning 帧相对 startedAt 的偏移(无帧则 null),
* duration_ms 为流消费完成相对 startedAt 的偏移。
*/
export class LLMBase {
/**
* @param {object} cfg
* @param {import('./index.js').KeyProvider} cfg.keyProvider
* @param {string} cfg.provider api_type,决定协议子类与 LLMError.provider。
* @param {string} [cfg.vendor] keyProvider 隔离维度(厂商身份);缺省回落 provider。
* @param {string} [cfg.model] 默认模型。
* @param {import('./index.js').TracePort} cfg.tracePort
* @param {RetryPolicy} [cfg.retryPolicy]
* @param {() => number} [cfg.nowMs] 注入时钟(计时用);缺省 Date.now。
*/
constructor({ keyProvider, provider, vendor, model, tracePort, retryPolicy, nowMs }) {
this.keyProvider = keyProvider
this.provider = provider
this.vendor = vendor ?? provider
this.model = model
this.tracePort = tracePort
this.retryPolicy = retryPolicy ?? DEFAULT_RETRY_POLICY
// 全局 fetch,无注入缝:测试打真实 HTTP 上游(base_url 指向本地真服务),不再注入假 fetch。
this.httpFetch = fetch
this.nowMs = nowMs ?? Date.now
}
/**
* 子类必须覆写:拼本厂商请求并返回中性 StreamResult。
* @param {import('./index.js').ChatRequest} _req
* @param {import('./index.js').ChatOpts} [_opts]
* @returns {Promise<StreamResult>}
*/
async chat(_req, _opts) {
throw new Error('LLMBase.chat must be overridden by a provider subclass')
}
/**
* 子类必须覆写:把一帧厂商 SSE data 的已解析 JSON 转成统一 ParsedValue(或 null 跳过)。
* @param {any} _rawFrame
* @returns {ParsedValue | null}
*/
messageToValue(_rawFrame) {
throw new Error('LLMBase.messageToValue must be overridden by a provider subclass')
}
/**
* 能力声明钩子:本 provider 是否支持视频输入。缺省 false(openai/anthropic/moonshot/azure_foundry),
* 视频厂商(gemini/volcengine)覆写返回 true。与 supportsThinkingParam() 同为能力钩子,
* 非厂商 if 分支——video 护栏在 request() 单点消费它,基类零厂商分支。
* @protected
* @returns {boolean}
*/
supportsVideo() {
return false
}
/**
* 多模态归一入口(唯一 mime 归一点):把 images/videos(Buffer|string)按 MECE 四态归一为
* 单一 MediaPart 形态(恰含 mime+data 或 url 其一),交由子类落各厂商消息形态。
* 落地处零 mime 回退:未识别字节在此一次性 badRequest,子类信任 part.mime 非空。
* @param {Array<Buffer|string>|undefined} items
* @param {'image'|'video'} kind
* @returns {Array<MediaPart>}
*/
buildMediaParts(items, kind) {
if (!items || items.length === 0) return []
return items.map((it) => {
if (Buffer.isBuffer(it)) {
const mime = sniffMime(it)
if (mime == null) throw this._mediaError(kind)
return { kind, mime, data: it.toString('base64') }
}
if (it.startsWith('data:')) {
const parsed = parseDataUrl(it)
if (parsed == null) throw this._mediaError(kind)
return { kind, mime: parsed.mime, data: parsed.data }
}
if (it.startsWith('http')) return { kind, url: it }
const mime = sniffMime(Buffer.from(it, 'base64'))
if (mime == null) throw this._mediaError(kind)
return { kind, mime, data: it }
})
}
/**
* @private
* @param {'image'|'video'} kind
* @returns {LLMError}
*/
_mediaError(kind) {
return new LLMError({
provider: this.provider,
reason: 'badRequest',
retryable: false,
message: `[${this.provider}] unrecognized ${kind} bytes (no mime magic match)`,
})
}
/**
* responseFormat 分派表钩子:provider 覆写返回「归一 type → 落地函数」表。
* 缺省空表——任何 responseFormat 都由 applyResponseFormat 统一 badRequest(不支持结构化输出)。
* 支持厂商只声明自己能表达的 type(如 moonshot 仅 json_object),不能表达的 type 天然落 badRequest,
* 无需各写 if-else。
* @protected
* @returns {Record<string, (body:Record<string,any>, headers:Record<string,string>, rf:{schema?:object, name?:string}) => void>}
*/
responseFormatHandlers() {
return {}
}
/**
* responseFormat(structured output)唯一落地入口:入口一次归一 → 按归一 type 查 provider 分派表 →
* 命中即落地,未命中显式 badRequest。子类零 if-else、零静默忽略;每个 provider 的 chat 调它一次(null 时 no-op)。
* @protected
* @param {Record<string, any>} body
* @param {Record<string, string>} headers
* @param {object|undefined} responseFormat
* @returns {void}
*/
applyResponseFormat(body, headers, responseFormat) {
if (responseFormat == null) return
const normalized = normalizeResponseFormat(responseFormat, this.provider)
const handler = this.responseFormatHandlers()[normalized.type]
if (!handler) {
throw new LLMError({
provider: this.provider,
reason: 'badRequest',
retryable: false,
message: `[${this.provider}] does not support responseFormat type '${normalized.type}'`,
})
}
handler(body, headers, normalized)
}
/**
* 公共请求执行:acquire key → 带重试地发请求并解析流 → reportUsage/reportRateLimited → release。
* 子类调它,传入已拼好的「发请求闭包」`doFetch(lease)`,本方法负责其余全部编排。
*
* @param {object} args
* @param {(lease: import('./index.js').KeyLease) => Promise<Response>} args.doFetch
* 子类用 lease.apiKey/baseUrl 拼 body/headers 并发 fetch,返回原始 Response。
* @param {import('./index.js').ChatRequest} args.req
* @param {import('./index.js').ChatOpts} [args.opts]
* @param {string} [args.affinity_key]
* @returns {Promise<StreamResult>}
*/
async request({ doFetch, req, opts = {}, affinity_key }) {
// 视频护栏(唯一点):所有子类 chat 都经此。缺省 supportsVideo()===false 覆盖
// openai/anthropic/moonshot/azure_foundry;视频厂商各自覆写返回 true。基类零厂商分支。
if (req.videos?.length && !this.supportsVideo()) {
throw new LLMError({
provider: this.provider,
reason: 'badRequest',
retryable: false,
message: `[${this.provider}] video input is not supported by this provider`,
})
}
const span = this.tracePort.startSpan(`llm.chat:${this.provider}`)
span.setAttr('provider', this.provider)
span.setAttr('model', req.model ?? this.model ?? '')
// sessionId 一次入口归一:有值才落 span 属性(对齐 gen_ai 语义约定),无值不写占位。
const sessionId = opts.trace_ctx?.session_id
if (sessionId) span.setAttr('gen_ai.conversation.id', sessionId)
// 计时起点:整个 request(含重试)相对它测 ttft / duration,交由 _consumeStream 采样。
const startedAt = this.nowMs()
// 单一重试循环:attempt 从 0(首次)起算;每次失败按 @drama/service-call 纯函数判定是否再试。
let attempt = 0
try {
for (;;) {
if (opts.signal?.aborted) {
throw new LLMError({ provider: this.provider, reason: ABORTED_REASON, retryable: false })
}
try {
return await this._attempt({ doFetch, req, opts, affinity_key, span, startedAt })
} catch (err) {
const mapped = this._toLLMError(err)
// 外部 abort 不算可重试错误、不重试、不告警;内部 timeout 不落此短路(reason 不同),正常进重试。
if (isAbortError(err) || mapped.reason === ABORTED_REASON) {
span.addEvent('aborted', {})
throw mapped
}
attempt += 1
span.addEvent('error', { reason: mapped.reason, attempt })
if (!shouldRetry(attempt, mapped.retryable && isRetryableReason(mapped.reason), this.retryPolicy)) {
throw mapped
}
await sleep(backoffDelayMs(attempt, this.retryPolicy), opts.signal)
}
}
} finally {
span.end()
}
}
/**
* 单次尝试:acquire key → doFetch → 非 2xx 归一并上报 → 解析流 → reportUsage → 必 release。
* 任何失败都抛(由 request 的循环决定是否重试)。
* @private
* @param {object} a
* @param {(lease: import('./index.js').KeyLease) => Promise<Response>} a.doFetch
* @param {import('./index.js').ChatRequest} a.req
* @param {import('./index.js').ChatOpts} a.opts
* @param {string} [a.affinity_key]
* @param {import('./index.js').Span} a.span
* @param {number} a.startedAt
* @returns {Promise<StreamResult>}
*/
async _attempt({ doFetch, req, opts, affinity_key, span, startedAt }) {
const lease = await this.keyProvider.acquire({
vendor: `text:${this.vendor}`,
model: req.model ?? this.model,
affinity_key,
})
// 这次 attempt 是哪把 key 服务的——审计要归因(认证失败时尤其),故 lease.keyId 必须离开这里。
// 本包不认识 keyrouter:keyId 只是 lease 上的一个值,经回调原样交给上层,不做解释。
opts.on_key_lease?.(lease.key_id)
// 每 attempt 独立计时:内部 AbortController 用 TimeoutError 中止,AbortSignal.any 组合外部 opts.signal。
const ac = new AbortController()
const signals = opts.signal ? [ac.signal, opts.signal] : [ac.signal]
const attemptSignal = AbortSignal.any(signals)
const timer = opts.timeout_ms
? setTimeout(() => ac.abort(new DOMException('llm attempt timeout', 'TimeoutError')), opts.timeout_ms)
: null
try {
const res = await doFetch(lease, { signal: attemptSignal })
if (!res.ok) {
const { reason, retryable } = mapHttpStatus(res.status)
const bodyText = await safeReadText(res)
const body = httpErrorBody(res, bodyText)
const errorInfo = this.httpErrorInfo(res, body, reason)
if (reason === 'rateLimit') this.keyProvider.report_rate_limited(lease.key_id, retryAfterMs(res))
else if (reason === 'auth') this.keyProvider.report_error(lease.key_id, 'auth')
else if (reason === 'transient') this.keyProvider.report_error(lease.key_id, 'transient')
throw new LLMError({
provider: this.provider,
reason,
retryable,
cause: body,
...errorInfo,
detail: {
type: 'http',
status: res.status,
headers: Object.fromEntries(res.headers.entries()),
body,
},
})
}
const result = await this._consumeStream(res, attemptSignal, opts, span, startedAt)
this.keyProvider.report_usage(lease.key_id, result.usage)
span.addEvent('usage', result.usage)
return result
} finally {
if (timer) clearTimeout(timer)
// release 必被 await(兼容同步/异步);失败不覆盖返回值,仅 span 落痕。
try {
await lease.release()
} catch (e) {
span.addEvent('release_error', { message: String(e?.message ?? e) })
}
}
}
/**
* 消费 SSE 流:分帧 → messageToValue → 累积 text/reasoning/tool_calls/usage + 触发回调。
* @private
* @param {Response} res
* @param {AbortSignal} attemptSignal 每 attempt 的组合信号(内部超时 + 外部 abort);中途可中断读循环。
* @param {import('./index.js').ChatOpts} opts
* @param {import('./index.js').Span} span
* @param {number} startedAt 计时起点(this.nowMs() 域);ttft/duration 相对它测。
* @returns {Promise<StreamResult>}
*/
async _consumeStream(res, attemptSignal, opts, span, startedAt) {
let content = ''
let reasoning = ''
/** @type {number | undefined} 首个 content/reasoning 帧的到达时刻(TTFT 采样点,只采一次)。 */
let firstFrameAt
/** @type {Array<object>} */
const tool_calls = []
/**
* 分片 tool_call 聚合缓冲(协议无关,base 独有):index → { id?, name?, argsText }。
* 真实流的 function.arguments 跨多帧分片,provider 逐帧发 tool_call_delta,base 按 index 聚合,
* 流结束时对每个完整调用一次 JSON.parse 得中性 { id, name, args(object) }。每次 _consumeStream 独立、不跨 attempt。
* @type {Map<number, { id?:string, name?:string, argsText:string }>}
*/
const toolCallBuffers = new Map()
/** @type {StreamResult['usage']} */
let usage = { ...EMPTY_USAGE }
let provider_request_id = null
let service_tier = null
let finish_reason = null
// 只有输出已经通过回调交付给调用方,才存在不可撤销的流式副作用;无回调时结果尚未离开
// Promise,失败后仍可安全重试。已交付后禁止 retry/fallback,避免混入两个 attempt 的内容。
let emittedOutput = false
try {
for await (const frame of iterateSse(res, attemptSignal)) {
opts.on_raw_frame?.(frame)
// messageToValue 单值/数组统一归一为数组,在入口一处展开(同帧多产物:text+usage、多路 tool_call 分片)。
for (const value of toValues(this.messageToValue(frame))) {
switch (value.type) {
case 'text':
if (value.text) {
content += value.text
firstFrameAt ??= this.nowMs()
if (opts.on_content) {
emittedOutput = true
opts.on_content(value.text)
}
}
break
case 'reasoning':
if (value.text) {
reasoning += value.text
firstFrameAt ??= this.nowMs()
if (opts.on_thinking) {
emittedOutput = true
opts.on_thinking(value.text)
}
}
break
case 'tool_call':
// 完整(非分片)中性调用:provider 已给 { id, name, args(object) }(整帧下发的厂商)。
if (value.tool_calls?.length) {
tool_calls.push(...value.tool_calls)
if (opts.on_tool_call) {
emittedOutput = true
opts.on_tool_call(value.tool_calls)
}
}
break
case 'tool_call_delta':
bufferToolCallDelta(toolCallBuffers, value)
break
case 'usage':
if (value.usage) usage = { ...usage, ...value.usage }
break
case 'metadata':
provider_request_id = value.provider_request_id
service_tier = value.service_tier
break
case 'finish':
finish_reason = normalizeFinishReason(value.finish_reason)
break
default:
throw new Error(`Unhandled parsed value type: ${value.type}`)
}
}
}
} catch (err) {
if (!emittedOutput) throw err
const mapped = this._toLLMError(err)
throw new LLMError({
provider: this.provider,
reason: mapped.reason,
retryable: false,
cause: err,
message: `[${this.provider}] stream failed after output started: ${mapped.reason}`,
detail: { type: 'stream', error: mapped.detail },
})
}
// 流结束:把聚合完成的分片 tool_call 落成中性 { id, name, args(object) },一次性回调。
const aggregated = finalizeToolCalls(toolCallBuffers)
if (aggregated.length) {
tool_calls.push(...aggregated)
opts.on_tool_call?.(aggregated)
}
span.addEvent('content', { length: content.length })
// Anthropic 分帧只给输入和输出分量,流结束时补齐总量。
if (!usage.total_tokens) {
usage = {
...usage,
total_tokens: usage.prompt_tokens + usage.completion_tokens,
}
}
const finishError = chatFinishReasonError({
provider: this.provider,
finish_reason,
content_length: content.length,
usage,
provider_request_id,
})
if (finishError) {
if (emittedOutput) {
throw new LLMError({
provider: this.provider,
reason: finishError.reason,
retryable: false,
cause: finishError,
message: `[${this.provider}] stream ended with finish_reason=${finish_reason} after output started`,
detail: {
type: 'stream_finish_reason',
finish_reason,
content_length: content.length,
usage,
},
provider_request_id,
})
}
throw finishError
}
/** @type {StreamResult} */
const out = { content, usage, provider_request_id, service_tier, finish_reason }
if (reasoning) out.reasoning = reasoning
if (tool_calls.length) out.tool_calls = tool_calls
// 计量取整只在出口一次:ttft 仅在有帧时有值,duration 恒为流消费总时长。
out.timings = {
ttft_ms: firstFrameAt === undefined ? null : firstFrameAt - startedAt,
duration_ms: this.nowMs() - startedAt,
}
return out
}
/**
* 把任意抛出物归一为 LLMError(已是 LLMError 则原样)。
* @private
* @param {unknown} err
* @returns {LLMError}
*/
_toLLMError(err) {
if (err instanceof LLMError) return err
const { reason, retryable } = mapThrownError(err)
if (!(err instanceof Error)) throw new Error(`[${this.provider}] provider 抛出了非 Error 值`)
return new LLMError({
provider: this.provider,
reason,
retryable,
cause: err,
message: err.name === 'KeyRouterError' ? `[${this.provider}] ${err.message}` : undefined,
detail: { type: 'runtime', name: err.name, message: err.message },
})
}
httpErrorInfo(res, _body, reason) {
return {
message: `[${this.provider}] HTTP ${res.status}: ${reason}`,
provider_request_id: res.headers.get('x-request-id'),
}
}
}
function normalizeFinishReason(reason) {
if (reason === null || typeof reason === 'string') return reason
throw new Error(`finish_reason must be string or null, got ${typeof reason}`)
}
function chatFinishReasonError({ provider, finish_reason, content_length, usage, provider_request_id }) {
const mapped = mapChatFinishReason(finish_reason)
if (mapped === null) return null
return new LLMError({
provider,
reason: mapped.reason,
retryable: mapped.retryable,
message: `[${provider}] chat completion ended with finish_reason=${finish_reason}`,
detail: {
type: 'finish_reason',
finish_reason,
content_length,
usage,
},
provider_request_id,
})
}
/**
* messageToValue 返回归一为 ParsedValue 数组(单值/数组/null 三态一处收口),消费端零形态判断。
* @param {import('./base.js').ParsedValue | Array<import('./base.js').ParsedValue> | null | undefined} v
* @returns {Array<import('./base.js').ParsedValue>}
*/
function toValues(v) {
if (v == null) return []
return Array.isArray(v) ? v : [v]
}
/**
* 把一帧 tool_call_delta 并入按 index 聚合的缓冲:id/name 首帧给出(合并),args_fragment 跨帧拼接。
* @param {Map<number, { id?:string, name?:string, argsText:string }>} buffers
* @param {{ index?:number, id?:string, name?:string, args_fragment?:string }} delta
* @returns {void}
*/
function bufferToolCallDelta(buffers, delta) {
const index = delta.index ?? 0
let buf = buffers.get(index)
if (!buf) {
buf = { argsText: '' }
buffers.set(index, buf)
}
if (delta.id != null) buf.id = delta.id
if (delta.name != null) buf.name = delta.name
if (delta.args_fragment) buf.argsText += delta.args_fragment
}
/**
* 聚合缓冲 → 中性 tool_call 列表:按 index 升序,每个 argsText 做一次 JSON.parse(args 恒为对象)。
* args 为空时归一为 {};非法 JSON 由 JSON.parse 自然抛出(真实上游不会给坏 JSON,坏则是真错误)。
* @param {Map<number, { id?:string, name?:string, argsText:string }>} buffers
* @returns {Array<{ id?:string, name?:string, args:object }>}
*/
function finalizeToolCalls(buffers) {
return [...buffers.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, buf]) => ({ id: buf.id, name: buf.name, args: JSON.parse(buf.argsText || '{}') }))
}
/**
* 安全读取响应文本(错误体),失败返回空串。
* @param {Response} res
* @returns {Promise<string>}
*/
async function safeReadText(res) {
try {
return await res.text()
} catch {
return ''
}
}
function httpErrorBody(res, bodyText) {
const contentType = res.headers.get('content-type')
return contentType !== null && contentType.startsWith('application/json')
? JSON.parse(bodyText)
: bodyText
}
/**
* 从 429 响应取 Retry-After(秒)→ 毫秒;无则 undefined。
* @param {Response} res
* @returns {number | undefined}
*/
function retryAfterMs(res) {
const h = res.headers?.get?.('retry-after')
if (!h) return undefined
const sec = Number(h)
return Number.isFinite(sec) ? sec * 1000 : undefined
}
/**
* 可被 abort 的 sleep。
* @param {number} ms
* @param {AbortSignal} [signal]
* @returns {Promise<void>}
*/
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(abortReason(signal))
return
}
const t = setTimeout(() => {
signal?.removeEventListener?.('abort', onAbort)
resolve()
}, ms)
function onAbort() {
clearTimeout(t)
reject(abortReason(signal))
}
signal?.addEventListener?.('abort', onAbort, { once: true })
})
}
/**
* 中止原因:优先透传 signal.reason(内部超时的 TimeoutError / 外部的 AbortError),缺省造 AbortError。
* @param {AbortSignal} [signal]
* @returns {unknown}
*/
function abortReason(signal) {
return signal?.reason ?? Object.assign(new Error('aborted'), { name: 'AbortError' })
}