dsh-btw-plugin 0.0.0-stage → 0.5.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 +97 -0
- package/LICENSE +21 -0
- package/README.md +205 -2
- package/cordis.patch.yml +14 -0
- package/lib/client.js +1980 -0
- package/lib/index.js +829 -0
- package/lib/logic.js +628 -0
- package/package.json +49 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-btw — host half.
|
|
3
|
+
*
|
|
4
|
+
* The plugin answers a one-off side question ("by the way") from the current
|
|
5
|
+
* conversation. It follows ds-bot 0.2.4's `src/host/btw.js`: snapshot the live
|
|
6
|
+
* session with requestHeader() + trimToolBoundary(deriveMessages()) +
|
|
7
|
+
* toolHistory(), make ONE `ctx.llm.stream()` call, and stream the answer back
|
|
8
|
+
* over a plugin-owned SSE route. No Agent is created and no Session event is
|
|
9
|
+
* written, so the answer never enters that conversation's memory.
|
|
10
|
+
*
|
|
11
|
+
* Routes:
|
|
12
|
+
* GET /btw/health — liveness + resolved config (for the client's diagnostics)
|
|
13
|
+
* POST /btw/context — context preview: range → {stats, dropped, cold}
|
|
14
|
+
* POST /btw/ask — JSON in, text/event-stream out; request admission is
|
|
15
|
+
* consulted through ctx.connection.requestRejection.
|
|
16
|
+
*
|
|
17
|
+
* The route is unauthenticated by the web server itself, which is why every
|
|
18
|
+
* request goes through the fence first: on loopback any local process could
|
|
19
|
+
* otherwise spend the user's model quota.
|
|
20
|
+
*/
|
|
21
|
+
import { randomUUID } from 'node:crypto'
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_CONFIG,
|
|
25
|
+
FOCUS_MAX_CHARS,
|
|
26
|
+
buildBtwMessages,
|
|
27
|
+
buildInjectionBody,
|
|
28
|
+
buildSummaryPrompt,
|
|
29
|
+
cleanSummary,
|
|
30
|
+
fallbackSummary,
|
|
31
|
+
foldEventsToSnapshot,
|
|
32
|
+
mapFailure,
|
|
33
|
+
messageOf,
|
|
34
|
+
messageQuote,
|
|
35
|
+
normaliseDialogMessages,
|
|
36
|
+
normalisePriorTurns,
|
|
37
|
+
normaliseRange,
|
|
38
|
+
normaliseSummaryMeta,
|
|
39
|
+
pickSession,
|
|
40
|
+
previewContext,
|
|
41
|
+
snapshotOf,
|
|
42
|
+
sseFrame,
|
|
43
|
+
} from './logic.js'
|
|
44
|
+
|
|
45
|
+
export const name = 'dsh-btw'
|
|
46
|
+
|
|
47
|
+
export const VERSION = '0.5.2'
|
|
48
|
+
|
|
49
|
+
// All are real host services in DSH 0.2.1-rc.5 (see ds-bot's and
|
|
50
|
+
// dsh-better-sidebar's inject lists). A missing service only delays apply().
|
|
51
|
+
export const inject = ['webServer', 'llm', 'sessions', 'connection']
|
|
52
|
+
|
|
53
|
+
export function apply(ctx, config = {}) {
|
|
54
|
+
try {
|
|
55
|
+
const cfg = { ...DEFAULT_CONFIG, ...(config && typeof config === 'object' ? config : {}) }
|
|
56
|
+
if (cfg.enabled === false) return
|
|
57
|
+
|
|
58
|
+
const register = (path, handler, label) => {
|
|
59
|
+
try {
|
|
60
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path, handler }), label)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// A route conflict must fail only this plugin row, never host startup.
|
|
63
|
+
try { ctx.logger?.warn?.('dsh-btw: cannot register %s', path, error) } catch { /* no logger */ }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
register('/btw/health', (req, res) => handleHealth(ctx, cfg, req, res), 'dsh-btw: /btw/health')
|
|
68
|
+
register('/btw/models', (req, res) => handleModels(ctx, cfg, req, res), 'dsh-btw: /btw/models')
|
|
69
|
+
register('/btw/context', (req, res) => handleContext(ctx, cfg, req, res), 'dsh-btw: /btw/context')
|
|
70
|
+
register('/btw/ask', (req, res) => handleAsk(ctx, cfg, req, res), 'dsh-btw: /btw/ask')
|
|
71
|
+
register('/btw/summarize', (req, res) => handleSummarize(ctx, cfg, req, res), 'dsh-btw: /btw/summarize')
|
|
72
|
+
register('/btw/commit', (req, res) => handleCommit(ctx, cfg, req, res), 'dsh-btw: /btw/commit')
|
|
73
|
+
} catch (error) {
|
|
74
|
+
try { ctx.logger?.warn?.('dsh-btw: activation failed', error) } catch { /* no logger */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ------------------------------------------------------------------ routes */
|
|
79
|
+
|
|
80
|
+
function handleHealth(ctx, cfg, req, res) {
|
|
81
|
+
if (!fence(ctx, req, res)) return
|
|
82
|
+
if (req.method !== 'GET') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'GET only'))
|
|
83
|
+
writeJson(res, 200, {
|
|
84
|
+
ok: true,
|
|
85
|
+
plugin: name,
|
|
86
|
+
version: VERSION,
|
|
87
|
+
coldRead: coldReadKind(ctx),
|
|
88
|
+
defaultRoute: defaultRoute(ctx),
|
|
89
|
+
config: {
|
|
90
|
+
maxParentMessages: cfg.maxParentMessages,
|
|
91
|
+
maxRangeMessages: cfg.maxRangeMessages,
|
|
92
|
+
defaultRecentMessages: cfg.defaultRecentMessages,
|
|
93
|
+
maxPriorTurns: cfg.maxPriorTurns,
|
|
94
|
+
maxSummaryChars: cfg.maxSummaryChars,
|
|
95
|
+
toolParity: cfg.toolParity === true,
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* --------------------------------------------------------------- model list */
|
|
101
|
+
|
|
102
|
+
const MODEL_CACHE_TTL_MS = 60_000
|
|
103
|
+
const MODEL_LIST_TIMEOUT_MS = 2_500
|
|
104
|
+
let modelCache = { at: 0, providers: [] }
|
|
105
|
+
|
|
106
|
+
/** GET /btw/models — provider/model catalog for the btw picker. */
|
|
107
|
+
async function handleModels(ctx, cfg, req, res) {
|
|
108
|
+
if (!fence(ctx, req, res)) return
|
|
109
|
+
if (req.method !== 'GET') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'GET only'))
|
|
110
|
+
|
|
111
|
+
let sessionId = ''
|
|
112
|
+
try {
|
|
113
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
114
|
+
sessionId = pickString(url.searchParams.get('sessionId')) ?? ''
|
|
115
|
+
} catch { /* keep the global catalog */ }
|
|
116
|
+
|
|
117
|
+
const providers = await modelCatalog(ctx)
|
|
118
|
+
let current = null
|
|
119
|
+
if (sessionId) {
|
|
120
|
+
const session = pickSession(ctx, sessionId)
|
|
121
|
+
if (session) {
|
|
122
|
+
try {
|
|
123
|
+
const config = headerConfigOf({ header: session.requestHeader() })
|
|
124
|
+
if (pickString(config.provider) && pickString(config.model)) {
|
|
125
|
+
current = { provider: config.provider, model: config.model }
|
|
126
|
+
}
|
|
127
|
+
} catch { /* the session has no resolved route yet */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const fallback = defaultRoute(ctx)
|
|
131
|
+
// Catalog-driven adapters may report no models (the base adapter returns an
|
|
132
|
+
// empty list). Keep the two routes the harness itself knows about selectable.
|
|
133
|
+
ensureRoute(providers, current)
|
|
134
|
+
ensureRoute(providers, fallback)
|
|
135
|
+
writeJson(res, 200, { ok: true, providers, default: fallback, current })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Add a provider/model pair to the catalog when the adapter did not list it. */
|
|
139
|
+
function ensureRoute(providers, route) {
|
|
140
|
+
if (!route || !pickString(route.provider) || !pickString(route.model)) return
|
|
141
|
+
let entry = providers.find((provider) => provider.id === route.provider)
|
|
142
|
+
if (!entry) {
|
|
143
|
+
entry = { id: route.provider, name: route.provider, models: [] }
|
|
144
|
+
providers.push(entry)
|
|
145
|
+
}
|
|
146
|
+
if (!entry.models.some((model) => model.id === route.model)) {
|
|
147
|
+
entry.models.push({ id: route.model, name: route.model })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function modelCatalog(ctx) {
|
|
152
|
+
const now = Date.now()
|
|
153
|
+
if (now - modelCache.at < MODEL_CACHE_TTL_MS && modelCache.providers.length > 0) {
|
|
154
|
+
return modelCache.providers
|
|
155
|
+
}
|
|
156
|
+
const llm = serviceOf(ctx, 'llm') || ctx.llm
|
|
157
|
+
let listed = []
|
|
158
|
+
try {
|
|
159
|
+
listed = typeof llm?.listProviders === 'function' ? llm.listProviders() : []
|
|
160
|
+
} catch { /* registry unavailable: return an empty catalog */ }
|
|
161
|
+
|
|
162
|
+
const providers = await Promise.all((Array.isArray(listed) ? listed : []).map(async (provider) => {
|
|
163
|
+
if (!provider || !pickString(provider.id)) return null
|
|
164
|
+
const entry = { id: provider.id, name: provider.name || provider.id, models: [] }
|
|
165
|
+
if (typeof llm?.listModels !== 'function') return entry
|
|
166
|
+
try {
|
|
167
|
+
// Some adapters probe the network here; never let one slow provider hang
|
|
168
|
+
// the whole picker (the dialog would otherwise show "读取模型…" forever).
|
|
169
|
+
const models = await withTimeout(llm.listModels(provider.id), MODEL_LIST_TIMEOUT_MS)
|
|
170
|
+
entry.models = (Array.isArray(models) ? models : [])
|
|
171
|
+
.filter((model) => model && pickString(model.id))
|
|
172
|
+
.map((model) => ({
|
|
173
|
+
id: model.id,
|
|
174
|
+
name: model.name || model.id,
|
|
175
|
+
...(pickString(model.description) ? { description: model.description } : {}),
|
|
176
|
+
}))
|
|
177
|
+
} catch (error) {
|
|
178
|
+
entry.error = messageOf(error)
|
|
179
|
+
}
|
|
180
|
+
return entry
|
|
181
|
+
}))
|
|
182
|
+
const catalog = providers.filter(Boolean)
|
|
183
|
+
// Do not cache an empty catalog: the first request may race provider mounts.
|
|
184
|
+
if (catalog.length > 0) modelCache = { at: now, providers: catalog }
|
|
185
|
+
return catalog
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function withTimeout(promise, ms) {
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
|
|
191
|
+
Promise.resolve(promise).then(
|
|
192
|
+
(value) => { clearTimeout(timer); resolve(value) },
|
|
193
|
+
(error) => { clearTimeout(timer); reject(error) },
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The harness-wide default model selection, when that service is present. */
|
|
199
|
+
function defaultRoute(ctx) {
|
|
200
|
+
try {
|
|
201
|
+
const selection = serviceOf(ctx, 'agentDefaultModel')?.currentSelection?.()
|
|
202
|
+
if (selection && pickString(selection.provider) && pickString(selection.model)) {
|
|
203
|
+
return { provider: selection.provider, model: selection.model }
|
|
204
|
+
}
|
|
205
|
+
} catch { /* optional service */ }
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Turn a client focus hint into an inlined quote. A message focus is resolved
|
|
211
|
+
* against the full (pre-range) snapshot, so the quote still works when the
|
|
212
|
+
* selected range would drop that message. Unknown ids are ignored instead of
|
|
213
|
+
* failing the whole ask.
|
|
214
|
+
*/
|
|
215
|
+
function normaliseFocus(raw, snapshotMessages) {
|
|
216
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
217
|
+
if (raw.kind === 'selection') {
|
|
218
|
+
const text = pickString(raw.text)
|
|
219
|
+
if (!text || !text.trim()) return null
|
|
220
|
+
return { kind: 'selection', text: text.trim().slice(0, FOCUS_MAX_CHARS), role: null }
|
|
221
|
+
}
|
|
222
|
+
if (raw.kind === 'message') {
|
|
223
|
+
const id = pickString(raw.messageId)
|
|
224
|
+
if (!id || !Array.isArray(snapshotMessages)) return null
|
|
225
|
+
const message = snapshotMessages.find((candidate) => candidate && candidate.id === id)
|
|
226
|
+
if (!message) return null
|
|
227
|
+
const text = messageQuote(message, FOCUS_MAX_CHARS)
|
|
228
|
+
if (!text) return null
|
|
229
|
+
return { kind: 'message', text, role: pickString(message.role) ?? null }
|
|
230
|
+
}
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** POST /btw/context — how much history would be sent for a given range. */
|
|
235
|
+
async function handleContext(ctx, cfg, req, res) {
|
|
236
|
+
if (!fence(ctx, req, res)) return
|
|
237
|
+
if (req.method !== 'POST') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'POST only'))
|
|
238
|
+
|
|
239
|
+
let payload
|
|
240
|
+
try {
|
|
241
|
+
payload = await readJsonBody(req, cfg.maxBodyBytes)
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return writeJson(res, Number.isInteger(error?.statusCode) ? error.statusCode : 400,
|
|
244
|
+
failure('BAD_REQUEST', messageOf(error)))
|
|
245
|
+
}
|
|
246
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
247
|
+
return writeJson(res, 400, failure('BAD_REQUEST', 'JSON object body expected'))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const sessionId = pickString(payload.sessionId) ? payload.sessionId.trim() : ''
|
|
251
|
+
if (!sessionId) return writeJson(res, 400, failure('BAD_REQUEST', 'sessionId is required'))
|
|
252
|
+
const range = normaliseRange(payload.range, cfg)
|
|
253
|
+
|
|
254
|
+
let snapshot
|
|
255
|
+
try {
|
|
256
|
+
snapshot = await resolveSnapshot(ctx, sessionId, cfg)
|
|
257
|
+
} catch (error) {
|
|
258
|
+
const coldUnavailable = error?.code === 'SESSION_NOT_LIVE' || error?.code === 'SESSION_NOT_FOUND'
|
|
259
|
+
return writeJson(res, coldUnavailable ? 404 : 500,
|
|
260
|
+
failure(coldUnavailable ? 'SESSION_NOT_LIVE' : (error?.code || 'SNAPSHOT_FAILED'), messageOf(error)))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let preview
|
|
264
|
+
try {
|
|
265
|
+
preview = previewContext(snapshot.messages, range, cfg)
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return writeJson(res, 500, failure('SNAPSHOT_FAILED', messageOf(error)))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const headerConfig = headerConfigOf(snapshot)
|
|
271
|
+
writeJson(res, 200, {
|
|
272
|
+
ok: true,
|
|
273
|
+
sessionId,
|
|
274
|
+
cold: snapshot.cold === true,
|
|
275
|
+
range: preview.range,
|
|
276
|
+
stats: preview.stats,
|
|
277
|
+
dropped: preview.dropped,
|
|
278
|
+
provider: pickString(headerConfig.provider) ?? null,
|
|
279
|
+
model: pickString(headerConfig.model) ?? null,
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function handleAsk(ctx, cfg, req, res) {
|
|
284
|
+
if (!fence(ctx, req, res)) return
|
|
285
|
+
if (req.method !== 'POST') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'POST only'))
|
|
286
|
+
|
|
287
|
+
let payload
|
|
288
|
+
try {
|
|
289
|
+
payload = await readJsonBody(req, cfg.maxBodyBytes)
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return writeJson(res, Number.isInteger(error?.statusCode) ? error.statusCode : 400,
|
|
292
|
+
failure('BAD_REQUEST', messageOf(error)))
|
|
293
|
+
}
|
|
294
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
295
|
+
return writeJson(res, 400, failure('BAD_REQUEST', 'JSON object body expected'))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const sessionId = pickString(payload.sessionId) ? payload.sessionId.trim() : ''
|
|
299
|
+
const question = pickString(payload.question) ? payload.question.trim() : ''
|
|
300
|
+
if (!sessionId) return writeJson(res, 400, failure('BAD_REQUEST', 'sessionId is required'))
|
|
301
|
+
if (!question) return writeJson(res, 400, failure('BAD_REQUEST', 'question is required'))
|
|
302
|
+
if (question.length > cfg.maxQuestionChars) {
|
|
303
|
+
return writeJson(res, 400, failure('BAD_REQUEST', `question is longer than ${cfg.maxQuestionChars} characters`))
|
|
304
|
+
}
|
|
305
|
+
const range = normaliseRange(payload.range, cfg)
|
|
306
|
+
|
|
307
|
+
let snapshot
|
|
308
|
+
try {
|
|
309
|
+
snapshot = await resolveSnapshot(ctx, sessionId, cfg)
|
|
310
|
+
} catch (error) {
|
|
311
|
+
const coldUnavailable = error?.code === 'SESSION_NOT_LIVE' || error?.code === 'SESSION_NOT_FOUND'
|
|
312
|
+
return writeJson(res, coldUnavailable ? 404 : 500,
|
|
313
|
+
failure(coldUnavailable ? 'SESSION_NOT_LIVE' : (error?.code || 'SNAPSHOT_FAILED'), messageOf(error)))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let preview
|
|
317
|
+
try {
|
|
318
|
+
preview = previewContext(snapshot.messages, range, cfg)
|
|
319
|
+
} catch (error) {
|
|
320
|
+
return writeJson(res, 500, failure('SNAPSHOT_FAILED', messageOf(error)))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const focus = normaliseFocus(payload.focus, snapshot.messages)
|
|
324
|
+
|
|
325
|
+
const headerConfig = headerConfigOf(snapshot)
|
|
326
|
+
const provider = pickString(payload.provider) ?? pickString(headerConfig.provider)
|
|
327
|
+
const model = pickString(payload.model) ?? pickString(headerConfig.model)
|
|
328
|
+
if (!provider || !model) {
|
|
329
|
+
return writeJson(res, 500, failure('NO_ROUTE', 'the conversation has no resolved provider/model yet'))
|
|
330
|
+
}
|
|
331
|
+
// An explicit picker choice is validated before the stream so a typo fails
|
|
332
|
+
// with a readable 400 instead of a terminal NO_ADAPTER finish frame.
|
|
333
|
+
if (pickString(payload.provider) || pickString(payload.model)) {
|
|
334
|
+
try {
|
|
335
|
+
await ctx.llm.resolveModelInfo(provider, model)
|
|
336
|
+
} catch (error) {
|
|
337
|
+
return writeJson(res, 400, failure('BAD_MODEL', `无法使用 ${provider}/${model}:${messageOf(error)}`))
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const priorTurns = normalisePriorTurns(payload.priorTurns, cfg.maxPriorTurns)
|
|
342
|
+
let messages
|
|
343
|
+
try {
|
|
344
|
+
messages = buildBtwMessages(preview.messages, question, priorTurns, {
|
|
345
|
+
fallbackRoute: { provider, model },
|
|
346
|
+
focus,
|
|
347
|
+
})
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return writeJson(res, 400, failure('BAD_REQUEST', messageOf(error)))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const options = { ...headerConfig, provider, model, messages, sessionId }
|
|
353
|
+
if (pickString(payload.reasoningEffort)) options.reasoningEffort = payload.reasoningEffort
|
|
354
|
+
if (typeof payload.temperature === 'number' && Number.isFinite(payload.temperature)
|
|
355
|
+
&& payload.temperature >= 0 && payload.temperature <= 2) {
|
|
356
|
+
options.temperature = payload.temperature
|
|
357
|
+
}
|
|
358
|
+
if (Number.isInteger(payload.maxTokens) && payload.maxTokens > 0) {
|
|
359
|
+
options.maxTokens = Math.min(payload.maxTokens, 32_768)
|
|
360
|
+
}
|
|
361
|
+
if (cfg.toolParity === true) {
|
|
362
|
+
// Parity mode (ds-bot callAside): same request shape as the conversation.
|
|
363
|
+
// The prompt forbids tools, and a tool-calls finish is reported as an error.
|
|
364
|
+
if (Array.isArray(snapshot.header?.tools)) options.tools = [...snapshot.header.tools]
|
|
365
|
+
if (snapshot.toolHistory !== undefined) options.toolHistory = snapshot.toolHistory
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const abort = new AbortController()
|
|
369
|
+
const onClose = () => abort.abort()
|
|
370
|
+
res.once('close', onClose)
|
|
371
|
+
|
|
372
|
+
// Safety net: a stalled provider stream must not keep the socket open forever.
|
|
373
|
+
const timeoutMs = Number.isFinite(cfg.requestTimeoutMs) && cfg.requestTimeoutMs > 0
|
|
374
|
+
? cfg.requestTimeoutMs
|
|
375
|
+
: 5 * 60 * 1000
|
|
376
|
+
const watchdog = setTimeout(() => abort.abort(), timeoutMs)
|
|
377
|
+
watchdog.unref?.()
|
|
378
|
+
|
|
379
|
+
writeSseHead(res)
|
|
380
|
+
// The first frame mirrors /btw/context so the badge stays truthful even if
|
|
381
|
+
// the user changed the range right before pressing send.
|
|
382
|
+
writeSse(res, 'meta', {
|
|
383
|
+
range: preview.range,
|
|
384
|
+
stats: preview.stats,
|
|
385
|
+
dropped: preview.dropped,
|
|
386
|
+
cold: snapshot.cold === true,
|
|
387
|
+
provider,
|
|
388
|
+
model,
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
let text = ''
|
|
392
|
+
let usage
|
|
393
|
+
let finish
|
|
394
|
+
try {
|
|
395
|
+
for await (const chunk of ctx.llm.stream({ ...options, signal: abort.signal })) {
|
|
396
|
+
if (res.writableEnded) break
|
|
397
|
+
switch (chunk?.type) {
|
|
398
|
+
case 'text-delta':
|
|
399
|
+
text += chunk.text
|
|
400
|
+
writeSse(res, 'delta', { kind: 'text', text: chunk.text })
|
|
401
|
+
break
|
|
402
|
+
case 'reasoning-delta':
|
|
403
|
+
writeSse(res, 'delta', { kind: 'reasoning', text: chunk.text })
|
|
404
|
+
break
|
|
405
|
+
case 'usage':
|
|
406
|
+
usage = chunk.usage
|
|
407
|
+
writeSse(res, 'usage', chunk.usage)
|
|
408
|
+
break
|
|
409
|
+
case 'finish':
|
|
410
|
+
finish = chunk.reason
|
|
411
|
+
break
|
|
412
|
+
default:
|
|
413
|
+
break
|
|
414
|
+
}
|
|
415
|
+
if (abort.signal.aborted) break
|
|
416
|
+
}
|
|
417
|
+
} catch (error) {
|
|
418
|
+
clearTimeout(watchdog)
|
|
419
|
+
res.off?.('close', onClose)
|
|
420
|
+
if (!res.writableEnded) {
|
|
421
|
+
endSse(res, 'error', abort.signal.aborted
|
|
422
|
+
? { code: 'ABORTED', message: '已中止' }
|
|
423
|
+
: { code: 'STREAM_ERROR', message: messageOf(error) })
|
|
424
|
+
}
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
clearTimeout(watchdog)
|
|
428
|
+
res.off?.('close', onClose)
|
|
429
|
+
if (res.writableEnded) return
|
|
430
|
+
if (abort.signal.aborted) return endSse(res, 'error', { code: 'ABORTED', message: '已中止' })
|
|
431
|
+
|
|
432
|
+
const summary = {
|
|
433
|
+
range: preview.range,
|
|
434
|
+
stats: preview.stats,
|
|
435
|
+
dropped: preview.dropped,
|
|
436
|
+
cold: snapshot.cold === true,
|
|
437
|
+
}
|
|
438
|
+
const reason = finish && typeof finish === 'object' ? finish : { kind: 'stop' }
|
|
439
|
+
switch (reason.kind) {
|
|
440
|
+
case 'stop':
|
|
441
|
+
return endSse(res, 'done', { text, usage, provider, model, truncated: false, ...summary })
|
|
442
|
+
case 'max-tokens':
|
|
443
|
+
return endSse(res, 'done', { text, usage, provider, model, truncated: true, ...summary })
|
|
444
|
+
case 'tool-calls':
|
|
445
|
+
return endSse(res, 'error', {
|
|
446
|
+
code: 'TOOL_CALLS',
|
|
447
|
+
message: '模型尝试调用工具,btw 只做纯问答;请重试或换个问法',
|
|
448
|
+
})
|
|
449
|
+
case 'aborted':
|
|
450
|
+
return endSse(res, 'error', { code: 'ABORTED', message: '已中止' })
|
|
451
|
+
case 'error':
|
|
452
|
+
return endSse(res, 'error', mapFailure(reason.failure))
|
|
453
|
+
default:
|
|
454
|
+
return endSse(res, 'done', { text, usage, provider, model, truncated: false, ...summary })
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* ------------------------------------------------------ transfer to parent */
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Compress the dialog transcript into a short record. The model call is a
|
|
462
|
+
* normal, non-streaming llm.stream(); any failure (or an empty answer) falls
|
|
463
|
+
* back to a deterministic digest so the user can still review and send it.
|
|
464
|
+
*/
|
|
465
|
+
async function handleSummarize(ctx, cfg, req, res) {
|
|
466
|
+
if (!fence(ctx, req, res)) return
|
|
467
|
+
if (req.method !== 'POST') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'POST only'))
|
|
468
|
+
|
|
469
|
+
let payload
|
|
470
|
+
try {
|
|
471
|
+
payload = await readJsonBody(req, cfg.maxBodyBytes)
|
|
472
|
+
} catch (error) {
|
|
473
|
+
return writeJson(res, Number.isInteger(error?.statusCode) ? error.statusCode : 400,
|
|
474
|
+
failure('BAD_REQUEST', messageOf(error)))
|
|
475
|
+
}
|
|
476
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
477
|
+
return writeJson(res, 400, failure('BAD_REQUEST', 'JSON object body expected'))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const sessionId = pickString(payload.sessionId) ? payload.sessionId.trim() : ''
|
|
481
|
+
if (!sessionId) return writeJson(res, 400, failure('BAD_REQUEST', 'sessionId is required'))
|
|
482
|
+
const messages = normaliseDialogMessages(payload.messages)
|
|
483
|
+
if (messages.length === 0) {
|
|
484
|
+
return writeJson(res, 400, failure('BAD_REQUEST', 'messages must contain at least one user or assistant turn'))
|
|
485
|
+
}
|
|
486
|
+
const focus = payload.focus && typeof payload.focus === 'object' && pickString(payload.focus.text)
|
|
487
|
+
? { text: payload.focus.text.trim().slice(0, FOCUS_MAX_CHARS) }
|
|
488
|
+
: null
|
|
489
|
+
|
|
490
|
+
let prompt
|
|
491
|
+
try {
|
|
492
|
+
prompt = buildSummaryPrompt(messages, { focus })
|
|
493
|
+
} catch (error) {
|
|
494
|
+
return writeJson(res, 400, failure('BAD_REQUEST', messageOf(error)))
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
let route
|
|
498
|
+
try {
|
|
499
|
+
route = await resolveSummaryRoute(ctx, payload)
|
|
500
|
+
} catch (error) {
|
|
501
|
+
const status = error?.code === 'BAD_MODEL' ? 400 : 500
|
|
502
|
+
return writeJson(res, status, failure(error?.code || 'NO_ROUTE', messageOf(error)))
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const abort = new AbortController()
|
|
506
|
+
const timeoutMs = Number.isFinite(cfg.summaryTimeoutMs) && cfg.summaryTimeoutMs > 0
|
|
507
|
+
? cfg.summaryTimeoutMs
|
|
508
|
+
: 60_000
|
|
509
|
+
const watchdog = setTimeout(() => abort.abort(), timeoutMs)
|
|
510
|
+
watchdog.unref?.()
|
|
511
|
+
|
|
512
|
+
let text = ''
|
|
513
|
+
let usage
|
|
514
|
+
let warning = null
|
|
515
|
+
try {
|
|
516
|
+
for await (const chunk of ctx.llm.stream({
|
|
517
|
+
provider: route.provider,
|
|
518
|
+
model: route.model,
|
|
519
|
+
sessionId,
|
|
520
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
521
|
+
temperature: 0.2,
|
|
522
|
+
maxTokens: 1200,
|
|
523
|
+
signal: abort.signal,
|
|
524
|
+
})) {
|
|
525
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
526
|
+
else if (chunk?.type === 'usage') usage = chunk.usage
|
|
527
|
+
}
|
|
528
|
+
} catch (error) {
|
|
529
|
+
warning = abort.signal.aborted ? '摘要生成超时,已改用本地摘要' : `摘要生成失败:${messageOf(error)}`
|
|
530
|
+
} finally {
|
|
531
|
+
clearTimeout(watchdog)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
let summary = cleanSummary(text, 4000)
|
|
535
|
+
let fallback = false
|
|
536
|
+
if (summary === '') {
|
|
537
|
+
summary = fallbackSummary(messages)
|
|
538
|
+
fallback = true
|
|
539
|
+
if (!warning) warning = '模型没有返回摘要,已改用本地摘要'
|
|
540
|
+
}
|
|
541
|
+
writeJson(res, 200, {
|
|
542
|
+
ok: true,
|
|
543
|
+
summary,
|
|
544
|
+
fallback,
|
|
545
|
+
warning: warning || undefined,
|
|
546
|
+
provider: route.provider,
|
|
547
|
+
model: route.model,
|
|
548
|
+
usage,
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function resolveSummaryRoute(ctx, payload) {
|
|
553
|
+
const provider = pickString(payload.provider)
|
|
554
|
+
const model = pickString(payload.model)
|
|
555
|
+
if (provider || model) {
|
|
556
|
+
if (!provider || !model) throw coded(new Error('provider 和 model 需要同时提供'), 'BAD_MODEL')
|
|
557
|
+
try {
|
|
558
|
+
await ctx.llm.resolveModelInfo(provider, model)
|
|
559
|
+
} catch (error) {
|
|
560
|
+
throw coded(new Error(`无法使用 ${provider}/${model}:${messageOf(error)}`), 'BAD_MODEL')
|
|
561
|
+
}
|
|
562
|
+
return { provider, model }
|
|
563
|
+
}
|
|
564
|
+
const fallback = defaultRoute(ctx)
|
|
565
|
+
if (fallback) return fallback
|
|
566
|
+
throw coded(new Error('没有可用的默认模型,请先在 btw 里选一个模型'), 'NO_ROUTE')
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Append the reviewed summary to the parent as a user/message with a custom
|
|
571
|
+
* `source.kind = 'btw'`. DSH renders non-`user` sources as a context/notice
|
|
572
|
+
* card and the next request's deriveMessages() picks the event up, so the
|
|
573
|
+
* parent agent sees it without waking a turn now.
|
|
574
|
+
*/
|
|
575
|
+
async function handleCommit(ctx, cfg, req, res) {
|
|
576
|
+
if (!fence(ctx, req, res)) return
|
|
577
|
+
if (req.method !== 'POST') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'POST only'))
|
|
578
|
+
|
|
579
|
+
let payload
|
|
580
|
+
try {
|
|
581
|
+
payload = await readJsonBody(req, cfg.maxBodyBytes)
|
|
582
|
+
} catch (error) {
|
|
583
|
+
return writeJson(res, Number.isInteger(error?.statusCode) ? error.statusCode : 400,
|
|
584
|
+
failure('BAD_REQUEST', messageOf(error)))
|
|
585
|
+
}
|
|
586
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
587
|
+
return writeJson(res, 400, failure('BAD_REQUEST', 'JSON object body expected'))
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const sessionId = pickString(payload.sessionId) ? payload.sessionId.trim() : ''
|
|
591
|
+
if (!sessionId) return writeJson(res, 400, failure('BAD_REQUEST', 'sessionId is required'))
|
|
592
|
+
const rawSummary = typeof payload.summary === 'string' ? payload.summary.trim() : ''
|
|
593
|
+
if (!rawSummary) return writeJson(res, 400, failure('BAD_REQUEST', 'summary is required'))
|
|
594
|
+
const limit = Number.isInteger(cfg.maxSummaryChars) && cfg.maxSummaryChars > 0 ? cfg.maxSummaryChars : 8000
|
|
595
|
+
if (rawSummary.length > limit) {
|
|
596
|
+
return writeJson(res, 400, failure('BAD_REQUEST', `summary is longer than ${limit} characters`))
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const session = pickSession(ctx, sessionId)
|
|
600
|
+
if (!session || typeof session.append !== 'function') {
|
|
601
|
+
return writeJson(res, 409, failure('SESSION_NOT_LIVE', '主会话未打开,无法写入;请回到该会话后重试'))
|
|
602
|
+
}
|
|
603
|
+
let running = false
|
|
604
|
+
try {
|
|
605
|
+
running = serviceOf(ctx, 'agents')?.get?.(sessionId)?.status === 'running'
|
|
606
|
+
} catch { /* the agent registry is optional for this check */ }
|
|
607
|
+
if (running) {
|
|
608
|
+
return writeJson(res, 409, failure('SESSION_BUSY', '主会话正在生成回答,请等它结束再送入'))
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const meta = normaliseSummaryMeta({ turns: payload.turns, at: payload.at, focus: payload.focus === true })
|
|
612
|
+
let body
|
|
613
|
+
try {
|
|
614
|
+
body = buildInjectionBody(rawSummary, meta)
|
|
615
|
+
} catch (error) {
|
|
616
|
+
return writeJson(res, 400, failure('BAD_REQUEST', messageOf(error)))
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const message = {
|
|
620
|
+
id: randomUUID(),
|
|
621
|
+
role: 'user',
|
|
622
|
+
content: [{ type: 'text', text: body }],
|
|
623
|
+
source: { kind: 'btw', form: 'notice', at: meta.at, turns: meta.turns, focus: meta.focus },
|
|
624
|
+
}
|
|
625
|
+
let event
|
|
626
|
+
try {
|
|
627
|
+
event = session.append('user/message', message, { surfaceOp: 'append' })
|
|
628
|
+
} catch (error) {
|
|
629
|
+
return writeJson(res, 500, failure('APPEND_FAILED', `写入主会话失败:${messageOf(error)}`))
|
|
630
|
+
}
|
|
631
|
+
writeJson(res, 200, {
|
|
632
|
+
ok: true,
|
|
633
|
+
seq: Number.isFinite(event?.seq) ? event.seq : null,
|
|
634
|
+
turns: meta.turns,
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/* --------------------------------------------------------------- snapshots */
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Resolve the model-visible history for `sessionId`: live sessions first, then
|
|
642
|
+
* the cold path (persisted log folded into messages). Throws
|
|
643
|
+
* `code: 'SESSION_NOT_FOUND'` when neither source knows the id.
|
|
644
|
+
*/
|
|
645
|
+
async function resolveSnapshot(ctx, sessionId, cfg) {
|
|
646
|
+
const session = pickSession(ctx, sessionId)
|
|
647
|
+
if (session) {
|
|
648
|
+
try {
|
|
649
|
+
return { ...snapshotOf(session), cold: false }
|
|
650
|
+
} catch (error) {
|
|
651
|
+
throw coded(error, 'SNAPSHOT_FAILED')
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return coldSnapshot(ctx, sessionId, cfg)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** What cold-read capability this composition has (for /btw/health). */
|
|
658
|
+
function coldReadKind(ctx) {
|
|
659
|
+
if (typeof serviceOf(ctx, 'sessionQuery')?.readSession === 'function') return 'sessionQuery'
|
|
660
|
+
if (typeof serviceOf(ctx, 'sessionPersistence')?.open === 'function') return 'sessionPersistence'
|
|
661
|
+
return 'none'
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function coldSnapshot(ctx, sessionId, cfg) {
|
|
665
|
+
const query = serviceOf(ctx, 'sessionQuery')
|
|
666
|
+
const persistence = serviceOf(ctx, 'sessionPersistence')
|
|
667
|
+
let events
|
|
668
|
+
try {
|
|
669
|
+
if (query && typeof query.readSession === 'function') {
|
|
670
|
+
const loaded = await query.readSession(sessionId)
|
|
671
|
+
events = loaded && Array.isArray(loaded.events) ? loaded.events : undefined
|
|
672
|
+
} else if (persistence && typeof persistence.open === 'function') {
|
|
673
|
+
const handle = await persistence.open(sessionId, 'read')
|
|
674
|
+
try {
|
|
675
|
+
const read = await handle.read()
|
|
676
|
+
events = read && Array.isArray(read.events) ? read.events : undefined
|
|
677
|
+
} finally {
|
|
678
|
+
try { await handle.close?.() } catch { /* closing is best effort */ }
|
|
679
|
+
}
|
|
680
|
+
} else {
|
|
681
|
+
throw coded(new Error('this build exposes neither ctx.sessionQuery nor ctx.sessionPersistence'), 'SESSION_NOT_LIVE')
|
|
682
|
+
}
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (isNotFound(error)) throw coded(new Error('session not found'), 'SESSION_NOT_FOUND')
|
|
685
|
+
throw coded(error, error?.code && String(error.code).startsWith('SESSION_QUERY_') ? 'SESSION_NOT_LIVE' : 'COLD_READ_FAILED')
|
|
686
|
+
}
|
|
687
|
+
if (!Array.isArray(events) || events.length === 0) throw coded(new Error('the persisted session has no events'), 'SESSION_NOT_FOUND')
|
|
688
|
+
|
|
689
|
+
try {
|
|
690
|
+
return foldEventsToSnapshot(events, sessionId)
|
|
691
|
+
} catch (error) {
|
|
692
|
+
throw coded(error, 'COLD_READ_FAILED')
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function headerConfigOf(snapshot) {
|
|
697
|
+
return snapshot?.header?.config && typeof snapshot.header.config === 'object'
|
|
698
|
+
? snapshot.header.config
|
|
699
|
+
: {}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function coded(error, code) {
|
|
703
|
+
const wrapped = error instanceof Error ? error : new Error(String(error))
|
|
704
|
+
if (!wrapped.code) wrapped.code = code
|
|
705
|
+
return wrapped
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function isNotFound(error) {
|
|
709
|
+
if (!error) return false
|
|
710
|
+
const code = typeof error.code === 'string' ? error.code : ''
|
|
711
|
+
if (code.includes('NOT_FOUND')) return true
|
|
712
|
+
if (error.name === 'SessionPersistenceNotFoundError') return true
|
|
713
|
+
return /not found|no such session|absent/i.test(String(error.message || ''))
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* ------------------------------------------------------------------ helpers */
|
|
717
|
+
|
|
718
|
+
function pickString(value) {
|
|
719
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function failure(code, message) {
|
|
723
|
+
return { ok: false, error: { code, message } }
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Cordis service lookup that also works for optional (non-injected) services. */
|
|
727
|
+
function serviceOf(ctx, name) {
|
|
728
|
+
if (!ctx) return null
|
|
729
|
+
if (typeof ctx.get === 'function') {
|
|
730
|
+
try {
|
|
731
|
+
const service = ctx.get(name)
|
|
732
|
+
if (service) return service
|
|
733
|
+
} catch { /* not mounted in this composition */ }
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
return ctx[name] || null
|
|
737
|
+
} catch {
|
|
738
|
+
return null
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Consult the host admission policy (DSH 0.2.0-rc.2+); 401/403 when rejected. */
|
|
743
|
+
function fence(ctx, req, res) {
|
|
744
|
+
let rejection
|
|
745
|
+
try {
|
|
746
|
+
rejection = typeof ctx.connection?.requestRejection === 'function'
|
|
747
|
+
? ctx.connection.requestRejection(req)
|
|
748
|
+
: undefined
|
|
749
|
+
} catch {
|
|
750
|
+
rejection = 403
|
|
751
|
+
}
|
|
752
|
+
if (rejection === undefined || rejection === null || rejection === false) return true
|
|
753
|
+
writeJson(res, rejection === 401 ? 401 : 403, failure('FORBIDDEN', 'rejected by the host admission policy'))
|
|
754
|
+
return false
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function writeJson(res, status, body) {
|
|
758
|
+
if (res.writableEnded) return
|
|
759
|
+
const text = JSON.stringify(body)
|
|
760
|
+
res.writeHead(status, {
|
|
761
|
+
'content-type': 'application/json; charset=utf-8',
|
|
762
|
+
'content-length': Buffer.byteLength(text),
|
|
763
|
+
'cache-control': 'no-store',
|
|
764
|
+
})
|
|
765
|
+
res.end(text)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function writeSseHead(res) {
|
|
769
|
+
res.writeHead(200, {
|
|
770
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
771
|
+
'cache-control': 'no-cache, no-transform',
|
|
772
|
+
connection: 'keep-alive',
|
|
773
|
+
'x-accel-buffering': 'no',
|
|
774
|
+
})
|
|
775
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders()
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function writeSse(res, event, data) {
|
|
779
|
+
if (res.writableEnded) return
|
|
780
|
+
try {
|
|
781
|
+
res.write(sseFrame(event, data))
|
|
782
|
+
} catch {
|
|
783
|
+
/* the peer went away; the close listener aborts the upstream request */
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function endSse(res, event, data) {
|
|
788
|
+
writeSse(res, event, data)
|
|
789
|
+
try { res.end() } catch { /* already closed */ }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Read a JSON request body with a hard byte cap (413 on overflow). */
|
|
793
|
+
function readJsonBody(req, limit) {
|
|
794
|
+
return new Promise((resolve, reject) => {
|
|
795
|
+
const chunks = []
|
|
796
|
+
let size = 0
|
|
797
|
+
let failed = false
|
|
798
|
+
req.on('data', (chunk) => {
|
|
799
|
+
if (failed) return
|
|
800
|
+
size += chunk.length
|
|
801
|
+
if (size > limit) {
|
|
802
|
+
failed = true
|
|
803
|
+
const error = new Error(`request body exceeds ${limit} bytes`)
|
|
804
|
+
error.statusCode = 413
|
|
805
|
+
reject(error)
|
|
806
|
+
req.resume()
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
chunks.push(chunk)
|
|
810
|
+
})
|
|
811
|
+
req.on('end', () => {
|
|
812
|
+
if (failed) return
|
|
813
|
+
try {
|
|
814
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
815
|
+
resolve(raw.trim() === '' ? {} : JSON.parse(raw))
|
|
816
|
+
} catch {
|
|
817
|
+
const error = new Error('invalid JSON body')
|
|
818
|
+
error.statusCode = 400
|
|
819
|
+
reject(error)
|
|
820
|
+
}
|
|
821
|
+
})
|
|
822
|
+
req.on('error', (error) => {
|
|
823
|
+
if (!failed) {
|
|
824
|
+
failed = true
|
|
825
|
+
reject(error)
|
|
826
|
+
}
|
|
827
|
+
})
|
|
828
|
+
})
|
|
829
|
+
}
|