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.
@@ -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
+ }