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.
- package/ARCHITECTURE.md +24 -0
- package/CHANGELOG.md +15 -0
- package/README.md +177 -125
- package/README_EN.md +180 -116
- package/cordis.patch.yml +1 -0
- package/lib/client.js +15 -18
- package/lib/compact-v2.js +21 -37
- package/lib/dsh-responses.js +13 -6
- package/lib/index.js +69 -43
- package/lib/native-checkpoint.js +18 -7
- package/lib/responses-replay.js +63 -276
- package/lib/responses-request.js +145 -0
- package/lib/responses-stream.js +539 -0
- package/lib/route.js +58 -43
- package/lib/transport.js +10 -4
- package/lib/web-search-alpha.js +5 -2
- package/package.json +4 -4
|
@@ -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
|
+
}
|