@tanstack/openai-base 0.2.1 → 0.3.1

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 +480 -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 +661 -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 +605 -117
  39. package/src/adapters/chat-completions-tool-converter.ts +9 -5
  40. package/src/adapters/responses-text.ts +869 -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,46 +1,40 @@
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 { convertToolsToChatCompletionsFormat } from './chat-completions-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 {
14
+ ChatCompletionChunk,
15
+ ChatCompletionContentPart,
16
+ ChatCompletionCreateParamsStreaming,
17
+ ChatCompletionMessageParam,
18
+ } from 'openai/resources/chat/completions/completions'
13
19
  import type {
14
20
  ContentPart,
15
21
  DefaultMessageMetadataByModality,
16
22
  Modality,
17
23
  ModelMessage,
24
+ RunFinishedEvent,
18
25
  StreamChunk,
19
26
  TextOptions,
20
27
  } from '@tanstack/ai'
21
- import type { OpenAICompatibleClientConfig } from '../types/config'
22
-
23
- /** Cast an event object to StreamChunk. Adapters construct events with string
24
- * literal types which are structurally compatible with the EventType enum. */
25
- const asChunk = (chunk: Record<string, unknown>) =>
26
- chunk as unknown as StreamChunk
27
28
 
28
29
  /**
29
- * OpenAI-compatible Chat Completions Text Adapter
30
- *
31
- * A generalized base class for providers that use the OpenAI Chat Completions API
32
- * (`/v1/chat/completions`). Providers like Grok, Groq, OpenRouter, and others can
33
- * extend this class and only need to:
34
- * - Set `baseURL` in the config
35
- * - Lock the generic type parameters to provider-specific types
36
- * - Override specific methods for quirks
37
- *
38
- * All methods that build requests or process responses are `protected` so subclasses
39
- * can override them.
30
+ * Shared implementation of the OpenAI Chat Completions API. Holds the
31
+ * stream-accumulator + AG-UI lifecycle logic and calls the OpenAI SDK
32
+ * directly. Subclasses (ai-openai, ai-grok, ai-groq) construct an OpenAI
33
+ * client with their provider-specific `baseURL` / headers and pass it in.
40
34
  */
41
- export class OpenAICompatibleChatCompletionsTextAdapter<
35
+ export abstract class OpenAIBaseChatCompletionsTextAdapter<
42
36
  TModel extends string,
43
- TProviderOptions extends Record<string, any> = Record<string, any>,
37
+ TProviderOptions extends Record<string, unknown> = Record<string, unknown>,
44
38
  TInputModalities extends ReadonlyArray<Modality> = ReadonlyArray<Modality>,
45
39
  TMessageMetadata extends DefaultMessageMetadataByModality =
46
40
  DefaultMessageMetadataByModality,
