dsh-codex-ws 0.0.0-stage → 1.3.0
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/README.md +59 -2
- package/client.js +233 -0
- package/cordis.patch.yml +10 -0
- package/icon.svg +6 -0
- package/index.js +274 -0
- package/lib/config.js +239 -0
- package/lib/continuation.js +63 -0
- package/lib/responses.js +517 -0
- package/lib/transport.js +294 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +25 -4
package/lib/responses.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Responses-API protocol this plugin speaks, in both transports:
|
|
3
|
+
* - request building: harness GenerateOptions → `response.create` body (the same body
|
|
4
|
+
* Codex CLI sends its relay: instructions + full input items + tools, store=false);
|
|
5
|
+
* - event mapping: Responses streaming events (identical on SSE `data:` frames and on
|
|
6
|
+
* WebSocket messages) → harness StreamChunks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
|
|
11
|
+
/** Stable, provider-acceptable cache key from a session id (≤64 chars of [A-Za-z0-9_-]). */
|
|
12
|
+
export function promptCacheKey(sessionId) {
|
|
13
|
+
const raw = typeof sessionId === 'string' ? sessionId : ''
|
|
14
|
+
const cleaned = raw.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 64)
|
|
15
|
+
return cleaned.length > 0 ? cleaned : undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Map a harness reasoning effort to the wire effort; unknown escalations clamp to xhigh. */
|
|
19
|
+
export function wireEffort(effort) {
|
|
20
|
+
if (effort === undefined || effort === null || effort === '' || effort === 'off') return undefined
|
|
21
|
+
if (effort === 'minimal') return 'low'
|
|
22
|
+
if (['low', 'medium', 'high', 'xhigh'].includes(effort)) return effort
|
|
23
|
+
return 'xhigh'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const textOf = (block) => (block && typeof block.text === 'string' ? block.text : '')
|
|
27
|
+
const nonEmpty = (s) => (typeof s === 'string' && s.length > 0 ? s : undefined)
|
|
28
|
+
|
|
29
|
+
/** Join the text blocks of one message, keeping image/file projections as-is (already text). */
|
|
30
|
+
function joinText(content) {
|
|
31
|
+
return (Array.isArray(content) ? content : [])
|
|
32
|
+
.filter((block) => block && block.type === 'text')
|
|
33
|
+
.map(textOf)
|
|
34
|
+
.join('')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the complete canonical payload. The WebSocket transport may replace its input
|
|
39
|
+
* with a verified suffix and previous_response_id on a retained session connection.
|
|
40
|
+
* Keeping this full body available permits safe reconstruction after connection loss.
|
|
41
|
+
*/
|
|
42
|
+
export function buildCreateBody(settings, options, { transport }) {
|
|
43
|
+
const messages = Array.isArray(options.messages) ? options.messages : []
|
|
44
|
+
const instructions = []
|
|
45
|
+
let index = 0
|
|
46
|
+
for (; index < messages.length; index += 1) {
|
|
47
|
+
const message = messages[index]
|
|
48
|
+
if (message?.role !== 'system') break
|
|
49
|
+
const text = joinText(message.content)
|
|
50
|
+
if (text.length > 0) instructions.push(text)
|
|
51
|
+
}
|
|
52
|
+
if (typeof options.system === 'string' && options.system.length > 0) instructions.unshift(options.system)
|
|
53
|
+
|
|
54
|
+
const items = []
|
|
55
|
+
for (; index < messages.length; index += 1) {
|
|
56
|
+
const message = messages[index]
|
|
57
|
+
if (!message) continue
|
|
58
|
+
const blocks = Array.isArray(message.content) ? message.content : []
|
|
59
|
+
switch (message.role) {
|
|
60
|
+
case 'system': {
|
|
61
|
+
// A mid-conversation system message: the Responses input has no system role.
|
|
62
|
+
const text = joinText(message.content)
|
|
63
|
+
if (text.length > 0) items.push(messageItem('developer', text))
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
case 'developer': {
|
|
67
|
+
const text = joinText(message.content)
|
|
68
|
+
if (text.length > 0) items.push(messageItem('developer', text))
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
case 'user': {
|
|
72
|
+
const text = joinText(message.content)
|
|
73
|
+
if (text.length > 0) items.push(messageItem('user', text))
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
case 'assistant': {
|
|
77
|
+
const text = joinText(message.content)
|
|
78
|
+
if (text.length > 0) items.push({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] })
|
|
79
|
+
for (const block of blocks) {
|
|
80
|
+
if (block?.type !== 'tool-call') continue
|
|
81
|
+
items.push({
|
|
82
|
+
type: 'function_call',
|
|
83
|
+
call_id: String(block.id ?? ''),
|
|
84
|
+
name: String(block.name ?? ''),
|
|
85
|
+
arguments: typeof block.arguments === 'string' ? block.arguments : JSON.stringify(block.arguments ?? {}),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
case 'tool': {
|
|
91
|
+
const text = joinText(message.content) || (message.isError ? '(tool error)' : '(no output)')
|
|
92
|
+
items.push({ type: 'function_call_output', call_id: String(message.toolCallId ?? ''), output: text })
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (items.length === 0) items.push(messageItem('user', '(empty request)'))
|
|
100
|
+
|
|
101
|
+
const tools = (Array.isArray(options.tools) ? options.tools : [])
|
|
102
|
+
.filter((tool) => tool && typeof tool.name === 'string' && tool.name.length > 0 && tool.deferLoading !== true)
|
|
103
|
+
.map((tool) => ({
|
|
104
|
+
type: 'function',
|
|
105
|
+
name: tool.name,
|
|
106
|
+
description: typeof tool.description === 'string' ? tool.description : '',
|
|
107
|
+
parameters: tool.parameters ?? { type: 'object', properties: {} },
|
|
108
|
+
strict: false,
|
|
109
|
+
}))
|
|
110
|
+
|
|
111
|
+
const effort = wireEffort(options.reasoningEffort)
|
|
112
|
+
const body = {
|
|
113
|
+
model: options.model,
|
|
114
|
+
...(settings.fast === true ? { service_tier: 'priority' } : {}),
|
|
115
|
+
store: false,
|
|
116
|
+
instructions: instructions.length > 0 ? instructions.join('\n\n') : undefined,
|
|
117
|
+
input: items,
|
|
118
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
119
|
+
tool_choice: tools.length > 0 ? 'auto' : undefined,
|
|
120
|
+
parallel_tool_calls: true,
|
|
121
|
+
include: ['reasoning.encrypted_content'],
|
|
122
|
+
prompt_cache_key: promptCacheKey(options.sessionId),
|
|
123
|
+
...(options.maxTokens !== undefined ? { max_output_tokens: Math.max(16, Math.floor(options.maxTokens)) } : {}),
|
|
124
|
+
...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
125
|
+
...(settings.verbosity.length > 0 ? { text: { verbosity: settings.verbosity } } : {}),
|
|
126
|
+
...(effort !== undefined ? { reasoning: { effort, summary: 'auto' } } : {}),
|
|
127
|
+
}
|
|
128
|
+
if (transport === 'sse') body.stream = true
|
|
129
|
+
return { body, requestId: randomUUID() }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function messageItem(role, text) {
|
|
133
|
+
return { type: 'message', role, content: [{ type: 'input_text', text }] }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------------------------
|
|
137
|
+
// Adapter-boundary projection
|
|
138
|
+
// ---------------------------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Text handle for one file block, mirroring the harness adapter projection the
|
|
142
|
+
* openai route would have applied (dsh-llm fileHandleText): the model is told
|
|
143
|
+
* where the verbatim copy lives instead of receiving the raw file.
|
|
144
|
+
*/
|
|
145
|
+
export function fileHandleText(attachment, readonlyPath) {
|
|
146
|
+
const digest = String(attachment.attachmentId).slice(7, 15)
|
|
147
|
+
const identity = `File ${JSON.stringify(attachment.name)} (${attachment.bytes} bytes, sha256:${digest})`
|
|
148
|
+
if (readonlyPath === undefined) {
|
|
149
|
+
return `[${identity} was uploaded, but the current execution environment cannot access a readable path. Report that limitation if its contents are needed; do not claim to have read it.]`
|
|
150
|
+
}
|
|
151
|
+
return `[${identity}: verbatim read-only copy saved at ${JSON.stringify(readonlyPath)}. Read that path with your file tools when its contents are needed; copy it to a writable location before modifying it. When delegating file work, include this saved path in the delegation prompt; only subagents sharing this execution environment can read it.]`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Image placeholder for a text-only wire path (dsh-llm textOnlyImageText). */
|
|
155
|
+
export const textOnlyImageText = (attachment) =>
|
|
156
|
+
`[image omitted because this model accepts text only; attachment sha256:${String(attachment.attachmentId).slice(7, 15)}]`
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The `llm/stream` waterfall sits just above `adapterStream`, so an intercepting
|
|
160
|
+
* listener must reproduce the boundary projections a dedicated channel got for
|
|
161
|
+
* free: file blocks become readable handles, image blocks become text (the WS
|
|
162
|
+
* body carries input_text only), developer messages are dropped, and deferred
|
|
163
|
+
* tool loading flags are stripped from the declarations.
|
|
164
|
+
*/
|
|
165
|
+
export function projectOptions(options, resolvePath) {
|
|
166
|
+
const messages = []
|
|
167
|
+
let changed = false
|
|
168
|
+
for (const message of options.messages) {
|
|
169
|
+
if (!message) continue
|
|
170
|
+
if (message.role === 'developer') {
|
|
171
|
+
changed = true
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
let content = message.content
|
|
175
|
+
if (Array.isArray(content)) {
|
|
176
|
+
let next = null
|
|
177
|
+
for (const [index, block] of content.entries()) {
|
|
178
|
+
if (block?.type === 'file') {
|
|
179
|
+
next ??= content.slice(0, index)
|
|
180
|
+
next.push({ type: 'text', text: fileHandleText(block.attachment ?? {}, resolvePath(block.attachment)) })
|
|
181
|
+
} else if (block?.type === 'image') {
|
|
182
|
+
next ??= content.slice(0, index)
|
|
183
|
+
next.push({ type: 'text', text: textOnlyImageText(block.attachment ?? {}) })
|
|
184
|
+
} else {
|
|
185
|
+
next?.push(block)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (next !== null) content = next
|
|
189
|
+
}
|
|
190
|
+
if (content !== message.content) changed = true
|
|
191
|
+
messages.push(content === message.content ? message : { ...message, content })
|
|
192
|
+
}
|
|
193
|
+
const tools = Array.isArray(options.tools) && options.tools.some((tool) => tool?.deferLoading === true)
|
|
194
|
+
? options.tools.map((tool) => {
|
|
195
|
+
if (tool?.deferLoading !== true) return tool
|
|
196
|
+
const { deferLoading: _deferLoading, ...rest } = tool
|
|
197
|
+
return rest
|
|
198
|
+
})
|
|
199
|
+
: options.tools
|
|
200
|
+
if (tools !== options.tools) changed = true
|
|
201
|
+
return changed ? { ...options, messages, tools } : options
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------------------------
|
|
205
|
+
// Event → chunk mapping
|
|
206
|
+
// ---------------------------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/** Classify a provider failure text into the harness failure code taxonomy. */
|
|
209
|
+
export function classifyFailure(message, code) {
|
|
210
|
+
const text = `${message ?? ''} ${code ?? ''}`
|
|
211
|
+
if (/\b(?:401|403)\b|invalid[_ ]api[_ ]key|unauthorized|forbidden/i.test(text)) return 'AUTH'
|
|
212
|
+
if (/insufficient[_ ]quota|quota[_ ]exceeded|balance/i.test(text)) return 'QUOTA_EXCEEDED'
|
|
213
|
+
if (/\b429\b|rate.?limit/i.test(text)) return 'RATE_LIMIT'
|
|
214
|
+
if (/\b5\d\d\b|internal server|bad gateway|service unavailable|upstream/i.test(text)) return 'SERVER'
|
|
215
|
+
if (/context.*(length|window|exceed)|maximum context/i.test(text)) return 'CONTEXT_WINDOW_EXCEEDED'
|
|
216
|
+
if (/websocket|connection|socket|network|fetch|terminated|premature|timed?\s?out|timeout/i.test(text)) return 'TRANSPORT'
|
|
217
|
+
if (/\b400\b|invalid.?request/i.test(text)) return 'INVALID_REQUEST'
|
|
218
|
+
return 'PROVIDER_ERROR'
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function failureOf(message, code) {
|
|
222
|
+
return { message: nonEmpty(String(message ?? code ?? 'provider failure')).slice(0, 500), code: classifyFailure(message, code) }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function usageOf(usage) {
|
|
226
|
+
const u = usage ?? {}
|
|
227
|
+
const input = Number(u.input_tokens ?? u.prompt_tokens ?? 0) || 0
|
|
228
|
+
const output = Number(u.output_tokens ?? u.completion_tokens ?? 0) || 0
|
|
229
|
+
const total = Number(u.total_tokens ?? input + output) || input + output
|
|
230
|
+
const cached = Number(u.input_tokens_details?.cached_tokens ?? u.prompt_tokens_details?.cached_tokens ?? 0) || 0
|
|
231
|
+
return {
|
|
232
|
+
inputTokens: input,
|
|
233
|
+
outputTokens: output,
|
|
234
|
+
totalTokens: total,
|
|
235
|
+
...(cached > 0 ? { cacheReadTokens: cached } : {}),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* One response's streaming state machine. Feed every decoded event JSON to
|
|
241
|
+
* {@link handleEvent}; on a terminal event it returns the trailing usage/finish chunks.
|
|
242
|
+
*/
|
|
243
|
+
export class ResponseStreamMapper {
|
|
244
|
+
constructor() {
|
|
245
|
+
this.nextIndex = 0
|
|
246
|
+
/** text part blocks keyed `${item_id}|${content_index}` */
|
|
247
|
+
this.parts = new Map()
|
|
248
|
+
/** item-level blocks (reasoning, function_call) keyed by item id */
|
|
249
|
+
this.itemBlocks = new Map()
|
|
250
|
+
this.sawText = false
|
|
251
|
+
this.sawToolCall = false
|
|
252
|
+
this.completed = false
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
get blockCount() {
|
|
256
|
+
return this.nextIndex
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Open the next harness block index. */
|
|
260
|
+
openBlock(store, key, blockType) {
|
|
261
|
+
const index = this.nextIndex
|
|
262
|
+
this.nextIndex += 1
|
|
263
|
+
store.set(key, { index, text: '', closed: false })
|
|
264
|
+
return { type: 'block-start', index, blockType }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
handleEvent(event) {
|
|
268
|
+
if (!event || typeof event !== 'object') return []
|
|
269
|
+
switch (event.type) {
|
|
270
|
+
case 'response.output_item.added':
|
|
271
|
+
return this.onItemAdded(event.item ?? {}, event.output_index)
|
|
272
|
+
case 'response.content_part.added':
|
|
273
|
+
return this.onPartAdded(event)
|
|
274
|
+
case 'response.output_text.delta':
|
|
275
|
+
return this.onTextDelta(event)
|
|
276
|
+
case 'response.output_text.done':
|
|
277
|
+
return this.onTextDone(event)
|
|
278
|
+
case 'response.content_part.done':
|
|
279
|
+
return this.onPartDone(event)
|
|
280
|
+
case 'response.reasoning_summary_text.delta':
|
|
281
|
+
case 'response.reasoning_text.delta':
|
|
282
|
+
return this.onReasoningDelta(event)
|
|
283
|
+
case 'response.function_call_arguments.delta':
|
|
284
|
+
return this.onArgumentsDelta(event)
|
|
285
|
+
case 'response.output_item.done':
|
|
286
|
+
return this.onItemDone(event.item ?? {})
|
|
287
|
+
case 'response.completed':
|
|
288
|
+
return this.onCompleted(event.response ?? {})
|
|
289
|
+
case 'response.incomplete':
|
|
290
|
+
return this.onIncomplete(event.response ?? {})
|
|
291
|
+
case 'response.failed':
|
|
292
|
+
return this.onFailed(event.response ?? {})
|
|
293
|
+
case 'error':
|
|
294
|
+
return this.onErrorEvent(event)
|
|
295
|
+
default:
|
|
296
|
+
return []
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
onItemAdded(item) {
|
|
301
|
+
const id = String(item.id ?? '')
|
|
302
|
+
if (item.type === 'reasoning') {
|
|
303
|
+
const chunk = this.openBlock(this.itemBlocks, `reasoning:${id}`, 'reasoning')
|
|
304
|
+
return [chunk]
|
|
305
|
+
}
|
|
306
|
+
if (item.type === 'function_call') {
|
|
307
|
+
const chunk = this.openBlock(this.itemBlocks, `call:${id}`, 'tool-call')
|
|
308
|
+
const entry = this.itemBlocks.get(`call:${id}`)
|
|
309
|
+
entry.id = String(item.call_id ?? '')
|
|
310
|
+
entry.name = String(item.name ?? '')
|
|
311
|
+
return [chunk]
|
|
312
|
+
}
|
|
313
|
+
if (item.type === 'message') {
|
|
314
|
+
// Blocks open per content part (see onPartAdded); remember nothing here.
|
|
315
|
+
return []
|
|
316
|
+
}
|
|
317
|
+
return []
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
onPartAdded(event) {
|
|
321
|
+
const itemId = String(event.item_id ?? '')
|
|
322
|
+
const part = event.part ?? {}
|
|
323
|
+
if (part.type !== 'output_text' && part.type !== 'refusal') return []
|
|
324
|
+
const key = `text:${itemId}|${event.content_index}`
|
|
325
|
+
if (this.parts.has(key)) return []
|
|
326
|
+
const chunk = this.openBlock(this.parts, key, 'text')
|
|
327
|
+
return [chunk]
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
onTextDelta(event) {
|
|
331
|
+
const key = `text:${event.item_id}|${event.content_index}`
|
|
332
|
+
const entry = this.parts.get(key)
|
|
333
|
+
if (!entry || typeof event.delta !== 'string') return []
|
|
334
|
+
entry.text += event.delta
|
|
335
|
+
this.sawText = this.sawText || entry.text.length > 0
|
|
336
|
+
return [{ type: 'text-delta', index: entry.index, text: event.delta }]
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
onTextDone(event) {
|
|
340
|
+
const key = `text:${event.item_id}|${event.content_index}`
|
|
341
|
+
const entry = this.parts.get(key)
|
|
342
|
+
if (!entry) return []
|
|
343
|
+
if (typeof event.text === 'string' && event.text.length > 0) entry.final = event.text
|
|
344
|
+
return []
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
onReasoningDelta(event) {
|
|
348
|
+
const entry = this.itemBlocks.get(`reasoning:${event.item_id}`)
|
|
349
|
+
if (!entry || typeof event.delta !== 'string') return []
|
|
350
|
+
entry.text += event.delta
|
|
351
|
+
return [{ type: 'reasoning-delta', index: entry.index, text: event.delta }]
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
onArgumentsDelta(event) {
|
|
355
|
+
const entry = this.itemBlocks.get(`call:${event.item_id}`)
|
|
356
|
+
if (!entry || typeof event.delta !== 'string') return []
|
|
357
|
+
entry.text += event.delta
|
|
358
|
+
return [
|
|
359
|
+
{
|
|
360
|
+
type: 'tool-call-delta',
|
|
361
|
+
index: entry.index,
|
|
362
|
+
id: entry.id ?? '',
|
|
363
|
+
...(entry.name ? { name: entry.name } : {}),
|
|
364
|
+
argumentsDelta: event.delta,
|
|
365
|
+
},
|
|
366
|
+
]
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
onPartDone(event) {
|
|
370
|
+
const key = `text:${event.item_id}|${event.content_index}`
|
|
371
|
+
const entry = this.parts.get(key)
|
|
372
|
+
if (!entry || entry.closed) return []
|
|
373
|
+
entry.closed = true
|
|
374
|
+
const part = event.part ?? {}
|
|
375
|
+
const text = (typeof part.text === 'string' && part.text.length > 0 ? part.text : undefined) ?? entry.final ?? entry.text
|
|
376
|
+
this.sawText = this.sawText || text.length > 0
|
|
377
|
+
return [{ type: 'block-end', index: entry.index, block: { type: 'text', text } }]
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
onItemDone(item) {
|
|
381
|
+
const id = String(item.id ?? '')
|
|
382
|
+
const chunks = []
|
|
383
|
+
if (item.type === 'message') {
|
|
384
|
+
for (const [key, entry] of this.parts) {
|
|
385
|
+
if (!key.startsWith(`text:${id}|`) || entry.closed) continue
|
|
386
|
+
entry.closed = true
|
|
387
|
+
const text = entry.final ?? entry.text
|
|
388
|
+
this.sawText = this.sawText || text.length > 0
|
|
389
|
+
chunks.push({ type: 'block-end', index: entry.index, block: { type: 'text', text } })
|
|
390
|
+
}
|
|
391
|
+
return chunks
|
|
392
|
+
}
|
|
393
|
+
if (item.type === 'reasoning') {
|
|
394
|
+
const entry = this.itemBlocks.get(`reasoning:${id}`)
|
|
395
|
+
if (!entry || entry.closed) return chunks
|
|
396
|
+
entry.closed = true
|
|
397
|
+
const summary = Array.isArray(item.summary)
|
|
398
|
+
? item.summary.map((part) => (typeof part?.text === 'string' ? part.text : '')).join('')
|
|
399
|
+
: ''
|
|
400
|
+
chunks.push({ type: 'block-end', index: entry.index, block: { type: 'reasoning', text: entry.text || summary } })
|
|
401
|
+
return chunks
|
|
402
|
+
}
|
|
403
|
+
if (item.type === 'function_call') {
|
|
404
|
+
const entry = this.itemBlocks.get(`call:${id}`)
|
|
405
|
+
if (!entry || entry.closed) return chunks
|
|
406
|
+
entry.closed = true
|
|
407
|
+
this.sawToolCall = true
|
|
408
|
+
const callId = String(item.call_id ?? entry.id ?? '')
|
|
409
|
+
const name = String(item.name ?? entry.name ?? '')
|
|
410
|
+
const argumentsText = typeof item.arguments === 'string' && item.arguments.length > 0 ? item.arguments : entry.text || '{}'
|
|
411
|
+
chunks.push({
|
|
412
|
+
type: 'block-end',
|
|
413
|
+
index: entry.index,
|
|
414
|
+
block: { type: 'tool-call', id: callId, name, arguments: argumentsText },
|
|
415
|
+
})
|
|
416
|
+
return chunks
|
|
417
|
+
}
|
|
418
|
+
return chunks
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Close every still-open block (used when a terminal event arrives early). */
|
|
422
|
+
closeOpenBlocks() {
|
|
423
|
+
const chunks = []
|
|
424
|
+
for (const [key, entry] of this.parts) {
|
|
425
|
+
if (entry.closed) continue
|
|
426
|
+
entry.closed = true
|
|
427
|
+
const text = entry.final ?? entry.text
|
|
428
|
+
this.sawText = this.sawText || text.length > 0
|
|
429
|
+
chunks.push({ type: 'block-end', index: entry.index, block: { type: 'text', text } })
|
|
430
|
+
this.parts.delete(key)
|
|
431
|
+
}
|
|
432
|
+
for (const [key, entry] of this.itemBlocks) {
|
|
433
|
+
if (entry.closed) continue
|
|
434
|
+
entry.closed = true
|
|
435
|
+
if (key.startsWith('reasoning:')) {
|
|
436
|
+
chunks.push({ type: 'block-end', index: entry.index, block: { type: 'reasoning', text: entry.text } })
|
|
437
|
+
} else if (key.startsWith('call:')) {
|
|
438
|
+
this.sawToolCall = true
|
|
439
|
+
chunks.push({
|
|
440
|
+
type: 'block-end',
|
|
441
|
+
index: entry.index,
|
|
442
|
+
block: { type: 'tool-call', id: entry.id ?? '', name: entry.name ?? '', arguments: entry.text || '{}' },
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return chunks
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
onCompleted(response) {
|
|
450
|
+
if (this.completed) return []
|
|
451
|
+
this.completed = true
|
|
452
|
+
const chunks = this.closeOpenBlocks()
|
|
453
|
+
chunks.push({ type: 'usage', usage: usageOf(response.usage) })
|
|
454
|
+
if (!this.sawText && !this.sawToolCall) {
|
|
455
|
+
chunks.push({
|
|
456
|
+
type: 'finish',
|
|
457
|
+
reason: { kind: 'error', failure: { message: `model "${response.model ?? ''}" returned a completed response with no content`, code: 'EMPTY_RESPONSE' } },
|
|
458
|
+
})
|
|
459
|
+
return chunks
|
|
460
|
+
}
|
|
461
|
+
chunks.push({ type: 'finish', reason: this.sawToolCall ? { kind: 'tool-calls' } : { kind: 'stop' } })
|
|
462
|
+
return chunks
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
onIncomplete(response) {
|
|
466
|
+
if (this.completed) return []
|
|
467
|
+
this.completed = true
|
|
468
|
+
const chunks = this.closeOpenBlocks()
|
|
469
|
+
if (response.usage) chunks.push({ type: 'usage', usage: usageOf(response.usage) })
|
|
470
|
+
const reason = response.incomplete_details?.reason
|
|
471
|
+
if (this.sawToolCall) {
|
|
472
|
+
chunks.push({ type: 'finish', reason: { kind: 'tool-calls' } })
|
|
473
|
+
} else if (reason === 'max_output_tokens') {
|
|
474
|
+
chunks.push({ type: 'finish', reason: { kind: 'max-tokens' } })
|
|
475
|
+
} else {
|
|
476
|
+
chunks.push({
|
|
477
|
+
type: 'finish',
|
|
478
|
+
reason: { kind: 'error', failure: failureOf(`response incomplete: ${reason ?? 'unknown reason'}`, reason) },
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
return chunks
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
onFailed(response) {
|
|
485
|
+
if (this.completed) return []
|
|
486
|
+
this.completed = true
|
|
487
|
+
const chunks = this.closeOpenBlocks()
|
|
488
|
+
const error = response.error ?? {}
|
|
489
|
+
chunks.push({ type: 'finish', reason: { kind: 'error', failure: failureOf(error.message ?? 'response failed', error.code) } })
|
|
490
|
+
return chunks
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
onErrorEvent(event) {
|
|
494
|
+
if (this.completed) return []
|
|
495
|
+
this.completed = true
|
|
496
|
+
const chunks = this.closeOpenBlocks()
|
|
497
|
+
const error = event.error && typeof event.error === 'object' ? event.error : {}
|
|
498
|
+
const message = error.message ?? event.message ?? 'provider error'
|
|
499
|
+
const code = error.code ?? event.code
|
|
500
|
+
chunks.push({ type: 'finish', reason: { kind: 'error', failure: failureOf(message, code) } })
|
|
501
|
+
return chunks
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Terminal chunks for a caller abort. */
|
|
506
|
+
export function abortedFinish(message = 'request aborted by caller') {
|
|
507
|
+
return { type: 'finish', reason: { kind: 'aborted', failure: { message, code: 'ABORTED' } } }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Terminal chunks for a transport/protocol failure before or during the stream. */
|
|
511
|
+
export function errorFinish(error, { aborted = false } = {}) {
|
|
512
|
+
const failure = failureOf(error instanceof Error ? error.message : String(error), error?.code)
|
|
513
|
+
return {
|
|
514
|
+
type: 'finish',
|
|
515
|
+
reason: aborted ? { kind: 'aborted', failure: { ...failure, code: 'ABORTED' } } : { kind: 'error', failure },
|
|
516
|
+
}
|
|
517
|
+
}
|