@goodandready/dsh-clinebot 0.6.29 → 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.
@@ -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.29'
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
- if (typeof timer.unref === 'function') timer.unref()
148
- }),
149
- ])
150
- } finally {
151
- if (timer) clearTimeout(timer)
152
- }
153
- }
154
-
155
- export async function readResponseText(response, {
156
- maxBytes = DEFAULT_MAX_BODY_BYTES,
157
- idleTimeoutMs = DEFAULT_STREAM_IDLE_TIMEOUT_MS,
158
- signal,
159
- } = {}) {
160
- if (!response?.body) return ''
161
- const reader = response.body.getReader()
162
- const decoder = new TextDecoder('utf8')
163
- let size = 0
164
- let out = ''
165
- try {
166
- while (true) {
167
- if (signal?.aborted) throw abortError()
168
- const { done, value } = await readChunkWithIdle(reader, idleTimeoutMs)
169
- if (done) break
170
- size += value.byteLength
171
- if (size > maxBytes) {
172
- await reader.cancel(`body exceeded ${maxBytes} bytes`).catch(() => {})
173
- throw new Error(`Upstream response exceeded ${maxBytes} bytes`)
174
- }
175
- out += decoder.decode(value, { stream: true })
176
- }
177
- out += decoder.decode()
178
- return out
179
- } catch (error) {
180
- await reader.cancel(error?.message || 'body read failed').catch(() => {})
181
- throw error
182
- } finally {
183
- try { reader.releaseLock() } catch { /* explicit best-effort fallback: non-fatal by design */ }
184
- }
185
- }
186
-
187
- /**
188
- * Whether a response is an SSE stream.
189
- * @param {Response} response
190
- * @returns {boolean}
191
- */
192
- export function isEventStream(response) {
193
- return String(response?.headers?.get?.('content-type') || '').toLowerCase().includes('text/event-stream')
194
- }
195
-
196
- /**
197
- * Send one OpenAI-compatible chat completion request.
198
- *
199
- * The connect timeout covers headers only; once headers arrive the caller owns
200
- * the stream and applies its own idle watchdog.
201
- *
202
- * @param {{
203
- * baseUrl: string,
204
- * apiKey: string,
205
- * body: Record<string, unknown>,
206
- * signal?: AbortSignal,
207
- * connectTimeoutMs?: number,
208
- * extraHeaders?: Record<string, string>,
209
- * }} options
210
- * @returns {Promise<Response>}
211
- */
212
- export async function requestChatCompletion({
213
- baseUrl,
214
- apiKey,
215
- body,
216
- signal,
217
- connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
218
- extraHeaders = {},
219
- }) {
220
- if (!isSecureRemoteBaseUrl(baseUrl)) {
221
- throw new Error('Insecure protocol: remote chat requests require https:')
222
- }
223
- const connectCtrl = new AbortController()
224
- let timedOut = false
225
- const timer = setTimeout(() => {
226
- timedOut = true
227
- connectCtrl.abort(abortError(`Upstream connect timeout after ${connectTimeoutMs}ms`))
228
- }, Math.max(1, Number(connectTimeoutMs) || DEFAULT_CONNECT_TIMEOUT_MS))
229
- if (typeof timer.unref === 'function') timer.unref()
230
- const combined = signal
231
- ? AbortSignal.any([signal, connectCtrl.signal])
232
- : connectCtrl.signal
233
-
234
- try {
235
- return await fetch(`${String(baseUrl).replace(/\/+$/, '')}/chat/completions`, {
236
- method: 'POST',
237
- headers: {
238
- 'Content-Type': 'application/json',
239
- Authorization: `Bearer ${apiKey}`,
240
- Accept: 'text/event-stream, application/json',
241
- 'User-Agent': USER_AGENT,
242
- ...extraHeaders,
243
- },
244
- body: JSON.stringify(body),
245
- signal: combined,
246
- keepalive: true,
247
- })
248
- } catch (error) {
249
- if (timedOut) {
250
- const timeoutError = new Error(`Upstream connect timeout after ${connectTimeoutMs}ms`)
251
- timeoutError.code = 'CONNECT_TIMEOUT'
252
- timeoutError.cause = error
253
- throw timeoutError
254
- }
255
- throw error
256
- } finally {
257
- clearTimeout(timer)
258
- }
259
- }
260
-
261
- /**
262
- * Parse one SSE block's `data:` payload.
263
- * @param {string} raw
264
- * @returns {Record<string, unknown> | { done: true } | { raw: string } | undefined}
265
- */
266
- function parseSseBlock(raw) {
267
- const lines = String(raw || '').replace(/\r\n/g, '\n').split('\n')
268
- const dataLines = []
269
- for (const line of lines) {
270
- if (!line.startsWith('data:')) continue
271
- dataLines.push(line.slice(5).replace(/^ /, ''))
272
- }
273
- if (dataLines.length === 0) return undefined
274
- const data = dataLines.join('\n').trim()
275
- if (data === '') return undefined
276
- if (data === '[DONE]') return { done: true }
277
- try {
278
- return JSON.parse(data)
279
- } catch {
280
- return { raw: data }
281
- }
282
- }
283
-
284
- /**
285
- * Read SSE events from an OpenAI-compatible response as parsed objects.
286
- *
287
- * Yields `{ done: true }` for `[DONE]`, parsed JSON objects otherwise, and
288
- * `{ raw }` for unrecognized non-JSON payloads. Throws an abort-shaped error
289
- * when the caller signal aborts, and a `STREAM_IDLE_TIMEOUT` error when no
290
- * bytes arrive for `idleTimeoutMs`.
291
- *
292
- * @param {Response} response
293
- * @param {{ signal?: AbortSignal, idleTimeoutMs?: number }} [options]
294
- * @returns {AsyncGenerator<Record<string, unknown> | { done: true } | { raw: string }>}
295
- */
296
- export async function* readSseEvents(response, {
297
- signal,
298
- idleTimeoutMs = DEFAULT_STREAM_IDLE_TIMEOUT_MS,
299
- maxEventBytes = DEFAULT_MAX_SSE_EVENT_BYTES,
300
- } = {}) {
301
- if (!response?.body) return
302
- const reader = response.body.getReader()
303
- const decoder = new TextDecoder('utf8')
304
- let buffer = ''
305
-
306
- const readWithIdle = () => readChunkWithIdle(reader, idleTimeoutMs)
307
-
308
- try {
309
- while (true) {
310
- if (signal?.aborted) {
311
- try { await reader.cancel('aborted') } catch { /* explicit best-effort fallback: non-fatal by design */ }
312
- throw abortError()
313
- }
314
- const { done, value } = await readWithIdle()
315
- if (done) break
316
- buffer += decoder.decode(value, { stream: true })
317
- buffer = buffer.replace(/\r\n/g, '\n')
318
- let boundary = buffer.indexOf('\n\n')
319
- while (boundary >= 0) {
320
- const block = buffer.slice(0, boundary)
321
- if (block.length > maxEventBytes) {
322
- throw new Error(`SSE event exceeded ${maxEventBytes} bytes`)
323
- }
324
- buffer = buffer.slice(boundary + 2)
325
- const parsed = parseSseBlock(block)
326
- if (parsed !== undefined) yield parsed
327
- boundary = buffer.indexOf('\n\n')
328
- }
329
- if (buffer.length > maxEventBytes) {
330
- throw new Error(`SSE event exceeded ${maxEventBytes} bytes`)
331
- }
332
- }
333
- buffer += decoder.decode()
334
- const tail = parseSseBlock(buffer)
335
- if (tail !== undefined) yield tail
336
- } catch (error) {
337
- try { await reader.cancel(error?.message || 'stream error') } catch { /* explicit best-effort fallback: non-fatal by design */ }
338
- if (signal?.aborted) throw abortError()
339
- throw error
340
- } finally {
341
- try { reader.releaseLock() } catch { /* explicit best-effort fallback: non-fatal by design */ }
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
+ }