@@ -54,34 +48,33 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
54
48
  > {
55
49
  readonly kind = 'text' as const
56
50
  readonly name: string
51
+ protected client: OpenAI
57
52
 
58
- protected client: OpenAI_SDK
59
-
60
- constructor(
61
- config: OpenAICompatibleClientConfig,
62
- model: TModel,
63
- name: string = 'openai-compatible',
64
- ) {
53
+ constructor(model: TModel, name: string, client: OpenAI) {
65
54
  super({}, model)
66
55
  this.name = name
67
- this.client = createOpenAICompatibleClient(config)
56
+ this.client = client
68
57
  }
69
58
 
70
59
  async *chatStream(
71
60
  options: TextOptions<TProviderOptions>,
72
61
  ): AsyncIterable<StreamChunk> {
73
- const requestParams = this.mapOptionsToRequest(options)
74
- const timestamp = Date.now()
75
-
76
62
  // AG-UI lifecycle tracking (mutable state object for ESLint compatibility)
77
63
  const aguiState = {
78
64
  runId: generateId(this.name),
65
+ threadId: options.threadId ?? generateId(this.name),
79
66
  messageId: generateId(this.name),
80
- timestamp,
81
67
  hasEmittedRunStarted: false,
82
68
  }
83
69
 
84
70
  try {
71
+ // mapOptionsToRequest can throw (e.g. fail-loud guards in convertMessage
72
+ // for empty content or unsupported parts). Keep it inside the try so
73
+ // those failures surface as a single RUN_ERROR event, matching every
74
+ // other failure mode here — callers iterating chatStream then only need
75
+ // one error-handling path instead of both a try/catch around iteration
76
+ // and a RUN_ERROR handler.
77
+ const requestParams = this.mapOptionsToRequest(options)
85
78
  options.logger.request(
86
79
  `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`,
87
80
  { provider: this.name, model: this.model },
@@ -107,22 +100,25 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
107
100
  // Emit RUN_STARTED if not yet emitted
108
101
  if (!aguiState.hasEmittedRunStarted) {
109
102
  aguiState.hasEmittedRunStarted = true
110
- yield asChunk({
111
- type: 'RUN_STARTED',
103
+ yield {
104
+ type: EventType.RUN_STARTED,
112
105
  runId: aguiState.runId,
106
+ threadId: aguiState.threadId,
113
107
  model: options.model,
114
- timestamp,
115
- })
108
+ timestamp: Date.now(),
109
+ parentRunId: options.parentRunId,
110
+ }
116
111
  }
117
112
 
118
113
  // Emit AG-UI RUN_ERROR
119
- yield asChunk({
120
- type: 'RUN_ERROR',
121
- runId: aguiState.runId,
114
+ yield {
115
+ type: EventType.RUN_ERROR,
122
116
  model: options.model,
123
- timestamp,
117
+ timestamp: Date.now(),
118
+ message: errorPayload.message,
119
+ code: errorPayload.code,
124
120
  error: errorPayload,
125
- })
121
+ }
126
122
 
127
123
  options.logger.errors(`${this.name}.chatStream fatal`, {
128
124
  error: errorPayload,
@@ -181,8 +177,16 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
181
177
  extractRequestOptions(chatOptions.request),
182
178
  )
183
179
 
184
- // Extract text content from the response
185
- const rawText = response.choices[0]?.message.content || ''
180
+ // Extract text content from the response. Fail loud on empty content
181
+ // rather than letting it cascade into a JSON-parse error on '' — the
182
+ // root cause (the model returned no content for the structured request)
183
+ // is then visible in logs.
184
+ const rawText = response.choices[0]?.message.content
185
+ if (typeof rawText !== 'string' || rawText.length === 0) {
186
+ throw new Error(
187
+ `${this.name}.structuredOutput: response contained no content`,
188
+ )
189
+ }
186
190
 
187
191
  // Parse the JSON response
188
192
  let parsed: unknown
@@ -195,8 +199,10 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
195
199
  }
196
200
 
197
201
  // Transform null values to undefined to match original Zod schema expectations
198
- // Provider returns null for optional fields we made nullable in the schema
199
- const transformed = transformNullsToUndefined(parsed)
202
+ // Provider returns null for optional fields we made nullable in the schema.
203
+ // Subclasses can override `transformStructuredOutput` to skip this — e.g.
204
+ // OpenRouter historically passed nulls through unchanged.
205
+ const transformed = this.transformStructuredOutput(parsed)
200
206
 
201
207
  return {
202
208
  data: transformed,
@@ -213,6 +219,338 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
213
219
  }
214
220
  }
215
221
 
222
+ /**
223
+ * Stream structured output. Single Chat Completions request with
224
+ * `response_format: json_schema` + `stream: true`. Emits the standard
225
+ * AG-UI lifecycle (`RUN_STARTED` → `REASONING_*?` → `TEXT_MESSAGE_*`
226
+ * carrying raw JSON deltas → terminal `CUSTOM 'structured-output.complete'`
227
+ * → `RUN_FINISHED`). Subclasses use the same SDK-call / reasoning /
228
+ * structured-output-transform hooks as `chatStream` / `structuredOutput` —
229
+ * no per-subclass override should be needed.
230
+ */
231
+ async *structuredOutputStream(
232
+ options: StructuredOutputOptions<TProviderOptions>,
233
+ ): AsyncIterable<StreamChunk> {
234
+ const { chatOptions, outputSchema } = options
235
+ const requestParams = this.mapOptionsToRequest(chatOptions)
236
+
237
+ const jsonSchema = this.makeStructuredOutputCompatible(
238
+ outputSchema,
239
+ outputSchema.required,
240
+ )
241
+
242
+ const timestamp = Date.now()
243
+ const aguiState = {
244
+ runId: generateId(this.name),
245
+ threadId: chatOptions.threadId ?? generateId(this.name),
246
+ messageId: generateId(this.name),
247
+ timestamp,
248
+ hasEmittedRunStarted: false,
249
+ }
250
+
251
+ let accumulatedContent = ''
252
+ let accumulatedReasoning = ''
253
+ let hasEmittedTextMessageStart = false
254
+ let reasoningMessageId: string | undefined
255
+ let hasClosedReasoning = false
256
+ let stepId: string | undefined
257
+ let lastModel: string | undefined
258
+ let lastUsage:
259
+ | OpenAI.Chat.Completions.ChatCompletionChunk['usage']
260
+ | undefined
261
+
262
+ const closeReasoningLifecycle = function* (this: {
263
+ name: string
264
+ }): Generator<StreamChunk> {
265
+ if (reasoningMessageId && !hasClosedReasoning) {
266
+ hasClosedReasoning = true
267
+ yield {
268
+ type: EventType.REASONING_MESSAGE_END,
269
+ messageId: reasoningMessageId,
270
+ model: lastModel || chatOptions.model,
271
+ timestamp,
272
+ }
273
+ yield {
274
+ type: EventType.REASONING_END,
275
+ messageId: reasoningMessageId,
276
+ model: lastModel || chatOptions.model,
277
+ timestamp,
278
+ }
279
+ if (stepId) {
280
+ yield {
281
+ type: EventType.STEP_FINISHED,
282
+ stepName: stepId,
283
+ stepId,
284
+ model: lastModel || chatOptions.model,
285
+ timestamp,
286
+ content: accumulatedReasoning,
287
+ }
288
+ }
289
+ }
290
+ }.bind(this)
291
+
292
+ try {
293
+ // Strip stream_options + tools from the base request. Structured output
294
+ // sends `response_format: json_schema` and doesn't carry tools — keeping
295
+ // them in the request can confuse strict-mode validation upstream.
296
+ const {
297
+ stream_options: _so,
298
+ stream: _s,
299
+ tools: _t,
300
+ ...cleanParams
301
+ } = requestParams
302
+
303
+ chatOptions.logger.request(
304
+ `activity=structuredOutputStream provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`,
305
+ { provider: this.name, model: this.model },
306
+ )
307
+
308
+ const stream = await this.client.chat.completions.create(
309
+ {
310
+ ...cleanParams,
311
+ stream: true,
312
+ stream_options: { include_usage: true },
313
+ response_format: {
314
+ type: 'json_schema',
315
+ json_schema: {
316
+ name: 'structured_output',
317
+ schema: jsonSchema,
318
+ strict: true,
319
+ },
320
+ },
321
+ },
322
+ extractRequestOptions(chatOptions.request),
323
+ )
324
+
325
+ for await (const chunk of stream) {
326
+ const choiceForLog = chunk.choices[0]
327
+ chatOptions.logger.provider(
328
+ `provider=${this.name} finish_reason=${choiceForLog?.finish_reason ?? 'none'} hasContent=${!!choiceForLog?.delta.content} hasUsage=${!!chunk.usage}`,
329
+ { provider: this.name, model: chunk.model },
330
+ )
331
+
332
+ if (chunk.model) lastModel = chunk.model
333
+
334
+ // Usage may arrive on a chunk with empty `choices` (OpenAI's
335
+ // include_usage terminal chunk) or piggybacked on a finish chunk
336
+ // (`x_groq.usage` on Groq). Capture from either independent of
337
+ // choices[0].
338
+ const usage =
339
+ chunk.usage ??
340
+ (chunk as { x_groq?: { usage?: typeof chunk.usage } }).x_groq?.usage
341
+ if (usage) lastUsage = usage
342
+
343
+ if (!aguiState.hasEmittedRunStarted) {
344
+ aguiState.hasEmittedRunStarted = true
345
+ yield {
346
+ type: EventType.RUN_STARTED,
347
+ runId: aguiState.runId,
348
+ threadId: aguiState.threadId,
349
+ model: chunk.model || chatOptions.model,
350
+ timestamp,
351
+ parentRunId: chatOptions.parentRunId,
352
+ }
353
+ }
354
+
355
+ // Reasoning (via the extractReasoning hook — same hook as chatStream).
356
+ const reasoning = this.extractReasoning(chunk)
357
+ if (reasoning && reasoning.text) {
358
+ if (!reasoningMessageId) {
359
+ reasoningMessageId = generateId(this.name)
360
+ stepId = generateId(this.name)
361
+ yield {
362
+ type: EventType.REASONING_START,
363
+ messageId: reasoningMessageId,
364
+ model: chunk.model || chatOptions.model,
365
+ timestamp,
366
+ }
367
+ yield {
368
+ type: EventType.REASONING_MESSAGE_START,
369
+ messageId: reasoningMessageId,
370
+ role: 'reasoning' as const,
371
+ model: chunk.model || chatOptions.model,
372
+ timestamp,
373
+ }
374
+ yield {
375
+ type: EventType.STEP_STARTED,
376
+ stepName: stepId,
377
+ stepId,
378
+ model: chunk.model || chatOptions.model,
379
+ timestamp,
380
+ stepType: 'thinking',
381
+ }
382
+ }
383
+ accumulatedReasoning += reasoning.text
384
+ yield {
385
+ type: EventType.REASONING_MESSAGE_CONTENT,
386
+ messageId: reasoningMessageId,
387
+ delta: reasoning.text,
388
+ model: chunk.model || chatOptions.model,
389
+ timestamp,
390
+ }
391
+ }
392
+
393
+ const choice = chunk.choices[0]
394
+ if (!choice) continue
395
+
396
+ const deltaContent = choice.delta.content
397
+ if (deltaContent) {
398
+ yield* closeReasoningLifecycle()
399
+
400
+ if (!hasEmittedTextMessageStart) {
401
+ hasEmittedTextMessageStart = true
402
+ yield {
403
+ type: EventType.TEXT_MESSAGE_START,
404
+ messageId: aguiState.messageId,
405
+ model: chunk.model || chatOptions.model,
406
+ timestamp,
407
+ role: 'assistant',
408
+ }
409
+ }
410
+
411
+ accumulatedContent += deltaContent
412
+
413
+ yield {
414
+ type: EventType.TEXT_MESSAGE_CONTENT,
415
+ messageId: aguiState.messageId,
416
+ model: chunk.model || chatOptions.model,
417
+ timestamp,
418
+ delta: deltaContent,
419
+ content: accumulatedContent,
420
+ }
421
+ }
422
+ }
423
+
424
+ // Finalisation: close any open lifecycle, parse + validate, emit
425
+ // terminal events. This block always runs unless the loop threw — abort
426
+ // and SDK errors land in the catch block below.
427
+ yield* closeReasoningLifecycle()
428
+
429
+ if (hasEmittedTextMessageStart) {
430
+ yield {
431
+ type: EventType.TEXT_MESSAGE_END,
432
+ messageId: aguiState.messageId,
433
+ model: lastModel || chatOptions.model,
434
+ timestamp,
435
+ }
436
+ }
437
+
438
+ if (accumulatedContent.length === 0) {
439
+ yield {
440
+ type: EventType.RUN_ERROR,
441
+ runId: aguiState.runId,
442
+ model: lastModel || chatOptions.model,
443
+ timestamp,
444
+ message: `${this.name}.structuredOutputStream: response contained no content`,
445
+ code: 'empty-response',
446
+ error: {
447
+ message: `${this.name}.structuredOutputStream: response contained no content`,
448
+ code: 'empty-response',
449
+ },
450
+ }
451
+ return
452
+ }
453
+
454
+ let parsed: unknown
455
+ try {
456
+ parsed = JSON.parse(accumulatedContent)
457
+ } catch {
458
+ yield {
459
+ type: EventType.RUN_ERROR,
460
+ runId: aguiState.runId,
461
+ model: lastModel || chatOptions.model,
462
+ timestamp,
463
+ message: `Failed to parse structured output as JSON. Content: ${accumulatedContent.slice(0, 200)}${accumulatedContent.length > 200 ? '...' : ''}`,
464
+ code: 'parse-error',
465
+ error: {
466
+ message: 'Failed to parse structured output as JSON',
467
+ code: 'parse-error',
468
+ },
469
+ }
470
+ return
471
+ }
472
+
473
+ const transformed = this.transformStructuredOutput(parsed)
474
+
475
+ yield {
476
+ type: EventType.CUSTOM,
477
+ name: 'structured-output.complete',
478
+ value: {
479
+ object: transformed,
480
+ raw: accumulatedContent,
481
+ ...(accumulatedReasoning ? { reasoning: accumulatedReasoning } : {}),
482
+ },
483
+ model: lastModel || chatOptions.model,
484
+ timestamp,
485
+ }
486
+
487
+ yield {
488
+ type: EventType.RUN_FINISHED,
489
+ runId: aguiState.runId,
490
+ threadId: aguiState.threadId,
491
+ model: lastModel || chatOptions.model,
492
+ timestamp,
493
+ finishReason: 'stop',
494
+ ...(lastUsage && {
495
+ usage: {
496
+ promptTokens: lastUsage.prompt_tokens,
497
+ completionTokens: lastUsage.completion_tokens,
498
+ totalTokens: lastUsage.total_tokens,
499
+ },
500
+ }),
501
+ }
502
+ } catch (error: unknown) {
503
+ if (!aguiState.hasEmittedRunStarted) {
504
+ aguiState.hasEmittedRunStarted = true
505
+ yield {
506
+ type: EventType.RUN_STARTED,
507
+ runId: aguiState.runId,
508
+ threadId: aguiState.threadId,
509
+ model: chatOptions.model,
510
+ timestamp,
511
+ parentRunId: chatOptions.parentRunId,
512
+ }
513
+ }
514
+
515
+ const isAbort = this.isAbortError(error)
516
+ const errorPayload = toRunErrorPayload(
517
+ error,
518
+ `${this.name}.structuredOutputStream failed`,
519
+ )
520
+
521
+ yield {
522
+ type: EventType.RUN_ERROR,
523
+ runId: aguiState.runId,
524
+ model: lastModel || chatOptions.model,
525
+ timestamp,
526
+ message: errorPayload.message,
527
+ code: isAbort ? 'aborted' : errorPayload.code,
528
+ error: { ...errorPayload, ...(isAbort && { code: 'aborted' }) },
529
+ }
530
+
531
+ chatOptions.logger.errors(`${this.name}.structuredOutputStream fatal`, {
532
+ error: errorPayload,
533
+ source: `${this.name}.structuredOutputStream`,
534
+ })
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Cross-SDK abort detection for `structuredOutputStream`. Default duck-types
540
+ * on `name === 'APIUserAbortError'` (OpenAI SDK), `code === 'ERR_CANCELED'`,
541
+ * and standard `AbortError`s. Subclasses with proprietary error types (e.g.
542
+ * `@openrouter/sdk`'s `RequestAbortedError`) override to extend the check.
543
+ */
544
+ protected isAbortError(error: unknown): boolean {
545
+ if (!error || typeof error !== 'object') return false
546
+ const e = error as { name?: unknown; code?: unknown }
547
+ return (
548
+ e.name === 'APIUserAbortError' ||
549
+ e.name === 'AbortError' ||
550
+ e.code === 'ERR_CANCELED'
551
+ )
552
+ }
553
+
216
554
  /**
217
555
  * Applies provider-specific transformations for structured output compatibility.
218
556
  * Override this in subclasses to handle provider-specific quirks.
@@ -224,22 +562,43 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
224
562
  return makeStructuredOutputCompatible(schema, originalRequired)
225
563
  }
226
564
 
565
+ /**
566
+ * Extract reasoning content from a stream chunk. Default returns
567
+ * `undefined` because the OpenAI Chat Completions chunk shape doesn't
568
+ * carry reasoning. The chunk param is typed `unknown` so an override can
569
+ * narrow to its own SDK chunk type without an `as` dance — the base only
570
+ * passes through `processStreamChunks`'s structurally-iterated chunk.
571
+ */
572
+ protected extractReasoning(_chunk: unknown): { text: string } | undefined {
573
+ return undefined
574
+ }
575
+
576
+ /**
577
+ * Final shaping pass applied to parsed structured-output JSON before it is
578
+ * returned to the caller. Default converts `null` values to `undefined` so
579
+ * the result aligns with the original Zod schema's optional-field
580
+ * semantics. Subclasses with different conventions (OpenRouter historically
581
+ * preserves nulls) can override.
582
+ */
583
+ protected transformStructuredOutput(parsed: unknown): unknown {
584
+ return transformNullsToUndefined(parsed)
585
+ }
586
+
227
587
  /**
228
588
  * Processes streamed chunks from the Chat Completions API and yields AG-UI events.
229
589
  * Override this in subclasses to handle provider-specific stream behavior.
230
590
  */
231
591
  protected async *processStreamChunks(
232
- stream: AsyncIterable<OpenAI_SDK.Chat.Completions.ChatCompletionChunk>,
592
+ stream: AsyncIterable<ChatCompletionChunk>,
233
593
  options: TextOptions,
234
594
  aguiState: {
235
595
  runId: string
596
+ threadId: string
236
597
  messageId: string
237
- timestamp: number
238
598
  hasEmittedRunStarted: boolean
239
599
  },
240
600
  ): AsyncIterable<StreamChunk> {
241
601
  let accumulatedContent = ''
242
- const timestamp = aguiState.timestamp
243
602
  let hasEmittedTextMessageStart = false
244
603
  let lastModel: string | undefined
245
604
  // Track usage from any chunk that carries it. With
@@ -248,11 +607,9 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
248
607
  // earlier `finish_reason` chunk does NOT include token counts. We must
249
608
  // therefore defer RUN_FINISHED until the iterator is exhausted so we can
250
609
  // pick up usage from the trailing chunk regardless of arrival order.
251
- let lastUsage:
252
- | OpenAI_SDK.Chat.Completions.ChatCompletionChunk['usage']
253
- | undefined
610
+ let lastUsage: ChatCompletionChunk['usage'] | undefined
254
611
  let pendingFinishReason:
255
- | OpenAI_SDK.Chat.Completions.ChatCompletionChunk.Choice['finish_reason']
612
+ | ChatCompletionChunk['choices'][number]['finish_reason']
256
613
  | undefined
257
614
 
258
615
  // Track tool calls being streamed (arguments come in chunks)
@@ -265,6 +622,17 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
265
622
  started: boolean // Track if TOOL_CALL_START has been emitted
266
623
  }
267
624
  >()
625
+
626
+ // Reasoning lifecycle (driven by extractReasoning() hook — see method
627
+ // docs). The base wire format (OpenAI Chat Completions) has no reasoning,
628
+ // so these stay unused for openai/grok/groq. OpenRouter etc. opt in.
629
+ let reasoningMessageId: string | undefined
630
+ let hasClosedReasoning = false
631
+ // Legacy STEP_STARTED/STEP_FINISHED pair emitted alongside REASONING_*
632
+ // for back-compat with consumers (UI, devtools) that haven't migrated
633
+ // to the spec REASONING_* events yet.
634
+ let stepId: string | undefined
635
+ let accumulatedReasoning = ''
268
636
  // Track whether ANY tool call lifecycle was actually completed across the
269
637
  // entire stream. Lets us downgrade a `tool_calls` finish_reason to `stop`
270
638
  // when the upstream signalled tool calls but never produced a complete
@@ -298,12 +666,57 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
298
666
  // `hasEmittedRunStarted`).
299
667
  if (!aguiState.hasEmittedRunStarted) {
300
668
  aguiState.hasEmittedRunStarted = true
301
- yield asChunk({
302
- type: 'RUN_STARTED',
669
+ yield {
670
+ type: EventType.RUN_STARTED,
303
671
  runId: aguiState.runId,
672
+ threadId: aguiState.threadId,
304
673
  model: chunk.model || options.model,
305
- timestamp,
306
- })
674
+ timestamp: Date.now(),
675
+ parentRunId: options.parentRunId,
676
+ }
677
+ }
678
+
679
+ // Reasoning content (extractReasoning() hook). Run before reading
680
+ // choice/delta so reasoning-only chunks (no `choices`) still drive
681
+ // the REASONING_* lifecycle on providers that send reasoning out of
682
+ // band. The base default returns undefined.
683
+ const reasoning = this.extractReasoning(chunk)
684
+ if (reasoning && reasoning.text) {
685
+ if (!reasoningMessageId) {
686
+ reasoningMessageId = generateId(this.name)
687
+ stepId = generateId(this.name)
688
+ yield {
689
+ type: EventType.REASONING_START,
690
+ messageId: reasoningMessageId,
691
+ model: chunk.model || options.model,
692
+ timestamp: Date.now(),
693
+ }
694
+ yield {
695
+ type: EventType.REASONING_MESSAGE_START,
696
+ messageId: reasoningMessageId,
697
+ role: 'reasoning' as const,
698
+ model: chunk.model || options.model,
699
+ timestamp: Date.now(),
700
+ }
701
+ // Legacy STEP_STARTED (single emission, paired with the
702
+ // STEP_FINISHED below when reasoning closes).
703
+ yield {
704
+ type: EventType.STEP_STARTED,
705
+ stepName: stepId,
706
+ stepId,
707
+ model: chunk.model || options.model,
708
+ timestamp: Date.now(),
709
+ stepType: 'thinking',
710
+ }
711
+ }
712
+ accumulatedReasoning += reasoning.text
713
+ yield {
714
+ type: EventType.REASONING_MESSAGE_CONTENT,
715
+ messageId: reasoningMessageId,
716
+ delta: reasoning.text,
717
+ model: chunk.model || options.model,
718
+ timestamp: Date.now(),
719
+ }
307
720
  }
308
721
 
309
722
  const choice = chunk.choices[0]
@@ -316,29 +729,57 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
316
729
 
317
730
  // Handle content delta
318
731
  if (deltaContent) {
732
+ // Close reasoning before text starts so consumers see a clean
733
+ // REASONING_END before any TEXT_MESSAGE_START.
734
+ if (reasoningMessageId && !hasClosedReasoning) {
735
+ hasClosedReasoning = true
736
+ yield {
737
+ type: EventType.REASONING_MESSAGE_END,
738
+ messageId: reasoningMessageId,
739
+ model: chunk.model || options.model,
740
+ timestamp: Date.now(),
741
+ }
742
+ yield {
743
+ type: EventType.REASONING_END,
744
+ messageId: reasoningMessageId,
745
+ model: chunk.model || options.model,
746
+ timestamp: Date.now(),
747
+ }
748
+ if (stepId) {
749
+ yield {
750
+ type: EventType.STEP_FINISHED,
751
+ stepName: stepId,
752
+ stepId,
753
+ model: chunk.model || options.model,
754
+ timestamp: Date.now(),
755
+ content: accumulatedReasoning,
756
+ }
757
+ }
758
+ }
759
+
319
760
  // Emit TEXT_MESSAGE_START on first text content
320
761
  if (!hasEmittedTextMessageStart) {
321
762
  hasEmittedTextMessageStart = true
322
- yield asChunk({
323
- type: 'TEXT_MESSAGE_START',
763
+ yield {
764
+ type: EventType.TEXT_MESSAGE_START,
324
765
  messageId: aguiState.messageId,
325
766
  model: chunk.model || options.model,
326
- timestamp,
767
+ timestamp: Date.now(),
327
768
  role: 'assistant',
328
- })
769
+ }
329
770
  }
330
771
 
331
772
  accumulatedContent += deltaContent
332
773
 
333
774
  // Emit AG-UI TEXT_MESSAGE_CONTENT
334
- yield asChunk({
335
- type: 'TEXT_MESSAGE_CONTENT',
775
+ yield {
776
+ type: EventType.TEXT_MESSAGE_CONTENT,
336
777
  messageId: aguiState.messageId,
337
778
  model: chunk.model || options.model,
338
- timestamp,
779
+ timestamp: Date.now(),
339
780
  delta: deltaContent,
340
781
  content: accumulatedContent,
341
- })
782
+ }
342
783
  }
343
784
 
344
785
  // Handle tool calls - they come in as deltas
@@ -372,26 +813,26 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
372
813
  // Emit TOOL_CALL_START when we have id and name
373
814
  if (toolCall.id && toolCall.name && !toolCall.started) {
374
815
  toolCall.started = true
375
- yield asChunk({
376
- type: 'TOOL_CALL_START',
816
+ yield {
817
+ type: EventType.TOOL_CALL_START,
377
818
  toolCallId: toolCall.id,
378
819
  toolCallName: toolCall.name,
379
820
  toolName: toolCall.name,
380
821
  model: chunk.model || options.model,
381
- timestamp,
822
+ timestamp: Date.now(),
382
823
  index,
383
- })
824
+ }
384
825
  }
385
826
 
386
827
  // Emit TOOL_CALL_ARGS for argument deltas
387
828
  if (toolCallDelta.function?.arguments && toolCall.started) {
388
- yield asChunk({
389
- type: 'TOOL_CALL_ARGS',
829
+ yield {
830
+ type: EventType.TOOL_CALL_ARGS,
390
831
  toolCallId: toolCall.id,
391
832
  model: chunk.model || options.model,
392
- timestamp,
833
+ timestamp: Date.now(),
393
834
  delta: toolCallDelta.function.arguments,
394
- })
835
+ }
395
836
  }
396
837
  }
397
838
  }
@@ -445,15 +886,15 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
445
886
  }
446
887
 
447
888
  // Emit AG-UI TOOL_CALL_END
448
- yield asChunk({
449
- type: 'TOOL_CALL_END',
889
+ yield {
890
+ type: EventType.TOOL_CALL_END,
450
891
  toolCallId: toolCall.id,
451
892
  toolCallName: toolCall.name,
452
893
  toolName: toolCall.name,
453
894
  model: chunk.model || options.model,
454
- timestamp,
895
+ timestamp: Date.now(),
455
896
  input: parsedInput,
456
- })
897
+ }
457
898
  emittedAnyToolCallEnd = true
458
899
  }
459
900
  // Clear tool-call state after emission so a subsequent
@@ -464,12 +905,12 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
464
905
 
465
906
  // Emit TEXT_MESSAGE_END if we had text content
466
907
  if (hasEmittedTextMessageStart) {
467
- yield asChunk({
468
- type: 'TEXT_MESSAGE_END',
908
+ yield {
909
+ type: EventType.TEXT_MESSAGE_END,
469
910
  messageId: aguiState.messageId,
470
911
  model: chunk.model || options.model,
471
- timestamp,
472
- })
912
+ timestamp: Date.now(),
913
+ }
473
914
  hasEmittedTextMessageStart = false
474
915
  }
475
916
 
@@ -497,19 +938,36 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
497
938
  try {
498
939
  const parsed: unknown = JSON.parse(toolCall.arguments)
499
940
  parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
500
- } catch {
941
+ } catch (parseError) {
942
+ // Mirror the finish_reason path's logger call — a truncated
943
+ // stream emitting malformed tool-call JSON would otherwise
944
+ // silently invoke the tool with `{}`, the exact failure the
945
+ // finish_reason logger was added to prevent.
946
+ options.logger.errors(
947
+ `${this.name}.processStreamChunks tool-args JSON parse failed (drain)`,
948
+ {
949
+ error: toRunErrorPayload(
950
+ parseError,
951
+ `tool ${toolCall.name} (${toolCall.id}) returned malformed JSON arguments`,
952
+ ),
953
+ source: `${this.name}.processStreamChunks`,
954
+ toolCallId: toolCall.id,
955
+ toolName: toolCall.name,
956
+ rawArguments: toolCall.arguments,
957
+ },
958
+ )
501
959
  parsedInput = {}
502
960
  }
503
961
  }
504
- yield asChunk({
505
- type: 'TOOL_CALL_END',
962
+ yield {
963
+ type: EventType.TOOL_CALL_END,
506
964
  toolCallId: toolCall.id,
507
965
  toolCallName: toolCall.name,
508
966
  toolName: toolCall.name,
509
967
  model: lastModel || options.model,
510
- timestamp,
968
+ timestamp: Date.now(),
511
969
  input: parsedInput,
512
- })
970
+ }
513
971
  pendingToolCount += 1
514
972
  emittedAnyToolCallEnd = true
515
973
  }
@@ -518,33 +976,66 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
518
976
  // Make sure the text message lifecycle is closed even on early
519
977
  // termination paths where finish_reason never arrives.
520
978
  if (hasEmittedTextMessageStart) {
521
- yield asChunk({
522
- type: 'TEXT_MESSAGE_END',
979
+ yield {
980
+ type: EventType.TEXT_MESSAGE_END,
523
981
  messageId: aguiState.messageId,
524
982
  model: lastModel || options.model,
525
- timestamp,
526
- })
983
+ timestamp: Date.now(),
984
+ }
527
985
  }
528
986
 
529
- // Map upstream finish_reason to AG-UI's narrower vocabulary while
530
- // preserving the upstream value when it falls outside the AG-UI set.
987
+ // Close any reasoning lifecycle that text never closed (no text
988
+ // content arrived, or the stream cut off before text started).
989
+ if (reasoningMessageId && !hasClosedReasoning) {
990
+ hasClosedReasoning = true
991
+ yield {
992
+ type: EventType.REASONING_MESSAGE_END,
993
+ messageId: reasoningMessageId,
994
+ model: lastModel || options.model,
995
+ timestamp: Date.now(),
996
+ }
997
+ yield {
998
+ type: EventType.REASONING_END,
999
+ messageId: reasoningMessageId,
1000
+ model: lastModel || options.model,
1001
+ timestamp: Date.now(),
1002
+ }
1003
+ if (stepId) {
1004
+ yield {
1005
+ type: EventType.STEP_FINISHED,
1006
+ stepName: stepId,
1007
+ stepId,
1008
+ model: lastModel || options.model,
1009
+ timestamp: Date.now(),
1010
+ content: accumulatedReasoning,
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ // Map upstream finish_reason to AG-UI's narrower vocabulary.
531
1016
  // Collapsing length / content_filter to 'stop' would hide why the
532
1017
  // run terminated — surface it instead. Use `tool_calls` only when
533
1018
  // a TOOL_CALL_END was actually emitted: an upstream that signalled
534
1019
  // `tool_calls` but never produced a started/ended pair must NOT
535
1020
  // surface `tool_calls` here, since downstream consumers wait for
536
- // tool results that would never arrive.
537
- const finishReason: string = emittedAnyToolCallEnd
538
- ? 'tool_calls'
539
- : pendingFinishReason === 'tool_calls'
540
- ? 'stop'
541
- : (pendingFinishReason ?? 'stop')
542
-
543
- yield asChunk({
544
- type: 'RUN_FINISHED',
1021
+ // tool results that would never arrive. OpenAI's legacy
1022
+ // `function_call` value (from the v1 function-calling API) is
1023
+ // normalized to `tool_calls` — semantically the same termination.
1024
+ const finishReason: NonNullable<RunFinishedEvent['finishReason']> =
1025
+ emittedAnyToolCallEnd
1026
+ ? 'tool_calls'
1027
+ : pendingFinishReason === 'tool_calls'
1028
+ ? 'stop'
1029
+ : pendingFinishReason === 'function_call'
1030
+ ? 'tool_calls'
1031
+ : (pendingFinishReason ?? 'stop')
1032
+
1033
+ yield {
1034
+ type: EventType.RUN_FINISHED,
545
1035
  runId: aguiState.runId,
1036
+ threadId: aguiState.threadId,
546
1037
  model: lastModel || options.model,
547
- timestamp,
1038
+ timestamp: Date.now(),
548
1039
  usage: lastUsage
549
1040
  ? {
550
1041
  promptTokens: lastUsage.prompt_tokens || 0,
@@ -553,7 +1044,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
553
1044
  }
554
1045
  : undefined,
555
1046
  finishReason,
556
- })
1047
+ }
557
1048
  }
558
1049
  } catch (error: unknown) {
559
1050
  // Narrow before logging: raw SDK errors can carry request metadata
@@ -568,13 +1059,14 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
568
1059
  })
569
1060
 
570
1061
  // Emit AG-UI RUN_ERROR
571
- yield asChunk({
572
- type: 'RUN_ERROR',
573
- runId: aguiState.runId,
1062
+ yield {
1063
+ type: EventType.RUN_ERROR,
574
1064
  model: options.model,
575
- timestamp,
1065
+ timestamp: Date.now(),
1066
+ message: errorPayload.message,
1067
+ code: errorPayload.code,
576
1068
  error: errorPayload,
577
- })
1069
+ }
578
1070
  }
