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