@tanstack/openai-base 0.2.1 → 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.
- package/README.md +121 -0
- package/dist/esm/adapters/chat-completions-text.d.ts +49 -21
- package/dist/esm/adapters/chat-completions-text.js +476 -68
- package/dist/esm/adapters/chat-completions-text.js.map +1 -1
- package/dist/esm/adapters/chat-completions-tool-converter.d.ts +8 -4
- package/dist/esm/adapters/chat-completions-tool-converter.js.map +1 -1
- package/dist/esm/adapters/responses-text.d.ts +46 -33
- package/dist/esm/adapters/responses-text.js +657 -142
- package/dist/esm/adapters/responses-text.js.map +1 -1
- package/dist/esm/index.d.ts +2 -9
- package/dist/esm/index.js +4 -16
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/tools/apply-patch-tool.d.ts +2 -2
- package/dist/esm/tools/apply-patch-tool.js.map +1 -1
- package/dist/esm/tools/code-interpreter-tool.d.ts +3 -2
- package/dist/esm/tools/code-interpreter-tool.js.map +1 -1
- package/dist/esm/tools/computer-use-tool.d.ts +2 -2
- package/dist/esm/tools/computer-use-tool.js.map +1 -1
- package/dist/esm/tools/custom-tool.d.ts +2 -2
- package/dist/esm/tools/custom-tool.js.map +1 -1
- package/dist/esm/tools/file-search-tool.d.ts +2 -2
- package/dist/esm/tools/file-search-tool.js.map +1 -1
- package/dist/esm/tools/function-tool.d.ts +2 -2
- package/dist/esm/tools/function-tool.js.map +1 -1
- package/dist/esm/tools/image-generation-tool.d.ts +3 -2
- package/dist/esm/tools/image-generation-tool.js.map +1 -1
- package/dist/esm/tools/local-shell-tool.d.ts +3 -2
- package/dist/esm/tools/local-shell-tool.js.map +1 -1
- package/dist/esm/tools/mcp-tool.d.ts +3 -2
- package/dist/esm/tools/mcp-tool.js.map +1 -1
- package/dist/esm/tools/shell-tool.d.ts +2 -2
- package/dist/esm/tools/shell-tool.js.map +1 -1
- package/dist/esm/tools/web-search-preview-tool.d.ts +2 -2
- package/dist/esm/tools/web-search-preview-tool.js.map +1 -1
- package/dist/esm/tools/web-search-tool.d.ts +2 -2
- package/dist/esm/tools/web-search-tool.js.map +1 -1
- package/package.json +6 -6
- package/src/adapters/chat-completions-text.ts +601 -117
- package/src/adapters/chat-completions-tool-converter.ts +9 -5
- package/src/adapters/responses-text.ts +865 -210
- package/src/index.ts +2 -12
- package/src/tools/apply-patch-tool.ts +2 -2
- package/src/tools/code-interpreter-tool.ts +4 -2
- package/src/tools/computer-use-tool.ts +2 -2
- package/src/tools/custom-tool.ts +2 -2
- package/src/tools/file-search-tool.ts +3 -3
- package/src/tools/function-tool.ts +2 -2
- package/src/tools/image-generation-tool.ts +4 -2
- package/src/tools/local-shell-tool.ts +4 -2
- package/src/tools/mcp-tool.ts +4 -2
- package/src/tools/shell-tool.ts +2 -2
- package/src/tools/web-search-preview-tool.ts +2 -2
- package/src/tools/web-search-tool.ts +2 -2
- package/dist/esm/adapters/image.d.ts +0 -32
- package/dist/esm/adapters/image.js +0 -89
- package/dist/esm/adapters/image.js.map +0 -1
- package/dist/esm/adapters/summarize.d.ts +0 -28
- package/dist/esm/adapters/summarize.js +0 -112
- package/dist/esm/adapters/summarize.js.map +0 -1
- package/dist/esm/adapters/transcription.d.ts +0 -34
- package/dist/esm/adapters/transcription.js +0 -131
- package/dist/esm/adapters/transcription.js.map +0 -1
- package/dist/esm/adapters/tts.d.ts +0 -26
- package/dist/esm/adapters/tts.js +0 -78
- package/dist/esm/adapters/tts.js.map +0 -1
- package/dist/esm/adapters/video.d.ts +0 -72
- package/dist/esm/adapters/video.js +0 -238
- package/dist/esm/adapters/video.js.map +0 -1
- package/dist/esm/types/config.d.ts +0 -4
- package/dist/esm/utils/client.d.ts +0 -3
- package/dist/esm/utils/client.js +0 -8
- package/dist/esm/utils/client.js.map +0 -1
- package/src/adapters/image.ts +0 -158
- package/src/adapters/summarize.ts +0 -174
- package/src/adapters/transcription.ts +0 -194
- package/src/adapters/tts.ts +0 -124
- package/src/adapters/video.ts +0 -385
- package/src/types/config.ts +0 -5
- 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
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
35
|
+
export abstract class OpenAIBaseChatCompletionsTextAdapter<
|
|
42
36
|
TModel extends string,
|
|
43
|
-
TProviderOptions extends Record<string,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
111
|
-
type:
|
|
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
|
|
120
|
-
type:
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
|
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
|
|
302
|
-
type:
|
|
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
|
|
323
|
-
type:
|
|
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
|
|
335
|
-
type:
|
|
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
|
|
376
|
-
type:
|
|
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
|
|
389
|
-
type:
|
|
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
|
|
449
|
-
type:
|
|
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
|
|
468
|
-
type:
|
|
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
|
|
505
|
-
type:
|
|
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
|
|
522
|
-
type:
|
|
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
|
-
//
|
|
530
|
-
//
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
572
|
-
type:
|
|
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
|
-
):
|
|
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<
|
|
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<
|
|
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
|
-
):
|
|
1233
|
+
): ChatCompletionContentPart | null {
|
|
750
1234
|
if (part.type === 'text') {
|
|
751
1235
|
return { type: 'text', text: part.content }
|
|
752
1236
|
}
|