579
1071
  }
580
1072
 
@@ -584,7 +1076,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
584
1076
  */
585
1077
  protected mapOptionsToRequest(
586
1078
  options: TextOptions,
587
- ): OpenAI_SDK.Chat.Completions.ChatCompletionCreateParamsStreaming {
1079
+ ): ChatCompletionCreateParamsStreaming {
588
1080
  const tools = options.tools
589
1081
  ? convertToolsToChatCompletionsFormat(
590
1082
  options.tools,
@@ -593,8 +1085,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
593
1085
  : undefined
594
1086
 
595
1087
  // Build messages array with system prompts
596
- const messages: Array<OpenAI_SDK.Chat.Completions.ChatCompletionMessageParam> =
597
- []
1088
+ const messages: Array<ChatCompletionMessageParam> = []
598
1089
 
599
1090
  // Add system prompts first
600
1091
  if (options.systemPrompts && options.systemPrompts.length > 0) {
@@ -641,9 +1132,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
641
1132
  * Converts a single ModelMessage to the Chat Completions API message format.
642
1133
  * Override this in subclasses to handle provider-specific message formats.
643
1134
  */
644
- protected convertMessage(
645
- message: ModelMessage,
646
- ): OpenAI_SDK.Chat.Completions.ChatCompletionMessageParam {
1135
+ protected convertMessage(message: ModelMessage): ChatCompletionMessageParam {
647
1136
  // Handle tool messages
648
1137
  if (message.role === 'tool') {
649
1138
  return {
@@ -709,8 +1198,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
709
1198
  // content parts rather than silently dropping them — a message of all
710
1199
  // unsupported parts would otherwise turn into an empty user prompt and
711
1200
  // mask a real capability mismatch.
712
- const parts: Array<OpenAI_SDK.Chat.Completions.ChatCompletionContentPart> =
713
- []
1201
+ const parts: Array<ChatCompletionContentPart> = []
714
1202
  for (const part of contentParts) {
715
1203
  const converted = this.convertContentPart(part)
716
1204
  if (!converted) {
@@ -746,7 +1234,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter<
746
1234
  */
747
1235
  protected convertContentPart(
748
1236
  part: ContentPart,
749
- ): OpenAI_SDK.Chat.Completions.ChatCompletionContentPart | null {
1237
+ ): ChatCompletionContentPart | null {
750
1238
  if (part.type === 'text') {
751
1239
  return { type: 'text', text: part.content }
752
1240
  }