@tanstack/openai-base 0.2.0 → 0.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.
Files changed (79) hide show
  1. package/README.md +121 -0
  2. package/dist/esm/adapters/chat-completions-text.d.ts +49 -21
  3. package/dist/esm/adapters/chat-completions-text.js +476 -68
  4. package/dist/esm/adapters/chat-completions-text.js.map +1 -1
  5. package/dist/esm/adapters/chat-completions-tool-converter.d.ts +8 -4
  6. package/dist/esm/adapters/chat-completions-tool-converter.js.map +1 -1
  7. package/dist/esm/adapters/responses-text.d.ts +46 -33
  8. package/dist/esm/adapters/responses-text.js +657 -142
  9. package/dist/esm/adapters/responses-text.js.map +1 -1
  10. package/dist/esm/index.d.ts +2 -9
  11. package/dist/esm/index.js +4 -16
  12. package/dist/esm/index.js.map +1 -1
  13. package/dist/esm/tools/apply-patch-tool.d.ts +2 -2
  14. package/dist/esm/tools/apply-patch-tool.js.map +1 -1
  15. package/dist/esm/tools/code-interpreter-tool.d.ts +3 -2
  16. package/dist/esm/tools/code-interpreter-tool.js.map +1 -1
  17. package/dist/esm/tools/computer-use-tool.d.ts +2 -2
  18. package/dist/esm/tools/computer-use-tool.js.map +1 -1
  19. package/dist/esm/tools/custom-tool.d.ts +2 -2
  20. package/dist/esm/tools/custom-tool.js.map +1 -1
  21. package/dist/esm/tools/file-search-tool.d.ts +2 -2
  22. package/dist/esm/tools/file-search-tool.js.map +1 -1
  23. package/dist/esm/tools/function-tool.d.ts +2 -2
  24. package/dist/esm/tools/function-tool.js.map +1 -1
  25. package/dist/esm/tools/image-generation-tool.d.ts +3 -2
  26. package/dist/esm/tools/image-generation-tool.js.map +1 -1
  27. package/dist/esm/tools/local-shell-tool.d.ts +3 -2
  28. package/dist/esm/tools/local-shell-tool.js.map +1 -1
  29. package/dist/esm/tools/mcp-tool.d.ts +3 -2
  30. package/dist/esm/tools/mcp-tool.js.map +1 -1
  31. package/dist/esm/tools/shell-tool.d.ts +2 -2
  32. package/dist/esm/tools/shell-tool.js.map +1 -1
  33. package/dist/esm/tools/web-search-preview-tool.d.ts +2 -2
  34. package/dist/esm/tools/web-search-preview-tool.js.map +1 -1
  35. package/dist/esm/tools/web-search-tool.d.ts +2 -2
  36. package/dist/esm/tools/web-search-tool.js.map +1 -1
  37. package/package.json +6 -6
  38. package/src/adapters/chat-completions-text.ts +601 -117
  39. package/src/adapters/chat-completions-tool-converter.ts +9 -5
  40. package/src/adapters/responses-text.ts +865 -210
  41. package/src/index.ts +2 -12
  42. package/src/tools/apply-patch-tool.ts +2 -2
  43. package/src/tools/code-interpreter-tool.ts +4 -2
  44. package/src/tools/computer-use-tool.ts +2 -2
  45. package/src/tools/custom-tool.ts +2 -2
  46. package/src/tools/file-search-tool.ts +3 -3
  47. package/src/tools/function-tool.ts +2 -2
  48. package/src/tools/image-generation-tool.ts +4 -2
  49. package/src/tools/local-shell-tool.ts +4 -2
  50. package/src/tools/mcp-tool.ts +4 -2
  51. package/src/tools/shell-tool.ts +2 -2
  52. package/src/tools/web-search-preview-tool.ts +2 -2
  53. package/src/tools/web-search-tool.ts +2 -2
  54. package/dist/esm/adapters/image.d.ts +0 -32
  55. package/dist/esm/adapters/image.js +0 -89
  56. package/dist/esm/adapters/image.js.map +0 -1
  57. package/dist/esm/adapters/summarize.d.ts +0 -28
  58. package/dist/esm/adapters/summarize.js +0 -112
  59. package/dist/esm/adapters/summarize.js.map +0 -1
  60. package/dist/esm/adapters/transcription.d.ts +0 -34
  61. package/dist/esm/adapters/transcription.js +0 -131
  62. package/dist/esm/adapters/transcription.js.map +0 -1
  63. package/dist/esm/adapters/tts.d.ts +0 -26
  64. package/dist/esm/adapters/tts.js +0 -78
  65. package/dist/esm/adapters/tts.js.map +0 -1
  66. package/dist/esm/adapters/video.d.ts +0 -72
  67. package/dist/esm/adapters/video.js +0 -238
  68. package/dist/esm/adapters/video.js.map +0 -1
  69. package/dist/esm/types/config.d.ts +0 -4
  70. package/dist/esm/utils/client.d.ts +0 -3
  71. package/dist/esm/utils/client.js +0 -8
  72. package/dist/esm/utils/client.js.map +0 -1
  73. package/src/adapters/image.ts +0 -158
  74. package/src/adapters/summarize.ts +0 -174
  75. package/src/adapters/transcription.ts +0 -194
  76. package/src/adapters/tts.ts +0 -124
  77. package/src/adapters/video.ts +0 -385
  78. package/src/types/config.ts +0 -5
  79. package/src/utils/client.ts +0 -8
@@ -1,16 +1,22 @@
1
+ import { EventType } from '@tanstack/ai'
1
2
  import { BaseTextAdapter } from '@tanstack/ai/adapters'
2
3
  import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
3
4
  import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils'
4
- import { createOpenAICompatibleClient } from '../utils/client'
5
5
  import { extractRequestOptions } from '../utils/request-options'
6
6
  import { makeStructuredOutputCompatible } from '../utils/schema-converter'
7
7
  import { convertToolsToResponsesFormat } from './responses-tool-converter'
8
+ import type OpenAI from 'openai'
8
9
  import type {
9
10
  StructuredOutputOptions,
10
11
  StructuredOutputResult,
11
12
  } from '@tanstack/ai/adapters'
