@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.
- package/CHANGELOG.md +125 -0
- package/LICENSE +26 -0
- package/README.md +223 -2
- package/client/index.js +1092 -0
- package/cordis.patch.yml +28 -0
- package/docs/PUBLISHING.md +87 -0
- package/docs/community-entry.yml +21 -0
- package/docs/models.md +74 -0
- package/docs/pill-composer.png +0 -0
- package/docs/protocol.md +403 -0
- package/docs/verify/live-check.mjs +406 -0
- package/icon-mask.png +0 -0
- package/icon.png +0 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -3
- package/src/account/auth.js +297 -0
- package/src/account/credits.js +373 -0
- package/src/account/desktop-session.js +127 -0
- package/src/account/models.js +216 -0
- package/src/account/session.js +115 -0
- package/src/core/endpoints.js +78 -0
- package/src/core/http.js +124 -0
- package/src/core/paths.js +71 -0
- package/src/core/sign.js +166 -0
- package/src/index.js +429 -0
- package/src/llm/adapter.js +698 -0
- package/src/llm/endings.js +116 -0
- package/src/llm/sse.js +80 -0
- package/src/llm/wire.js +272 -0
- package/src/panel/api.js +661 -0
|
@@ -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
|
+
}
|