@tanstack/openai-base 0.1.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 (110) hide show
  1. package/dist/esm/adapters/chat-completions-text.d.ts +76 -0
  2. package/dist/esm/adapters/chat-completions-text.js +411 -0
  3. package/dist/esm/adapters/chat-completions-text.js.map +1 -0
  4. package/dist/esm/adapters/chat-completions-tool-converter.d.ts +24 -0
  5. package/dist/esm/adapters/chat-completions-tool-converter.js +29 -0
  6. package/dist/esm/adapters/chat-completions-tool-converter.js.map +1 -0
  7. package/dist/esm/adapters/image.d.ts +32 -0
  8. package/dist/esm/adapters/image.js +69 -0
  9. package/dist/esm/adapters/image.js.map +1 -0
  10. package/dist/esm/adapters/responses-text.d.ts +115 -0
  11. package/dist/esm/adapters/responses-text.js +635 -0
  12. package/dist/esm/adapters/responses-text.js.map +1 -0
  13. package/dist/esm/adapters/responses-tool-converter.d.ts +35 -0
  14. package/dist/esm/adapters/responses-tool-converter.js +27 -0
  15. package/dist/esm/adapters/responses-tool-converter.js.map +1 -0
  16. package/dist/esm/adapters/summarize.d.ts +28 -0
  17. package/dist/esm/adapters/summarize.js +74 -0
  18. package/dist/esm/adapters/summarize.js.map +1 -0
  19. package/dist/esm/adapters/transcription.d.ts +39 -0
  20. package/dist/esm/adapters/transcription.js +139 -0
  21. package/dist/esm/adapters/transcription.js.map +1 -0
  22. package/dist/esm/adapters/tts.d.ts +26 -0
  23. package/dist/esm/adapters/tts.js +65 -0
  24. package/dist/esm/adapters/tts.js.map +1 -0
  25. package/dist/esm/adapters/video.d.ts +48 -0
  26. package/dist/esm/adapters/video.js +192 -0
  27. package/dist/esm/adapters/video.js.map +1 -0
  28. package/dist/esm/index.d.ts +15 -0
  29. package/dist/esm/index.js +65 -0
  30. package/dist/esm/index.js.map +1 -0
  31. package/dist/esm/tools/apply-patch-tool.d.ts +11 -0
  32. package/dist/esm/tools/apply-patch-tool.js +17 -0
  33. package/dist/esm/tools/apply-patch-tool.js.map +1 -0
  34. package/dist/esm/tools/code-interpreter-tool.d.ts +11 -0
  35. package/dist/esm/tools/code-interpreter-tool.js +22 -0
  36. package/dist/esm/tools/code-interpreter-tool.js.map +1 -0
  37. package/dist/esm/tools/computer-use-tool.d.ts +11 -0
  38. package/dist/esm/tools/computer-use-tool.js +23 -0
  39. package/dist/esm/tools/computer-use-tool.js.map +1 -0
  40. package/dist/esm/tools/custom-tool.d.ts +11 -0
  41. package/dist/esm/tools/custom-tool.js +23 -0
  42. package/dist/esm/tools/custom-tool.js.map +1 -0
  43. package/dist/esm/tools/file-search-tool.d.ts +11 -0
  44. package/dist/esm/tools/file-search-tool.js +30 -0
  45. package/dist/esm/tools/file-search-tool.js.map +1 -0
  46. package/dist/esm/tools/function-tool.d.ts +15 -0
  47. package/dist/esm/tools/function-tool.js +24 -0
  48. package/dist/esm/tools/function-tool.js.map +1 -0
  49. package/dist/esm/tools/image-generation-tool.d.ts +11 -0
  50. package/dist/esm/tools/image-generation-tool.js +27 -0
  51. package/dist/esm/tools/image-generation-tool.js.map +1 -0
  52. package/dist/esm/tools/index.d.ts +27 -0
  53. package/dist/esm/tools/local-shell-tool.d.ts +11 -0
  54. package/dist/esm/tools/local-shell-tool.js +17 -0
  55. package/dist/esm/tools/local-shell-tool.js.map +1 -0
  56. package/dist/esm/tools/mcp-tool.d.ts +12 -0
  57. package/dist/esm/tools/mcp-tool.js +31 -0
  58. package/dist/esm/tools/mcp-tool.js.map +1 -0
  59. package/dist/esm/tools/shell-tool.d.ts +11 -0
  60. package/dist/esm/tools/shell-tool.js +17 -0
  61. package/dist/esm/tools/shell-tool.js.map +1 -0
  62. package/dist/esm/tools/tool-choice.d.ts +17 -0
  63. package/dist/esm/tools/tool-converter.d.ts +6 -0
  64. package/dist/esm/tools/tool-converter.js +61 -0
  65. package/dist/esm/tools/tool-converter.js.map +1 -0
  66. package/dist/esm/tools/web-search-preview-tool.d.ts +11 -0
  67. package/dist/esm/tools/web-search-preview-tool.js +20 -0
  68. package/dist/esm/tools/web-search-preview-tool.js.map +1 -0
  69. package/dist/esm/tools/web-search-tool.d.ts +11 -0
  70. package/dist/esm/tools/web-search-tool.js +16 -0
  71. package/dist/esm/tools/web-search-tool.js.map +1 -0
  72. package/dist/esm/types/config.d.ts +4 -0
  73. package/dist/esm/types/message-metadata.d.ts +19 -0
  74. package/dist/esm/types/provider-options.d.ts +40 -0
  75. package/dist/esm/utils/client.d.ts +3 -0
  76. package/dist/esm/utils/client.js +8 -0
  77. package/dist/esm/utils/client.js.map +1 -0
  78. package/dist/esm/utils/schema-converter.d.ts +12 -0
  79. package/dist/esm/utils/schema-converter.js +65 -0
  80. package/dist/esm/utils/schema-converter.js.map +1 -0
  81. package/package.json +57 -0
  82. package/src/adapters/chat-completions-text.ts +817 -0
  83. package/src/adapters/chat-completions-tool-converter.ts +70 -0
  84. package/src/adapters/image.ts +158 -0
  85. package/src/adapters/responses-text.ts +1147 -0
  86. package/src/adapters/responses-tool-converter.ts +77 -0
  87. package/src/adapters/summarize.ts +174 -0
  88. package/src/adapters/transcription.ts +194 -0
  89. package/src/adapters/tts.ts +124 -0
  90. package/src/adapters/video.ts +385 -0
  91. package/src/index.ts +24 -0
  92. package/src/tools/apply-patch-tool.ts +32 -0
  93. package/src/tools/code-interpreter-tool.ts +39 -0
  94. package/src/tools/computer-use-tool.ts +38 -0
  95. package/src/tools/custom-tool.ts +33 -0
  96. package/src/tools/file-search-tool.ts +51 -0
  97. package/src/tools/function-tool.ts +44 -0
  98. package/src/tools/image-generation-tool.ts +51 -0
  99. package/src/tools/index.ts +41 -0
  100. package/src/tools/local-shell-tool.ts +32 -0
  101. package/src/tools/mcp-tool.ts +47 -0
  102. package/src/tools/shell-tool.ts +30 -0
  103. package/src/tools/tool-choice.ts +31 -0
  104. package/src/tools/tool-converter.ts +68 -0
  105. package/src/tools/web-search-preview-tool.ts +39 -0
  106. package/src/tools/web-search-tool.ts +38 -0
  107. package/src/types/config.ts +5 -0
  108. package/src/utils/client.ts +8 -0
  109. package/src/utils/request-options.ts +16 -0
  110. package/src/utils/schema-converter.ts +89 -0