12
- import type OpenAI_SDK from 'openai'
13
- import type { Responses } from 'openai/resources'
13
+ import type {
14
+ Response,
15
+ ResponseCreateParams,
16
+ ResponseInput,
17
+ ResponseInputContent,
18
+ ResponseStreamEvent,
19
+ } from 'openai/resources/responses/responses'
14
20
  import type {
15
21
  ContentPart,
16
22
  DefaultMessageMetadataByModality,
@@ -19,39 +25,16 @@ import type {
19
25
  StreamChunk,
20
26
  TextOptions,
21
27
  } from '@tanstack/ai'
22
- import type { OpenAICompatibleClientConfig } from '../types/config'
23
-
24
- /** Cast an event object to StreamChunk. Adapters construct events with string
25
- * literal types which are structurally compatible with the EventType enum. */
26
- const asChunk = (chunk: Record<string, unknown>) =>
27
- chunk as unknown as StreamChunk
28
28
 
29
29
  /**
30
- * OpenAI-compatible Responses API Text Adapter
31
- *
32
- * A generalized base class for providers that use the OpenAI Responses API
33
- * (`/v1/responses`). Providers like OpenAI (native), Azure OpenAI, and others
34
- * that implement the Responses API can extend this class and only need to:
35
- * - Set `baseURL` in the config
36
- * - Lock the generic type parameters to provider-specific types
37
- * - Override specific methods for quirks
38
- *
39
- * Key differences from the Chat Completions adapter:
40
- * - Uses `client.responses.create()` instead of `client.chat.completions.create()`
41
- * - Messages use `ResponseInput` format
42
- * - System prompts go in `instructions` field, not as array messages
43
- * - Streaming events are completely different (9+ event types vs simple delta chunks)
44
- * - Supports reasoning/thinking tokens via `response.reasoning_text.delta`
45
- * - Structured output uses `text.format` in the request (not `response_format`)
46
- * - Tool calls use `response.function_call_arguments.delta`
47
- * - Content parts are `input_text`, `input_image`, `input_file`
48
- *
49
- * All methods that build requests or process responses are `protected` so subclasses
50
- * can override them.
30
+ * Shared implementation of the OpenAI Responses API. Holds the stream-event
31
+ * accumulator + AG-UI lifecycle and calls the OpenAI SDK directly. Subclasses
32
+ * (today: ai-openai) construct an OpenAI client with their provider-specific
33
+ * `baseURL` / headers and pass it in.
51
34
  */
52
- export class OpenAICompatibleResponsesTextAdapter<
35
+ export abstract class OpenAIBaseResponsesTextAdapter<
53
36
  TModel extends string,
54
- TProviderOptions extends Record<string, any> = Record<string, any>,
37
+ TProviderOptions extends Record<string, unknown> = Record<string, unknown>,
55
38
  TInputModalities extends ReadonlyArray<Modality> = ReadonlyArray<Modality>,
56
39
  TMessageMetadata extends DefaultMessageMetadataByModality =
57
40
  DefaultMessageMetadataByModality,
@@ -65,17 +48,12 @@ export class OpenAICompatibleResponsesTextAdapter<
65
48
  > {
66
49
  readonly kind = 'text' as const
67
50
  readonly name: string
51
+ protected client: OpenAI
68
52
 
69
- protected client: OpenAI_SDK
70
-
71
- constructor(
72
- config: OpenAICompatibleClientConfig,
73
- model: TModel,
74
- name: string = 'openai-compatible-responses',
75
- ) {
53
+ constructor(model: TModel, name: string, client: OpenAI) {
76
54
  super({}, model)
77
55
  this.name = name
78
- this.client = createOpenAICompatibleClient(config)
56
+ this.client = client
79
57
  }
80
58
 
81
59
  async *chatStream(
@@ -87,20 +65,34 @@ export class OpenAICompatibleResponsesTextAdapter<
87
65
  // We assign our own indices as we encounter unique tool call IDs.
88
66
  const toolCallMetadata = new Map<
89
67
  string,
90
- { index: number; name: string; started: boolean }
68
+ {
69
+ index: number
70
+ name: string
71
+ started: boolean
72
+ // Set once TOOL_CALL_END has been emitted (via args.done or the
73
+ // output_item.done backfill) so the two paths don't double-emit.
74
+ ended?: boolean
75
+ // Set when args.done arrives before TOOL_CALL_START could fire
76
+ // (output_item.added lacked a name). output_item.done picks these
77
+ // up to emit the missing END.
78
+ pendingArguments?: string
79
+ }
91
80
  >()
92
- const requestParams = this.mapOptionsToRequest(options)
93
- const timestamp = Date.now()
94
81
 
95
82
  // AG-UI lifecycle tracking
96
83
  const aguiState = {
97
84
  runId: generateId(this.name),
85
+ threadId: options.threadId ?? generateId(this.name),
98
86
  messageId: generateId(this.name),
99
- timestamp,
100
87
  hasEmittedRunStarted: false,
101
88
  }
102
89
 
103
90
  try {
91
+ // mapOptionsToRequest can throw on caller-side validation failures
92
+ // (empty user content, unsupported parts, webSearchTool() rejection in
93
+ // the OpenRouter override). Keep it inside the try so those failures
94
+ // surface as RUN_ERROR events instead of iterator throws.
95
+ const requestParams = this.mapOptionsToRequest(options)
104
96
  options.logger.request(
105
97
  `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`,
106
98
  { provider: this.name, model: this.model },
@@ -130,22 +122,24 @@ export class OpenAICompatibleResponsesTextAdapter<
130
122
  // Emit RUN_STARTED if not yet emitted
131
123
  if (!aguiState.hasEmittedRunStarted) {
132
124
  aguiState.hasEmittedRunStarted = true
133
- yield asChunk({
134
- type: 'RUN_STARTED',
125
+ yield {
126
+ type: EventType.RUN_STARTED,
135
127
  runId: aguiState.runId,
128
+ threadId: aguiState.threadId,
136
129
  model: options.model,
137
- timestamp,
138
- })
130
+ timestamp: Date.now(),
131
+ }
139
132
  }
140
133
 
141
134
  // Emit AG-UI RUN_ERROR
142
- yield asChunk({
143
- type: 'RUN_ERROR',
144
- runId: aguiState.runId,
135
+ yield {
136
+ type: EventType.RUN_ERROR,
145
137
  model: options.model,
146
- timestamp,
138
+ timestamp: Date.now(),
139
+ message: errorPayload.message,
140
+ code: errorPayload.code,
147
141
  error: errorPayload,
148
- })
142
+ }
149
143
 
150
144
  options.logger.errors(`${this.name}.chatStream fatal`, {
151
145
  error: errorPayload,
@@ -195,10 +189,7 @@ export class OpenAICompatibleResponsesTextAdapter<
195
189
  )
196
190
  const response = await this.client.responses.create(
197
191
  {
198
- ...(cleanParams as Omit<
199
- OpenAI_SDK.Responses.ResponseCreateParams,
200
- 'stream'
201
- >),
192
+ ...(cleanParams as Omit<ResponseCreateParams, 'stream'>),
202
193
  stream: false,
203
194
  // Configure structured output via text.format
204
195
  text: {
@@ -217,9 +208,17 @@ export class OpenAICompatibleResponsesTextAdapter<
217
208
  // SDK return type to `Response`, but the explicit annotation makes
218
209
  // that contract local rather than relying on inference through the
219
210
  // overloaded `client.responses.create` signature.
220
- const rawText = this.extractTextFromResponse(
221
- response satisfies OpenAI_SDK.Responses.Response,
222
- )
211
+ const rawText = this.extractTextFromResponse(response satisfies Response)
212
+
213
+ // Fail loud on empty content rather than letting it cascade into a
214
+ // confusing "Failed to parse JSON. Content: " error — the root cause
215
+ // (the model returned no text content for the structured request) is
216
+ // then visible in logs. Mirrors the chat-completions sibling.
217
+ if (rawText.length === 0) {
218
+ throw new Error(
219
+ `${this.name}.structuredOutput: response contained no content`,
220
+ )
221
+ }
223
222
 
224
223
  // Parse the JSON response
225
224
  let parsed: unknown
@@ -231,9 +230,13 @@ export class OpenAICompatibleResponsesTextAdapter<
231
230
  )
232
231
  }
233
232
 
234
- // Transform null values to undefined to match original Zod schema expectations
235
- // Provider returns null for optional fields we made nullable in the schema
236
- const transformed = transformNullsToUndefined(parsed)
233
+ // Apply the provider-specific post-parse shaping (default: null →
234
+ // undefined to align with the original Zod schema's optional-field
235
+ // semantics; subclasses with different conventions can override
236
+ // `transformStructuredOutput`, mirroring the chat-completions base's
237
+ // hook so OpenRouter and other providers that preserve nulls in
238
+ // structured output can opt out without forking `structuredOutput`).
239
+ const transformed = this.transformStructuredOutput(parsed)
237
240
 
238
241
  return {
239
242
  data: transformed,
@@ -250,6 +253,387 @@ export class OpenAICompatibleResponsesTextAdapter<
250
253
  }
251
254
  }
252
255
 
256
+ /**
257
+ * Stream structured output via the Responses API: single request with
258
+ * `text.format: json_schema` + `stream: true`. Consumes Responses-API
259
+ * events (`response.output_text.delta`, `response.reasoning_text.delta`,
260
+ * `response.reasoning_summary_text.delta`, `response.refusal.delta`,
261
+ * `response.completed`, `response.failed`) and re-emits the standard AG-UI
262
+ * lifecycle ending with `CUSTOM 'structured-output.complete'`.
263
+ *
264
+ * Tools are stripped (structured output is mutually exclusive with tool
265
+ * calls in this path). Reasoning text is accumulated and surfaced both as
266
+ * REASONING_* lifecycle events during the stream and on the terminal
267
+ * CUSTOM event's `value.reasoning`.
268
+ */
269
+ async *structuredOutputStream(
270
+ options: StructuredOutputOptions<TProviderOptions>,
271
+ ): AsyncIterable<StreamChunk> {
272
+ const { chatOptions, outputSchema } = options
273
+ const requestParams = this.mapOptionsToRequest(chatOptions)
274
+
275
+ const jsonSchema = this.makeStructuredOutputCompatible(
276
+ outputSchema,
277
+ outputSchema.required,
278
+ )
279
+
280
+ const timestamp = Date.now()
281
+ const aguiState = {
282
+ runId: generateId(this.name),
283
+ threadId: chatOptions.threadId ?? generateId(this.name),
284
+ messageId: generateId(this.name),
285
+ timestamp,
286
+ hasEmittedRunStarted: false,
287
+ }
288
+
289
+ let accumulatedContent = ''
290
+ let accumulatedReasoning = ''
291
+ let hasEmittedTextMessageStart = false
292
+ let reasoningMessageId: string | undefined
293
+ let stepId: string | undefined
294
+ let hasClosedReasoning = false
295
+ let model: string = chatOptions.model
296
+ let usage: OpenAI.Responses.Response['usage'] | undefined
297
+
298
+ const closeReasoning = function* (this: {
299
+ name: string
300
+ }): Generator<StreamChunk> {
301
+ if (reasoningMessageId && !hasClosedReasoning) {
302
+ hasClosedReasoning = true
303
+ yield {
304
+ type: EventType.REASONING_MESSAGE_END,
305
+ messageId: reasoningMessageId,
306
+ model,
307
+ timestamp,
308
+ }
309
+ yield {
310
+ type: EventType.REASONING_END,
311
+ messageId: reasoningMessageId,
312
+ model,
313
+ timestamp,
314
+ }
315
+ if (stepId) {
316
+ yield {
317
+ type: EventType.STEP_FINISHED,
318
+ stepName: stepId,
319
+ stepId,
320
+ model,
321
+ timestamp,
322
+ content: accumulatedReasoning,
323
+ }
324
+ }
325
+ }
326
+ }.bind(this)
327
+
328
+ const openReasoning = function* (this: {
329
+ name: string
330
+ }): Generator<StreamChunk> {
331
+ if (reasoningMessageId) return
332
+ reasoningMessageId = generateId(this.name)
333
+ stepId = generateId(this.name)
334
+ yield {
335
+ type: EventType.REASONING_START,
336
+ messageId: reasoningMessageId,
337
+ model,
338
+ timestamp,
339
+ }
340
+ yield {
341
+ type: EventType.REASONING_MESSAGE_START,
342
+ messageId: reasoningMessageId,
343
+ role: 'reasoning' as const,
344
+ model,
345
+ timestamp,
346
+ }
347
+ yield {
348
+ type: EventType.STEP_STARTED,
349
+ stepName: stepId,
350
+ stepId,
351
+ model,
352
+ timestamp,
353
+ stepType: 'thinking',
354
+ }
355
+ }.bind(this)
356
+
357
+ try {
358
+ const { tools: _tools, ...cleanParams } = requestParams
359
+ void _tools
360
+
361
+ chatOptions.logger.request(
362
+ `activity=structuredOutputStream provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`,
363
+ { provider: this.name, model: this.model },
364
+ )
365
+
366
+ const stream = await this.client.responses.create(
367
+ {
368
+ ...cleanParams,
369
+ stream: true,
370
+ text: {
371
+ format: {
372
+ type: 'json_schema',
373
+ name: 'structured_output',
374
+ schema: jsonSchema,
375
+ strict: true,
376
+ },
377
+ },
378
+ },
379
+ extractRequestOptions(chatOptions.request),
380
+ )
381
+
382
+ for await (const chunk of stream) {
383
+ chatOptions.logger.provider(
384
+ `provider=${this.name} type=${chunk.type}`,
385
+ { provider: this.name, type: chunk.type },
386
+ )
387
+
388
+ if (!aguiState.hasEmittedRunStarted) {
389
+ aguiState.hasEmittedRunStarted = true
390
+ yield {
391
+ type: EventType.RUN_STARTED,
392
+ runId: aguiState.runId,
393
+ threadId: aguiState.threadId,
394
+ model,
395
+ timestamp,
396
+ }
397
+ }
398
+
399
+ if (
400
+ chunk.type === 'response.created' ||
401
+ chunk.type === 'response.in_progress'
402
+ ) {
403
+ const responseModel = (chunk as { response?: { model?: string } })
404
+ .response?.model
405
+ if (responseModel) model = responseModel
406
+ continue
407
+ }
408
+
409
+ if (chunk.type === 'response.refusal.delta') {
410
+ const delta =
411
+ typeof (chunk as { delta?: unknown }).delta === 'string'
412
+ ? (chunk as { delta: string }).delta
413
+ : ''
414
+ yield {
415
+ type: EventType.RUN_ERROR,
416
+ runId: aguiState.runId,
417
+ model,
418
+ timestamp,
419
+ message: `Model refused: ${delta}`,
420
+ code: 'refusal',
421
+ error: { message: `Model refused: ${delta}`, code: 'refusal' },
422
+ }
423
+ return
424
+ }
425
+
426
+ if (
427
+ chunk.type === 'response.reasoning_text.delta' ||
428
+ chunk.type === 'response.reasoning_summary_text.delta'
429
+ ) {
430
+ const raw = (chunk as { delta?: unknown }).delta
431
+ const reasoningDelta = Array.isArray(raw)
432
+ ? raw.join('')
433
+ : typeof raw === 'string'
434
+ ? raw
435
+ : ''
436
+ if (!reasoningDelta) continue
437
+ yield* openReasoning()
438
+ // openReasoning() guarantees reasoningMessageId is set on first call;
439
+ // TS can't see through the generator side-effect.
440
+ const messageId = reasoningMessageId!
441
+ accumulatedReasoning += reasoningDelta
442
+ yield {
443
+ type: EventType.REASONING_MESSAGE_CONTENT,
444
+ messageId,
445
+ delta: reasoningDelta,
446
+ model,
447
+ timestamp,
448
+ }
449
+ continue
450
+ }
451
+
452
+ if (chunk.type === 'response.output_text.delta') {
453
+ const raw = (chunk as { delta?: unknown }).delta
454
+ const textDelta = Array.isArray(raw)
455
+ ? raw.join('')
456
+ : typeof raw === 'string'
457
+ ? raw
458
+ : ''
459
+ if (!textDelta) continue
460
+
461
+ yield* closeReasoning()
462
+
463
+ if (!hasEmittedTextMessageStart) {
464
+ hasEmittedTextMessageStart = true
465
+ yield {
466
+ type: EventType.TEXT_MESSAGE_START,
467
+ messageId: aguiState.messageId,
468
+ model,
469
+ timestamp,
470
+ role: 'assistant',
471
+ }
472
+ }
473
+ accumulatedContent += textDelta
474
+ yield {
475
+ type: EventType.TEXT_MESSAGE_CONTENT,
476
+ messageId: aguiState.messageId,
477
+ model,
478
+ timestamp,
479
+ delta: textDelta,
480
+ content: accumulatedContent,
481
+ }
482
+ continue
483
+ }
484
+
485
+ if (chunk.type === 'response.completed') {
486
+ const response = chunk.response
487
+ if (response.usage) usage = response.usage
488
+ if (response.model) model = response.model
489
+ continue
490
+ }
491
+
492
+ if (chunk.type === 'response.failed') {
493
+ const response = (
494
+ chunk as {
495
+ response?: { error?: { message?: string; code?: string } }
496
+ }
497
+ ).response
498
+ const message =
499
+ response?.error?.message || 'Responses API stream failed'
500
+ yield {
501
+ type: EventType.RUN_ERROR,
502
+ runId: aguiState.runId,
503
+ model,
504
+ timestamp,
505
+ message,
506
+ code: response?.error?.code,
507
+ error: { message, code: response?.error?.code },
508
+ }
509
+ return
510
+ }
511
+ }
512
+
513
+ yield* closeReasoning()
514
+
515
+ if (hasEmittedTextMessageStart) {
516
+ yield {
517
+ type: EventType.TEXT_MESSAGE_END,
518
+ messageId: aguiState.messageId,
519
+ model,
520
+ timestamp,
521
+ }
522
+ }
523
+
524
+ if (accumulatedContent.length === 0) {
525
+ yield {
526
+ type: EventType.RUN_ERROR,
527
+ runId: aguiState.runId,
528
+ model,
529
+ timestamp,
530
+ message: `${this.name}.structuredOutputStream: response contained no content`,
531
+ code: 'empty-response',
532
+ error: {
533
+ message: `${this.name}.structuredOutputStream: response contained no content`,
534
+ code: 'empty-response',
535
+ },
536
+ }
537
+ return
538
+ }
539
+
540
+ let parsed: unknown
541
+ try {
542
+ parsed = JSON.parse(accumulatedContent)
543
+ } catch {
544
+ yield {
545
+ type: EventType.RUN_ERROR,
546
+ runId: aguiState.runId,
547
+ model,
548
+ timestamp,
549
+ message: `Failed to parse structured output as JSON. Content: ${accumulatedContent.slice(0, 200)}${accumulatedContent.length > 200 ? '...' : ''}`,
550
+ code: 'parse-error',
551
+ error: {
552
+ message: 'Failed to parse structured output as JSON',
553
+ code: 'parse-error',
554
+ },
555
+ }
556
+ return
557
+ }
558
+
559
+ const transformed = transformNullsToUndefined(parsed)
560
+
561
+ yield {
562
+ type: EventType.CUSTOM,
563
+ name: 'structured-output.complete',
564
+ value: {
565
+ object: transformed,
566
+ raw: accumulatedContent,
567
+ ...(accumulatedReasoning ? { reasoning: accumulatedReasoning } : {}),
568
+ },
569
+ model,
570
+ timestamp,
571
+ }
572
+
573
+ yield {
574
+ type: EventType.RUN_FINISHED,
575
+ runId: aguiState.runId,
576
+ threadId: aguiState.threadId,
577
+ model,
578
+ timestamp,
579
+ finishReason: 'stop',
580
+ ...(usage && {
581
+ usage: {
582
+ promptTokens: usage.input_tokens,
583
+ completionTokens: usage.output_tokens,
584
+ totalTokens: usage.total_tokens,
585
+ },
586
+ }),
587
+ }
588
+ } catch (error: unknown) {
589
+ if (!aguiState.hasEmittedRunStarted) {
590
+ aguiState.hasEmittedRunStarted = true
591
+ yield {
592
+ type: EventType.RUN_STARTED,
593
+ runId: aguiState.runId,
594
+ threadId: aguiState.threadId,
595
+ model,
596
+ timestamp,
597
+ }
598
+ }
599
+
600
+ const isAbort = this.isAbortError(error)
601
+ const errorPayload = toRunErrorPayload(
602
+ error,
603
+ `${this.name}.structuredOutputStream failed`,
604
+ )
605
+
606
+ yield {
607
+ type: EventType.RUN_ERROR,
608
+ runId: aguiState.runId,
609
+ model,
610
+ timestamp,
611
+ message: errorPayload.message,
612
+ code: isAbort ? 'aborted' : errorPayload.code,
613
+ error: { ...errorPayload, ...(isAbort && { code: 'aborted' }) },
614
+ }
615
+
616
+ chatOptions.logger.errors(`${this.name}.structuredOutputStream fatal`, {
617
+ error: errorPayload,
618
+ source: `${this.name}.structuredOutputStream`,
619
+ })
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Cross-SDK abort detection for `structuredOutputStream`. Mirrors the
625
+ * Chat Completions base; subclasses with proprietary error types override.
626
+ */
627
+ protected isAbortError(error: unknown): boolean {
628
+ if (!error || typeof error !== 'object') return false
629
+ const e = error as { name?: unknown; code?: unknown }
630
+ return (
631
+ e.name === 'APIUserAbortError' ||
632
+ e.name === 'AbortError' ||
633
+ e.code === 'ERR_CANCELED'
634
+ )
635
+ }
636
+
253
637
  /**
254
638
  * Applies provider-specific transformations for structured output compatibility.
255
639
  * Override this in subclasses to handle provider-specific quirks.
@@ -261,26 +645,49 @@ export class OpenAICompatibleResponsesTextAdapter<
261
645
  return makeStructuredOutputCompatible(schema, originalRequired)
262
646
  }
263
647
 
648
+ /**
649
+ * Final shaping pass applied to parsed structured-output JSON before it is
650
+ * returned to the caller. Default converts `null` values to `undefined` so
651
+ * the result aligns with the original Zod schema's optional-field
652
+ * semantics. Subclasses with different conventions (OpenRouter historically
653
+ * preserves nulls) can override — mirrors the chat-completions base's hook
654
+ * so a subclass that opts out of null-stripping doesn't have to fork the
655
+ * whole `structuredOutput` method.
656
+ */
657
+ protected transformStructuredOutput(parsed: unknown): unknown {
658
+ return transformNullsToUndefined(parsed)
659
+ }
660
+
264
661
  /**
265
662
  * Extract text content from a non-streaming Responses API response.
266
663
  * Override this in subclasses for provider-specific response shapes.
267
664
  */
268
- protected extractTextFromResponse(
269
- response: OpenAI_SDK.Responses.Response,
270
- ): string {
665
+ protected extractTextFromResponse(response: Response): string {
271
666
  let textContent = ''
272
667
  let refusal: string | undefined
668
+ let sawMessageItem = false
669
+ const observedItemTypes = new Set<string>()
273
670
 
274
671
  for (const item of response.output) {
672
+ observedItemTypes.add(item.type)
275
673
  if (item.type === 'message') {
674
+ sawMessageItem = true
276
675
  for (const part of item.content) {
277
- if (part.type === 'output_text') {
278
- textContent += part.text
676
+ // Cast off the discriminated union before the type discrimination
677
+ // so future SDK variants (e.g. `output_audio`, `output_image`) hit
678
+ // the explicit error path rather than being misreported as refusals
679
+ // when they get added to the union. Mirrors the streaming side's
680
+ // handleContentPart.
681
+ const partType = (part as { type: string }).type
682
+ if (partType === 'output_text') {
683
+ textContent += (part as { text?: string }).text ?? ''
684
+ } else if (partType === 'refusal') {
685
+ const refusalText = (part as { refusal?: string }).refusal
686
+ refusal = refusalText || refusal || 'Refused without explanation'
279
687
  } else {
280
- // The Responses SDK currently models message content as
281
- // `output_text | refusal`, so the only non-text branch is a
282
- // refusal. Capture it so we can surface a distinct error below.
283
- refusal = part.refusal || refusal || 'Refused without explanation'
688
+ throw new Error(
689
+ `${this.name}.extractTextFromResponse: unsupported message content part type "${partType}"`,
690
+ )
284
691
  }
285
692
  }
286
693
  }
@@ -295,6 +702,16 @@ export class OpenAICompatibleResponsesTextAdapter<
295
702
  throw err
296
703
  }
297
704
 
705
+ // Response had items but none carried message text (e.g. only
706
+ // function_call or reasoning items). Surface that explicitly so a
707
+ // downstream structured-output caller doesn't see a misleading
708
+ // "Failed to parse JSON. Content: " from an empty string.
709
+ if (!textContent && response.output.length > 0 && !sawMessageItem) {
710
+ throw new Error(
711
+ `${this.name}.extractTextFromResponse: response.output contained items of type(s) [${[...observedItemTypes].sort().join(', ')}] but no message text — the model returned a non-text response`,
712
+ )
713
+ }
714
+
298
715
  return textContent
299
716
  }
300
717
 
@@ -314,22 +731,27 @@ export class OpenAICompatibleResponsesTextAdapter<
314
731
  * - error
315
732
  */
316
733
  protected async *processStreamChunks(
317
- stream: AsyncIterable<OpenAI_SDK.Responses.ResponseStreamEvent>,
734
+ stream: AsyncIterable<ResponseStreamEvent>,
318
735
  toolCallMetadata: Map<
319
736
  string,
320
- { index: number; name: string; started: boolean }
737
+ {
738
+ index: number
739
+ name: string
740
+ started: boolean
741
+ ended?: boolean
742
+ pendingArguments?: string
743
+ }
321
744
  >,
322
745
  options: TextOptions<TProviderOptions>,
323
746
  aguiState: {
324
747
  runId: string
748
+ threadId: string
325
749
  messageId: string
326
- timestamp: number
327
750
  hasEmittedRunStarted: boolean
328
751
  },
329
752
  ): AsyncIterable<StreamChunk> {
330
753
  let accumulatedContent = ''
331
754
  let accumulatedReasoning = ''
332
- const timestamp = aguiState.timestamp
333
755
 
334
756
  // Track if we've been streaming deltas to avoid duplicating content from done events
335
757
  let hasStreamedContentDeltas = false
@@ -357,12 +779,13 @@ export class OpenAICompatibleResponsesTextAdapter<
357
779
  // Emit RUN_STARTED on first chunk
358
780
  if (!aguiState.hasEmittedRunStarted) {
359
781
  aguiState.hasEmittedRunStarted = true
360
- yield asChunk({
361
- type: 'RUN_STARTED',
782
+ yield {
783
+ type: EventType.RUN_STARTED,
362
784
  runId: aguiState.runId,
785
+ threadId: aguiState.threadId,
363
786
  model: model || options.model,
364
- timestamp,
365
- })
787
+ timestamp: Date.now(),
788
+ }
366
789
  }
367
790
 
368
791
  const handleContentPart = (contentPart: {
@@ -372,14 +795,14 @@ export class OpenAICompatibleResponsesTextAdapter<
372
795
  }): StreamChunk => {
373
796
  if (contentPart.type === 'output_text') {
374
797
  accumulatedContent += contentPart.text || ''
375
- return asChunk({
376
- type: 'TEXT_MESSAGE_CONTENT',
798
+ return {
799
+ type: EventType.TEXT_MESSAGE_CONTENT,
377
800
  messageId: aguiState.messageId,
378
801
  model: model || options.model,
379
- timestamp,
802
+ timestamp: Date.now(),
380
803
  delta: contentPart.text || '',
381
804
  content: accumulatedContent,
382
- })
805
+ }
383
806
  }
384
807
 
385
808
  if (contentPart.type === 'reasoning_text') {
@@ -391,14 +814,15 @@ export class OpenAICompatibleResponsesTextAdapter<
391
814
  if (!stepId) {
392
815
  stepId = generateId(this.name)
393
816
  }
394
- return asChunk({
395
- type: 'STEP_FINISHED',
817
+ return {
818
+ type: EventType.STEP_FINISHED,
819
+ stepName: stepId,
396
820
  stepId,
397
821
  model: model || options.model,
398
- timestamp,
822
+ timestamp: Date.now(),
399
823
  delta: contentPart.text || '',
400
824
  content: accumulatedReasoning,
401
- })
825
+ }
402
826
  }
403
827
  // Either a real refusal or an unknown content_part type. Surface
404
828
  // the part type in the error so unknown parts are debuggable
@@ -407,16 +831,15 @@ export class OpenAICompatibleResponsesTextAdapter<
407
831
  const message = isRefusal
408
832
  ? contentPart.refusal || 'Refused without explanation'
409
833
  : `Unsupported response content_part type: ${contentPart.type}`
410
- return asChunk({
411
- type: 'RUN_ERROR',
412
- runId: aguiState.runId,
834
+ const code = isRefusal ? 'refusal' : contentPart.type
835
+ return {
836
+ type: EventType.RUN_ERROR,
413
837
  model: model || options.model,
414
- timestamp,
415
- error: {
416
- message,
417
- code: isRefusal ? 'refusal' : contentPart.type,
418
- },
419
- })
838
+ timestamp: Date.now(),
839
+ message,
840
+ code,
841
+ error: { message, code },
842
+ }
420
843
  }
421
844
 
422
845
  // Capture model metadata from any of these events (created starts
@@ -451,12 +874,12 @@ export class OpenAICompatibleResponsesTextAdapter<
451
874
  chunk.type === 'response.incomplete'
452
875
  ) {
453
876
  if (hasEmittedTextMessageStart) {
454
- yield asChunk({
455
- type: 'TEXT_MESSAGE_END',
877
+ yield {
878
+ type: EventType.TEXT_MESSAGE_END,
456
879
  messageId: aguiState.messageId,
457
880
  model: chunk.response.model,
458
- timestamp,
459
- })
881
+ timestamp: Date.now(),
882
+ }
460
883
  hasEmittedTextMessageStart = false
461
884
  }
462
885
  // Coalesce error + incomplete_details into a single RUN_ERROR
@@ -469,23 +892,25 @@ export class OpenAICompatibleResponsesTextAdapter<
469
892
  ? 'Response failed'
470
893
  : 'Response ended incomplete')
471
894
  const errorCode =
472
- chunk.response.error?.code ||
473
- (chunk.response.incomplete_details ? 'incomplete' : undefined)
895
+ chunk.response.error?.code ??
896
+ (chunk.response.incomplete_details ? 'incomplete' : undefined) ??
897
+ undefined
474
898
  // Always emit RUN_ERROR for terminal failure events, even when the
475
899
  // upstream omitted both `error` and `incomplete_details`. Skipping
476
900
  // emission on a `response.incomplete` with no detail would let the
477
901
  // post-loop synthetic block silently coerce the run to a clean
478
902
  // `RUN_FINISHED { finishReason: 'stop' }` — masking the failure.
479
- yield asChunk({
480
- type: 'RUN_ERROR',
481
- runId: aguiState.runId,
903
+ yield {
904
+ type: EventType.RUN_ERROR,
482
905
  model: chunk.response.model,
483
- timestamp,
906
+ timestamp: Date.now(),
907
+ message: errorMessage,
908
+ ...(errorCode !== undefined && { code: errorCode }),
484
909
  error: {
485
910
  message: errorMessage,
486
911
  ...(errorCode !== undefined && { code: errorCode }),
487
912
  },
488
- })
913
+ }
489
914
  // RUN_ERROR is the terminal event for this run; stop processing
490
915
  // any further chunks the iterator might still deliver.
491
916
  runFinishedEmitted = true
@@ -506,25 +931,25 @@ export class OpenAICompatibleResponsesTextAdapter<
506
931
  // Emit TEXT_MESSAGE_START on first text content
507
932
  if (!hasEmittedTextMessageStart) {
508
933
  hasEmittedTextMessageStart = true
509
- yield asChunk({
510
- type: 'TEXT_MESSAGE_START',
934
+ yield {
935
+ type: EventType.TEXT_MESSAGE_START,
511
936
  messageId: aguiState.messageId,
512
937
  model: model || options.model,
513
- timestamp,
938
+ timestamp: Date.now(),
514
939
  role: 'assistant',
515
- })
940
+ }
516
941
  }
517
942
 
518
943
  accumulatedContent += textDelta
519
944
  hasStreamedContentDeltas = true
520
- yield asChunk({
521
- type: 'TEXT_MESSAGE_CONTENT',
945
+ yield {
946
+ type: EventType.TEXT_MESSAGE_CONTENT,
522
947
  messageId: aguiState.messageId,
523
948
  model: model || options.model,
524
- timestamp,
949
+ timestamp: Date.now(),
525
950
  delta: textDelta,
526
951
  content: accumulatedContent,
527
- })
952
+ }
528
953
  }
529
954
  }
530
955
 
@@ -543,25 +968,28 @@ export class OpenAICompatibleResponsesTextAdapter<
543
968
  if (!hasEmittedStepStarted) {
544
969
  hasEmittedStepStarted = true
545
970
  stepId = generateId(this.name)
546
- yield asChunk({
547
- type: 'STEP_STARTED',
971
+ yield {
972
+ type: EventType.STEP_STARTED,
973
+ stepName: stepId,
548
974
  stepId,
549
975
  model: model || options.model,
550
- timestamp,
976
+ timestamp: Date.now(),
551
977
  stepType: 'thinking',
552
- })
978
+ }
553
979
  }
554
980
 
555
981
  accumulatedReasoning += reasoningDelta
556
982
  hasStreamedReasoningDeltas = true
557
- yield asChunk({
558
- type: 'STEP_FINISHED',
559
- stepId: stepId || generateId(this.name),
983
+ const fallbackStepId = stepId || generateId(this.name)
984
+ yield {
985
+ type: EventType.STEP_FINISHED,
986
+ stepName: fallbackStepId,
987
+ stepId: fallbackStepId,
560
988
  model: model || options.model,
561
- timestamp,
989
+ timestamp: Date.now(),
562
990
  delta: reasoningDelta,
563
991
  content: accumulatedReasoning,
564
- })
992
+ }
565
993
  }
566
994
  }
567
995
 
@@ -579,25 +1007,28 @@ export class OpenAICompatibleResponsesTextAdapter<
579
1007
  if (!hasEmittedStepStarted) {
580
1008
  hasEmittedStepStarted = true
581
1009
  stepId = generateId(this.name)
582
- yield asChunk({
583
- type: 'STEP_STARTED',
1010
+ yield {
1011
+ type: EventType.STEP_STARTED,
1012
+ stepName: stepId,
584
1013
  stepId,
585
1014
  model: model || options.model,
586
- timestamp,
1015
+ timestamp: Date.now(),
587
1016
  stepType: 'thinking',
588
- })
1017
+ }
589
1018
  }
590
1019
 
591
1020
  accumulatedReasoning += summaryDelta
592
1021
  hasStreamedReasoningDeltas = true
593
- yield asChunk({
594
- type: 'STEP_FINISHED',
595
- stepId: stepId || generateId(this.name),
1022
+ const fallbackStepId = stepId || generateId(this.name)
1023
+ yield {
1024
+ type: EventType.STEP_FINISHED,
1025
+ stepName: fallbackStepId,
1026
+ stepId: fallbackStepId,
596
1027
  model: model || options.model,
597
- timestamp,
1028
+ timestamp: Date.now(),
598
1029
  delta: summaryDelta,
599
1030
  content: accumulatedReasoning,
600
- })
1031
+ }
601
1032
  }
602
1033
  }
603
1034
 
@@ -610,25 +1041,26 @@ export class OpenAICompatibleResponsesTextAdapter<
610
1041
  !hasEmittedTextMessageStart
611
1042
  ) {
612
1043
  hasEmittedTextMessageStart = true
613
- yield asChunk({
614
- type: 'TEXT_MESSAGE_START',
1044
+ yield {
1045
+ type: EventType.TEXT_MESSAGE_START,
615
1046
  messageId: aguiState.messageId,
616
1047
  model: model || options.model,
617
- timestamp,
1048
+ timestamp: Date.now(),
618
1049
  role: 'assistant',
619
- })
1050
+ }
620
1051
  }
621
1052
  // Emit STEP_STARTED if this is reasoning content
622
1053
  if (contentPart.type === 'reasoning_text' && !hasEmittedStepStarted) {
623
1054
  hasEmittedStepStarted = true
624
1055
  stepId = generateId(this.name)
625
- yield asChunk({
626
- type: 'STEP_STARTED',
1056
+ yield {
1057
+ type: EventType.STEP_STARTED,
1058
+ stepName: stepId,
627
1059
  stepId,
628
1060
  model: model || options.model,
629
- timestamp,
1061
+ timestamp: Date.now(),
630
1062
  stepType: 'thinking',
631
- })
1063
+ }
632
1064
  }
633
1065
  // Mark whichever stream we just emitted into so a subsequent
634
1066
  // `content_part.done` doesn't duplicate the same text. Without
@@ -668,6 +1100,40 @@ export class OpenAICompatibleResponsesTextAdapter<
668
1100
  continue
669
1101
  }
670
1102
 
1103
+ // Upstreams that emit `content_part.done` without any preceding
1104
+ // deltas (or `content_part.added`) still need a START event before
1105
+ // CONTENT — otherwise consumers tracking start/end pairs see content
1106
+ // without a start and never see an end. Emit the lifecycle opener
1107
+ // for whichever stream this content_part belongs to before yielding
1108
+ // the CONTENT chunk; the post-loop block emits the matching END.
1109
+ if (
1110
+ contentPart.type === 'output_text' &&
1111
+ !hasEmittedTextMessageStart
1112
+ ) {
1113
+ hasEmittedTextMessageStart = true
1114
+ yield {
1115
+ type: EventType.TEXT_MESSAGE_START,
1116
+ messageId: aguiState.messageId,
1117
+ model: model || options.model,
1118
+ timestamp: Date.now(),
1119
+ role: 'assistant',
1120
+ }
1121
+ } else if (
1122
+ contentPart.type === 'reasoning_text' &&
1123
+ !hasEmittedStepStarted
1124
+ ) {
1125
+ hasEmittedStepStarted = true
1126
+ stepId = generateId(this.name)
1127
+ yield {
1128
+ type: EventType.STEP_STARTED,
1129
+ stepName: stepId,
1130
+ stepId,
1131
+ model: model || options.model,
1132
+ timestamp: Date.now(),
1133
+ stepType: 'thinking',
1134
+ }
1135
+ }
1136
+
671
1137
  // Only emit if we haven't been streaming deltas (e.g., for non-streaming responses)
672
1138
  const doneChunk = handleContentPart(contentPart)
673
1139
  yield doneChunk
@@ -682,27 +1148,35 @@ export class OpenAICompatibleResponsesTextAdapter<
682
1148
  const item = chunk.item
683
1149
  if (item.type === 'function_call' && item.id) {
684
1150
  const existing = toolCallMetadata.get(item.id)
685
- // Only emit TOOL_CALL_START on the FIRST output_item.added for
686
- // an item id. A duplicate emission (which can happen on retried
687
- // streams or replay) would violate AG-UI's start-once contract.
688
- if (!existing?.started) {
689
- if (!existing) {
690
- toolCallMetadata.set(item.id, {
691
- index: chunk.output_index,
692
- name: item.name || '',
693
- started: false,
694
- })
695
- }
696
- yield asChunk({
697
- type: 'TOOL_CALL_START',
1151
+ // Track the item as soon as we see it so subsequent arg deltas
1152
+ // aren't logged as orphans, but only emit TOOL_CALL_START when
1153
+ // both id AND name are populated. Emitting START with an empty
1154
+ // name would propagate into TOOL_CALL_END (which reads the same
1155
+ // metadata) and route the tool call to whatever name happens to
1156
+ // match `''` downstream — a silent misroute.
1157
+ if (!existing) {
1158
+ toolCallMetadata.set(item.id, {
1159
+ index: chunk.output_index,
1160
+ name: item.name || '',
1161
+ started: false,
1162
+ })
1163
+ } else if (!existing.name && item.name) {
1164
+ // A later output_item.added for the same id finally carries
1165
+ // the name. Update so the gated emission below can fire.
1166
+ existing.name = item.name
1167
+ }
1168
+ const metadata = toolCallMetadata.get(item.id)!
1169
+ if (!metadata.started && metadata.name) {
1170
+ yield {
1171
+ type: EventType.TOOL_CALL_START,
698
1172
  toolCallId: item.id,
699
- toolCallName: item.name || '',
700
- toolName: item.name || '',
1173
+ toolCallName: metadata.name,
1174
+ toolName: metadata.name,
701
1175
  model: model || options.model,
702
- timestamp,
1176
+ timestamp: Date.now(),
703
1177
  index: chunk.output_index,
704
- })
705
- toolCallMetadata.get(item.id)!.started = true
1178
+ }
1179
+ metadata.started = true
706
1180
  }
707
1181
  }
708
1182
  }
@@ -735,13 +1209,13 @@ export class OpenAICompatibleResponsesTextAdapter<
735
1209
  )
736
1210
  continue
737
1211
  }
738
- yield asChunk({
739
- type: 'TOOL_CALL_ARGS',
1212
+ yield {
1213
+ type: EventType.TOOL_CALL_ARGS,
740
1214
  toolCallId: chunk.item_id,
741
1215
  model: model || options.model,
742
- timestamp,
1216
+ timestamp: Date.now(),
743
1217
  delta: chunk.delta,
744
- })
1218
+ }
745
1219
  }
