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