@@ -0,0 +1,817 @@
1
+ import { BaseTextAdapter } from '@tanstack/ai/adapters'
2
+ import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
3
+ import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils'
4
+ import { createOpenAICompatibleClient } from '../utils/client'
5
+ import { extractRequestOptions } from '../utils/request-options'
6
+ import { makeStructuredOutputCompatible } from '../utils/schema-converter'
7
+ import { convertToolsToChatCompletionsFormat } from './chat-completions-tool-converter'
8
+ import type {
9
+ StructuredOutputOptions,
10
+ StructuredOutputResult,
11
+ } from '@tanstack/ai/adapters'
12
+ import type OpenAI_SDK from 'openai'
13
+ import type {
14
+ ContentPart,
15
+ DefaultMessageMetadataByModality,
16
+ Modality,
17
+ ModelMessage,
18
+ StreamChunk,
19
+ TextOptions,
20
+ } 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
+ /**
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.
40
+ */
41
+ export class OpenAICompatibleChatCompletionsTextAdapter<
42
+ TModel extends string,
43
+ TProviderOptions extends Record<string, any> = Record<string, any>,
44
+ TInputModalities extends ReadonlyArray<Modality> = ReadonlyArray<Modality>,
45
+ TMessageMetadata extends DefaultMessageMetadataByModality =
46
+ DefaultMessageMetadataByModality,
47
+ TToolCapabilities extends ReadonlyArray<string> = ReadonlyArray<string>,
48
+ > extends BaseTextAdapter<
49
+ TModel,
50
+ TProviderOptions,
51
+ TInputModalities,
52
+ TMessageMetadata,
53
+ TToolCapabilities
54
+ > {
55
+ readonly kind = 'text' as const
56
+ readonly name: string
57
+
58
+ protected client: OpenAI_SDK
59
+
60
+ constructor(
61
+ config: OpenAICompatibleClientConfig,
62
+ model: TModel,
63
+ name: string = 'openai-compatible',
64
+ ) {
65
+ super({}, model)
66
+ this.name = name
67
+ this.client = createOpenAICompatibleClient(config)
68
+ }
69
+
70
+ async *chatStream(
71
+ options: TextOptions<TProviderOptions>,
72
+ ): AsyncIterable<StreamChunk> {
73
+ const requestParams = this.mapOptionsToRequest(options)
74
+ const timestamp = Date.now()
75
+
76
+ // AG-UI lifecycle tracking (mutable state object for ESLint compatibility)
77
+ const aguiState = {
78
+ runId: generateId(this.name),
79
+ messageId: generateId(this.name),
80
+ timestamp,
81
+ hasEmittedRunStarted: false,
82
+ }
83
+
84
+ try {
85
+ options.logger.request(
86
+ `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`,
87
+ { provider: this.name, model: this.model },
88
+ )
89
+ const stream = await this.client.chat.completions.create(
90
+ {
91
+ ...requestParams,
92
+ stream: true,
93
+ stream_options: { include_usage: true },
94
+ },
95
+ extractRequestOptions(options.request),
96
+ )
97
+
98
+ yield* this.processStreamChunks(stream, options, aguiState)
99
+ } catch (error: unknown) {
100
+ // Narrow before logging: raw SDK errors can carry request metadata
101
+ // (including auth headers) which we must never surface to user loggers.
102
+ const errorPayload = toRunErrorPayload(
103
+ error,
104
+ `${this.name}.chatStream failed`,
105
+ )
106
+
107
+ // Emit RUN_STARTED if not yet emitted
108
+ if (!aguiState.hasEmittedRunStarted) {
109
+ aguiState.hasEmittedRunStarted = true
110
+ yield asChunk({
111
+ type: 'RUN_STARTED',
112
+ runId: aguiState.runId,
113
+ model: options.model,
114
+ timestamp,
115
+ })
116
+ }
117
+
118
+ // Emit AG-UI RUN_ERROR
119
+ yield asChunk({
120
+ type: 'RUN_ERROR',
121
+ runId: aguiState.runId,
122
+ model: options.model,
123
+ timestamp,
124
+ error: errorPayload,
125
+ })
126
+
127
+ options.logger.errors(`${this.name}.chatStream fatal`, {
128
+ error: errorPayload,
129
+ source: `${this.name}.chatStream`,
130
+ })
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Generate structured output using the provider's JSON Schema response format.
136
+ * Uses stream: false to get the complete response in one call.
137
+ *
138
+ * OpenAI-compatible APIs have strict requirements for structured output:
139
+ * - All properties must be in the `required` array
140
+ * - Optional fields should have null added to their type union
141
+ * - additionalProperties must be false for all objects
142
+ *
143
+ * The outputSchema is already JSON Schema (converted in the ai layer).
144
+ * We apply provider-specific transformations for structured output compatibility.
145
+ */
146
+ async structuredOutput(
147
+ options: StructuredOutputOptions<TProviderOptions>,
148
+ ): Promise<StructuredOutputResult<unknown>> {
149
+ const { chatOptions, outputSchema } = options
150
+ const requestParams = this.mapOptionsToRequest(chatOptions)
151
+
152
+ const jsonSchema = this.makeStructuredOutputCompatible(
153
+ outputSchema,
154
+ outputSchema.required,
155
+ )
156
+
157
+ try {
158
+ // Strip stream_options which is only valid for streaming calls
159
+ const {
160
+ stream_options: _,
161
+ stream: __,
162
+ ...cleanParams
163
+ } = requestParams as any
164
+ chatOptions.logger.request(
165
+ `activity=structuredOutput provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`,
166
+ { provider: this.name, model: this.model },
167
+ )
168
+ const response = await this.client.chat.completions.create(
169
+ {
170
+ ...cleanParams,
171
+ stream: false,
172
+ response_format: {
173
+ type: 'json_schema',
174
+ json_schema: {
175
+ name: 'structured_output',
176
+ schema: jsonSchema,
177
+ strict: true,
178
+ },
179
+ },
180
+ },
181
+ extractRequestOptions(chatOptions.request),
182
+ )
183
+
184
+ // Extract text content from the response
185
+ const rawText = response.choices[0]?.message.content || ''
186
+
187
+ // Parse the JSON response
188
+ let parsed: unknown
189
+ try {
190
+ parsed = JSON.parse(rawText)
191
+ } catch {
192
+ throw new Error(
193
+ `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`,
194
+ )
195
+ }
196
+
197
+ // 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)
200
+
201
+ return {
202
+ data: transformed,
203
+ rawText,
204
+ }
205
+ } catch (error: unknown) {
206
+ // Narrow before logging: raw SDK errors can carry request metadata
207
+ // (including auth headers) which we must never surface to user loggers.
208
+ chatOptions.logger.errors(`${this.name}.structuredOutput fatal`, {
209
+ error: toRunErrorPayload(error, `${this.name}.structuredOutput failed`),
210
+ source: `${this.name}.structuredOutput`,
211
+ })
212
+ throw error
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Applies provider-specific transformations for structured output compatibility.
218
+ * Override this in subclasses to handle provider-specific quirks.
219
+ */
220
+ protected makeStructuredOutputCompatible(
221
+ schema: Record<string, any>,
222
+ originalRequired?: Array<string>,
223
+ ): Record<string, any> {
224
+ return makeStructuredOutputCompatible(schema, originalRequired)
225
+ }
226
+
227
+ /**
228
+ * Processes streamed chunks from the Chat Completions API and yields AG-UI events.
229
+ * Override this in subclasses to handle provider-specific stream behavior.
230
+ */
231
+ protected async *processStreamChunks(
232
+ stream: AsyncIterable<OpenAI_SDK.Chat.Completions.ChatCompletionChunk>,
233
+ options: TextOptions,
234
+ aguiState: {
235
+ runId: string
236
+ messageId: string
237
+ timestamp: number
238
+ hasEmittedRunStarted: boolean
239
+ },
240
+ ): AsyncIterable<StreamChunk> {
241
+ let accumulatedContent = ''
242
+ const timestamp = aguiState.timestamp
243
+ let hasEmittedTextMessageStart = false
244
+ let lastModel: string | undefined
245
+ // Track usage from any chunk that carries it. With
246
+ // `stream_options: { include_usage: true }` OpenAI emits a terminal chunk
247
+ // whose `choices` is `[]` and only the `usage` field is populated; the
248
+ // earlier `finish_reason` chunk does NOT include token counts. We must
249
+ // therefore defer RUN_FINISHED until the iterator is exhausted so we can
250
+ // pick up usage from the trailing chunk regardless of arrival order.
251
+ let lastUsage:
252
+ | OpenAI_SDK.Chat.Completions.ChatCompletionChunk['usage']
253
+ | undefined
254
+ let pendingFinishReason:
255
+ | OpenAI_SDK.Chat.Completions.ChatCompletionChunk.Choice['finish_reason']
256
+ | undefined
257
+
258
+ // Track tool calls being streamed (arguments come in chunks)
259
+ const toolCallsInProgress = new Map<
260
+ number,
261
+ {
262
+ id: string
263
+ name: string
264
+ arguments: string
265
+ started: boolean // Track if TOOL_CALL_START has been emitted
266
+ }
267
+ >()
268
+ // Track whether ANY tool call lifecycle was actually completed across the
269
+ // entire stream. Lets us downgrade a `tool_calls` finish_reason to `stop`
270
+ // when the upstream signalled tool calls but never produced a complete
271
+ // start/end pair — emitting RUN_FINISHED { finishReason: 'tool_calls' }
272
+ // with no matching TOOL_CALL_END would leave consumers waiting for tool
273
+ // results that never arrive.
274
+ let emittedAnyToolCallEnd = false
275
+
276
+ try {
277
+ for await (const chunk of stream) {
278
+ const choiceForLog = chunk.choices[0]
279
+ options.logger.provider(
280
+ `provider=${this.name} finish_reason=${choiceForLog?.finish_reason ?? 'none'} hasContent=${!!choiceForLog?.delta.content} hasToolCalls=${!!choiceForLog?.delta.tool_calls} hasUsage=${!!chunk.usage}`,
281
+ { provider: this.name, model: chunk.model },
282
+ )
283
+
284
+ // Capture usage from any chunk (including the terminal usage-only
285
+ // chunk emitted when `stream_options.include_usage` is on).
286
+ if (chunk.usage) {
287
+ lastUsage = chunk.usage
288
+ }
289
+ if (chunk.model) {
290
+ lastModel = chunk.model
291
+ }
292
+
293
+ // Emit RUN_STARTED on the first chunk of any kind so callers see a
294
+ // run lifecycle even on streams that arrive entirely as usage-only
295
+ // (no choices). Without this, a usage-first stream would skip
296
+ // RUN_STARTED via `if (!choice) continue` below and the post-loop
297
+ // synthetic block would also skip RUN_FINISHED (it gates on
298
+ // `hasEmittedRunStarted`).
299
+ if (!aguiState.hasEmittedRunStarted) {
300
+ aguiState.hasEmittedRunStarted = true
301
+ yield asChunk({
302
+ type: 'RUN_STARTED',
303
+ runId: aguiState.runId,
304
+ model: chunk.model || options.model,
305
+ timestamp,
306
+ })
307
+ }
308
+
309
+ const choice = chunk.choices[0]
310
+
311
+ if (!choice) continue
312
+
313
+ const delta = choice.delta
314
+ const deltaContent = delta.content
315
+ const deltaToolCalls = delta.tool_calls
316
+
317
+ // Handle content delta
318
+ if (deltaContent) {
319
+ // Emit TEXT_MESSAGE_START on first text content
320
+ if (!hasEmittedTextMessageStart) {
321
+ hasEmittedTextMessageStart = true
322
+ yield asChunk({
323
+ type: 'TEXT_MESSAGE_START',
324
+ messageId: aguiState.messageId,
325
+ model: chunk.model || options.model,
326
+ timestamp,
327
+ role: 'assistant',
328
+ })
329
+ }
330
+
331
+ accumulatedContent += deltaContent
332
+
333
+ // Emit AG-UI TEXT_MESSAGE_CONTENT
334
+ yield asChunk({
335
+ type: 'TEXT_MESSAGE_CONTENT',
336
+ messageId: aguiState.messageId,
337
+ model: chunk.model || options.model,
338
+ timestamp,
339
+ delta: deltaContent,
340
+ content: accumulatedContent,
341
+ })
342
+ }
343
+
344
+ // Handle tool calls - they come in as deltas
345
+ if (deltaToolCalls) {
346
+ for (const toolCallDelta of deltaToolCalls) {
347
+ const index = toolCallDelta.index
348
+
349
+ // Initialize or update the tool call in progress
350
+ if (!toolCallsInProgress.has(index)) {
351
+ toolCallsInProgress.set(index, {
352
+ id: toolCallDelta.id || '',
353
+ name: toolCallDelta.function?.name || '',
354
+ arguments: '',
355
+ started: false,
356
+ })
357
+ }
358
+
359
+ const toolCall = toolCallsInProgress.get(index)!
360
+
361
+ // Update with any new data from the delta
362
+ if (toolCallDelta.id) {
363
+ toolCall.id = toolCallDelta.id
364
+ }
365
+ if (toolCallDelta.function?.name) {
366
+ toolCall.name = toolCallDelta.function.name
367
+ }
368
+ if (toolCallDelta.function?.arguments) {
369
+ toolCall.arguments += toolCallDelta.function.arguments
370
+ }
371
+
372
+ // Emit TOOL_CALL_START when we have id and name
373
+ if (toolCall.id && toolCall.name && !toolCall.started) {
374
+ toolCall.started = true
375
+ yield asChunk({
376
+ type: 'TOOL_CALL_START',
377
+ toolCallId: toolCall.id,
378
+ toolCallName: toolCall.name,
379
+ toolName: toolCall.name,
380
+ model: chunk.model || options.model,
381
+ timestamp,
382
+ index,
383
+ })
384
+ }
385
+
386
+ // Emit TOOL_CALL_ARGS for argument deltas
387
+ if (toolCallDelta.function?.arguments && toolCall.started) {
388
+ yield asChunk({
389
+ type: 'TOOL_CALL_ARGS',
390
+ toolCallId: toolCall.id,
391
+ model: chunk.model || options.model,
392
+ timestamp,
393
+ delta: toolCallDelta.function.arguments,
394
+ })
395
+ }
396
+ }
397
+ }
398
+
399
+ // Handle finish reason. We DO emit TOOL_CALL_END and TEXT_MESSAGE_END
400
+ // here because the corresponding _START events have already fired,
401
+ // and tool execution downstream wants to begin as soon as possible.
402
+ // RUN_FINISHED is deferred until the iterator is fully exhausted so
403
+ // we can capture the trailing usage chunk that arrives AFTER this
404
+ // chunk when stream_options.include_usage is on.
405
+ if (choice.finish_reason) {
406
+ if (
407
+ choice.finish_reason === 'tool_calls' ||
408
+ toolCallsInProgress.size > 0
409
+ ) {
410
+ for (const [, toolCall] of toolCallsInProgress) {
411
+ // Skip tool calls that never emitted TOOL_CALL_START — emitting
412
+ // a stray TOOL_CALL_END here would violate AG-UI lifecycle
413
+ // (END without matching START) for partial deltas where the
414
+ // upstream never sent both id and name.
415
+ if (!toolCall.started) continue
416
+
417
+ // Parse arguments for TOOL_CALL_END. Surface parse failures via
418
+ // the logger so a model emitting malformed JSON for tool args
419
+ // is debuggable instead of silently invoking the tool with {}.
420
+ // Non-object JSON (e.g. a bare string or number) is also coerced
421
+ // to {} so downstream tool execution doesn't receive a primitive
422
+ // input, mirroring the Responses adapter's guard.
423
+ let parsedInput: unknown = {}
424
+ if (toolCall.arguments) {
425
+ try {
426
+ const parsed: unknown = JSON.parse(toolCall.arguments)
427
+ parsedInput =
428
+ parsed && typeof parsed === 'object' ? parsed : {}
429
+ } catch (parseError) {
430
+ options.logger.errors(
431
+ `${this.name}.processStreamChunks tool-args JSON parse failed`,
432
+ {
433
+ error: toRunErrorPayload(
434
+ parseError,
435
+ `tool ${toolCall.name} (${toolCall.id}) returned malformed JSON arguments`,
436
+ ),
437
+ source: `${this.name}.processStreamChunks`,
438
+ toolCallId: toolCall.id,
439
+ toolName: toolCall.name,
440
+ rawArguments: toolCall.arguments,
441
+ },
442
+ )
443
+ parsedInput = {}
444
+ }
445
+ }
446
+
447
+ // Emit AG-UI TOOL_CALL_END
448
+ yield asChunk({
449
+ type: 'TOOL_CALL_END',
450
+ toolCallId: toolCall.id,
451
+ toolCallName: toolCall.name,
452
+ toolName: toolCall.name,
453
+ model: chunk.model || options.model,
454
+ timestamp,
455
+ input: parsedInput,
456
+ })
457
+ emittedAnyToolCallEnd = true
458
+ }
459
+ // Clear tool-call state after emission so a subsequent
460
+ // `finish_reason: 'stop'` chunk (or the post-loop synthetic
461
+ // block) doesn't see lingering entries and misreport the finish.
462
+ toolCallsInProgress.clear()
463
+ }
464
+
465
+ // Emit TEXT_MESSAGE_END if we had text content
466
+ if (hasEmittedTextMessageStart) {
467
+ yield asChunk({
468
+ type: 'TEXT_MESSAGE_END',
469
+ messageId: aguiState.messageId,
470
+ model: chunk.model || options.model,
471
+ timestamp,
472
+ })
473
+ hasEmittedTextMessageStart = false
474
+ }
475
+
476
+ // Remember the upstream finish_reason; RUN_FINISHED is emitted at
477
+ // end-of-stream so we pick up the trailing usage-only chunk too.
478
+ pendingFinishReason = choice.finish_reason
479
+ }
480
+ }
481
+
482
+ // Emit a single terminal RUN_FINISHED after the iterator is exhausted.
483
+ // This both delivers accurate token counts (the trailing usage chunk
484
+ // may arrive AFTER the finish_reason chunk) and gives consumers a
485
+ // guaranteed terminal event even when the upstream cuts off mid-stream
486
+ // (no finish_reason chunk ever arrives).
487
+ if (aguiState.hasEmittedRunStarted) {
488
+ // Close any started tool calls that never got finish_reason. A
489
+ // truncated stream that emitted TOOL_CALL_START but never reached
490
+ // finish_reason would otherwise leave consumers with an unbalanced
491
+ // start. Skip non-started entries (no matching START to close).
492
+ let pendingToolCount = 0
493
+ for (const [, toolCall] of toolCallsInProgress) {
494
+ if (!toolCall.started) continue
495
+ let parsedInput: unknown = {}
496
+ if (toolCall.arguments) {
497
+ try {
498
+ const parsed: unknown = JSON.parse(toolCall.arguments)
499
+ parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
500
+ } catch {
501
+ parsedInput = {}
502
+ }
503
+ }
504
+ yield asChunk({
505
+ type: 'TOOL_CALL_END',
506
+ toolCallId: toolCall.id,
507
+ toolCallName: toolCall.name,
508
+ toolName: toolCall.name,
509
+ model: lastModel || options.model,
510
+ timestamp,
511
+ input: parsedInput,
512
+ })
513
+ pendingToolCount += 1
514
+ emittedAnyToolCallEnd = true
515
+ }
516
+ toolCallsInProgress.clear()
517
+
518
+ // Make sure the text message lifecycle is closed even on early
519
+ // termination paths where finish_reason never arrives.
520
+ if (hasEmittedTextMessageStart) {
521
+ yield asChunk({
522
+ type: 'TEXT_MESSAGE_END',
523
+ messageId: aguiState.messageId,
524
+ model: lastModel || options.model,
525
+ timestamp,
526
+ })
527
+ }
528
+
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.
531
+ // Collapsing length / content_filter to 'stop' would hide why the
532
+ // run terminated — surface it instead. Use `tool_calls` only when
533
+ // a TOOL_CALL_END was actually emitted: an upstream that signalled
534
+ // `tool_calls` but never produced a started/ended pair must NOT
535
+ // 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',
545
+ runId: aguiState.runId,
546
+ model: lastModel || options.model,
547
+ timestamp,
548
+ usage: lastUsage
549
+ ? {
550
+ promptTokens: lastUsage.prompt_tokens || 0,
551
+ completionTokens: lastUsage.completion_tokens || 0,
552
+ totalTokens: lastUsage.total_tokens || 0,
553
+ }
554
+ : undefined,
555
+ finishReason,
556
+ })
557
+ }
558
+ } catch (error: unknown) {
559
+ // Narrow before logging: raw SDK errors can carry request metadata
560
+ // (including auth headers) which we must never surface to user loggers.
561
+ const errorPayload = toRunErrorPayload(
562
+ error,
563
+ `${this.name}.processStreamChunks failed`,
564
+ )
565
+ options.logger.errors(`${this.name}.processStreamChunks fatal`, {
566
+ error: errorPayload,
567
+ source: `${this.name}.processStreamChunks`,
568
+ })
569
+
570
+ // Emit AG-UI RUN_ERROR
571
+ yield asChunk({
572
+ type: 'RUN_ERROR',
573
+ runId: aguiState.runId,
574
+ model: options.model,
575
+ timestamp,
576
+ error: errorPayload,
577
+ })
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Maps common TextOptions to Chat Completions API request format.
583
+ * Override this in subclasses to add provider-specific options.
584
+ */
585
+ protected mapOptionsToRequest(
586
+ options: TextOptions,
587
+ ): OpenAI_SDK.Chat.Completions.ChatCompletionCreateParamsStreaming {
588
+ const tools = options.tools
589
+ ? convertToolsToChatCompletionsFormat(
590
+ options.tools,
591
+ this.makeStructuredOutputCompatible.bind(this),
592
+ )
593
+ : undefined
594
+
595
+ // Build messages array with system prompts
596
+ const messages: Array<OpenAI_SDK.Chat.Completions.ChatCompletionMessageParam> =
597
+ []
598
+
599
+ // Add system prompts first
600
+ if (options.systemPrompts && options.systemPrompts.length > 0) {
601
+ messages.push({
602
+ role: 'system',
603
+ content: options.systemPrompts.join('\n'),
604
+ })
605
+ }
606
+
607
+ // Convert messages
608
+ for (const message of options.messages) {
609
+ messages.push(this.convertMessage(message))
610
+ }
611
+
612
+ const modelOptions = options.modelOptions
613
+
614
+ // Build the request so explicit top-level options win over modelOptions
615
+ // when set, but `undefined` top-level options do NOT clobber values the
616
+ // caller put in modelOptions. Keeping the merge nullish-aware fixes the
617
+ // silent regression where a `modelOptions: { temperature: 0.7 }` setting
618
+ // was overwritten with `temperature: undefined`.
619
+ return {
620
+ ...modelOptions,
621
+ model: options.model,
622
+ messages,
623
+ ...(options.temperature !== undefined && {
624
+ temperature: options.temperature,
625
+ }),
626
+ ...(options.maxTokens !== undefined && {
627
+ max_tokens: options.maxTokens,
628
+ }),
629
+ ...(options.topP !== undefined && { top_p: options.topP }),
630
+ // Conditional spread: `tools: undefined` would clobber any
631
+ // modelOptions.tools the caller set above.
632
+ ...(tools &&
633
+ tools.length > 0 && {
634
+ tools,
635
+ }),
636
+ stream: true,
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Converts a single ModelMessage to the Chat Completions API message format.
642
+ * Override this in subclasses to handle provider-specific message formats.
643
+ */
644
+ protected convertMessage(
645
+ message: ModelMessage,
646
+ ): OpenAI_SDK.Chat.Completions.ChatCompletionMessageParam {
647
+ // Handle tool messages
648
+ if (message.role === 'tool') {
649
+ return {
650
+ role: 'tool',
651
+ tool_call_id: message.toolCallId || '',
652
+ content:
653
+ typeof message.content === 'string'
654
+ ? message.content
655
+ : JSON.stringify(message.content),
656
+ }
657
+ }
658
+
659
+ // Handle assistant messages
660
+ if (message.role === 'assistant') {
661
+ const toolCalls = message.toolCalls?.map((tc) => ({
662
+ id: tc.id,
663
+ type: 'function' as const,
664
+ function: {
665
+ name: tc.function.name,
666
+ arguments:
667
+ typeof tc.function.arguments === 'string'
668
+ ? tc.function.arguments
669
+ : JSON.stringify(tc.function.arguments),
670
+ },
671
+ }))
672
+ const hasToolCalls = !!toolCalls && toolCalls.length > 0
673
+ const textContent = this.extractTextContent(message.content)
674
+
675
+ // Per the OpenAI Chat Completions contract, an assistant message that
676
+ // only carries tool_calls should have `content: null` (or omit content)
677
+ // rather than `content: ''`. Empty-string content interacts oddly with
678
+ // tokenization on some backends; null is the documented shape.
679
+ return {
680
+ role: 'assistant',
681
+ content: hasToolCalls && !textContent ? null : textContent,
682
+ ...(hasToolCalls ? { tool_calls: toolCalls } : {}),
683
+ }
684
+ }
685
+
686
+ // Handle user messages - support multimodal content
687
+ const contentParts = this.normalizeContent(message.content)
688
+
689
+ // If only text, use simple string format
690
+ if (contentParts.length === 1 && contentParts[0]?.type === 'text') {
691
+ const text = contentParts[0].content
692
+ if (text.length === 0) {
693
+ // Single empty text part is the same fail-loud condition as below —
694
+ // an empty paid request mask a real intent (caller passed `null`/'',
695
+ // or an upstream step normalised everything to an empty string).
696
+ throw new Error(
697
+ `User message for ${this.name} has empty text content. ` +
698
+ `Empty user messages would produce a paid request with no input; ` +
699
+ `provide non-empty content or omit the message.`,
700
+ )
701
+ }
702
+ return {
703
+ role: 'user',
704
+ content: text,
705
+ }
706
+ }
707
+
708
+ // Otherwise, use array format for multimodal. Fail fast on unsupported
709
+ // content parts rather than silently dropping them — a message of all
710
+ // unsupported parts would otherwise turn into an empty user prompt and
711
+ // mask a real capability mismatch.
712
+ const parts: Array<OpenAI_SDK.Chat.Completions.ChatCompletionContentPart> =
713
+ []
714
+ for (const part of contentParts) {
715
+ const converted = this.convertContentPart(part)
716
+ if (!converted) {
717
+ throw new Error(
718
+ `Unsupported content part type for ${this.name}: ${part.type}. ` +
719
+ `Override convertContentPart() in a subclass to handle this type, ` +
720
+ `or remove it from the message.`,
721
+ )
722
+ }
723
+ parts.push(converted)
724
+ }
725
+
726
+ if (parts.length === 0) {
727
+ // The original message had no content parts at all (e.g. content was
728
+ // explicitly null or []). Sending an empty user message to OpenAI
729
+ // produces a paid request with no signal — fail loud instead.
730
+ throw new Error(
731
+ `User message for ${this.name} has no content parts. ` +
732
+ `Empty user messages would produce a paid request with no input; ` +
733
+ `provide at least one text/image/audio part or omit the message.`,
734
+ )
735
+ }
736
+
737
+ return {
738
+ role: 'user',
739
+ content: parts,
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Converts a single ContentPart to the Chat Completions API content part format.
745
+ * Override this in subclasses to handle additional content types or provider-specific metadata.
746
+ */
747
+ protected convertContentPart(
748
+ part: ContentPart,
749
+ ): OpenAI_SDK.Chat.Completions.ChatCompletionContentPart | null {
750
+ if (part.type === 'text') {
751
+ return { type: 'text', text: part.content }
752
+ }
753
+
754
+ if (part.type === 'image') {
755
+ const imageMetadata = part.metadata as
756
+ | { detail?: 'auto' | 'low' | 'high' }
757
+ | undefined
758
+
759
+ // For base64 data, construct a data URI using the mimeType from source.
760
+ // Default to a generic octet-stream MIME if the source didn't provide
761
+ // one — interpolating `undefined` into the URI ("data:undefined;base64,
762
+ // ...") would produce an invalid URI the API rejects.
763
+ const imageValue = part.source.value
764
+ const imageMime = part.source.mimeType || 'application/octet-stream'
765
+ const imageUrl =
766
+ part.source.type === 'data' && !imageValue.startsWith('data:')
767
+ ? `data:${imageMime};base64,${imageValue}`
768
+ : imageValue
769
+
770
+ return {
771
+ type: 'image_url',
772
+ image_url: {
773
+ url: imageUrl,
774
+ detail: imageMetadata?.detail || 'auto',
775
+ },
776
+ }
777
+ }
778
+
779
+ // Unsupported content type — subclasses can override to handle more types
780
+ return null
781
+ }
782
+
783
+ /**
784
+ * Normalizes message content to an array of ContentPart.
785
+ * Handles backward compatibility with string content.
786
+ */
787
+ protected normalizeContent(
788
+ content: string | null | Array<ContentPart>,
789
+ ): Array<ContentPart> {
790
+ if (content === null) {
791
+ return []
792
+ }
793
+ if (typeof content === 'string') {
794
+ return [{ type: 'text', content: content }]
795
+ }
796
+ return content
797
+ }
798
+
799
+ /**
800
+ * Extracts text content from a content value that may be string, null, or ContentPart array.
801
+ */
802
+ protected extractTextContent(
803
+ content: string | null | Array<ContentPart>,
804
+ ): string {
805
+ if (content === null) {
806
+ return ''
807
+ }
808
+ if (typeof content === 'string') {
809
+ return content
810
+ }
811
+ // It's an array of ContentPart
812
+ return content
813
+ .filter((p) => p.type === 'text')
814
+ .map((p) => p.content)
815
+ .join('')
816
+ }
817
+ }