746
1220
 
747
1221
  if (chunk.type === 'response.function_call_arguments.done') {
@@ -749,13 +1223,19 @@ export class OpenAICompatibleResponsesTextAdapter<
749
1223
 
750
1224
  // Get the function name from metadata (captured in output_item.added)
751
1225
  const metadata = toolCallMetadata.get(item_id)
752
- // Skip TOOL_CALL_END for items whose start was never emitted (no
753
- // matching `output_item.added`). Emitting END without START would
754
- // produce an unbalanced AG-UI lifecycle event downstream consumers
755
- // can't pair.
1226
+ // If the matching START was never emitted (the upstream sent an
1227
+ // `output_item.added` without a name and no later event has filled
1228
+ // it in yet), defer END until `output_item.done` or
1229
+ // `response.completed` can backfill the name. We stash the raw
1230
+ // arguments so the late emission has them. Emitting END without
1231
+ // START would produce an unbalanced AG-UI lifecycle event
1232
+ // downstream consumers can't pair.
756
1233
  if (!metadata?.started) {
1234
+ if (metadata) {
1235
+ metadata.pendingArguments = chunk.arguments
1236
+ }
757
1237
  options.logger.errors(
758
- `${this.name}.processStreamChunks orphan function_call_arguments.done`,
1238
+ `${this.name}.processStreamChunks deferring function_call_arguments.done — TOOL_CALL_START not yet emitted (waiting for name)`,
759
1239
  {
760
1240
  source: `${this.name}.processStreamChunks`,
761
1241
  toolCallId: item_id,
@@ -764,7 +1244,12 @@ export class OpenAICompatibleResponsesTextAdapter<
764
1244
  )
765
1245
  continue
766
1246
  }
1247
+ // The output_item.done backstop may have already emitted END (when
1248
+ // it arrived before args.done with a populated item.arguments).
1249
+ // Skip so we never produce a duplicate close for the same id.
1250
+ if (metadata.ended) continue
767
1251
  const name = metadata.name || ''
1252
+ metadata.ended = true
768
1253
 
769
1254
  // Parse arguments. Surface parse failures via the logger so a
770
1255
  // model emitting malformed JSON is debuggable instead of silently
@@ -792,26 +1277,177 @@ export class OpenAICompatibleResponsesTextAdapter<
792
1277
  }
793
1278
  }
