dsh-lcx-codex 0.3.3 → 0.4.0-rc.13
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/ARCHITECTURE.md +93 -0
- package/CHANGELOG.md +143 -41
- package/README.md +208 -100
- package/README_EN.md +133 -101
- package/assets/dsh-lcx-codex-banner.jpg +0 -0
- package/cordis.patch.yml +10 -4
- package/lib/client.js +117 -194
- package/lib/compact-v2.js +124 -273
- package/lib/dsh-responses.js +299 -0
- package/lib/index.js +548 -1593
- package/lib/json-store.js +34 -0
- package/lib/legacy-v3.js +20 -0
- package/lib/native-checkpoint.js +93 -0
- package/lib/responses-replay.js +281 -0
- package/lib/route.js +222 -0
- package/lib/service-mutex.js +72 -0
- package/lib/transport.js +59 -380
- package/lib/web-run-output.js +13 -152
- package/lib/web-search-alpha.js +26 -448
- package/lib/web-search-capability.js +8 -10
- package/lib/web-search-hosted.js +22 -372
- package/lib/web-search-ref-store.js +7 -7
- package/package.json +20 -14
- package/scripts/probe-alpha.mjs +28 -76
- package/scripts/validate-dsh-schema.mjs +31 -0
- package/lib/checkpoint-store-v3.js +0 -459
- package/lib/compact.js +0 -752
- package/lib/dsh-pi-responses.js +0 -339
- package/lib/private-file.js +0 -18
- package/lib/session-lease.js +0 -70
- package/lib/web-search-store.js +0 -181
package/lib/transport.js
CHANGED
|
@@ -1,396 +1,75 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const MAX_RETRY_DELAY_MS = 30_000
|
|
4
|
-
const combinedSignalStates = new WeakMap()
|
|
5
|
-
|
|
6
|
-
export class LcxHttpError extends Error {
|
|
7
|
-
constructor(message, { code = 'LCX_HTTP_ERROR', status, retryable = false, cause } = {}) {
|
|
8
|
-
super(message, cause === undefined ? undefined : { cause })
|
|
9
|
-
this.name = 'LcxHttpError'
|
|
10
|
-
this.code = code
|
|
11
|
-
this.status = status
|
|
12
|
-
this.retryable = retryable
|
|
13
|
-
}
|
|
14
|
-
}
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 300_000
|
|
2
|
+
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
|
15
3
|
|
|
16
4
|
export function abortIfNeeded(signal) {
|
|
17
|
-
if (signal?.aborted) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
function retryableStatus(status) {
|
|
35
|
-
return status === 429 || (status >= 500 && status <= 599)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function combinedSignal(signal, timeoutMs) {
|
|
39
|
-
const controller = new AbortController()
|
|
40
|
-
let timer
|
|
41
|
-
const abort = (reason) => {
|
|
42
|
-
if (controller.signal.aborted) return
|
|
43
|
-
if (timer !== undefined) globalThis.clearTimeout(timer)
|
|
44
|
-
combinedSignalStates.delete(controller.signal)
|
|
45
|
-
signal?.removeEventListener('abort', onCallerAbort)
|
|
46
|
-
controller.abort(reason)
|
|
47
|
-
}
|
|
48
|
-
const onCallerAbort = () => abort(signal.reason)
|
|
49
|
-
if (Number.isFinite(timeoutMs)) {
|
|
50
|
-
timer = globalThis.setTimeout(() => {
|
|
51
|
-
abort(new DOMException(`The operation was aborted due to timeout`, 'TimeoutError'))
|
|
52
|
-
}, Math.max(0, timeoutMs))
|
|
53
|
-
timer.unref?.()
|
|
54
|
-
}
|
|
55
|
-
combinedSignalStates.set(controller.signal, { timer, callerSignal: signal, onCallerAbort })
|
|
56
|
-
if (signal?.aborted) abort(signal.reason)
|
|
57
|
-
else signal?.addEventListener('abort', onCallerAbort, { once: true })
|
|
58
|
-
return controller.signal
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function holdSignal(signal) {
|
|
62
|
-
combinedSignalStates.get(signal)?.timer?.ref?.()
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function releaseSignal(signal) {
|
|
66
|
-
const state = combinedSignalStates.get(signal)
|
|
67
|
-
if (!state) return
|
|
68
|
-
if (state.timer !== undefined) globalThis.clearTimeout(state.timer)
|
|
69
|
-
state.callerSignal?.removeEventListener('abort', state.onCallerAbort)
|
|
70
|
-
combinedSignalStates.delete(signal)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function retryAfterMilliseconds(response) {
|
|
74
|
-
const value = response?.headers?.get?.('retry-after')
|
|
75
|
-
if (!value) return undefined
|
|
76
|
-
const seconds = Number(value)
|
|
77
|
-
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(MAX_RETRY_DELAY_MS, Math.ceil(seconds * 1000))
|
|
78
|
-
const date = Date.parse(value)
|
|
79
|
-
return Number.isFinite(date) ? Math.min(MAX_RETRY_DELAY_MS, Math.max(0, date - Date.now())) : undefined
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function timeoutError(timeoutMs, cause) {
|
|
83
|
-
return new LcxHttpError(`LCX request timed out after ${timeoutMs} ms`, { code: 'LCX_TIMEOUT', retryable: true, cause })
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function abortRequestIfNeeded(requestSignal, callerSignal, timeoutMs) {
|
|
87
|
-
abortIfNeeded(callerSignal)
|
|
88
|
-
if (!requestSignal?.aborted) return
|
|
89
|
-
if (requestSignal.reason?.name === 'TimeoutError') throw timeoutError(timeoutMs, requestSignal.reason)
|
|
90
|
-
throw requestSignal.reason instanceof Error ? requestSignal.reason : new Error('request aborted')
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function retryWaitMilliseconds(error, baseDelayMs, attempt) {
|
|
94
|
-
const retryAfter = Number(error?.retryAfterMs)
|
|
95
|
-
if (Number.isFinite(retryAfter) && retryAfter >= 0) return Math.min(MAX_RETRY_DELAY_MS, retryAfter)
|
|
96
|
-
const exponential = baseDelayMs * (2 ** (attempt - 1))
|
|
97
|
-
return Number.isFinite(exponential) && exponential >= 0 ? Math.min(MAX_RETRY_DELAY_MS, exponential) : MAX_RETRY_DELAY_MS
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function readLimitedText(response, maxResponseBytes, signal, label) {
|
|
101
|
-
if (!response?.body) return ''
|
|
102
|
-
const reader = response.body.getReader()
|
|
103
|
-
const decoder = new TextDecoder()
|
|
104
|
-
const parts = []
|
|
105
|
-
let bytes = 0
|
|
106
|
-
const cancel = () => { reader.cancel(signal?.reason).catch(() => undefined) }
|
|
107
|
-
try {
|
|
108
|
-
signal?.addEventListener('abort', cancel, { once: true })
|
|
109
|
-
while (true) {
|
|
110
|
-
abortIfNeeded(signal)
|
|
111
|
-
const result = await reader.read()
|
|
112
|
-
abortIfNeeded(signal)
|
|
113
|
-
if (result.done) break
|
|
114
|
-
bytes += result.value.byteLength
|
|
115
|
-
if (bytes > maxResponseBytes) {
|
|
116
|
-
throw new LcxHttpError(`LCX ${label} response exceeds ${maxResponseBytes} bytes`, { code: 'LCX_RESPONSE_TOO_LARGE', status: response.status })
|
|
117
|
-
}
|
|
118
|
-
parts.push(decoder.decode(result.value, { stream: true }))
|
|
119
|
-
}
|
|
120
|
-
parts.push(decoder.decode())
|
|
121
|
-
return parts.join('')
|
|
122
|
-
} finally {
|
|
123
|
-
signal?.removeEventListener('abort', cancel)
|
|
124
|
-
await reader.cancel().catch(() => undefined)
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function normalizeReadError(error, requestSignal, timeoutMs) {
|
|
129
|
-
if (requestSignal?.aborted && requestSignal.reason?.name === 'TimeoutError') return timeoutError(timeoutMs, error)
|
|
130
|
-
return error
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function abortableResponse(response, requestSignal, callerSignal, timeoutMs, ownsRequestSignal = false) {
|
|
134
|
-
if (!response?.body || !requestSignal) return response
|
|
135
|
-
const source = response.body
|
|
136
|
-
const reader = source.getReader()
|
|
137
|
-
let abortError
|
|
138
|
-
let cleanedUp = false
|
|
139
|
-
let pendingReadReject
|
|
140
|
-
let onAbort
|
|
141
|
-
const cleanup = () => {
|
|
142
|
-
if (cleanedUp) return
|
|
143
|
-
cleanedUp = true
|
|
144
|
-
requestSignal.removeEventListener('abort', onAbort)
|
|
145
|
-
if (ownsRequestSignal) releaseSignal(requestSignal)
|
|
146
|
-
}
|
|
147
|
-
const abort = () => {
|
|
148
|
-
if (abortError) return
|
|
149
|
-
abortError = callerSignal?.aborted
|
|
150
|
-
? (callerSignal.reason instanceof Error ? callerSignal.reason : new Error('request aborted'))
|
|
151
|
-
: requestSignal.reason?.name === 'TimeoutError'
|
|
152
|
-
? timeoutError(timeoutMs, requestSignal.reason)
|
|
153
|
-
: (requestSignal.reason instanceof Error ? requestSignal.reason : new Error('request aborted'))
|
|
154
|
-
cleanup()
|
|
155
|
-
pendingReadReject?.(abortError)
|
|
156
|
-
reader.cancel(abortError).catch(() => undefined)
|
|
157
|
-
}
|
|
158
|
-
onAbort = abort
|
|
159
|
-
const stream = new ReadableStream({
|
|
160
|
-
start(controller) {
|
|
161
|
-
if (requestSignal.aborted) {
|
|
162
|
-
abort()
|
|
163
|
-
controller.error(abortError)
|
|
164
|
-
} else {
|
|
165
|
-
requestSignal.addEventListener('abort', onAbort, { once: true })
|
|
166
|
-
}
|
|
167
|
-
},
|
|
168
|
-
async pull(controller) {
|
|
169
|
-
if (abortError) return
|
|
170
|
-
try {
|
|
171
|
-
const result = await new Promise((resolve, reject) => {
|
|
172
|
-
pendingReadReject = reject
|
|
173
|
-
reader.read().then(resolve, reject)
|
|
174
|
-
})
|
|
175
|
-
pendingReadReject = undefined
|
|
176
|
-
if (abortError) return
|
|
177
|
-
if (result.done) {
|
|
178
|
-
cleanup()
|
|
179
|
-
controller.close()
|
|
180
|
-
} else {
|
|
181
|
-
controller.enqueue(result.value)
|
|
182
|
-
}
|
|
183
|
-
} catch (error) {
|
|
184
|
-
pendingReadReject = undefined
|
|
185
|
-
cleanup()
|
|
186
|
-
controller.error(abortError ?? error)
|
|
187
|
-
}
|
|
188
|
-
},
|
|
189
|
-
cancel(reason) {
|
|
190
|
-
cleanup()
|
|
191
|
-
reader.cancel(reason).catch(() => undefined)
|
|
192
|
-
},
|
|
193
|
-
})
|
|
194
|
-
return new Response(stream, {
|
|
195
|
-
status: response.status,
|
|
196
|
-
statusText: response.statusText,
|
|
197
|
-
headers: response.headers,
|
|
198
|
-
})
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export async function fetchJson(url, body, headers, signal, timeoutMs, options = {}) {
|
|
202
|
-
abortIfNeeded(signal)
|
|
203
|
-
const maxResponseBytes = options.maxResponseBytes ?? 4 * 1024 * 1024
|
|
204
|
-
const requestSignal = options.requestSignal ?? combinedSignal(signal, timeoutMs)
|
|
205
|
-
const ownsRequestSignal = !options.requestSignal
|
|
206
|
-
if (ownsRequestSignal) holdSignal(requestSignal)
|
|
207
|
-
try {
|
|
208
|
-
abortRequestIfNeeded(requestSignal, signal, timeoutMs)
|
|
209
|
-
let serializedBody
|
|
210
|
-
try {
|
|
211
|
-
serializedBody = JSON.stringify(body)
|
|
212
|
-
} catch (error) {
|
|
213
|
-
throw new LcxHttpError(`LCX request body is not serializable: ${String(error)}`, { code: 'LCX_INVALID_REQUEST', cause: error })
|
|
214
|
-
}
|
|
215
|
-
let response
|
|
216
|
-
try {
|
|
217
|
-
response = await fetch(url, {
|
|
218
|
-
method: 'POST',
|
|
219
|
-
redirect: 'error',
|
|
220
|
-
headers: { accept: 'application/json', 'content-type': 'application/json', ...headers },
|
|
221
|
-
body: serializedBody,
|
|
222
|
-
signal: requestSignal,
|
|
223
|
-
})
|
|
224
|
-
} catch (error) {
|
|
225
|
-
abortIfNeeded(signal)
|
|
226
|
-
if (requestSignal?.aborted) abortRequestIfNeeded(requestSignal, signal, timeoutMs)
|
|
227
|
-
if (error?.name === 'TimeoutError') throw timeoutError(timeoutMs, error)
|
|
228
|
-
throw error
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const contentLength = Number(response.headers.get('content-length') ?? 0)
|
|
232
|
-
if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
|
|
233
|
-
throw new LcxHttpError(`LCX response exceeds ${maxResponseBytes} bytes`, { code: 'LCX_RESPONSE_TOO_LARGE', status: response.status })
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
let text
|
|
237
|
-
try {
|
|
238
|
-
text = await readLimitedText(response, maxResponseBytes, requestSignal, 'JSON')
|
|
239
|
-
} catch (error) {
|
|
240
|
-
throw normalizeReadError(error, requestSignal, timeoutMs)
|
|
241
|
-
}
|
|
242
|
-
if (!response.ok) {
|
|
243
|
-
const detail = responseDetail(text)
|
|
244
|
-
const error = new LcxHttpError(`LCX request failed (HTTP ${response.status})${detail ? `: ${detail}` : ''}`, {
|
|
245
|
-
code: retryableStatus(response.status) ? 'LCX_HTTP_RETRYABLE' : 'LCX_HTTP_ERROR',
|
|
246
|
-
status: response.status,
|
|
247
|
-
retryable: retryableStatus(response.status),
|
|
248
|
-
})
|
|
249
|
-
error.retryAfterMs = retryAfterMilliseconds(response)
|
|
250
|
-
throw error
|
|
251
|
-
}
|
|
252
|
-
try {
|
|
253
|
-
return JSON.parse(text)
|
|
254
|
-
} catch (error) {
|
|
255
|
-
throw new LcxHttpError(`LCX returned a non-JSON response: ${String(error)}`, { code: 'LCX_INVALID_JSON', status: response.status, cause: error })
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
finally {
|
|
259
|
-
if (ownsRequestSignal) releaseSignal(requestSignal)
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export async function fetchSse(url, body, headers, signal, timeoutMs, options = {}) {
|
|
264
|
-
abortIfNeeded(signal)
|
|
265
|
-
const maxResponseBytes = options.maxResponseBytes ?? 8 * 1024 * 1024
|
|
266
|
-
const requestSignal = options.requestSignal ?? combinedSignal(signal, timeoutMs)
|
|
267
|
-
const ownsRequestSignal = !options.requestSignal
|
|
268
|
-
if (ownsRequestSignal) holdSignal(requestSignal)
|
|
269
|
-
let handedOff = false
|
|
270
|
-
try {
|
|
271
|
-
abortRequestIfNeeded(requestSignal, signal, timeoutMs)
|
|
272
|
-
let serializedBody
|
|
273
|
-
try {
|
|
274
|
-
serializedBody = JSON.stringify(body)
|
|
275
|
-
} catch (error) {
|
|
276
|
-
throw new LcxHttpError(`LCX request body is not serializable: ${String(error)}`, { code: 'LCX_INVALID_REQUEST', cause: error })
|
|
277
|
-
}
|
|
278
|
-
let response
|
|
279
|
-
try {
|
|
280
|
-
response = await fetch(url, {
|
|
281
|
-
method: 'POST',
|
|
282
|
-
redirect: 'error',
|
|
283
|
-
headers: { accept: 'text/event-stream', 'content-type': 'application/json', ...headers },
|
|
284
|
-
body: serializedBody,
|
|
285
|
-
signal: requestSignal,
|
|
286
|
-
})
|
|
287
|
-
} catch (error) {
|
|
5
|
+
if (signal?.aborted) throw signal.reason ?? Object.assign(new Error('request aborted'), { code: 'LCX_ABORTED' })
|
|
6
|
+
}
|
|
7
|
+
function makeError(message, code, extra = {}) { const error = new Error(message); error.code = code; Object.assign(error, extra); return error }
|
|
8
|
+
function retryableStatus(status) { return status === 408 || status === 409 || status === 425 || status === 429 || (status >= 500 && status <= 599) }
|
|
9
|
+
function delayFromHeaders(headers, attempt) {
|
|
10
|
+
const millis = Number(headers?.get?.('retry-after-ms')); if (Number.isFinite(millis) && millis >= 0) return Math.min(millis, 30_000)
|
|
11
|
+
const retryAfter = headers?.get?.('retry-after'); if (retryAfter) { const seconds = Number(retryAfter); if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 30_000); const date = Date.parse(retryAfter); if (!Number.isNaN(date)) return Math.min(Math.max(0, date - Date.now()), 30_000) }
|
|
12
|
+
return Math.min(10_000, 500 * 2 ** Math.max(0, attempt - 1))
|
|
13
|
+
}
|
|
14
|
+
async function sleep(ms, signal) { if (ms <= 0) return; await new Promise((resolve, reject) => { if (signal?.aborted) return reject(signal.reason); const timer = setTimeout(resolve, ms); signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason) }, { once: true }) }) }
|
|
15
|
+
function combinedSignal(signal, timeoutMs) { if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) return signal; const timeout = AbortSignal.timeout(timeoutMs); return signal ? AbortSignal.any([signal, timeout]) : timeout }
|
|
16
|
+
async function readLimited(response, maxBytes = DEFAULT_MAX_BYTES) { const buffer = new Uint8Array(await response.arrayBuffer()); if (buffer.byteLength > maxBytes) throw makeError(`response exceeds ${maxBytes} bytes`, 'LCX_RESPONSE_TOO_LARGE', { status: response.status }); return new TextDecoder().decode(buffer) }
|
|
17
|
+
|
|
18
|
+
export async function fetchJsonWithRetry(url, body, headers = {}, signal, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) {
|
|
19
|
+
const maxAttempts = Math.max(1, Math.min(6, Number(options.maxAttempts ?? 3))); const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_BYTES; let last
|
|
20
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
288
21
|
abortIfNeeded(signal)
|
|
289
|
-
if (requestSignal?.aborted) abortRequestIfNeeded(requestSignal, signal, timeoutMs)
|
|
290
|
-
if (error?.name === 'TimeoutError') throw timeoutError(timeoutMs, error)
|
|
291
|
-
throw error
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const contentLength = Number(response.headers.get('content-length') ?? 0)
|
|
295
|
-
if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
|
|
296
|
-
throw new LcxHttpError(`LCX SSE response exceeds ${maxResponseBytes} bytes`, { code: 'LCX_RESPONSE_TOO_LARGE', status: response.status })
|
|
297
|
-
}
|
|
298
|
-
if (!response.ok) {
|
|
299
|
-
let text
|
|
300
22
|
try {
|
|
301
|
-
|
|
23
|
+
const requestSignal = combinedSignal(signal, timeoutMs)
|
|
24
|
+
const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json', ...headers }, body: JSON.stringify(body), signal: requestSignal, redirect: 'error' })
|
|
25
|
+
const text = await readLimited(response, maxResponseBytes)
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const retryable = retryableStatus(response.status)
|
|
28
|
+
const error = makeError(`HTTP ${response.status}`, retryable ? 'LCX_HTTP_RETRYABLE' : 'LCX_HTTP_ERROR', { status: response.status, retryable, requestId: response.headers.get('x-request-id') ?? response.headers.get('request-id') ?? undefined })
|
|
29
|
+
if (!error.retryable || attempt >= maxAttempts) throw error; last = error; await sleep(delayFromHeaders(response.headers, attempt), signal); continue
|
|
30
|
+
}
|
|
31
|
+
let parsed
|
|
32
|
+
try { parsed = text ? JSON.parse(text) : {} } catch (cause) { throw makeError(`invalid JSON response from ${url}`, 'LCX_INVALID_JSON', { status: response.status, cause }) }
|
|
33
|
+
return parsed
|
|
302
34
|
} catch (error) {
|
|
303
|
-
|
|
35
|
+
if (signal?.aborted) throw signal.reason ?? error
|
|
36
|
+
const retryable = error?.retryable === true || error?.code === 'LCX_HTTP_RETRYABLE' || error instanceof TypeError || ['ECONNRESET','ECONNREFUSED','ETIMEDOUT','EAI_AGAIN','UND_ERR_CONNECT_TIMEOUT'].includes(error?.code)
|
|
37
|
+
if (!retryable || attempt >= maxAttempts) { if (attempt >= maxAttempts && retryable) throw makeError(`request failed after ${maxAttempts} attempts`, 'LCX_RETRY_EXHAUSTED', { cause: error, retryable: true }); throw error }
|
|
38
|
+
last = error; await sleep(Math.min(10_000, 500 * 2 ** (attempt - 1)), signal)
|
|
304
39
|
}
|
|
305
|
-
const detail = responseDetail(text)
|
|
306
|
-
const error = new LcxHttpError(`LCX request failed (HTTP ${response.status})${detail ? `: ${detail}` : ''}`, {
|
|
307
|
-
code: retryableStatus(response.status) ? 'LCX_HTTP_RETRYABLE' : 'LCX_HTTP_ERROR',
|
|
308
|
-
status: response.status,
|
|
309
|
-
retryable: retryableStatus(response.status),
|
|
310
|
-
})
|
|
311
|
-
error.retryAfterMs = retryAfterMilliseconds(response)
|
|
312
|
-
throw error
|
|
313
|
-
}
|
|
314
|
-
if (!response.body) {
|
|
315
|
-
throw new LcxHttpError('LCX SSE response has no body', { code: 'LCX_INVALID_SSE', status: response.status })
|
|
316
|
-
}
|
|
317
|
-
const wrapped = abortableResponse(response, requestSignal, signal, timeoutMs, ownsRequestSignal)
|
|
318
|
-
handedOff = ownsRequestSignal
|
|
319
|
-
return wrapped
|
|
320
|
-
} finally {
|
|
321
|
-
if (ownsRequestSignal && !handedOff) releaseSignal(requestSignal)
|
|
322
40
|
}
|
|
41
|
+
throw last ?? makeError('request failed', 'LCX_REQUEST_FAILED')
|
|
323
42
|
}
|
|
324
43
|
|
|
325
|
-
async function
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (!error || error.name === 'AbortError' || error.name === 'TimeoutError') return false
|
|
337
|
-
if (error instanceof LcxHttpError) return error.code === 'LCX_HTTP_RETRYABLE' || error.code === 'LCX_TIMEOUT'
|
|
338
|
-
return error.retryable === true || ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(error.code)
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
export async function fetchSseWithRetry(url, body, headers, signal, timeoutMs, options = {}) {
|
|
342
|
-
const maxAttempts = Math.max(1, Math.min(options.maxAttempts ?? 3, 6))
|
|
343
|
-
const baseDelayMs = Math.max(0, options.baseDelayMs ?? 250)
|
|
344
|
-
const consume = options.consume
|
|
345
|
-
if (typeof consume !== 'function') throw new TypeError('fetchSseWithRetry requires a consume callback')
|
|
346
|
-
const operationSignal = combinedSignal(signal, timeoutMs)
|
|
347
|
-
holdSignal(operationSignal)
|
|
348
|
-
let attempt = 0
|
|
349
|
-
try {
|
|
350
|
-
while (attempt < maxAttempts) {
|
|
351
|
-
attempt += 1
|
|
352
|
-
try {
|
|
353
|
-
abortRequestIfNeeded(operationSignal, signal, timeoutMs)
|
|
354
|
-
const response = await fetchSse(url, body, headers, signal, timeoutMs, { ...options, requestSignal: operationSignal })
|
|
355
|
-
try {
|
|
356
|
-
return await consume(response, { ...options, requestSignal: operationSignal })
|
|
357
|
-
} finally {
|
|
358
|
-
if (response.body) await response.body.cancel().catch(() => undefined)
|
|
359
|
-
}
|
|
360
|
-
} catch (error) {
|
|
361
|
-
abortIfNeeded(signal)
|
|
362
|
-
if (operationSignal.aborted) abortRequestIfNeeded(operationSignal, signal, timeoutMs)
|
|
363
|
-
if (!transientNetworkError(error) || attempt >= maxAttempts) throw error
|
|
364
|
-
await waitForRetry(retryWaitMilliseconds(error, baseDelayMs, attempt), operationSignal, signal, timeoutMs)
|
|
44
|
+
export async function fetchSseWithRetry(url, body, headers = {}, signal, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) {
|
|
45
|
+
const maxAttempts = Math.max(1, Math.min(6, Number(options.maxAttempts ?? 3))); let last
|
|
46
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
47
|
+
abortIfNeeded(signal)
|
|
48
|
+
try {
|
|
49
|
+
const requestSignal = combinedSignal(signal, timeoutMs)
|
|
50
|
+
const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...headers }, body: JSON.stringify(body), signal: requestSignal, redirect: 'error' })
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
await readLimited(response, Math.min(options.maxResponseBytes ?? DEFAULT_MAX_BYTES, 512 * 1024))
|
|
53
|
+
const retryable = retryableStatus(response.status); const error = makeError(`HTTP ${response.status}`, retryable ? 'LCX_HTTP_RETRYABLE' : 'LCX_HTTP_ERROR', { status: response.status, retryable, requestId: response.headers.get('x-request-id') ?? response.headers.get('request-id') ?? undefined })
|
|
54
|
+
if (!retryable || attempt >= maxAttempts) throw error; last = error; await sleep(delayFromHeaders(response.headers, attempt), signal); continue
|
|
365
55
|
}
|
|
56
|
+
if (typeof options.consume === 'function') return await options.consume(response, { requestSignal }); return response
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (signal?.aborted) throw signal.reason ?? error
|
|
59
|
+
const retryable = error?.retryable === true || error?.code === 'LCX_HTTP_RETRYABLE' || error instanceof TypeError || ['ECONNRESET','ECONNREFUSED','ETIMEDOUT','EAI_AGAIN','UND_ERR_CONNECT_TIMEOUT'].includes(error?.code)
|
|
60
|
+
if (!retryable || attempt >= maxAttempts) { if (attempt >= maxAttempts && retryable) throw makeError(`request failed after ${maxAttempts} attempts`, 'LCX_RETRY_EXHAUSTED', { cause: error, retryable: true }); throw error }
|
|
61
|
+
last = error; await sleep(Math.min(10_000, 500 * 2 ** (attempt - 1)), signal)
|
|
366
62
|
}
|
|
367
|
-
throw new LcxHttpError('LCX SSE retry loop exhausted', { code: 'LCX_RETRY_EXHAUSTED' })
|
|
368
|
-
} finally {
|
|
369
|
-
releaseSignal(operationSignal)
|
|
370
63
|
}
|
|
64
|
+
throw last ?? makeError('request failed', 'LCX_REQUEST_FAILED')
|
|
371
65
|
}
|
|
372
66
|
|
|
373
|
-
export async function
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
holdSignal(operationSignal)
|
|
378
|
-
let attempt = 0
|
|
67
|
+
export async function consumeSse(response, onEvent, options = {}) {
|
|
68
|
+
if (!response?.body) throw makeError('SSE response has no body', 'LCX_INVALID_SSE')
|
|
69
|
+
const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_BYTES; const reader = response.body.getReader(); const decoder = new TextDecoder(); let pending = ''; let dataLines = []; let bytes = 0
|
|
70
|
+
const dispatch = () => { if (dataLines.length === 0) return; const data = dataLines.join('\n'); dataLines = []; if (data === '[DONE]') return; let event; try { event = JSON.parse(data) } catch (cause) { throw makeError('malformed SSE JSON', 'LCX_INVALID_SSE', { cause }) } onEvent(event) }
|
|
379
71
|
try {
|
|
380
|
-
while (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
abortRequestIfNeeded(operationSignal, signal, timeoutMs)
|
|
384
|
-
return await fetchJson(url, body, headers, signal, timeoutMs, { ...options, requestSignal: operationSignal })
|
|
385
|
-
} catch (error) {
|
|
386
|
-
abortIfNeeded(signal)
|
|
387
|
-
if (operationSignal.aborted) abortRequestIfNeeded(operationSignal, signal, timeoutMs)
|
|
388
|
-
if (!transientNetworkError(error) || attempt >= maxAttempts) throw error
|
|
389
|
-
await waitForRetry(retryWaitMilliseconds(error, baseDelayMs, attempt), operationSignal, signal, timeoutMs)
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
throw new LcxHttpError('LCX retry loop exhausted', { code: 'LCX_RETRY_EXHAUSTED' })
|
|
393
|
-
} finally {
|
|
394
|
-
releaseSignal(operationSignal)
|
|
395
|
-
}
|
|
72
|
+
while (true) { abortIfNeeded(options.signal); const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; if (bytes > maxBytes) throw makeError(`SSE response exceeds ${maxBytes} bytes`, 'LCX_RESPONSE_TOO_LARGE'); pending += decoder.decode(value, { stream: true }); let newline; while ((newline = pending.indexOf('\n')) >= 0) { const line = pending.slice(0, newline).replace(/\r$/u, ''); pending = pending.slice(newline + 1); if (line === '') dispatch(); else if (!line.startsWith(':') && line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /u, '')) } }
|
|
73
|
+
pending += decoder.decode(); if (pending.startsWith('data:')) dataLines.push(pending.slice(5).replace(/^ /u, '')); dispatch()
|
|
74
|
+
} finally { await reader.cancel().catch(() => undefined) }
|
|
396
75
|
}
|
package/lib/web-run-output.js
CHANGED
|
@@ -3,155 +3,16 @@ const CITATION_PATTERN = /cite([^]+)/gu
|
|
|
3
3
|
const PAGE_LINE_PATTERN = /^L(\d+)(?:@P(\d+)(?:-(\d+))?)?:\s?(.*)$/u
|
|
4
4
|
const EMBEDDED_PAGE_LINE_PATTERN = / (?=L\d+(?:@P\d+(?:-\d+)?)?:)/gu
|
|
5
5
|
const TITLE_URL_PATTERN = /^(.*?)\s*\((https?:\/\/[^)]+)\)\s*$/u
|
|
6
|
-
|
|
7
|
-
function
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const existing = target.find((link) => link.id === id)
|
|
20
|
-
if (!existing) target.push({ id, label, ...(domain ? { domain } : {}) })
|
|
21
|
-
else if (!existing.domain && domain) existing.domain = domain
|
|
22
|
-
return true
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function cleanCitations(value, references, links) {
|
|
26
|
-
return String(value ?? '').replace(CITATION_PATTERN, (_match, payload) => {
|
|
27
|
-
if (/^turn[\w-]+$/u.test(payload)) {
|
|
28
|
-
pushUnique(references, payload)
|
|
29
|
-
return ''
|
|
30
|
-
}
|
|
31
|
-
if (pushLink(links, payload)) return payload.split('†')[1]
|
|
32
|
-
const separator = payload.indexOf('†')
|
|
33
|
-
return separator < 0 ? payload : payload.slice(separator + 1)
|
|
34
|
-
}).trim()
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function metadataParts(value) {
|
|
38
|
-
return value
|
|
39
|
-
.replace(/^\[wordlim:\s*(\d+)\]\s*/u, '$1-word excerpt; ')
|
|
40
|
-
.split(/;\s*/u)
|
|
41
|
-
.map((part) => part.trim())
|
|
42
|
-
.filter(Boolean)
|
|
43
|
-
.map((part) => {
|
|
44
|
-
const contentType = part.match(/^Content type:\s*(.+)$/u)
|
|
45
|
-
if (contentType) return contentType[1] === 'text/html' ? 'HTML' : contentType[1] === 'application/pdf' ? 'PDF' : contentType[1]
|
|
46
|
-
const totalLines = part.match(/^Total lines:\s*(\d+)$/u)
|
|
47
|
-
if (totalLines) return `${totalLines[1]} lines`
|
|
48
|
-
const pages = part.match(/^Number of pages:\s*(\d+)$/u)
|
|
49
|
-
if (pages) return `${pages[1]} pages`
|
|
50
|
-
return part
|
|
51
|
-
})
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function isMetadata(value) {
|
|
55
|
-
return value.startsWith('[wordlim:') || /^(?:Published|Crawled|Content type|Source|Total lines|Number of pages):/u.test(value)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function parseLine(value, references, links) {
|
|
59
|
-
const clean = cleanCitations(value, references, links)
|
|
60
|
-
const pageLine = clean.match(PAGE_LINE_PATTERN)
|
|
61
|
-
if (pageLine) {
|
|
62
|
-
const text = pageLine[4] ?? ''
|
|
63
|
-
const heading = text.match(/^(#{1,6})\s+(.+)$/u)
|
|
64
|
-
return {
|
|
65
|
-
line: Number(pageLine[1]),
|
|
66
|
-
...(pageLine[2] === undefined ? {} : { page: Number(pageLine[2]) }),
|
|
67
|
-
...(pageLine[3] === undefined ? {} : { pageEnd: Number(pageLine[3]) }),
|
|
68
|
-
text: heading?.[2] ?? text,
|
|
69
|
-
...(heading ? { heading: heading[1].length } : {}),
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
const heading = clean.match(/^(#{1,6})\s+(.+)$/u)
|
|
73
|
-
return heading ? { text: heading[2], heading: heading[1].length } : { text: clean }
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function parseBlock(value) {
|
|
77
|
-
const references = []
|
|
78
|
-
const links = []
|
|
79
|
-
const rawLines = String(value ?? '')
|
|
80
|
-
.replace(EMBEDDED_PAGE_LINE_PATTERN, '\n')
|
|
81
|
-
.split(/\r?\n/u)
|
|
82
|
-
.map((line) => line.trimEnd())
|
|
83
|
-
while (rawLines[0]?.trim() === '') rawLines.shift()
|
|
84
|
-
while (rawLines.at(-1)?.trim() === '') rawLines.pop()
|
|
85
|
-
if (rawLines.length === 0) return undefined
|
|
86
|
-
|
|
87
|
-
const firstLine = cleanCitations(rawLines[0], references, links)
|
|
88
|
-
const header = firstLine.match(TITLE_URL_PATTERN)
|
|
89
|
-
const titleOnly = firstLine.match(/^(.+?)\s*\(\)\s*$/u)
|
|
90
|
-
let title = header?.[1]?.trim() || titleOnly?.[1]?.trim() || undefined
|
|
91
|
-
const url = header?.[2]
|
|
92
|
-
let bodyStart = header || titleOnly ? 1 : 0
|
|
93
|
-
if (!header && /^\s*\([^)]*\)\s*$/u.test(firstLine)) bodyStart = 1
|
|
94
|
-
|
|
95
|
-
const metadata = []
|
|
96
|
-
while (bodyStart < rawLines.length) {
|
|
97
|
-
const clean = cleanCitations(rawLines[bodyStart], references, links)
|
|
98
|
-
if (!isMetadata(clean)) break
|
|
99
|
-
metadata.push(...metadataParts(clean))
|
|
100
|
-
bodyStart += 1
|
|
101
|
-
}
|
|
102
|
-
const lines = rawLines.slice(bodyStart).map((line) => parseLine(line, references, links))
|
|
103
|
-
if (!title && url) {
|
|
104
|
-
try { title = new URL(url).hostname } catch { title = url }
|
|
105
|
-
}
|
|
106
|
-
return {
|
|
107
|
-
...(title ? { title } : {}),
|
|
108
|
-
...(url ? { url } : {}),
|
|
109
|
-
references,
|
|
110
|
-
links,
|
|
111
|
-
metadata,
|
|
112
|
-
lines,
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function parseWebRunOutput(output) {
|
|
117
|
-
if (typeof output !== 'string' || output.length === 0) return []
|
|
118
|
-
return output.split(RESULT_SEPARATOR_PATTERN).map(parseBlock).filter(Boolean)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function outputLineRange(blocks) {
|
|
122
|
-
const numbers = (blocks ?? []).flatMap((block) => (block.lines ?? []).flatMap((line) => line.line === undefined ? [] : [line.line]))
|
|
123
|
-
if (numbers.length === 0) return undefined
|
|
124
|
-
return { first: Math.min(...numbers), last: Math.max(...numbers) }
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function outputDomains(blocks) {
|
|
128
|
-
const domains = []
|
|
129
|
-
for (const block of blocks ?? []) {
|
|
130
|
-
if (!block.url) continue
|
|
131
|
-
try { pushUnique(domains, new URL(block.url).hostname) } catch { /* ignore malformed source URLs */ }
|
|
132
|
-
}
|
|
133
|
-
return domains
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function outputLinks(blocks) {
|
|
137
|
-
const links = []
|
|
138
|
-
for (const block of blocks ?? []) {
|
|
139
|
-
for (const link of block.links ?? []) {
|
|
140
|
-
if (!links.some((value) => value.id === link.id)) links.push({ ...link })
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return links
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function outputPdfRefs(blocks) {
|
|
147
|
-
const refs = []
|
|
148
|
-
for (const block of blocks ?? []) {
|
|
149
|
-
if (!(block.metadata ?? []).includes('PDF')) continue
|
|
150
|
-
for (const ref of block.references ?? []) pushUnique(refs, ref)
|
|
151
|
-
}
|
|
152
|
-
return refs
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function blockPlainText(block) {
|
|
156
|
-
return (block?.lines ?? []).map((line) => line.text).filter(Boolean).join(' ')
|
|
157
|
-
}
|
|
6
|
+
function pushUnique(target, value) { if (!target.includes(value)) target.push(value) }
|
|
7
|
+
function pushLink(target, payload) { const [idValue, labelValue, domainValue] = payload.split('†'); if (!/^\d+$/u.test(idValue ?? '') || !labelValue) return false; const id = Number(idValue); if (!Number.isSafeInteger(id)) return false; const label = labelValue.trim().slice(0, 500); const domain = domainValue?.trim().slice(0, 253); if (!label) return false; const existing = target.find((link) => link.id === id); if (!existing) target.push({ id, label, ...(domain ? { domain } : {}) }); else if (!existing.domain && domain) existing.domain = domain; return true }
|
|
8
|
+
function cleanCitations(value, references, links) { return String(value ?? '').replace(CITATION_PATTERN, (_match, payload) => { if (/^turn[\w-]+$/u.test(payload)) { pushUnique(references, payload); return '' } if (pushLink(links, payload)) return payload.split('†')[1]; const separator = payload.indexOf('†'); return separator < 0 ? payload : payload.slice(separator + 1) }).trim() }
|
|
9
|
+
function metadataParts(value) { return value.replace(/^\[wordlim:\s*(\d+)\]\s*/u, '$1-word excerpt; ').split(/;\s*/u).map((p) => p.trim()).filter(Boolean).map((part) => { const contentType = part.match(/^Content type:\s*(.+)$/u); if (contentType) return contentType[1] === 'text/html' ? 'HTML' : contentType[1] === 'application/pdf' ? 'PDF' : contentType[1]; const totalLines = part.match(/^Total lines:\s*(\d+)$/u); if (totalLines) return `${totalLines[1]} lines`; const pages = part.match(/^Number of pages:\s*(\d+)$/u); if (pages) return `${pages[1]} pages`; return part }) }
|
|
10
|
+
function isMetadata(value) { return value.startsWith('[wordlim:') || /^(?:Published|Crawled|Content type|Source|Total lines|Number of pages):/u.test(value) }
|
|
11
|
+
function parseLine(value, references, links) { const clean = cleanCitations(value, references, links); const pageLine = clean.match(PAGE_LINE_PATTERN); if (pageLine) { const text = pageLine[4] ?? ''; const heading = text.match(/^(#{1,6})\s+(.+)$/u); return { line: Number(pageLine[1]), ...(pageLine[2] === undefined ? {} : { page: Number(pageLine[2]) }), ...(pageLine[3] === undefined ? {} : { pageEnd: Number(pageLine[3]) }), text: heading?.[2] ?? text, ...(heading ? { heading: heading[1].length } : {}) } } const heading = clean.match(/^(#{1,6})\s+(.+)$/u); return heading ? { text: heading[2], heading: heading[1].length } : { text: clean } }
|
|
12
|
+
function parseBlock(value) { const references = [], links = []; const rawLines = String(value ?? '').replace(EMBEDDED_PAGE_LINE_PATTERN, '\n').split(/\r?\n/u).map((line) => line.trimEnd()); while (rawLines[0]?.trim() === '') rawLines.shift(); while (rawLines.at(-1)?.trim() === '') rawLines.pop(); if (!rawLines.length) return undefined; const firstLine = cleanCitations(rawLines[0], references, links); const header = firstLine.match(TITLE_URL_PATTERN); const titleOnly = firstLine.match(/^(.+?)\s*\(\)\s*$/u); let title = header?.[1]?.trim() || titleOnly?.[1]?.trim() || undefined; const url = header?.[2]; let bodyStart = header || titleOnly ? 1 : 0; if (!header && /^\s*\([^)]*\)\s*$/u.test(firstLine)) bodyStart = 1; const metadata = []; while (bodyStart < rawLines.length) { const clean = cleanCitations(rawLines[bodyStart], references, links); if (!isMetadata(clean)) break; metadata.push(...metadataParts(clean)); bodyStart += 1 } const lines = rawLines.slice(bodyStart).map((line) => parseLine(line, references, links)); if (!title && url) { try { title = new URL(url).hostname } catch { title = url } } return { ...(title ? { title } : {}), ...(url ? { url } : {}), references, links, metadata, lines } }
|
|
13
|
+
export function parseWebRunOutput(output) { return typeof output === 'string' && output ? output.split(RESULT_SEPARATOR_PATTERN).map(parseBlock).filter(Boolean) : [] }
|
|
14
|
+
export function outputLineRange(blocks) { const numbers = (blocks ?? []).flatMap((block) => (block.lines ?? []).flatMap((line) => line.line === undefined ? [] : [line.line])); return numbers.length ? { first: Math.min(...numbers), last: Math.max(...numbers) } : undefined }
|
|
15
|
+
export function outputDomains(blocks) { const domains = []; for (const block of blocks ?? []) if (block.url) { try { pushUnique(domains, new URL(block.url).hostname) } catch {} } return domains }
|
|
16
|
+
export function outputLinks(blocks) { const links = []; for (const block of blocks ?? []) for (const link of block.links ?? []) if (!links.some((v) => v.id === link.id)) links.push({ ...link }); return links }
|
|
17
|
+
export function outputPdfRefs(blocks) { const refs = []; for (const block of blocks ?? []) { if (!(block.metadata ?? []).includes('PDF')) continue; for (const ref of block.references ?? []) pushUnique(refs, ref) } return refs }
|
|
18
|
+
export function blockPlainText(block) { return (block?.lines ?? []).map((line) => line.text).filter(Boolean).join(' ') }
|