@goodandready/dsh-clinebot 0.6.28 → 0.6.30
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 +13 -0
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.zh.md +1 -1
- package/lib/access.js +8 -5
- package/lib/client.js +5 -5
- package/lib/config.js +283 -283
- package/lib/http.js +6 -4
- package/lib/transport/cline-transport.js +342 -343
- package/package.json +117 -117
|
@@ -1,343 +1,342 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ClinePass OpenAI-compatible transport primitives.
|
|
3
|
-
*
|
|
4
|
-
* This module owns the wire boundary: request construction helpers, SSE framing,
|
|
5
|
-
* usage extraction, timeout/abort handling, and provider error classification.
|
|
6
|
-
* It intentionally contains no account routing or DSH adapter policy.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { createRequire } from 'node:module'
|
|
10
|
-
import { isSecureRemoteBaseUrl } from '../http.js'
|
|
11
|
-
|
|
12
|
-
const require = createRequire(import.meta.url)
|
|
13
|
-
let packageVersion = '0.6.
|
|
14
|
-
try {
|
|
15
|
-
packageVersion = require('../../package.json').version || packageVersion
|
|
16
|
-
} catch {
|
|
17
|
-
/* fallback when package.json is unavailable */
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
|
|
21
|
-
const DEFAULT_CONNECT_TIMEOUT_MS = 15000
|
|
22
|
-
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 30000
|
|
23
|
-
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
|
|
24
|
-
const DEFAULT_MAX_SSE_EVENT_BYTES = 1024 * 1024
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Build the abort-shaped error every transport caller understands.
|
|
28
|
-
* @param {string} [message]
|
|
29
|
-
* @returns {Error}
|
|
30
|
-
*/
|
|
31
|
-
export function abortError(message = 'Request aborted') {
|
|
32
|
-
const error = new Error(message)
|
|
33
|
-
error.name = 'AbortError'
|
|
34
|
-
error.code = 'ABORTED'
|
|
35
|
-
return error
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @param {unknown} error
|
|
40
|
-
* @returns {boolean}
|
|
41
|
-
*/
|
|
42
|
-
export function isAbortError(error) {
|
|
43
|
-
return Boolean(error && (error.name === 'AbortError' || error.code === 'ABORTED'))
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Normalize an OpenAI-compatible usage payload into DSH's disjoint token buckets.
|
|
48
|
-
*
|
|
49
|
-
* `prompt_tokens` includes cached input, while DSH expects `inputTokens` to be
|
|
50
|
-
* uncached input only. Cache read/write buckets are reported separately.
|
|
51
|
-
*
|
|
52
|
-
* @param {Record<string, unknown>} usage
|
|
53
|
-
* @returns {{ inputTokens: number, outputTokens: number, totalTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, reasoningTokens?: number, promptTokens: number, completionTokens: number }}
|
|
54
|
-
*/
|
|
55
|
-
export function extractUsage(usage) {
|
|
56
|
-
if (!usage || typeof usage !== 'object') {
|
|
57
|
-
return { inputTokens: 0, outputTokens: 0, promptTokens: 0, completionTokens: 0 }
|
|
58
|
-
}
|
|
59
|
-
const promptTokens = Math.max(0, Number(usage.prompt_tokens) || 0)
|
|
60
|
-
const completionTokens = Math.max(0, Number(usage.completion_tokens) || 0)
|
|
61
|
-
const cacheReadTokens = Math.max(0, Number(
|
|
62
|
-
usage.prompt_tokens_details?.cached_tokens ||
|
|
63
|
-
usage.cache_read_input_tokens ||
|
|
64
|
-
usage.cached_tokens ||
|
|
65
|
-
0
|
|
66
|
-
))
|
|
67
|
-
const cacheWriteTokens = Math.max(0, Number(
|
|
68
|
-
usage.prompt_tokens_details?.cache_write_tokens ||
|
|
69
|
-
usage.cache_creation_input_tokens ||
|
|
70
|
-
0
|
|
71
|
-
))
|
|
72
|
-
const reasoningTokens = Math.max(0, Number(
|
|
73
|
-
usage.completion_tokens_details?.reasoning_tokens ||
|
|
74
|
-
usage.reasoning_tokens ||
|
|
75
|
-
usage.prompt_tokens_details?.reasoning_tokens ||
|
|
76
|
-
0
|
|
77
|
-
))
|
|
78
|
-
const inputTokens = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens)
|
|
79
|
-
const totalTokens = Number.isFinite(Number(usage.total_tokens))
|
|
80
|
-
? Math.max(0, Number(usage.total_tokens))
|
|
81
|
-
: promptTokens + completionTokens
|
|
82
|
-
|
|
83
|
-
return {
|
|
84
|
-
inputTokens,
|
|
85
|
-
outputTokens: completionTokens,
|
|
86
|
-
...(totalTokens > 0 ? { totalTokens } : {}),
|
|
87
|
-
...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
|
|
88
|
-
...(cacheWriteTokens > 0 ? { cacheWriteTokens } : {}),
|
|
89
|
-
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
|
|
90
|
-
promptTokens,
|
|
91
|
-
completionTokens,
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Classify a non-2xx upstream response into an adapter error code.
|
|
97
|
-
*
|
|
98
|
-
* @param {number} status
|
|
99
|
-
* @param {string} [bodyText]
|
|
100
|
-
* @returns {{ code: string, retryable: boolean, message: string }}
|
|
101
|
-
*/
|
|
102
|
-
export function classifyUpstreamStatus(status, bodyText = '') {
|
|
103
|
-
const detail = String(bodyText || '').slice(0, 500)
|
|
104
|
-
const lower = detail.toLowerCase()
|
|
105
|
-
if (status === 401 || status === 403) {
|
|
106
|
-
return { code: 'AUTH', retryable: false, message: `ClinePass authentication failed (HTTP ${status})${detail ? `: ${detail}` : ''}` }
|
|
107
|
-
}
|
|
108
|
-
if (status === 429) {
|
|
109
|
-
return { code: 'RATE_LIMIT', retryable: true, message: `ClinePass rate limit exceeded (HTTP 429)${detail ? `: ${detail}` : ''}` }
|
|
110
|
-
}
|
|
111
|
-
if (status === 402 || /insufficient\s+credits?|not enough\s+credits?|credit balance|out of credits/i.test(lower)) {
|
|
112
|
-
return { code: 'INSUFFICIENT_CREDITS', retryable: true, message: `ClinePass account has insufficient credits (HTTP ${status})${detail ? `: ${detail}` : ''}` }
|
|
113
|
-
}
|
|
114
|
-
if (/quota|usage limit/i.test(lower)) {
|
|
115
|
-
return { code: 'QUOTA_EXCEEDED', retryable: true, message: `ClinePass quota exhausted (HTTP ${status})${detail ? `: ${detail}` : ''}` }
|
|
116
|
-
}
|
|
117
|
-
if (status === 413 || /too large|too many (?:json )?nodes?|request body.*(?:large|exceed)|payload too large/i.test(lower)) {
|
|
118
|
-
return { code: 'REQUEST_TOO_LARGE', retryable: false, message: `ClinePass request is too large (HTTP ${status}). Start a new session or reduce messages and images.${detail ? ` ${detail}` : ''}` }
|
|
119
|
-
}
|
|
120
|
-
if (status === 400 && /context|too long|maximum context|token limit/i.test(lower)) {
|
|
121
|
-
return { code: 'CONTEXT_WINDOW_EXCEEDED', retryable: false, message: `Context window exceeded (HTTP 400)${detail ? `: ${detail}` : ''}` }
|
|
122
|
-
}
|
|
123
|
-
if (status >= 500) {
|
|
124
|
-
return { code: 'PROVIDER_ERROR', retryable: true, message: `ClinePass upstream error (HTTP ${status})${detail ? `: ${detail}` : ''}` }
|
|
125
|
-
}
|
|
126
|
-
return { code: 'UPSTREAM_ERROR', retryable: false, message: `ClinePass request failed (HTTP ${status})${detail ? `: ${detail}` : ''}` }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Read a response body with a hard size bound.
|
|
131
|
-
*
|
|
132
|
-
* @param {Response} response
|
|
133
|
-
* @param {{ maxBytes?: number }} [options]
|
|
134
|
-
* @returns {Promise<string>}
|
|
135
|
-
*/
|
|
136
|
-
async function readChunkWithIdle(reader, idleTimeoutMs) {
|
|
137
|
-
let timer = null
|
|
138
|
-
try {
|
|
139
|
-
return await Promise.race([
|
|
140
|
-
reader.read(),
|
|
141
|
-
new Promise((_, reject) => {
|
|
142
|
-
timer = setTimeout(() => {
|
|
143
|
-
const error = new Error(`Upstream body idle timeout after ${idleTimeoutMs}ms`)
|
|
144
|
-
error.code = 'STREAM_IDLE_TIMEOUT'
|
|
145
|
-
reject(error)
|
|
146
|
-
}, Math.max(1, Number(idleTimeoutMs) || DEFAULT_STREAM_IDLE_TIMEOUT_MS))
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
let
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
size
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
out
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
*
|
|
189
|
-
* @
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* }
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
timeoutError.
|
|
252
|
-
timeoutError
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
*
|
|
263
|
-
* @
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (data === '') return
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* @param {
|
|
293
|
-
* @
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
buffer
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* ClinePass OpenAI-compatible transport primitives.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the wire boundary: request construction helpers, SSE framing,
|
|
5
|
+
* usage extraction, timeout/abort handling, and provider error classification.
|
|
6
|
+
* It intentionally contains no account routing or DSH adapter policy.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
10
|
+
import { isSecureRemoteBaseUrl } from '../http.js'
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url)
|
|
13
|
+
let packageVersion = '0.6.30'
|
|
14
|
+
try {
|
|
15
|
+
packageVersion = require('../../package.json').version || packageVersion
|
|
16
|
+
} catch {
|
|
17
|
+
/* fallback when package.json is unavailable */
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
|
|
21
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15000
|
|
22
|
+
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 30000
|
|
23
|
+
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
|
|
24
|
+
const DEFAULT_MAX_SSE_EVENT_BYTES = 1024 * 1024
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build the abort-shaped error every transport caller understands.
|
|
28
|
+
* @param {string} [message]
|
|
29
|
+
* @returns {Error}
|
|
30
|
+
*/
|
|
31
|
+
export function abortError(message = 'Request aborted') {
|
|
32
|
+
const error = new Error(message)
|
|
33
|
+
error.name = 'AbortError'
|
|
34
|
+
error.code = 'ABORTED'
|
|
35
|
+
return error
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {unknown} error
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function isAbortError(error) {
|
|
43
|
+
return Boolean(error && (error.name === 'AbortError' || error.code === 'ABORTED'))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize an OpenAI-compatible usage payload into DSH's disjoint token buckets.
|
|
48
|
+
*
|
|
49
|
+
* `prompt_tokens` includes cached input, while DSH expects `inputTokens` to be
|
|
50
|
+
* uncached input only. Cache read/write buckets are reported separately.
|
|
51
|
+
*
|
|
52
|
+
* @param {Record<string, unknown>} usage
|
|
53
|
+
* @returns {{ inputTokens: number, outputTokens: number, totalTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, reasoningTokens?: number, promptTokens: number, completionTokens: number }}
|
|
54
|
+
*/
|
|
55
|
+
export function extractUsage(usage) {
|
|
56
|
+
if (!usage || typeof usage !== 'object') {
|
|
57
|
+
return { inputTokens: 0, outputTokens: 0, promptTokens: 0, completionTokens: 0 }
|
|
58
|
+
}
|
|
59
|
+
const promptTokens = Math.max(0, Number(usage.prompt_tokens) || 0)
|
|
60
|
+
const completionTokens = Math.max(0, Number(usage.completion_tokens) || 0)
|
|
61
|
+
const cacheReadTokens = Math.max(0, Number(
|
|
62
|
+
usage.prompt_tokens_details?.cached_tokens ||
|
|
63
|
+
usage.cache_read_input_tokens ||
|
|
64
|
+
usage.cached_tokens ||
|
|
65
|
+
0
|
|
66
|
+
))
|
|
67
|
+
const cacheWriteTokens = Math.max(0, Number(
|
|
68
|
+
usage.prompt_tokens_details?.cache_write_tokens ||
|
|
69
|
+
usage.cache_creation_input_tokens ||
|
|
70
|
+
0
|
|
71
|
+
))
|
|
72
|
+
const reasoningTokens = Math.max(0, Number(
|
|
73
|
+
usage.completion_tokens_details?.reasoning_tokens ||
|
|
74
|
+
usage.reasoning_tokens ||
|
|
75
|
+
usage.prompt_tokens_details?.reasoning_tokens ||
|
|
76
|
+
0
|
|
77
|
+
))
|
|
78
|
+
const inputTokens = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens)
|
|
79
|
+
const totalTokens = Number.isFinite(Number(usage.total_tokens))
|
|
80
|
+
? Math.max(0, Number(usage.total_tokens))
|
|
81
|
+
: promptTokens + completionTokens
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
inputTokens,
|
|
85
|
+
outputTokens: completionTokens,
|
|
86
|
+
...(totalTokens > 0 ? { totalTokens } : {}),
|
|
87
|
+
...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
|
|
88
|
+
...(cacheWriteTokens > 0 ? { cacheWriteTokens } : {}),
|
|
89
|
+
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
|
|
90
|
+
promptTokens,
|
|
91
|
+
completionTokens,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Classify a non-2xx upstream response into an adapter error code.
|
|
97
|
+
*
|
|
98
|
+
* @param {number} status
|
|
99
|
+
* @param {string} [bodyText]
|
|
100
|
+
* @returns {{ code: string, retryable: boolean, message: string }}
|
|
101
|
+
*/
|
|
102
|
+
export function classifyUpstreamStatus(status, bodyText = '') {
|
|
103
|
+
const detail = String(bodyText || '').slice(0, 500)
|
|
104
|
+
const lower = detail.toLowerCase()
|
|
105
|
+
if (status === 401 || status === 403) {
|
|
106
|
+
return { code: 'AUTH', retryable: false, message: `ClinePass authentication failed (HTTP ${status})${detail ? `: ${detail}` : ''}` }
|
|
107
|
+
}
|
|
108
|
+
if (status === 429) {
|
|
109
|
+
return { code: 'RATE_LIMIT', retryable: true, message: `ClinePass rate limit exceeded (HTTP 429)${detail ? `: ${detail}` : ''}` }
|
|
110
|
+
}
|
|
111
|
+
if (status === 402 || /insufficient\s+credits?|not enough\s+credits?|credit balance|out of credits/i.test(lower)) {
|
|
112
|
+
return { code: 'INSUFFICIENT_CREDITS', retryable: true, message: `ClinePass account has insufficient credits (HTTP ${status})${detail ? `: ${detail}` : ''}` }
|
|
113
|
+
}
|
|
114
|
+
if (/quota|usage limit/i.test(lower)) {
|
|
115
|
+
return { code: 'QUOTA_EXCEEDED', retryable: true, message: `ClinePass quota exhausted (HTTP ${status})${detail ? `: ${detail}` : ''}` }
|
|
116
|
+
}
|
|
117
|
+
if (status === 413 || /too large|too many (?:json )?nodes?|request body.*(?:large|exceed)|payload too large/i.test(lower)) {
|
|
118
|
+
return { code: 'REQUEST_TOO_LARGE', retryable: false, message: `ClinePass request is too large (HTTP ${status}). Start a new session or reduce messages and images.${detail ? ` ${detail}` : ''}` }
|
|
119
|
+
}
|
|
120
|
+
if (status === 400 && /context|too long|maximum context|token limit/i.test(lower)) {
|
|
121
|
+
return { code: 'CONTEXT_WINDOW_EXCEEDED', retryable: false, message: `Context window exceeded (HTTP 400)${detail ? `: ${detail}` : ''}` }
|
|
122
|
+
}
|
|
123
|
+
if (status >= 500) {
|
|
124
|
+
return { code: 'PROVIDER_ERROR', retryable: true, message: `ClinePass upstream error (HTTP ${status})${detail ? `: ${detail}` : ''}` }
|
|
125
|
+
}
|
|
126
|
+
return { code: 'UPSTREAM_ERROR', retryable: false, message: `ClinePass request failed (HTTP ${status})${detail ? `: ${detail}` : ''}` }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read a response body with a hard size bound.
|
|
131
|
+
*
|
|
132
|
+
* @param {Response} response
|
|
133
|
+
* @param {{ maxBytes?: number }} [options]
|
|
134
|
+
* @returns {Promise<string>}
|
|
135
|
+
*/
|
|
136
|
+
async function readChunkWithIdle(reader, idleTimeoutMs) {
|
|
137
|
+
let timer = null
|
|
138
|
+
try {
|
|
139
|
+
return await Promise.race([
|
|
140
|
+
reader.read(),
|
|
141
|
+
new Promise((_, reject) => {
|
|
142
|
+
timer = setTimeout(() => {
|
|
143
|
+
const error = new Error(`Upstream body idle timeout after ${idleTimeoutMs}ms`)
|
|
144
|
+
error.code = 'STREAM_IDLE_TIMEOUT'
|
|
145
|
+
reject(error)
|
|
146
|
+
}, Math.max(1, Number(idleTimeoutMs) || DEFAULT_STREAM_IDLE_TIMEOUT_MS))
|
|
147
|
+
}),
|
|
148
|
+
])
|
|
149
|
+
} finally {
|
|
150
|
+
if (timer) clearTimeout(timer)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function readResponseText(response, {
|
|
155
|
+
maxBytes = DEFAULT_MAX_BODY_BYTES,
|
|
156
|
+
idleTimeoutMs = DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
157
|
+
signal,
|
|
158
|
+
} = {}) {
|
|
159
|
+
if (!response?.body) return ''
|
|
160
|
+
const reader = response.body.getReader()
|
|
161
|
+
const decoder = new TextDecoder('utf8')
|
|
162
|
+
let size = 0
|
|
163
|
+
let out = ''
|
|
164
|
+
try {
|
|
165
|
+
while (true) {
|
|
166
|
+
if (signal?.aborted) throw abortError()
|
|
167
|
+
const { done, value } = await readChunkWithIdle(reader, idleTimeoutMs)
|
|
168
|
+
if (done) break
|
|
169
|
+
size += value.byteLength
|
|
170
|
+
if (size > maxBytes) {
|
|
171
|
+
await reader.cancel(`body exceeded ${maxBytes} bytes`).catch(() => {})
|
|
172
|
+
throw new Error(`Upstream response exceeded ${maxBytes} bytes`)
|
|
173
|
+
}
|
|
174
|
+
out += decoder.decode(value, { stream: true })
|
|
175
|
+
}
|
|
176
|
+
out += decoder.decode()
|
|
177
|
+
return out
|
|
178
|
+
} catch (error) {
|
|
179
|
+
await reader.cancel(error?.message || 'body read failed').catch(() => {})
|
|
180
|
+
throw error
|
|
181
|
+
} finally {
|
|
182
|
+
try { reader.releaseLock() } catch { /* explicit best-effort fallback: non-fatal by design */ }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether a response is an SSE stream.
|
|
188
|
+
* @param {Response} response
|
|
189
|
+
* @returns {boolean}
|
|
190
|
+
*/
|
|
191
|
+
export function isEventStream(response) {
|
|
192
|
+
return String(response?.headers?.get?.('content-type') || '').toLowerCase().includes('text/event-stream')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Send one OpenAI-compatible chat completion request.
|
|
197
|
+
*
|
|
198
|
+
* The connect timeout covers headers only; once headers arrive the caller owns
|
|
199
|
+
* the stream and applies its own idle watchdog.
|
|
200
|
+
*
|
|
201
|
+
* @param {{
|
|
202
|
+
* baseUrl: string,
|
|
203
|
+
* apiKey: string,
|
|
204
|
+
* body: Record<string, unknown>,
|
|
205
|
+
* signal?: AbortSignal,
|
|
206
|
+
* connectTimeoutMs?: number,
|
|
207
|
+
* extraHeaders?: Record<string, string>,
|
|
208
|
+
* }} options
|
|
209
|
+
* @returns {Promise<Response>}
|
|
210
|
+
*/
|
|
211
|
+
export async function requestChatCompletion({
|
|
212
|
+
baseUrl,
|
|
213
|
+
apiKey,
|
|
214
|
+
body,
|
|
215
|
+
signal,
|
|
216
|
+
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
217
|
+
extraHeaders = {},
|
|
218
|
+
}) {
|
|
219
|
+
if (!isSecureRemoteBaseUrl(baseUrl)) {
|
|
220
|
+
throw new Error('Insecure protocol: remote chat requests require https:')
|
|
221
|
+
}
|
|
222
|
+
const connectCtrl = new AbortController()
|
|
223
|
+
let timedOut = false
|
|
224
|
+
const timer = setTimeout(() => {
|
|
225
|
+
timedOut = true
|
|
226
|
+
connectCtrl.abort(abortError(`Upstream connect timeout after ${connectTimeoutMs}ms`))
|
|
227
|
+
}, Math.max(1, Number(connectTimeoutMs) || DEFAULT_CONNECT_TIMEOUT_MS))
|
|
228
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
229
|
+
const combined = signal
|
|
230
|
+
? AbortSignal.any([signal, connectCtrl.signal])
|
|
231
|
+
: connectCtrl.signal
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
return await fetch(`${String(baseUrl).replace(/\/+$/, '')}/chat/completions`, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: {
|
|
237
|
+
'Content-Type': 'application/json',
|
|
238
|
+
Authorization: `Bearer ${apiKey}`,
|
|
239
|
+
Accept: 'text/event-stream, application/json',
|
|
240
|
+
'User-Agent': USER_AGENT,
|
|
241
|
+
...extraHeaders,
|
|
242
|
+
},
|
|
243
|
+
body: JSON.stringify(body),
|
|
244
|
+
signal: combined,
|
|
245
|
+
keepalive: true,
|
|
246
|
+
})
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (timedOut) {
|
|
249
|
+
const timeoutError = new Error(`Upstream connect timeout after ${connectTimeoutMs}ms`)
|
|
250
|
+
timeoutError.code = 'CONNECT_TIMEOUT'
|
|
251
|
+
timeoutError.cause = error
|
|
252
|
+
throw timeoutError
|
|
253
|
+
}
|
|
254
|
+
throw error
|
|
255
|
+
} finally {
|
|
256
|
+
clearTimeout(timer)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Parse one SSE block's `data:` payload.
|
|
262
|
+
* @param {string} raw
|
|
263
|
+
* @returns {Record<string, unknown> | { done: true } | { raw: string } | undefined}
|
|
264
|
+
*/
|
|
265
|
+
function parseSseBlock(raw) {
|
|
266
|
+
const lines = String(raw || '').replace(/\r\n/g, '\n').split('\n')
|
|
267
|
+
const dataLines = []
|
|
268
|
+
for (const line of lines) {
|
|
269
|
+
if (!line.startsWith('data:')) continue
|
|
270
|
+
dataLines.push(line.slice(5).replace(/^ /, ''))
|
|
271
|
+
}
|
|
272
|
+
if (dataLines.length === 0) return undefined
|
|
273
|
+
const data = dataLines.join('\n').trim()
|
|
274
|
+
if (data === '') return undefined
|
|
275
|
+
if (data === '[DONE]') return { done: true }
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(data)
|
|
278
|
+
} catch {
|
|
279
|
+
return { raw: data }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Read SSE events from an OpenAI-compatible response as parsed objects.
|
|
285
|
+
*
|
|
286
|
+
* Yields `{ done: true }` for `[DONE]`, parsed JSON objects otherwise, and
|
|
287
|
+
* `{ raw }` for unrecognized non-JSON payloads. Throws an abort-shaped error
|
|
288
|
+
* when the caller signal aborts, and a `STREAM_IDLE_TIMEOUT` error when no
|
|
289
|
+
* bytes arrive for `idleTimeoutMs`.
|
|
290
|
+
*
|
|
291
|
+
* @param {Response} response
|
|
292
|
+
* @param {{ signal?: AbortSignal, idleTimeoutMs?: number }} [options]
|
|
293
|
+
* @returns {AsyncGenerator<Record<string, unknown> | { done: true } | { raw: string }>}
|
|
294
|
+
*/
|
|
295
|
+
export async function* readSseEvents(response, {
|
|
296
|
+
signal,
|
|
297
|
+
idleTimeoutMs = DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
298
|
+
maxEventBytes = DEFAULT_MAX_SSE_EVENT_BYTES,
|
|
299
|
+
} = {}) {
|
|
300
|
+
if (!response?.body) return
|
|
301
|
+
const reader = response.body.getReader()
|
|
302
|
+
const decoder = new TextDecoder('utf8')
|
|
303
|
+
let buffer = ''
|
|
304
|
+
|
|
305
|
+
const readWithIdle = () => readChunkWithIdle(reader, idleTimeoutMs)
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
while (true) {
|
|
309
|
+
if (signal?.aborted) {
|
|
310
|
+
try { await reader.cancel('aborted') } catch { /* explicit best-effort fallback: non-fatal by design */ }
|
|
311
|
+
throw abortError()
|
|
312
|
+
}
|
|
313
|
+
const { done, value } = await readWithIdle()
|
|
314
|
+
if (done) break
|
|
315
|
+
buffer += decoder.decode(value, { stream: true })
|
|
316
|
+
buffer = buffer.replace(/\r\n/g, '\n')
|
|
317
|
+
let boundary = buffer.indexOf('\n\n')
|
|
318
|
+
while (boundary >= 0) {
|
|
319
|
+
const block = buffer.slice(0, boundary)
|
|
320
|
+
if (block.length > maxEventBytes) {
|
|
321
|
+
throw new Error(`SSE event exceeded ${maxEventBytes} bytes`)
|
|
322
|
+
}
|
|
323
|
+
buffer = buffer.slice(boundary + 2)
|
|
324
|
+
const parsed = parseSseBlock(block)
|
|
325
|
+
if (parsed !== undefined) yield parsed
|
|
326
|
+
boundary = buffer.indexOf('\n\n')
|
|
327
|
+
}
|
|
328
|
+
if (buffer.length > maxEventBytes) {
|
|
329
|
+
throw new Error(`SSE event exceeded ${maxEventBytes} bytes`)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
buffer += decoder.decode()
|
|
333
|
+
const tail = parseSseBlock(buffer)
|
|
334
|
+
if (tail !== undefined) yield tail
|
|
335
|
+
} catch (error) {
|
|
336
|
+
try { await reader.cancel(error?.message || 'stream error') } catch { /* explicit best-effort fallback: non-fatal by design */ }
|
|
337
|
+
if (signal?.aborted) throw abortError()
|
|
338
|
+
throw error
|
|
339
|
+
} finally {
|
|
340
|
+
try { reader.releaseLock() } catch { /* explicit best-effort fallback: non-fatal by design */ }
|
|
341
|
+
}
|
|
342
|
+
}
|