794
1279
 
795
- yield asChunk({
796
- type: 'TOOL_CALL_END',
1280
+ yield {
1281
+ type: EventType.TOOL_CALL_END,
797
1282
  toolCallId: item_id,
798
1283
  toolCallName: name,
799
1284
  toolName: name,
800
1285
  model: model || options.model,
801
- timestamp,
1286
+ timestamp: Date.now(),
802
1287
  input: parsedInput,
803
- })
1288
+ }
1289
+ }
1290
+
1291
+ // `output_item.done` is the last point at which a function_call's
1292
+ // name is guaranteed to be on the wire — it carries the fully-formed
1293
+ // ResponseFunctionToolCall. Use it as a backstop to recover any
1294
+ // tool call whose name was missing from `output_item.added` (and
1295
+ // whose START + END therefore never fired).
1296
+ if (chunk.type === 'response.output_item.done') {
1297
+ const item = chunk.item
1298
+ if (item.type === 'function_call' && item.id) {
1299
+ const metadata = toolCallMetadata.get(item.id) ?? {
1300
+ index: chunk.output_index,
1301
+ name: item.name || '',
1302
+ started: false,
1303
+ }
1304
+ if (!toolCallMetadata.has(item.id)) {
1305
+ toolCallMetadata.set(item.id, metadata)
1306
+ } else if (!metadata.name && item.name) {
1307
+ metadata.name = item.name
1308
+ }
1309
+ // Emit gated START if we now have a name and never started.
1310
+ if (!metadata.started && metadata.name) {
1311
+ yield {
1312
+ type: EventType.TOOL_CALL_START,
1313
+ toolCallId: item.id,
1314
+ toolCallName: metadata.name,
1315
+ toolName: metadata.name,
1316
+ model: model || options.model,
1317
+ timestamp: Date.now(),
1318
+ index: metadata.index,
1319
+ }
1320
+ metadata.started = true
1321
+ }
1322
+ // Emit END if we have args (either from a previously-deferred
1323
+ // args.done OR from item.arguments) and haven't already ended.
1324
+ const rawArgs =
1325
+ typeof item.arguments === 'string' && item.arguments.length > 0
1326
+ ? item.arguments
1327
+ : metadata.pendingArguments
1328
+ if (metadata.started && !metadata.ended && rawArgs !== undefined) {
1329
+ const name = metadata.name || ''
1330
+ let parsedInput: unknown = {}
1331
+ if (rawArgs) {
1332
+ try {
1333
+ const parsed = JSON.parse(rawArgs)
1334
+ parsedInput =
1335
+ parsed && typeof parsed === 'object' ? parsed : {}
1336
+ } catch (parseError) {
1337
+ options.logger.errors(
1338
+ `${this.name}.processStreamChunks tool-args JSON parse failed (output_item.done backfill)`,
1339
+ {
1340
+ error: toRunErrorPayload(
1341
+ parseError,
1342
+ `tool ${name} (${item.id}) returned malformed JSON arguments`,
1343
+ ),
1344
+ source: `${this.name}.processStreamChunks`,
1345
+ toolCallId: item.id,
1346
+ toolName: name,
1347
+ rawArguments: rawArgs,
1348
+ },
1349
+ )
1350
+ parsedInput = {}
1351
+ }
1352
+ }
1353
+ yield {
1354
+ type: EventType.TOOL_CALL_END,
1355
+ toolCallId: item.id,
1356
+ toolCallName: name,
1357
+ toolName: name,
1358
+ model: model || options.model,
1359
+ timestamp: Date.now(),
1360
+ input: parsedInput,
1361
+ }
1362
+ metadata.ended = true
1363
+ metadata.pendingArguments = undefined
1364
+ }
1365
+ }
804
1366
  }
