@kukonce/dsh-loomy 0.0.0-stage → 0.3.2

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,698 @@
1
+ /**
2
+ * The Loomy wire adapter.
3
+ *
4
+ * Duck-typed against the harness `LlmAdapter` contract (`providerInfo`, `listModels`,
5
+ * `resolveModel`, `prepareCall` -> `{ model, stream }`) instead of importing
6
+ * `@deepseek-ai/dsh-llm`. Two reasons: the npm package stays installable on its own,
7
+ * and a duplicate copy of the harness package cannot make `instanceof` checks fail.
8
+ *
9
+ * Wire shape, all on one OpenAI-compatible endpoint:
10
+ *
11
+ * POST {gateway}/chat/completions
12
+ * Authorization: Bearer <session> and token: <session> (both are checked)
13
+ * traceparent: 00-<32hex>-<16hex>-01 (the gateway stalls without it)
14
+ * loomy-version, X-Loomy-Request-Purpose (metering attribution)
15
+ *
16
+ * Streaming is the only mode that carries `reasoning_content`, which is where most of
17
+ * a reasoning model's tokens go. Measured 2026-11-11: with the default catalog effort,
18
+ * a two-sentence reply spent ~130 reasoning characters and ~35 answer characters, and
19
+ * `max_tokens` counts both. That is why the adapter raises a too-small ceiling instead of
20
+ * letting the answer be silently truncated (see `resolveBudget`).
21
+ */
22
+
23
+ import { randomUUID } from 'node:crypto'
24
+
25
+ import { CLIENT_VERSION, GATEWAY_BASE_URL, PURPOSE_CHAT_MESSAGE } from '../core/endpoints.js'
26
+ import { localDayKey } from '../core/http.js'
27
+ import { accountOf } from '../account/auth.js'
28
+ import { recordPointsSpent } from '../account/credits.js'
29
+ import { isChatModel, loadCatalog, placeholderEntry } from '../account/models.js'
30
+ import { isAuthFailure, markExpired, sessionOf } from '../account/session.js'
31
+ import { classifyEnding, isTruncatedArguments, normalizeToolArguments } from './endings.js'
32
+ import { SSE_DONE, readSse } from './sse.js'
33
+ import { hasUsableToolName, toWireMessages } from './wire.js'
34
+
35
+ /** How long a live catalog stays fresh inside one adapter instance. */
36
+ const CATALOG_TTL_MS = 10 * 60_000
37
+
38
+ /**
39
+ * The floor this adapter puts under `max_tokens`.
40
+ *
41
+ * Reasoning models bill their thinking against the same ceiling as their answer, and
42
+ * the thinking is not optional: with a 200-token ceiling the whole budget went to
43
+ * reasoning and the reply came back empty with `finish_reason: length`. 1024 leaves
44
+ * room for a short thinking pass plus a real answer, which is what a tool-calling step
45
+ * needs. A caller asking for more keeps its own value.
46
+ */
47
+ const MIN_OUTPUT_TOKENS = 1024
48
+
49
+ /** Reasoning effort ids, in the order the picker offers them. */
50
+ const EFFORT_LABELS = {
51
+ none: 'None',
52
+ low: 'Low',
53
+ medium: 'Medium',
54
+ high: 'High',
55
+ xhigh: 'Extra high'
56
+ }
57
+
58
+ const effortName = (id) => EFFORT_LABELS[id] ?? id.charAt(0).toUpperCase() + id.slice(1)
59
+
60
+ /**
61
+ * Build the error the harness classifies.
62
+ *
63
+ * `code` is what the retry policy and the transcript read, so it is set on both the
64
+ * error itself (for a caller that inspects it) and on a `failure` object (for the
65
+ * adapter-failure normalizer, which reads only that shape).
66
+ */
67
+ function llmError(message, code, extra = {}) {
68
+ const error = new Error(message)
69
+ error.name = 'LlmError'
70
+ error.code = code
71
+ Object.assign(error, extra)
72
+ error.failure = { message, code, ...(extra.status === undefined ? {} : { status: extra.status }) }
73
+ return error
74
+ }
75
+
76
+ /** A readable reason out of a gateway error body, which comes in several shapes. */
77
+ function errorDetail(body) {
78
+ try {
79
+ const data = JSON.parse(body)
80
+ const localized = typeof data?.error?.message === 'string' ? data.error.message : undefined
81
+ const parts = [
82
+ localized,
83
+ typeof data?.desc === 'string' ? data.desc : undefined,
84
+ typeof data?.message === 'string' ? data.message : undefined,
85
+ typeof data?.msg === 'string' ? data.msg : undefined
86
+ ].filter((value) => typeof value === 'string' && value.length > 0)
87
+ if (parts.length > 0) return parts.join(' ')
88
+ } catch {
89
+ // not JSON: fall through to the raw prefix
90
+ }
91
+ const raw = String(body ?? '')
92
+ // The gateway answers an upstream failure with a full HTML error page. Pasted into a
93
+ // transcript that is unreadable, and it buries the one fact worth keeping -- which
94
+ // proxy produced it. Reduce it to a single line naming the origin when there is one.
95
+ if (/<html/i.test(raw)) {
96
+ const origin = /<(?:center|address|hr)[^>]*>\s*([^<\s][^<]{0,60})\s*</i.exec(raw)?.[1]?.trim()
97
+ const title = /<title>([^<]{0,120})<\/title>/i.exec(raw)?.[1]?.trim()
98
+ return [title, origin !== undefined && origin !== title ? `(来自 ${origin})` : ''].filter(Boolean).join('')
99
+ || '上游返回了一个 HTML 错误页'
100
+ }
101
+ return raw.slice(0, 400)
102
+ }
103
+
104
+ /**
105
+ * Map an HTTP failure onto the harness failure code.
106
+ *
107
+ * `100002` is Loomy's own "session is dead" code and arrives with a 200 on the points
108
+ * API, but the gateway uses a plain 401/403 -- both have to read as `AUTH` or the retry
109
+ * package will happily rerun a request that cannot succeed.
110
+ */
111
+ function httpErrorCode(status, body) {
112
+ if (status === 401 || status === 403) return 'AUTH'
113
+ if (status === 429) return 'RATE_LIMIT'
114
+ if (status === 400) {
115
+ return /context|too long|prompt is too long|exceed/i.test(String(body)) ? 'CONTEXT_WINDOW_EXCEEDED' : 'INVALID_REQUEST'
116
+ }
117
+ if (/insufficient|积分余额不足|points/i.test(String(body))) return 'QUOTA'
118
+ if (status >= 500) return 'SERVER'
119
+ return status >= 400 ? `HTTP_${status}` : 'UNKNOWN'
120
+ }
121
+
122
+ /** `traceparent` per the W3C trace-context format. */
123
+ function traceparent() {
124
+ const trace = randomUUID().replace(/-/gu, '')
125
+ const span = randomUUID().replace(/-/gu, '').slice(0, 16)
126
+ return `00-${trace}-${span}-01`
127
+ }
128
+
129
+ /**
130
+ * Merge two usage reports, keeping the larger of each counter.
131
+ *
132
+ * The gateway sends usage once at the end, but a proxied stream can repeat it; summing
133
+ * would double-count the prompt, so the merge takes the max, which is correct for both.
134
+ */
135
+ function mergeUsage(previous, next) {
136
+ if (next === undefined) return previous
137
+ if (previous === undefined) return next
138
+ const pick = (key) => {
139
+ const a = previous[key]
140
+ const b = next[key]
141
+ if (typeof a !== 'number') return b
142
+ if (typeof b !== 'number') return a
143
+ return Math.max(a, b)
144
+ }
145
+ return {
146
+ inputTokens: pick('inputTokens'),
147
+ outputTokens: pick('outputTokens'),
148
+ ...(previous.cacheReadTokens === undefined && next.cacheReadTokens === undefined
149
+ ? {}
150
+ : { cacheReadTokens: pick('cacheReadTokens') }),
151
+ ...(previous.reasoningTokens === undefined && next.reasoningTokens === undefined
152
+ ? {}
153
+ : { reasoningTokens: pick('reasoningTokens') })
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Describe a stream that ended without its terminator.
159
+ *
160
+ * The remote's reason is not observable, so the report carries what is: how much
161
+ * arrived, how long it took, and how long the silence before the cut.
162
+ */
163
+ function describeCut({ receivedBytes, startedAt, lastDataAt }) {
164
+ const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1)
165
+ const silence = ((Date.now() - lastDataAt) / 1000).toFixed(1)
166
+ const size = receivedBytes < 1024 ? `${receivedBytes} B` : `${(receivedBytes / 1024).toFixed(1)} kB`
167
+ return `收到 ${size},用时 ${elapsed}s,最后一段数据在 ${silence}s 前`
168
+ }
169
+
170
+ /* -- the adapter ------------------------------------------------------------ */
171
+
172
+ export class LoomyAdapter {
173
+ /**
174
+ * @param {object} ctx - Cordis context (uses `ctx.get('attachments')` and `ctx.logger`).
175
+ * @param {object} account - one entry from the account table.
176
+ * @param {string} provider - the route this adapter serves.
177
+ */
178
+ constructor(ctx, account, provider) {
179
+ this.ctx = ctx
180
+ this.account = account
181
+ this.provider = provider
182
+ /** A per-conversation identity, used for the prompt-cache key. */
183
+ this.sessionId = randomUUID().replace(/-/gu, '')
184
+ this.catalog = undefined
185
+ this.catalogAt = 0
186
+ this.catalogSource = 'none'
187
+ }
188
+
189
+ get displayName() {
190
+ const label = this.account.label.length > 0 ? this.account.label : this.account.id
191
+ return `Loomy (${label})`
192
+ }
193
+
194
+ /** The adapter's identity for one route; registration refuses a mismatched id. */
195
+ providerInfo(provider) {
196
+ return { id: provider, name: this.displayName }
197
+ }
198
+
199
+ /**
200
+ * Part of the duck-typed registration surface.
201
+ *
202
+ * The method has to exist: registration calls it directly, and a missing one throws
203
+ * during `registerAdapter`, which fails the whole plugin apply() -- no providers, no
204
+ * routes, a panel that answers 404. Returning undefined means "use the defaults".
205
+ */
206
+ providerRetryPolicy() {
207
+ return undefined
208
+ }
209
+
210
+ /** Also part of the duck-typed surface, called when a request carries images. */
211
+ imageRequestPricing() {
212
+ return undefined
213
+ }
214
+
215
+ /** Preserve the normalized image as-is: this route does not resize. */
216
+ imageRequestTarget(_provider, _model, image) {
217
+ return { width: image.width, height: image.height, maxBytes: image.bytes }
218
+ }
219
+
220
+ /** A fresh conversation identity, used after an auth or cache-key reset. */
221
+ rotateIdentity() {
222
+ this.sessionId = randomUUID().replace(/-/gu, '')
223
+ }
224
+
225
+ /** The session string, re-read from disk so a re-login takes effect immediately. */
226
+ currentAccount() {
227
+ const latest = accountOf(this.account.id)
228
+ if (latest !== undefined) this.account = latest
229
+ return this.account
230
+ }
231
+
232
+ /**
233
+ * The account's live catalog, cached in memory for {CATALOG_TTL_MS}.
234
+ *
235
+ * The on-disk copy behind {loadCatalog} outlives this object, so a restart during an
236
+ * outage still offers the last list the account could see.
237
+ */
238
+ async models() {
239
+ const fresh = this.catalog !== undefined && Date.now() - this.catalogAt < CATALOG_TTL_MS
240
+ if (fresh) return this.catalog
241
+ const account = this.currentAccount()
242
+ const session = sessionOf(account)
243
+ const loaded = await loadCatalog(account.id, session)
244
+ this.catalog = loaded.entries
245
+ this.catalogSource = loaded.source
246
+ this.catalogAt = Date.now()
247
+ if (loaded.isAuthError === true) markExpired(account.id, '模型列表读取被拒绝')
248
+ return this.catalog
249
+ }
250
+
251
+ /** Drop the in-memory catalog so the next call sees a freshly signed-in account. */
252
+ invalidateCatalog() {
253
+ this.catalog = undefined
254
+ this.catalogAt = 0
255
+ this.catalogSource = 'none'
256
+ }
257
+
258
+ /** One catalog entry, or a stand-in so a known id stays callable during an outage. */
259
+ async entryOf(model) {
260
+ const catalog = await this.models()
261
+ return catalog.find((entry) => entry.id === model) ?? placeholderEntry(model)
262
+ }
263
+
264
+ /**
265
+ * The models the picker may offer.
266
+ *
267
+ * `inputModalities` is required by the harness's model picker, and an entry whose
268
+ * modalities are missing makes the picker drop the whole provider group -- which looks
269
+ * exactly like "the plugin is not installed". So a catalog entry always carries at
270
+ * least `['text']`.
271
+ */
272
+ async listModels(provider) {
273
+ let catalog
274
+ try {
275
+ catalog = await this.models()
276
+ } catch (error) {
277
+ this.ctx.logger.warn(`[loomy] ${this.account.id}: 读取模型列表失败 -- ${error instanceof Error ? error.message : String(error)}`)
278
+ return []
279
+ }
280
+ // Generation models are listed in the same catalog and share the same URL, but
281
+ // answer 404 to /chat/completions; the picker only offers what can answer a turn.
282
+ const offered = catalog.filter((entry) => isChatModel(entry))
283
+ const skipped = catalog.length - offered.length
284
+ this.ctx.logger.info(`[loomy] ${this.account.id}: 选择器读取目录 -> ${offered.length} 个可选(来源 ${this.catalogSource})${skipped > 0 ? `,${skipped} 个生图模型不计入` : ''}`)
285
+ return offered.map((entry) => ({
286
+ provider,
287
+ id: entry.id,
288
+ name: entry.name,
289
+ inputModalities: entry.input.length > 0 ? [...entry.input] : ['text']
290
+ }))
291
+ }
292
+
293
+ /**
294
+ * Declared metadata for one model.
295
+ *
296
+ * The harness validates this hard: an empty `name`, a non-integer `contextWindow`, or a
297
+ * `reasoning.defaultEffort` outside the declared efforts makes the picker reject the
298
+ * provider. Every field below is therefore either absent or well-formed.
299
+ */
300
+ async resolveModel(provider, model) {
301
+ let entry
302
+ try {
303
+ entry = await this.entryOf(model)
304
+ } catch {
305
+ entry = placeholderEntry(model)
306
+ }
307
+ const efforts = Array.isArray(entry.efforts) ? entry.efforts : []
308
+ const defaultEffort = entry.defaultEffort !== undefined && efforts.includes(entry.defaultEffort)
309
+ ? entry.defaultEffort
310
+ : (efforts.includes('medium') ? 'medium' : efforts[0])
311
+ return {
312
+ provider,
313
+ id: model,
314
+ name: entry.name.length > 0 ? entry.name : model,
315
+ inputModalities: entry.input.length > 0 ? [...entry.input] : ['text'],
316
+ ...(typeof entry.contextLength === 'number' ? { context: { contextWindow: entry.contextLength } } : {}),
317
+ ...(typeof entry.maxOutputTokens === 'number' ? { defaultMaxTokens: entry.maxOutputTokens } : {}),
318
+ ...(efforts.length === 0 ? {} : {
319
+ reasoning: {
320
+ efforts: efforts.map((id) => ({ id, name: effortName(id) })),
321
+ ...(defaultEffort === undefined ? {} : { defaultEffort })
322
+ }
323
+ })
324
+ }
325
+ }
326
+
327
+ /** Bind metadata and the dispatch to one generation of this adapter. */
328
+ async prepareCall(provider, model) {
329
+ return {
330
+ model: await this.resolveModel(provider, model),
331
+ stream: (options) => this.stream(options)
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Resolve image attachment references to data URLs.
337
+ *
338
+ * The harness stores images behind the `attachments` service; the gateway wants either
339
+ * a URL or an inline data URL, so each reference is read once and encoded. A reference
340
+ * that fails to read is left out -- the wire converter then substitutes placeholder
341
+ * text rather than dropping the message.
342
+ */
343
+ async resolveImages(messages) {
344
+ const refs = new Map()
345
+ const collect = (content) => {
346
+ if (!Array.isArray(content)) return
347
+ for (const block of content) {
348
+ if (block?.type === 'image' && block.attachment?.attachmentId !== undefined) {
349
+ refs.set(String(block.attachment.attachmentId), block.attachment)
350
+ }
351
+ if (Array.isArray(block?.content)) collect(block.content)
352
+ }
353
+ }
354
+ for (const message of messages) collect(message.content)
355
+ if (refs.size === 0) return undefined
356
+ const attachments = this.ctx.get('attachments')
357
+ const urls = new Map()
358
+ for (const [id, ref] of refs) {
359
+ try {
360
+ const stored = await attachments?.readImage?.(ref)
361
+ const mediaType = stored?.ref?.mediaType
362
+ if (stored?.data !== undefined && typeof mediaType === 'string') {
363
+ urls.set(id, `data:${mediaType};base64,${Buffer.from(stored.data).toString('base64')}`)
364
+ }
365
+ } catch (error) {
366
+ this.ctx.logger.warn(`[loomy] ${this.account.id}: 读取图片附件 ${id} 失败 -- ${error instanceof Error ? error.message : String(error)}`)
367
+ }
368
+ }
369
+ return urls.size === 0 ? undefined : urls
370
+ }
371
+
372
+ /**
373
+ * The output ceiling for one call.
374
+ *
375
+ * A reasoning model spends its `max_tokens` on thinking first, so a small ceiling
376
+ * produces an empty answer with `finish_reason: length` -- a silent truncation the user
377
+ * reads as "the model refused". Raising the floor is the only fix available from this
378
+ * side; hiding the reasoning would break the transcript's reasoning blocks.
379
+ */
380
+ resolveBudget(options, entry) {
381
+ const requested = typeof options.maxTokens === 'number' && Number.isSafeInteger(options.maxTokens) && options.maxTokens > 0
382
+ ? options.maxTokens
383
+ : (typeof entry.maxOutputTokens === 'number' ? entry.maxOutputTokens : undefined)
384
+ if (requested === undefined) return undefined
385
+ return Math.max(requested, MIN_OUTPUT_TOKENS)
386
+ }
387
+
388
+ /**
389
+ * The request body.
390
+ *
391
+ * Reasoning effort is chosen from what the catalog declares, never from the model's
392
+ * name: the caller's own choice wins when the model has it, then the model's default,
393
+ * then `medium` when offered, then the first declared level. A model that declares no
394
+ * levels gets no field at all, which lets the deployment decide.
395
+ */
396
+ buildBody(options, model, entry, imageUrls) {
397
+ const messages = toWireMessages(options.messages ?? [], imageUrls)
398
+ if (typeof options.system === 'string' && options.system.length > 0) {
399
+ messages.unshift({ role: 'system', content: options.system })
400
+ }
401
+ // The gateway rejects an empty conversation outright (HTTP 400), and a request with
402
+ // no system message is accepted but judged differently; the desktop client always
403
+ // sends one, so do the same.
404
+ if (messages.length === 0 || messages[0].role !== 'system') {
405
+ messages.unshift({ role: 'system', content: 'You are a helpful assistant.' })
406
+ }
407
+
408
+ const body = {
409
+ model,
410
+ messages,
411
+ stream: true,
412
+ // Asking for usage explicitly is the only way to get the accounting frame, and the
413
+ // token meter is what makes the context gauge and the cost line work.
414
+ stream_options: { include_usage: true },
415
+ // Server-side prefix cache. Without a stable key the hit rate is always zero.
416
+ prompt_cache_key: this.sessionId
417
+ }
418
+
419
+ const tools = options.tools?.map((tool) => ({
420
+ type: 'function',
421
+ function: { name: tool.name, description: tool.description, parameters: tool.parameters }
422
+ }))
423
+ if (tools !== undefined && tools.length > 0) body.tools = tools
424
+ if (options.temperature !== undefined) body.temperature = options.temperature
425
+ if (Array.isArray(options.stop) && options.stop.length > 0) body.stop = options.stop
426
+
427
+ const budget = this.resolveBudget(options, entry)
428
+ if (budget !== undefined) body.max_tokens = budget
429
+
430
+ const efforts = Array.isArray(entry.efforts) ? entry.efforts : []
431
+ const preferred = efforts.includes(options.reasoningEffort)
432
+ ? options.reasoningEffort
433
+ : (efforts.includes(entry.defaultEffort) ? entry.defaultEffort : undefined)
434
+ const effort = preferred ?? (efforts.includes('medium') ? 'medium' : efforts[0])
435
+ if (effort !== undefined) body.reasoning_effort = effort
436
+ return body
437
+ }
438
+
439
+ /**
440
+ * Request headers.
441
+ *
442
+ * Both auth spellings are sent because the two halves of the same host disagree about
443
+ * which they read: the points API reads `token`, the gateway reads `Authorization`,
444
+ * and a request carrying only one of them is rejected by the other. `traceparent` is
445
+ * not decoration -- measured on the live gateway, an otherwise valid request without
446
+ * it hangs until the client times out.
447
+ */
448
+ headers(session) {
449
+ return {
450
+ Authorization: `Bearer ${session}`,
451
+ token: session,
452
+ Accept: 'text/event-stream',
453
+ 'Content-Type': 'application/json',
454
+ traceparent: traceparent(),
455
+ 'loomy-version': CLIENT_VERSION,
456
+ 'X-Loomy-Request-Purpose': PURPOSE_CHAT_MESSAGE
457
+ }
458
+ }
459
+
460
+ /** The single wire call, with transport failures mapped onto the harness vocabulary. */
461
+ async send(session, body, signal) {
462
+ try {
463
+ return await fetch(`${GATEWAY_BASE_URL}/chat/completions`, {
464
+ method: 'POST',
465
+ headers: this.headers(session),
466
+ body,
467
+ signal
468
+ })
469
+ } catch (error) {
470
+ if (signal?.aborted === true) throw error
471
+ throw llmError(`${this.provider}: 传输失败 -- ${error instanceof Error ? error.message : String(error)}`, 'TRANSPORT', { cause: error })
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Stream one model call.
477
+ *
478
+ * One provider attempt only: retrying belongs to `@deepseek-ai/dsh-llm-retry`, which
479
+ * runs at the persisted step boundary. The exception is a 401/403, where the session
480
+ * may simply have been retired -- but there is no refresh endpoint to try, so that ends
481
+ * as an AUTH failure the panel turns into a re-login prompt.
482
+ */
483
+ async *stream(options) {
484
+ const model = options.model
485
+ const account = this.currentAccount()
486
+ const session = sessionOf(account)
487
+ const entry = await this.entryOf(model)
488
+ const imageUrls = await this.resolveImages(options.messages ?? [])
489
+ const payload = JSON.stringify(this.buildBody(options, model, entry, imageUrls))
490
+ const startedAt = Date.now()
491
+ const state = { receivedBytes: 0, lastDataAt: startedAt }
492
+
493
+ let response = await this.send(session, payload, options.signal)
494
+ let rejection = ''
495
+ if (!response.ok) {
496
+ rejection = await response.text().catch(() => '')
497
+ const code = httpErrorCode(response.status, rejection)
498
+ if (code === 'AUTH') markExpired(account.id, '模型请求被拒绝(HTTP ' + response.status + ')')
499
+ throw llmError(
500
+ `${this.provider}: 模型请求失败(HTTP ${response.status})${errorDetail(rejection)}`,
501
+ code,
502
+ { status: response.status }
503
+ )
504
+ }
505
+
506
+ const blocks = []
507
+ let nextIndex = 0
508
+ let finishReason
509
+ let streamEnded = false
510
+ let usage
511
+ const toolCalls = new Map()
512
+ const toolOrder = []
513
+ const toolIds = new Map()
514
+
515
+ const textBlock = () => {
516
+ let block = blocks.find((candidate) => candidate.kind === 'text')
517
+ if (block === undefined) {
518
+ block = { index: nextIndex, kind: 'text', text: '' }
519
+ nextIndex += 1
520
+ blocks.push(block)
521
+ }
522
+ return block
523
+ }
524
+ const reasoningBlock = () => {
525
+ let block = blocks.find((candidate) => candidate.kind === 'reasoning')
526
+ if (block === undefined) {
527
+ block = { index: nextIndex, kind: 'reasoning', text: '' }
528
+ nextIndex += 1
529
+ blocks.push(block)
530
+ }
531
+ return block
532
+ }
533
+
534
+ const finishChunk = (reason) => ({
535
+ type: 'finish',
536
+ reason,
537
+ response: {
538
+ kind: 'loomy',
539
+ version: 1,
540
+ api: 'openai-completions',
541
+ provider: this.provider,
542
+ model,
543
+ stopReason: finishReason ?? reason.kind,
544
+ ...(usage === undefined ? {} : { outputTokens: usage.outputTokens }),
545
+ blocks: blocks.map((block) => ({ type: block.kind }))
546
+ }
547
+ })
548
+
549
+ try {
550
+ for await (const payloadLine of readSse(response, state)) {
551
+ if (payloadLine === SSE_DONE) {
552
+ streamEnded = true
553
+ break
554
+ }
555
+ let data
556
+ try {
557
+ data = JSON.parse(payloadLine)
558
+ } catch {
559
+ continue
560
+ }
561
+ if (data.error !== undefined) {
562
+ throw llmError(`${this.provider}: ${data.error?.message ?? '未知错误'}`, 'SERVER')
563
+ }
564
+ if (data.choices === undefined && typeof data.code === 'string' && data.code !== '000000') {
565
+ throw llmError(`${this.provider}: code=${data.code} ${errorDetail(payloadLine)}`, 'INVALID_REQUEST')
566
+ }
567
+ const choice = data.choices?.[0]
568
+ const delta = choice?.delta ?? {}
569
+ if (typeof choice?.finish_reason === 'string' && choice.finish_reason.length > 0) {
570
+ finishReason = choice.finish_reason
571
+ }
572
+
573
+ if (typeof delta.content === 'string' && delta.content.length > 0) {
574
+ const block = textBlock()
575
+ if (block.text.length === 0) yield { type: 'block-start', index: block.index, blockType: 'text' }
576
+ block.text += delta.content
577
+ yield { type: 'text-delta', index: block.index, text: delta.content }
578
+ }
579
+ if (typeof delta.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
580
+ const block = reasoningBlock()
581
+ if (block.text.length === 0) yield { type: 'block-start', index: block.index, blockType: 'reasoning' }
582
+ block.text += delta.reasoning_content
583
+ yield { type: 'reasoning-delta', index: block.index, text: delta.reasoning_content }
584
+ }
585
+
586
+ for (const call of delta.tool_calls ?? []) {
587
+ const wireIndex = call.index ?? 0
588
+ if (typeof call.id === 'string' && call.id.length > 0) toolIds.set(wireIndex, call.id)
589
+ const callId = toolIds.get(wireIndex) ?? `call_${wireIndex}`
590
+ let block = toolCalls.get(wireIndex)
591
+ if (block === undefined) {
592
+ block = { index: nextIndex, text: '', callId, announced: false, name: undefined }
593
+ nextIndex += 1
594
+ toolCalls.set(wireIndex, block)
595
+ }
596
+ block.callId = callId
597
+ if (typeof call.function?.name === 'string' && call.function.name.length > 0) {
598
+ block.name = call.function.name
599
+ }
600
+ block.text += call.function?.arguments ?? ''
601
+ // Arguments are collected, not streamed on: a call whose arguments are cut
602
+ // short must not reach the harness at all, and once a delta is emitted there
603
+ // is no taking it back (the assembler keeps an open tool block).
604
+ if (!block.announced && hasUsableToolName(block.name)) {
605
+ block.announced = true
606
+ toolOrder.push(block.index)
607
+ }
608
+ }
609
+
610
+ if (data.usage !== undefined && data.usage !== null) {
611
+ const promptTokens = data.usage.prompt_tokens ?? 0
612
+ const cachedTokens = data.usage.prompt_tokens_details?.cached_tokens ?? 0
613
+ const reasoningTokens = data.usage.completion_tokens_details?.reasoning_tokens
614
+ usage = mergeUsage(usage, {
615
+ inputTokens: cachedTokens > 0 ? promptTokens - cachedTokens : promptTokens,
616
+ outputTokens: data.usage.completion_tokens ?? 0,
617
+ ...(cachedTokens > 0 ? { cacheReadTokens: cachedTokens } : {}),
618
+ ...(typeof reasoningTokens === 'number' && reasoningTokens > 0 ? { reasoningTokens } : {})
619
+ })
620
+ // The accounting frame is also where the turn's point cost surfaces.
621
+ // Recorded the moment it arrives so a mid-stream abort still books the
622
+ // part the gateway billed; the projection in /state reads this file.
623
+ if (typeof data.usage.points_consumed === 'number' && data.usage.points_consumed > 0) {
624
+ recordPointsSpent(account.id, data.usage.points_consumed)
625
+ }
626
+ yield { type: 'usage', usage }
627
+ }
628
+ }
629
+ } catch (error) {
630
+ // A user abort is not a network failure and must be rethrown untouched; a real
631
+ // socket error becomes a retryable TRANSPORT ending so the step can recover.
632
+ if (options.signal?.aborted === true) throw error
633
+ if (error?.name === 'LlmError') throw error
634
+ const detail = describeCut({ receivedBytes: state.receivedBytes, startedAt, lastDataAt: state.lastDataAt })
635
+ this.ctx.logger.warn(`[loomy] ${account.id}: ${model} 的响应在网络层中断(${detail};${error instanceof Error ? error.message : String(error)})`)
636
+ yield finishChunk({
637
+ kind: 'error',
638
+ failure: {
639
+ message: `${this.provider}: 响应流在网络层中断(${detail};可重试的传输错误)`,
640
+ code: 'TRANSPORT'
641
+ }
642
+ })
643
+ return
644
+ }
645
+
646
+ for (const index of toolOrder.sort((left, right) => left - right)) {
647
+ const block = [...toolCalls.values()].find((candidate) => candidate.index === index)
648
+ if (block === undefined || !hasUsableToolName(block.name)) continue
649
+ if (isTruncatedArguments(block.text)) continue
650
+ yield { type: 'block-start', index, blockType: 'tool-call' }
651
+ yield { type: 'tool-call-delta', index, id: block.callId, name: block.name, argumentsDelta: block.text }
652
+ yield {
653
+ type: 'block-end',
654
+ index,
655
+ block: {
656
+ type: 'tool-call',
657
+ id: block.callId,
658
+ name: block.name,
659
+ arguments: normalizeToolArguments(block.text)
660
+ }
661
+ }
662
+ }
663
+
664
+ const prose = blocks.find((block) => block.kind === 'text')
665
+ if (prose !== undefined) {
666
+ yield { type: 'block-end', index: prose.index, block: { type: 'text', text: prose.text } }
667
+ }
668
+ const reasoning = blocks.find((block) => block.kind === 'reasoning')
669
+ if (reasoning !== undefined && reasoning.text.trim().length > 0) {
670
+ yield { type: 'block-end', index: reasoning.index, block: { type: 'reasoning', text: reasoning.text } }
671
+ }
672
+
673
+ const argsTruncated = [...toolCalls.values()].some((block) => isTruncatedArguments(block.text))
674
+ const orphanCalls = [...toolCalls.values()].filter((block) => !hasUsableToolName(block.name) && block.text.trim().length > 0).length
675
+ const blockCount = blocks.length + toolOrder.length
676
+ const ending = classifyEnding({
677
+ streamEnded,
678
+ finishReason,
679
+ argsTruncated,
680
+ orphanCalls,
681
+ blockCount,
682
+ hasTools: toolOrder.length > 0
683
+ }, this.provider)
684
+
685
+ if (ending.note === 'cut') {
686
+ const detail = describeCut({ receivedBytes: state.receivedBytes, startedAt, lastDataAt: state.lastDataAt })
687
+ this.ctx.logger.warn(`[loomy] ${account.id}: ${model} 的响应在 finish_reason/[DONE] 之前中断(${detail}),按传输错误上报,可自动重试`)
688
+ ending.failure.message = `${ending.failure.message}(${detail})`
689
+ } else if (ending.note === 'filtered') {
690
+ this.ctx.logger.warn(`[loomy] ${account.id}: ${model} 的上游以内容审核中断了回答,不重试`)
691
+ } else if (ending.note === 'budget' || ending.note === 'empty-budget') {
692
+ this.ctx.logger.warn(`[loomy] ${account.id}: ${model} 的输出达到上限,本步被截断(输出 ${usage?.outputTokens ?? '未知'} tokens)`)
693
+ } else if (ending.note === 'done-without-reason') {
694
+ this.ctx.logger.warn(`[loomy] ${account.id}: ${model} 的响应以 [DONE] 收尾但没有 finish_reason,按 stop 处理`)
695
+ }
696
+ yield finishChunk(ending)
697
+ }
698
+ }