dsh-lcx-codex 0.4.1 → 0.4.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.
@@ -0,0 +1,539 @@
1
+ // @ts-check
2
+
3
+ import { processResponsesStream } from '@earendil-works/pi-ai/api/openai-responses-shared'
4
+ import { fetchSseWithRetry } from './transport.js'
5
+
6
+ /** @typedef {Record<string, unknown>} UnknownRecord */
7
+ /** @typedef {import('openai/resources/responses/responses.js').ResponseStreamEvent} ResponseStreamEvent */
8
+ /** @typedef {{ message: string, code: string, status?: number, requestId?: string, providerRetryAfterMs?: number }} ManagedFailure */
9
+ /** @typedef {Error & { code?: string, status?: number, requestId?: string, providerRetryAfterMs?: number, cause?: unknown }} LcxError */
10
+
11
+ function emptyUsage() {
12
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }
13
+ }
14
+
15
+ /** @param {unknown} value @returns {value is UnknownRecord} */
16
+ function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
17
+
18
+ class PiEventQueue {
19
+ constructor() {
20
+ /** @type {unknown[]} */
21
+ this.queue = []
22
+ /** @type {Array<(value: IteratorResult<unknown>) => void>} */
23
+ this.waiting = []
24
+ this.done = false
25
+ }
26
+ /** @param {unknown} event */
27
+ push(event) {
28
+ if (this.done) return
29
+ const waiter = this.waiting.shift()
30
+ if (waiter) waiter({ value: event, done: false })
31
+ else this.queue.push(event)
32
+ }
33
+ end() {
34
+ this.done = true
35
+ while (this.waiting.length > 0) this.waiting.shift()?.({ value: undefined, done: true })
36
+ }
37
+ async *[Symbol.asyncIterator]() {
38
+ while (true) {
39
+ if (this.queue.length > 0) yield this.queue.shift()
40
+ else if (this.done) return
41
+ else {
42
+ const result = await new Promise((resolve) => this.waiting.push(resolve))
43
+ if (result.done) return
44
+ yield result.value
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ /** @param {unknown} raw */
51
+ function safeStatus(raw) { return Number.isInteger(raw) && Number(raw) >= 100 && Number(raw) <= 599 ? Number(raw) : undefined }
52
+
53
+ /** @param {unknown} raw */
54
+ function safeRequestId(raw) {
55
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > 160) return undefined
56
+ return /^[A-Za-z0-9._:\/-]+$/u.test(raw) ? raw : undefined
57
+ }
58
+
59
+ /** @param {unknown} error */
60
+ function errorFacts(error) {
61
+ const seen = new Set()
62
+ const codes = []
63
+ const texts = []
64
+ let status
65
+ let requestId
66
+ let providerRetryAfterMs
67
+ let current = error
68
+ for (let depth = 0; depth < 8 && (current instanceof Error || isObject(current)) && !seen.has(current); depth += 1) {
69
+ seen.add(current)
70
+ const value = /** @type {LcxError} */ (current)
71
+ if (status === undefined) status = safeStatus(value.status)
72
+ if (requestId === undefined) requestId = safeRequestId(value.requestId)
73
+ if (providerRetryAfterMs === undefined && Number.isFinite(value.providerRetryAfterMs) && Number(value.providerRetryAfterMs) > 0) providerRetryAfterMs = Number(value.providerRetryAfterMs)
74
+ if (typeof value.code === 'string' && value.code) codes.push(value.code)
75
+ if (typeof value.message === 'string' && value.message) texts.push(value.message)
76
+ current = value.cause
77
+ }
78
+ return { status, requestId, providerRetryAfterMs, codes, text: texts.join(' | ') }
79
+ }
80
+
81
+ /**
82
+ * Provider bodies/messages are deliberately not surfaced. Only a stable class and safe facts leave the wire boundary.
83
+ * @param {unknown} error
84
+ * @param {AbortSignal} [signal]
85
+ */
86
+ export function managedFailure(error, signal) {
87
+ const facts = errorFacts(error)
88
+ const sourceCodes = new Set(facts.codes)
89
+ const text = facts.text
90
+ let code = 'RESPONSES_ERROR'
91
+ if (signal?.aborted || sourceCodes.has('LCX_ABORTED') || /\babort(?:ed)?\b/iu.test(text)) code = 'ABORTED'
92
+ else if (sourceCodes.has('LCX_RESPONSES_UNSUPPORTED_OPTION')) code = 'UNSUPPORTED_OPTION'
93
+ else if (sourceCodes.has('LCX_RESPONSES_ROUTE_UNAVAILABLE') || sourceCodes.has('LCX_RESPONSES_MODEL_UNAVAILABLE')) code = 'NO_ADAPTER'
94
+ else if (facts.status === 401 || facts.status === 403 || sourceCodes.has('AUTH') || sourceCodes.has('LCX_CREDENTIAL_UNAVAILABLE')) code = 'AUTH'
95
+ else if (facts.status === 408) code = 'TIMEOUT'
96
+ else if (facts.status === 409 || facts.status === 425) code = 'TRANSPORT'
97
+ else if (facts.status === 429 || /rate.?limit|quota exceeded/iu.test(text)) code = 'RATE_LIMIT'
98
+ else if (facts.status !== undefined && facts.status >= 500) code = 'SERVER'
99
+ else if (/context (?:window|length)|maximum context|too many tokens/iu.test(text) || [...sourceCodes].some((value) => /context.*(?:window|length|exceed)/iu.test(value))) code = 'CONTEXT_WINDOW_EXCEEDED'
100
+ else if (facts.status === 400 || facts.status === 404 || facts.status === 413 || facts.status === 422 || sourceCodes.has('LCX_RESPONSES_INVALID_INPUT') || /invalid.?request|payload too large|length limit exceeded/iu.test(text)) code = 'INVALID_REQUEST'
101
+ else if (sourceCodes.has('LCX_RESPONSE_TOO_LARGE')) code = 'INVALID_REQUEST'
102
+ else if (sourceCodes.has('LCX_INVALID_SSE') || /stream ended before|without a terminal|malformed.*sse/iu.test(text)) code = 'TRANSPORT'
103
+ else if (/time(?:d)?\s*out|timeout/iu.test(text) || sourceCodes.has('TimeoutError')) code = 'TIMEOUT'
104
+ else if (error instanceof TypeError || /\bnetwork|connection|socket|fetch|ECONN|EAI_AGAIN|terminated|premature close\b/iu.test(text)) code = 'TRANSPORT'
105
+ const messages = /** @type {Record<string, string>} */ ({
106
+ ABORTED: 'Responses request was aborted',
107
+ AUTH: 'Responses request was rejected by authentication',
108
+ UNSUPPORTED_OPTION: 'LCX Responses does not support this request option',
109
+ NO_ADAPTER: 'LCX could not resolve the selected Responses route',
110
+ RATE_LIMIT: 'Responses provider rate limit was reached',
111
+ SERVER: 'Responses provider returned a server failure',
112
+ INVALID_REQUEST: 'Responses provider rejected the request',
113
+ CONTEXT_WINDOW_EXCEEDED: 'Responses request exceeded the model context window',
114
+ TIMEOUT: 'Responses request timed out',
115
+ TRANSPORT: 'Responses transport failed',
116
+ RESPONSES_ERROR: 'Responses request failed',
117
+ })
118
+ return {
119
+ message: messages[code] ?? messages.RESPONSES_ERROR,
120
+ code,
121
+ ...(facts.status === undefined ? {} : { status: facts.status }),
122
+ ...(facts.requestId === undefined ? {} : { requestId: facts.requestId }),
123
+ ...(facts.providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs: facts.providerRetryAfterMs }),
124
+ }
125
+ }
126
+
127
+ /** @param {unknown} error @param {AbortSignal} [signal] */
128
+ export function managedFailureChunk(error, signal) {
129
+ const failure = managedFailure(error, signal)
130
+ return { type: 'finish', reason: failure.code === 'ABORTED' ? { kind: 'aborted', failure } : { kind: 'error', failure } }
131
+ }
132
+
133
+ /**
134
+ * Minimal JSON SSE reader. LCX owns the exact HTTP wire; Pi owns event semantics after this boundary.
135
+ * @param {Response} response
136
+ * @param {{ signal?: AbortSignal, maxResponseBytes?: number }} [options]
137
+ */
138
+ async function* responseEvents(response, options = {}) {
139
+ if (!response?.body) throw Object.assign(new Error('Responses stream returned no body'), { code: 'LCX_INVALID_SSE', status: response?.status })
140
+ const reader = response.body.getReader()
141
+ const decoder = new TextDecoder()
142
+ const maxBytes = options.maxResponseBytes ?? 8 * 1024 * 1024
143
+ let bytes = 0
144
+ let pending = ''
145
+ /** @type {string[]} */
146
+ let dataLines = []
147
+ const decode = () => {
148
+ if (dataLines.length === 0) return undefined
149
+ const data = dataLines.join('\n')
150
+ dataLines = []
151
+ if (data === '[DONE]') return undefined
152
+ try { return JSON.parse(data) }
153
+ catch (cause) { throw Object.assign(new Error('Responses stream contained malformed SSE JSON', { cause }), { code: 'LCX_INVALID_SSE' }) }
154
+ }
155
+ try {
156
+ while (true) {
157
+ if (options.signal?.aborted) throw options.signal.reason ?? Object.assign(new Error('request aborted'), { code: 'LCX_ABORTED' })
158
+ const { done, value } = await reader.read()
159
+ if (done) break
160
+ bytes += value.byteLength
161
+ if (bytes > maxBytes) throw Object.assign(new Error(`Responses SSE exceeds ${maxBytes} bytes`), { code: 'LCX_RESPONSE_TOO_LARGE' })
162
+ pending += decoder.decode(value, { stream: true })
163
+ let newline
164
+ while ((newline = pending.indexOf('\n')) >= 0) {
165
+ const line = pending.slice(0, newline).replace(/\r$/u, '')
166
+ pending = pending.slice(newline + 1)
167
+ if (line === '') {
168
+ const event = decode()
169
+ if (event !== undefined) yield event
170
+ } else if (!line.startsWith(':') && line.startsWith('data:')) {
171
+ dataLines.push(line.slice(5).replace(/^ /u, ''))
172
+ }
173
+ }
174
+ }
175
+ pending += decoder.decode()
176
+ if (pending.startsWith('data:')) dataLines.push(pending.slice(5).replace(/^ /u, ''))
177
+ const event = decode()
178
+ if (event !== undefined) yield event
179
+ } finally {
180
+ await reader.cancel().catch(() => undefined)
181
+ }
182
+ }
183
+
184
+ /** @param {UnknownRecord} item */
185
+ function itemKind(item) {
186
+ if (item.type === 'message') return 'text'
187
+ if (item.type === 'reasoning') return 'reasoning'
188
+ if (item.type === 'function_call' || item.type === 'custom_tool_call') return 'tool-call'
189
+ return undefined
190
+ }
191
+
192
+ /** @param {UnknownRecord} item */
193
+ function itemIdentity(item) {
194
+ const id = typeof item.id === 'string' && item.id ? item.id : typeof item.call_id === 'string' ? item.call_id : ''
195
+ return id ? `${String(item.type)}:${id}` : undefined
196
+ }
197
+
198
+ /** @param {UnknownRecord} item */
199
+ function itemText(item) {
200
+ if (item.type === 'message') return (Array.isArray(item.content) ? item.content : []).map((part) => isObject(part) && (part.type === 'output_text' || part.type === 'refusal') ? String(part.text ?? part.refusal ?? '') : '').join('')
201
+ if (item.type === 'reasoning') {
202
+ const parts = Array.isArray(item.summary) && item.summary.length > 0 ? item.summary : Array.isArray(item.content) ? item.content : []
203
+ return parts.map((part) => isObject(part) ? String(part.text ?? '') : '').join('\n\n')
204
+ }
205
+ if (item.type === 'function_call') return String(item.arguments ?? '')
206
+ if (item.type === 'custom_tool_call') return String(item.input ?? '')
207
+ return ''
208
+ }
209
+
210
+ /** @param {UnknownRecord} item @param {number} index */
211
+ function normalizedTerminalItem(item, index) {
212
+ if (item.type === 'message') return { ...structuredClone(item), id: typeof item.id === 'string' && item.id ? item.id : `msg_lcx_${index}`, role: 'assistant', status: item.status ?? 'completed', content: Array.isArray(item.content) ? structuredClone(item.content) : [] }
213
+ if (item.type === 'reasoning') return { ...structuredClone(item), id: typeof item.id === 'string' && item.id ? item.id : `rs_lcx_${index}`, summary: Array.isArray(item.summary) ? structuredClone(item.summary) : [] }
214
+ if (item.type === 'function_call') return { ...structuredClone(item), id: typeof item.id === 'string' && item.id ? item.id : `fc_lcx_${index}`, call_id: typeof item.call_id === 'string' && item.call_id ? item.call_id : `call_lcx_${index}`, name: String(item.name ?? ''), arguments: String(item.arguments ?? '') }
215
+ if (item.type === 'custom_tool_call') return { ...structuredClone(item), id: typeof item.id === 'string' && item.id ? item.id : `ctc_lcx_${index}`, call_id: typeof item.call_id === 'string' && item.call_id ? item.call_id : `call_lcx_${index}`, name: String(item.name ?? ''), input: String(item.input ?? '') }
216
+ return structuredClone(item)
217
+ }
218
+
219
+ /** @param {UnknownRecord} item @param {number} index */
220
+ function addedShell(item, index) {
221
+ if (item.type === 'message') return { type: 'message', id: item.id ?? `msg_lcx_${index}`, role: 'assistant', status: 'in_progress', content: [] }
222
+ if (item.type === 'reasoning') return { type: 'reasoning', id: item.id ?? `rs_lcx_${index}`, summary: [] }
223
+ if (item.type === 'function_call') return { type: 'function_call', id: item.id ?? `fc_lcx_${index}`, call_id: item.call_id ?? `call_lcx_${index}`, name: String(item.name ?? ''), arguments: '' }
224
+ return { type: 'custom_tool_call', id: item.id ?? `ctc_lcx_${index}`, call_id: item.call_id ?? `call_lcx_${index}`, name: String(item.name ?? ''), input: '' }
225
+ }
226
+
227
+ /** @param {UnknownRecord} record @param {UnknownRecord} item */
228
+ function recordMatches(record, item) {
229
+ if (record.kind !== itemKind(item)) return false
230
+ const recordItem = /** @type {UnknownRecord} */ (record.item)
231
+ if (itemIdentity(recordItem) && itemIdentity(recordItem) === itemIdentity(item)) return true
232
+ const streamed = String(record.text ?? '')
233
+ const terminal = itemText(item)
234
+ return streamed.length > 0 && terminal.length > 0 && (streamed === terminal || streamed.startsWith(terminal) || terminal.startsWith(streamed))
235
+ }
236
+
237
+ /**
238
+ * Some compatible gateways omit `response.output_item.added/done`, or terminal output indexes drift
239
+ * when a reasoning item is inserted. Normalize only the missing framing; Pi remains authoritative for item semantics.
240
+ * @param {AsyncIterable<unknown>} source
241
+ * @param {{ responseModel?: string }} [meta]
242
+ */
243
+ async function* normalizedResponseEvents(source, meta = {}) {
244
+ const open = new Map()
245
+ const completed = new Set()
246
+ /** @param {number} index @param {UnknownRecord} item */
247
+ const ensure = function* (index, item) {
248
+ let record = open.get(index)
249
+ if (record) return record
250
+ const normalized = normalizedTerminalItem(item, index)
251
+ record = { kind: itemKind(normalized), item: normalized, text: '' }
252
+ open.set(index, record)
253
+ yield { type: 'response.output_item.added', output_index: index, item: addedShell(normalized, index) }
254
+ return record
255
+ }
256
+
257
+ for await (const raw of source) {
258
+ if (!isObject(raw)) continue
259
+ const event = /** @type {UnknownRecord} */ (raw)
260
+ const index = Number.isInteger(event.output_index) ? Number(event.output_index) : 0
261
+ if (event.type === 'response.output_item.added' && isObject(event.item)) {
262
+ const item = normalizedTerminalItem(/** @type {UnknownRecord} */ (event.item), index)
263
+ open.set(index, { kind: itemKind(item), item, text: itemText(item) })
264
+ yield { ...event, item }
265
+ continue
266
+ }
267
+ if (event.type === 'response.output_text.delta') {
268
+ let record = open.get(index)
269
+ if (!record) {
270
+ const item = { type: 'message', id: typeof event.item_id === 'string' ? event.item_id : `msg_lcx_${index}` }
271
+ const generated = ensure(index, item)
272
+ let next = generated.next()
273
+ while (!next.done) { yield next.value; next = generated.next() }
274
+ record = next.value
275
+ }
276
+ record.text = String(record.text ?? '') + String(event.delta ?? '')
277
+ yield event
278
+ continue
279
+ }
280
+ if (event.type === 'response.reasoning_summary_text.delta' || event.type === 'response.reasoning_text.delta') {
281
+ let record = open.get(index)
282
+ if (!record) {
283
+ const item = { type: 'reasoning', id: typeof event.item_id === 'string' ? event.item_id : `rs_lcx_${index}` }
284
+ const generated = ensure(index, item)
285
+ let next = generated.next()
286
+ while (!next.done) { yield next.value; next = generated.next() }
287
+ record = next.value
288
+ }
289
+ record.text = String(record.text ?? '') + String(event.delta ?? '')
290
+ yield event
291
+ continue
292
+ }
293
+ if (event.type === 'response.function_call_arguments.delta') {
294
+ let record = open.get(index)
295
+ if (!record) {
296
+ const item = { type: 'function_call', id: typeof event.item_id === 'string' ? event.item_id : `fc_lcx_${index}`, call_id: typeof event.call_id === 'string' ? event.call_id : `call_lcx_${index}`, name: String(event.name ?? '') }
297
+ const generated = ensure(index, item)
298
+ let next = generated.next()
299
+ while (!next.done) { yield next.value; next = generated.next() }
300
+ record = next.value
301
+ }
302
+ record.text = String(record.text ?? '') + String(event.delta ?? '')
303
+ yield event
304
+ continue
305
+ }
306
+ if (event.type === 'response.custom_tool_call_input.delta') {
307
+ let record = open.get(index)
308
+ if (!record) {
309
+ const item = { type: 'custom_tool_call', id: typeof event.item_id === 'string' ? event.item_id : `ctc_lcx_${index}`, call_id: typeof event.call_id === 'string' ? event.call_id : `call_lcx_${index}`, name: String(event.name ?? '') }
310
+ const generated = ensure(index, item)
311
+ let next = generated.next()
312
+ while (!next.done) { yield next.value; next = generated.next() }
313
+ record = next.value
314
+ }
315
+ record.text = String(record.text ?? '') + String(event.delta ?? '')
316
+ yield event
317
+ continue
318
+ }
319
+ if (event.type === 'response.output_item.done' && isObject(event.item)) {
320
+ const item = normalizedTerminalItem(/** @type {UnknownRecord} */ (event.item), index)
321
+ const identity = itemIdentity(item)
322
+ if (identity) completed.add(identity)
323
+ open.delete(index)
324
+ yield { ...event, item }
325
+ continue
326
+ }
327
+ if ((event.type === 'response.completed' || event.type === 'response.incomplete') && isObject(event.response)) {
328
+ const response = /** @type {UnknownRecord} */ (event.response)
329
+ if (typeof response.model === 'string' && response.model.length > 0) meta.responseModel = response.model
330
+ const output = Array.isArray(response.output) ? response.output.map((item, terminalIndex) => isObject(item) ? normalizedTerminalItem(/** @type {UnknownRecord} */ (item), terminalIndex) : item) : []
331
+ const used = new Set()
332
+ for (const [streamIndex, record] of open) {
333
+ let terminalIndex = output.findIndex((item, candidateIndex) => !used.has(candidateIndex) && isObject(item) && candidateIndex === streamIndex && recordMatches(record, /** @type {UnknownRecord} */ (item)))
334
+ if (terminalIndex < 0) terminalIndex = output.findIndex((item, candidateIndex) => !used.has(candidateIndex) && isObject(item) && recordMatches(record, /** @type {UnknownRecord} */ (item)))
335
+ const item = terminalIndex >= 0
336
+ ? /** @type {UnknownRecord} */ (output[terminalIndex])
337
+ : normalizedTerminalItem(/** @type {UnknownRecord} */ (record.item), streamIndex)
338
+ if (terminalIndex >= 0) used.add(terminalIndex)
339
+ yield { type: 'response.output_item.done', output_index: streamIndex, item }
340
+ const identity = itemIdentity(item)
341
+ if (identity) completed.add(identity)
342
+ }
343
+ open.clear()
344
+ for (const [terminalIndex, candidate] of output.entries()) {
345
+ if (!isObject(candidate) || !itemKind(/** @type {UnknownRecord} */ (candidate)) || used.has(terminalIndex)) continue
346
+ const item = /** @type {UnknownRecord} */ (candidate)
347
+ const identity = itemIdentity(item)
348
+ if (identity && completed.has(identity)) continue
349
+ yield { type: 'response.output_item.added', output_index: terminalIndex, item: addedShell(item, terminalIndex) }
350
+ const text = itemText(item)
351
+ if (text) {
352
+ if (item.type === 'message') yield { type: 'response.output_text.delta', output_index: terminalIndex, content_index: 0, item_id: item.id, delta: text }
353
+ else if (item.type === 'reasoning') yield { type: 'response.reasoning_summary_text.delta', output_index: terminalIndex, summary_index: 0, item_id: item.id, delta: text }
354
+ else if (item.type === 'function_call') yield { type: 'response.function_call_arguments.delta', output_index: terminalIndex, item_id: item.id, call_id: item.call_id, name: item.name, delta: text }
355
+ else yield { type: 'response.custom_tool_call_input.delta', output_index: terminalIndex, item_id: item.id, call_id: item.call_id, name: item.name, delta: text }
356
+ }
357
+ yield { type: 'response.output_item.done', output_index: terminalIndex, item }
358
+ if (identity) completed.add(identity)
359
+ }
360
+ yield { ...event, response: { ...response, output } }
361
+ continue
362
+ }
363
+ yield event
364
+ }
365
+ }
366
+
367
+ /** @param {unknown} usage */
368
+ function dshUsage(usage) {
369
+ const value = /** @type {UnknownRecord} */ (isObject(usage) ? usage : {})
370
+ return {
371
+ inputTokens: Number(value.input ?? 0),
372
+ outputTokens: Number(value.output ?? 0),
373
+ ...(Number(value.cacheRead ?? 0) > 0 ? { cacheReadTokens: Number(value.cacheRead) } : {}),
374
+ ...(Number(value.cacheWrite ?? 0) > 0 ? { cacheWriteTokens: Number(value.cacheWrite) } : {}),
375
+ ...(Number(value.reasoning ?? 0) > 0 ? { reasoningTokens: Number(value.reasoning) } : {}),
376
+ }
377
+ }
378
+
379
+ /** @param {unknown} value */
380
+ function rawArguments(value) {
381
+ try { return JSON.stringify(isObject(value) ? value : {}) }
382
+ catch { return '{}' }
383
+ }
384
+
385
+ /** @param {unknown} message */
386
+ function replayState(message) {
387
+ if (!isObject(message)) return undefined
388
+ const content = /** @type {UnknownRecord[]} */ (Array.isArray(message.content) ? message.content.filter(isObject) : [])
389
+ const provider = typeof message.provider === 'string' ? message.provider : undefined
390
+ const model = typeof message.model === 'string' ? message.model : undefined
391
+ const api = typeof message.api === 'string' ? message.api : undefined
392
+ if (!provider || !model || !api) return undefined
393
+ return {
394
+ response: {
395
+ kind: 'pi-ai',
396
+ version: 2,
397
+ api,
398
+ provider,
399
+ model,
400
+ ...(typeof message.responseModel === 'string' ? { responseModel: message.responseModel } : {}),
401
+ ...(typeof message.responseId === 'string' ? { responseId: message.responseId } : {}),
402
+ stopReason: message.stopReason,
403
+ },
404
+ blocks: content.map((block) => {
405
+ if (block?.type === 'text') return { type: 'text', ...(typeof block.textSignature === 'string' ? { textSignature: block.textSignature } : {}) }
406
+ if (block?.type === 'thinking') return { type: 'reasoning', ...(typeof block.thinkingSignature === 'string' ? { thinkingSignature: block.thinkingSignature } : {}), ...(typeof block.redacted === 'boolean' ? { redacted: block.redacted } : {}) }
407
+ return {
408
+ type: 'tool-call',
409
+ ...(typeof block?.thoughtSignature === 'string' ? { thoughtSignature: block.thoughtSignature } : {}),
410
+ ...(typeof block?.namespace === 'string' && block.namespace.length > 0 ? { namespace: block.namespace } : {}),
411
+ }
412
+ }),
413
+ }
414
+ }
415
+
416
+ /** @param {unknown} message */
417
+ function successfulFinish(message) {
418
+ const value = /** @type {UnknownRecord} */ (isObject(message) ? message : {})
419
+ const stopReason = String(value.stopReason ?? 'stop')
420
+ const content = /** @type {UnknownRecord[]} */ (Array.isArray(value.content) ? value.content.filter(isObject) : [])
421
+ if (stopReason === 'length') return { kind: 'max-tokens' }
422
+ if (stopReason === 'toolUse' || content.some((block) => block?.type === 'toolCall')) return { kind: 'tool-calls' }
423
+ if (stopReason === 'stop' && content.length === 0) return { kind: 'error', failure: { message: 'Responses provider completed without content', code: 'EMPTY_RESPONSE' } }
424
+ return { kind: 'stop' }
425
+ }
426
+
427
+ /**
428
+ * Pi event vocabulary -> DSH StreamChunk. This remains thin and provider-neutral.
429
+ * @param {AsyncIterable<unknown>} events
430
+ * @param {AbortSignal} [signal]
431
+ */
432
+ async function* toDshChunks(events, signal) {
433
+ const toolIds = new Map()
434
+ for await (const raw of events) {
435
+ const event = /** @type {UnknownRecord} */ (raw)
436
+ switch (event.type) {
437
+ case 'start': break
438
+ case 'text_start': yield { type: 'block-start', index: Number(event.contentIndex), blockType: 'text' }; break
439
+ case 'text_delta': yield { type: 'text-delta', index: Number(event.contentIndex), text: String(event.delta ?? '') }; break
440
+ case 'text_end': yield { type: 'block-end', index: Number(event.contentIndex), block: { type: 'text', text: String(event.content ?? '') } }; break
441
+ case 'thinking_start': yield { type: 'block-start', index: Number(event.contentIndex), blockType: 'reasoning' }; break
442
+ case 'thinking_delta': yield { type: 'reasoning-delta', index: Number(event.contentIndex), text: String(event.delta ?? '') }; break
443
+ case 'thinking_end': yield { type: 'block-end', index: Number(event.contentIndex), block: { type: 'reasoning', text: String(event.content ?? '') } }; break
444
+ case 'toolcall_start': {
445
+ const partial = /** @type {UnknownRecord | undefined} */ (isObject(event.partial) ? event.partial : undefined)
446
+ const content = Array.isArray(partial?.content) ? partial.content : []
447
+ const block = /** @type {UnknownRecord | undefined} */ (content[Number(event.contentIndex)])
448
+ toolIds.set(Number(event.contentIndex), { id: String(block?.id ?? ''), name: String(block?.name ?? '') })
449
+ yield { type: 'block-start', index: Number(event.contentIndex), blockType: 'tool-call' }
450
+ break
451
+ }
452
+ case 'toolcall_delta': {
453
+ const known = toolIds.get(Number(event.contentIndex)) ?? { id: '', name: '' }
454
+ yield { type: 'tool-call-delta', index: Number(event.contentIndex), id: known.id, ...(known.name ? { name: known.name } : {}), argumentsDelta: String(event.delta ?? '') }
455
+ break
456
+ }
457
+ case 'toolcall_end': {
458
+ const call = /** @type {UnknownRecord} */ (isObject(event.toolCall) ? event.toolCall : {})
459
+ yield { type: 'block-end', index: Number(event.contentIndex), block: { type: 'tool-call', id: String(call.id ?? ''), name: String(call.name ?? ''), arguments: rawArguments(call.arguments) } }
460
+ break
461
+ }
462
+ case 'done': {
463
+ const message = /** @type {UnknownRecord} */ (isObject(event.message) ? event.message : {})
464
+ yield { type: 'usage', usage: dshUsage(message.usage) }
465
+ const reason = successfulFinish(message)
466
+ const replay = reason.kind === 'error' ? undefined : replayState(message)
467
+ yield { type: 'finish', reason, ...(replay === undefined ? {} : { replayState: replay }) }
468
+ return
469
+ }
470
+ case 'error': {
471
+ const message = /** @type {UnknownRecord} */ (isObject(event.error) ? event.error : {})
472
+ yield { type: 'usage', usage: dshUsage(message.usage) }
473
+ const failure = /** @type {ManagedFailure} */ (isObject(message.__lcxFailure) ? message.__lcxFailure : managedFailure(Object.assign(new Error(String(message.errorMessage ?? 'Responses stream failed')), { code: message.stopReason === 'aborted' ? 'LCX_ABORTED' : undefined }), signal))
474
+ yield { type: 'finish', reason: failure.code === 'ABORTED' ? { kind: 'aborted', failure } : { kind: 'error', failure } }
475
+ return
476
+ }
477
+ default: break
478
+ }
479
+ }
480
+ yield managedFailureChunk(Object.assign(new Error('Responses event stream ended without done/error'), { code: 'LCX_INVALID_SSE' }), signal)
481
+ }
482
+
483
+ /**
484
+ * Send one LCX-owned OpenAI Responses request. Ordinary and replay use one provider attempt;
485
+ * the DSH agent recovery layer remains the visible retry owner.
486
+ * @param {object} options
487
+ * @param {string} options.baseURL
488
+ * @param {string} options.provider
489
+ * @param {string} options.model
490
+ * @param {UnknownRecord} options.piModel
491
+ * @param {UnknownRecord} options.body
492
+ * @param {Map<string, string>} [options.grammarToolInputProperties]
493
+ * @param {Record<string, string>} [options.headers]
494
+ * @param {AbortSignal} [options.signal]
495
+ * @param {number} [options.timeoutMs]
496
+ * @param {number} [options.maxAttempts]
497
+ * @param {number} [options.maxResponseBytes]
498
+ */
499
+ export async function* streamResponsesRequest({ baseURL, provider, model, piModel, body, grammarToolInputProperties, headers, signal, timeoutMs, maxAttempts = 1, maxResponseBytes }) {
500
+ try {
501
+ const response = await fetchSseWithRetry(`${String(baseURL).replace(/\/+$/u, '')}/responses`, body, headers, signal, timeoutMs, { maxAttempts, maxResponseBytes })
502
+ /** @type {import('@earendil-works/pi-ai').AssistantMessage & { __lcxFailure?: ManagedFailure }} */
503
+ const output = {
504
+ role: 'assistant', content: [], api: 'openai-responses', provider, model,
505
+ usage: emptyUsage(), stopReason: 'pending', timestamp: Date.now(),
506
+ }
507
+ const piEvents = new PiEventQueue()
508
+ /** @type {{ responseModel?: string }} */
509
+ const wireMeta = {}
510
+ const parser = (async () => {
511
+ try {
512
+ piEvents.push({ type: 'start', partial: output })
513
+ await processResponsesStream(
514
+ /** @type {AsyncIterable<ResponseStreamEvent>} */ (/** @type {unknown} */ (normalizedResponseEvents(responseEvents(response, { signal, maxResponseBytes }), wireMeta))),
515
+ output,
516
+ /** @type {any} */ (piEvents),
517
+ /** @type {any} */ (piModel),
518
+ { grammarToolInputProperties },
519
+ )
520
+ if (typeof wireMeta.responseModel === 'string' && wireMeta.responseModel.length > 0) output.responseModel = wireMeta.responseModel
521
+ if (signal?.aborted) throw signal.reason ?? Object.assign(new Error('request aborted'), { code: 'LCX_ABORTED' })
522
+ if (output.stopReason === 'pending') throw Object.assign(new Error('Responses stream ended without a stop reason'), { code: 'LCX_INVALID_SSE' })
523
+ if (output.stopReason === 'aborted' || output.stopReason === 'error') throw new Error('Responses stream ended in failure')
524
+ piEvents.push({ type: 'done', reason: /** @type {'stop' | 'length' | 'toolUse' | 'deferred'} */ (output.stopReason), message: output })
525
+ piEvents.end()
526
+ } catch (error) {
527
+ output.stopReason = signal?.aborted ? 'aborted' : 'error'
528
+ output.errorMessage = error instanceof Error ? error.message : 'Responses stream failed'
529
+ output.__lcxFailure = managedFailure(error, signal)
530
+ piEvents.push({ type: 'error', reason: /** @type {'aborted' | 'error'} */ (output.stopReason), error: output })
531
+ piEvents.end()
532
+ }
533
+ })()
534
+ yield* toDshChunks(piEvents, signal)
535
+ await parser
536
+ } catch (error) {
537
+ yield managedFailureChunk(error, signal)
538
+ }
539
+ }