805
1367
 
806
1368
  if (chunk.type === 'response.completed') {
1369
+ // Final backstop for function_call lifecycle: if a function_call
1370
+ // appears in `response.output[]` but was never matched by an
1371
+ // output_item.added/done with a name, recover the missing START
1372
+ // (and END if args were pending). Without this, a tool call could
1373
+ // be silently dropped from the AG-UI stream while `hasFunctionCalls`
1374
+ // below still routes the run's finishReason to 'tool_calls' —
1375
+ // leaving consumers waiting for tool results they never saw start.
1376
+ for (const item of chunk.response.output) {
1377
+ if (item.type !== 'function_call' || !item.id) continue
1378
+ const metadata = toolCallMetadata.get(item.id) ?? {
1379
+ index: 0,
1380
+ name: item.name || '',
1381
+ started: false,
1382
+ }
1383
+ if (!toolCallMetadata.has(item.id)) {
1384
+ toolCallMetadata.set(item.id, metadata)
1385
+ } else if (!metadata.name && item.name) {
1386
+ metadata.name = item.name
1387
+ }
1388
+ if (!metadata.started && metadata.name) {
1389
+ yield {
1390
+ type: EventType.TOOL_CALL_START,
1391
+ toolCallId: item.id,
1392
+ toolCallName: metadata.name,
1393
+ toolName: metadata.name,
1394
+ model: model || options.model,
1395
+ timestamp: Date.now(),
1396
+ index: metadata.index,
1397
+ }
1398
+ metadata.started = true
1399
+ }
1400
+ const rawArgs =
1401
+ typeof item.arguments === 'string' && item.arguments.length > 0
1402
+ ? item.arguments
1403
+ : metadata.pendingArguments
1404
+ if (metadata.started && !metadata.ended) {
1405
+ const name = metadata.name || ''
1406
+ let parsedInput: unknown = {}
1407
+ if (rawArgs) {
1408
+ try {
1409
+ const parsed = JSON.parse(rawArgs)
1410
+ parsedInput =
1411
+ parsed && typeof parsed === 'object' ? parsed : {}
1412
+ } catch (parseError) {
1413
+ options.logger.errors(
1414
+ `${this.name}.processStreamChunks tool-args JSON parse failed (response.completed backfill)`,
1415
+ {
1416
+ error: toRunErrorPayload(
1417
+ parseError,
1418
+ `tool ${name} (${item.id}) returned malformed JSON arguments`,
1419
+ ),
1420
+ source: `${this.name}.processStreamChunks`,
1421
+ toolCallId: item.id,
1422
+ toolName: name,
1423
+ rawArguments: rawArgs,
1424
+ },
1425
+ )
1426
+ parsedInput = {}
1427
+ }
1428
+ }
1429
+ yield {
1430
+ type: EventType.TOOL_CALL_END,
1431
+ toolCallId: item.id,
1432
+ toolCallName: name,
1433
+ toolName: name,
1434
+ model: model || options.model,
1435
+ timestamp: Date.now(),
1436
+ input: parsedInput,
1437
+ }
1438
+ metadata.ended = true
1439
+ metadata.pendingArguments = undefined
1440
+ }
1441
+ }
1442
+
807
1443
  // Emit TEXT_MESSAGE_END if we had text content
808
1444
  if (hasEmittedTextMessageStart) {
809
- yield asChunk({
810
- type: 'TEXT_MESSAGE_END',
1445
+ yield {
1446
+ type: EventType.TEXT_MESSAGE_END,
811
1447
  messageId: aguiState.messageId,
812
1448
  model: model || options.model,
813
- timestamp,
814
- })
1449
+ timestamp: Date.now(),
1450
+ }
815
1451
  hasEmittedTextMessageStart = false
816
1452
  }
817
1453
 
@@ -819,43 +1455,62 @@ export class OpenAICompatibleResponsesTextAdapter<
819
1455
  // Otherwise surface incomplete_details.reason when present so
820
1456
  // callers can distinguish length-limit / content-filter cutoffs
821
1457
  // from a clean stop, mirroring the chat-completions adapter.
1458
+ // The Responses API's incomplete_details.reason ('max_output_tokens'
1459
+ // | 'content_filter') maps to the AG-UI finishReason vocabulary:
1460
+ // max_output_tokens → 'length', content_filter → 'content_filter'.
822
1461
  const hasFunctionCalls = chunk.response.output.some(
823
1462
  (item: unknown) =>
824
1463
  (item as { type: string }).type === 'function_call',
825
1464
  )
826
- const finishReason: string = hasFunctionCalls
1465
+ const incompleteReason = chunk.response.incomplete_details?.reason
1466
+ const finishReason:
1467
+ | 'tool_calls'
1468
+ | 'length'
1469
+ | 'content_filter'
1470
+ | 'stop' = hasFunctionCalls
827
1471
  ? 'tool_calls'
828
- : (chunk.response.incomplete_details?.reason ?? 'stop')
829
-
830
- yield asChunk({
831
- type: 'RUN_FINISHED',
1472
+ : incompleteReason === 'max_output_tokens'
1473
+ ? 'length'
1474
+ : incompleteReason === 'content_filter'
1475
+ ? 'content_filter'
1476
+ : 'stop'
1477
+
1478
+ yield {
1479
+ type: EventType.RUN_FINISHED,
832
1480
  runId: aguiState.runId,
1481
+ threadId: aguiState.threadId,
833
1482
  model: model || options.model,
834
- timestamp,
1483
+ timestamp: Date.now(),
835
1484
  usage: {
836
1485
  promptTokens: chunk.response.usage?.input_tokens || 0,
837
1486
  completionTokens: chunk.response.usage?.output_tokens || 0,
838
1487
  totalTokens: chunk.response.usage?.total_tokens || 0,
839
1488
  },
840
1489
  finishReason,
841
- })
1490
+ }
842
1491
  runFinishedEmitted = true
843
1492
  }
