@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.
- package/dist/esm/adapters/chat-completions-text.d.ts +76 -0
- package/dist/esm/adapters/chat-completions-text.js +411 -0
- package/dist/esm/adapters/chat-completions-text.js.map +1 -0
- package/dist/esm/adapters/chat-completions-tool-converter.d.ts +24 -0
- package/dist/esm/adapters/chat-completions-tool-converter.js +29 -0
- package/dist/esm/adapters/chat-completions-tool-converter.js.map +1 -0
- package/dist/esm/adapters/image.d.ts +32 -0
- package/dist/esm/adapters/image.js +69 -0
- package/dist/esm/adapters/image.js.map +1 -0
- package/dist/esm/adapters/responses-text.d.ts +115 -0
- package/dist/esm/adapters/responses-text.js +635 -0
- package/dist/esm/adapters/responses-text.js.map +1 -0
- package/dist/esm/adapters/responses-tool-converter.d.ts +35 -0
- package/dist/esm/adapters/responses-tool-converter.js +27 -0
- package/dist/esm/adapters/responses-tool-converter.js.map +1 -0
- package/dist/esm/adapters/summarize.d.ts +28 -0
- package/dist/esm/adapters/summarize.js +74 -0
- package/dist/esm/adapters/summarize.js.map +1 -0
- package/dist/esm/adapters/transcription.d.ts +39 -0
- package/dist/esm/adapters/transcription.js +139 -0
- package/dist/esm/adapters/transcription.js.map +1 -0
- package/dist/esm/adapters/tts.d.ts +26 -0
- package/dist/esm/adapters/tts.js +65 -0
- package/dist/esm/adapters/tts.js.map +1 -0
- package/dist/esm/adapters/video.d.ts +48 -0
- package/dist/esm/adapters/video.js +192 -0
- package/dist/esm/adapters/video.js.map +1 -0
- package/dist/esm/index.d.ts +15 -0
- package/dist/esm/index.js +65 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/tools/apply-patch-tool.d.ts +11 -0
- package/dist/esm/tools/apply-patch-tool.js +17 -0
- package/dist/esm/tools/apply-patch-tool.js.map +1 -0
- package/dist/esm/tools/code-interpreter-tool.d.ts +11 -0
- package/dist/esm/tools/code-interpreter-tool.js +22 -0
- package/dist/esm/tools/code-interpreter-tool.js.map +1 -0
- package/dist/esm/tools/computer-use-tool.d.ts +11 -0
- package/dist/esm/tools/computer-use-tool.js +23 -0
- package/dist/esm/tools/computer-use-tool.js.map +1 -0
- package/dist/esm/tools/custom-tool.d.ts +11 -0
- package/dist/esm/tools/custom-tool.js +23 -0
- package/dist/esm/tools/custom-tool.js.map +1 -0
- package/dist/esm/tools/file-search-tool.d.ts +11 -0
- package/dist/esm/tools/file-search-tool.js +30 -0
- package/dist/esm/tools/file-search-tool.js.map +1 -0
- package/dist/esm/tools/function-tool.d.ts +15 -0
- package/dist/esm/tools/function-tool.js +24 -0
- package/dist/esm/tools/function-tool.js.map +1 -0
- package/dist/esm/tools/image-generation-tool.d.ts +11 -0
- package/dist/esm/tools/image-generation-tool.js +27 -0
- package/dist/esm/tools/image-generation-tool.js.map +1 -0
- package/dist/esm/tools/index.d.ts +27 -0
- package/dist/esm/tools/local-shell-tool.d.ts +11 -0
- package/dist/esm/tools/local-shell-tool.js +17 -0
- package/dist/esm/tools/local-shell-tool.js.map +1 -0
- package/dist/esm/tools/mcp-tool.d.ts +12 -0
- package/dist/esm/tools/mcp-tool.js +31 -0
- package/dist/esm/tools/mcp-tool.js.map +1 -0
- package/dist/esm/tools/shell-tool.d.ts +11 -0
- package/dist/esm/tools/shell-tool.js +17 -0
- package/dist/esm/tools/shell-tool.js.map +1 -0
- package/dist/esm/tools/tool-choice.d.ts +17 -0
- package/dist/esm/tools/tool-converter.d.ts +6 -0
- package/dist/esm/tools/tool-converter.js +61 -0
- package/dist/esm/tools/tool-converter.js.map +1 -0
- package/dist/esm/tools/web-search-preview-tool.d.ts +11 -0
- package/dist/esm/tools/web-search-preview-tool.js +20 -0
- package/dist/esm/tools/web-search-preview-tool.js.map +1 -0
- package/dist/esm/tools/web-search-tool.d.ts +11 -0
- package/dist/esm/tools/web-search-tool.js +16 -0
- package/dist/esm/tools/web-search-tool.js.map +1 -0
- package/dist/esm/types/config.d.ts +4 -0
- package/dist/esm/types/message-metadata.d.ts +19 -0
- package/dist/esm/types/provider-options.d.ts +40 -0
- package/dist/esm/utils/client.d.ts +3 -0
- package/dist/esm/utils/client.js +8 -0
- package/dist/esm/utils/client.js.map +1 -0
- package/dist/esm/utils/schema-converter.d.ts +12 -0
- package/dist/esm/utils/schema-converter.js +65 -0
- package/dist/esm/utils/schema-converter.js.map +1 -0
- package/package.json +57 -0
- package/src/adapters/chat-completions-text.ts +817 -0
- package/src/adapters/chat-completions-tool-converter.ts +70 -0
- package/src/adapters/image.ts +158 -0
- package/src/adapters/responses-text.ts +1147 -0
- package/src/adapters/responses-tool-converter.ts +77 -0
- package/src/adapters/summarize.ts +174 -0
- package/src/adapters/transcription.ts +194 -0
- package/src/adapters/tts.ts +124 -0
- package/src/adapters/video.ts +385 -0
- package/src/index.ts +24 -0
- package/src/tools/apply-patch-tool.ts +32 -0
- package/src/tools/code-interpreter-tool.ts +39 -0
- package/src/tools/computer-use-tool.ts +38 -0
- package/src/tools/custom-tool.ts +33 -0
- package/src/tools/file-search-tool.ts +51 -0
- package/src/tools/function-tool.ts +44 -0
- package/src/tools/image-generation-tool.ts +51 -0
- package/src/tools/index.ts +41 -0
- package/src/tools/local-shell-tool.ts +32 -0
- package/src/tools/mcp-tool.ts +47 -0
- package/src/tools/shell-tool.ts +30 -0
- package/src/tools/tool-choice.ts +31 -0
- package/src/tools/tool-converter.ts +68 -0
- package/src/tools/web-search-preview-tool.ts +39 -0
- package/src/tools/web-search-tool.ts +38 -0
- package/src/types/config.ts +5 -0
- package/src/utils/client.ts +8 -0
- package/src/utils/request-options.ts +16 -0
- 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
|
+
}
|