844
1493
 
845
1494
  if (chunk.type === 'error') {
846
- yield asChunk({
847
- type: 'RUN_ERROR',
848
- runId: aguiState.runId,
1495
+ yield {
1496
+ type: EventType.RUN_ERROR,
849
1497
  model: model || options.model,
850
- timestamp,
1498
+ timestamp: Date.now(),
1499
+ message: chunk.message,
1500
+ code: chunk.code ?? undefined,
851
1501
  error: {
852
1502
  message: chunk.message,
853
1503
  code: chunk.code ?? undefined,
854
1504
  },
855
- })
1505
+ }
856
1506
  // RUN_ERROR is terminal — don't let the synthetic RUN_FINISHED
857
- // block fire after a top-level stream error event.
1507
+ // block fire after a top-level stream error event, and stop
1508
+ // processing further chunks so no in-flight lifecycle events
1509
+ // (TEXT_MESSAGE_CONTENT, TOOL_CALL_*) leak past the terminal
1510
+ // error. Mirrors the `response.failed` / `response.incomplete`
1511
+ // branches above which return after their RUN_ERROR emission.
858
1512
  runFinishedEmitted = true
1513
+ return
859
1514
  }
860
1515
  }
861
1516
 
@@ -865,21 +1520,22 @@ export class OpenAICompatibleResponsesTextAdapter<
865
1520
  // see a terminal event for every started run.
866
1521
  if (!runFinishedEmitted && aguiState.hasEmittedRunStarted) {
867
1522
  if (hasEmittedTextMessageStart) {
868
- yield asChunk({
869
- type: 'TEXT_MESSAGE_END',
1523
+ yield {
1524
+ type: EventType.TEXT_MESSAGE_END,
870
1525
  messageId: aguiState.messageId,
871
1526
  model: model || options.model,
872
- timestamp,
873
- })
1527
+ timestamp: Date.now(),
1528
+ }
874
1529
  }
875
- yield asChunk({
876
- type: 'RUN_FINISHED',
1530
+ yield {
1531
+ type: EventType.RUN_FINISHED,
877
1532
  runId: aguiState.runId,
1533
+ threadId: aguiState.threadId,
878
1534
  model: model || options.model,
879
- timestamp,
1535
+ timestamp: Date.now(),
880
1536
  usage: undefined,
881
1537
  finishReason: toolCallMetadata.size > 0 ? 'tool_calls' : 'stop',
882
- })
1538
+ }
883
1539
  }
884
1540
  } catch (error: unknown) {
885
1541
  // Narrow before logging: raw SDK errors can carry request metadata
@@ -892,13 +1548,14 @@ export class OpenAICompatibleResponsesTextAdapter<
892
1548
  error: errorPayload,
893
1549
  source: `${this.name}.processStreamChunks`,
894
1550
  })
895
- yield asChunk({
896
- type: 'RUN_ERROR',
897
- runId: aguiState.runId,
1551
+ yield {
1552
+ type: EventType.RUN_ERROR,
898
1553
  model: options.model,
899
- timestamp,
1554
+ timestamp: Date.now(),
1555
+ message: errorPayload.message,
1556
+ code: errorPayload.code,
900
1557
  error: errorPayload,
901
- })
1558
+ }
902
1559
  }
903
1560
  }
904
1561
 
@@ -908,7 +1565,7 @@ export class OpenAICompatibleResponsesTextAdapter<
908
1565
  */
909
1566
  protected mapOptionsToRequest(
910
1567
  options: TextOptions<TProviderOptions>,
911
- ): Omit<OpenAI_SDK.Responses.ResponseCreateParams, 'stream'> {
1568
+ ): Omit<ResponseCreateParams, 'stream'> {
912
1569
  const input = this.convertMessagesToInput(options.messages)
913
1570
 
914
1571
  const tools = options.tools
@@ -961,8 +1618,8 @@ export class OpenAICompatibleResponsesTextAdapter<
961
1618
  */
962
1619
  protected convertMessagesToInput(
963
1620
  messages: Array<ModelMessage>,
964
- ): Responses.ResponseInput {
965
- const result: Responses.ResponseInput = []
1621
+ ): ResponseInput {
1622
+ const result: ResponseInput = []
966
1623
 
967
1624
  for (const message of messages) {
968
1625
  // Handle tool messages - convert to FunctionToolCallOutput
@@ -1016,7 +1673,7 @@ export class OpenAICompatibleResponsesTextAdapter<
1016
1673
 
1017
1674
  // Handle user messages (default case) — support multimodal content
1018
1675
  const contentParts = this.normalizeContent(message.content)
1019
- const inputContent: Array<Responses.ResponseInputContent> = []
1676
+ const inputContent: Array<ResponseInputContent> = []
1020
1677
 
1021
1678
  for (const part of contentParts) {
1022
1679
  inputContent.push(this.convertContentPartToInput(part))
@@ -1049,9 +1706,7 @@ export class OpenAICompatibleResponsesTextAdapter<
1049
1706
  * Handles text, image, and audio content parts.
1050
1707
  * Override this in subclasses for additional content types or provider-specific metadata.
1051
1708
  */
1052
- protected convertContentPartToInput(
1053
- part: ContentPart,
1054
- ): Responses.ResponseInputContent {
1709
+ protected convertContentPartToInput(part: ContentPart): ResponseInputContent {
1055
1710
  switch (part.type) {
1056
1711
  case 'text':
1057
1712
  return {