@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,16 +1,22 @@
|
|
|
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 { convertToolsToResponsesFormat } from './responses-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
|
-
|
|
13
|
+
import type {
|
|
14
|
+
Response,
|
|
15
|
+
ResponseCreateParams,
|
|
16
|
+
ResponseInput,
|
|
17
|
+
ResponseInputContent,
|
|
18
|
+
ResponseStreamEvent,
|
|
19
|
+
} from 'openai/resources/responses/responses'
|
|
14
20
|
import type {
|
|
15
21
|
ContentPart,
|
|
16
22
|
DefaultMessageMetadataByModality,
|
|
@@ -19,39 +25,16 @@ import type {
|
|
|
19
25
|
StreamChunk,
|
|
20
26
|
TextOptions,
|
|
21
27
|
} from '@tanstack/ai'
|
|
22
|
-
import type { OpenAICompatibleClientConfig } from '../types/config'
|
|
23
|
-
|
|
24
|
-
/** Cast an event object to StreamChunk. Adapters construct events with string
|
|
25
|
-
* literal types which are structurally compatible with the EventType enum. */
|
|
26
|
-
const asChunk = (chunk: Record<string, unknown>) =>
|
|
27
|
-
chunk as unknown as StreamChunk
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* OpenAI
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* that implement the Responses API can extend this class and only need to:
|
|
35
|
-
* - Set `baseURL` in the config
|
|
36
|
-
* - Lock the generic type parameters to provider-specific types
|
|
37
|
-
* - Override specific methods for quirks
|
|
38
|
-
*
|
|
39
|
-
* Key differences from the Chat Completions adapter:
|
|
40
|
-
* - Uses `client.responses.create()` instead of `client.chat.completions.create()`
|
|
41
|
-
* - Messages use `ResponseInput` format
|
|
42
|
-
* - System prompts go in `instructions` field, not as array messages
|
|
43
|
-
* - Streaming events are completely different (9+ event types vs simple delta chunks)
|
|
44
|
-
* - Supports reasoning/thinking tokens via `response.reasoning_text.delta`
|
|
45
|
-
* - Structured output uses `text.format` in the request (not `response_format`)
|
|
46
|
-
* - Tool calls use `response.function_call_arguments.delta`
|
|
47
|
-
* - Content parts are `input_text`, `input_image`, `input_file`
|
|
48
|
-
*
|
|
49
|
-
* All methods that build requests or process responses are `protected` so subclasses
|
|
50
|
-
* can override them.
|
|
30
|
+
* Shared implementation of the OpenAI Responses API. Holds the stream-event
|
|
31
|
+
* accumulator + AG-UI lifecycle and calls the OpenAI SDK directly. Subclasses
|
|
32
|
+
* (today: ai-openai) construct an OpenAI client with their provider-specific
|
|
33
|
+
* `baseURL` / headers and pass it in.
|
|
51
34
|
*/
|
|
52
|
-
export class
|
|
35
|
+
export abstract class OpenAIBaseResponsesTextAdapter<
|
|
53
36
|
TModel extends string,
|
|
54
|
-
TProviderOptions extends Record<string,
|
|
37
|
+
TProviderOptions extends Record<string, unknown> = Record<string, unknown>,
|
|
55
38
|
TInputModalities extends ReadonlyArray<Modality> = ReadonlyArray<Modality>,
|
|
56
39
|
TMessageMetadata extends DefaultMessageMetadataByModality =
|
|
57
40
|
DefaultMessageMetadataByModality,
|
|
@@ -65,17 +48,12 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
65
48
|
> {
|
|
66
49
|
readonly kind = 'text' as const
|
|
67
50
|
readonly name: string
|
|
51
|
+
protected client: OpenAI
|
|
68
52
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
constructor(
|
|
72
|
-
config: OpenAICompatibleClientConfig,
|
|
73
|
-
model: TModel,
|
|
74
|
-
name: string = 'openai-compatible-responses',
|
|
75
|
-
) {
|
|
53
|
+
constructor(model: TModel, name: string, client: OpenAI) {
|
|
76
54
|
super({}, model)
|
|
77
55
|
this.name = name
|
|
78
|
-
this.client =
|
|
56
|
+
this.client = client
|
|
79
57
|
}
|
|
80
58
|
|
|
81
59
|
async *chatStream(
|
|
@@ -87,20 +65,34 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
87
65
|
// We assign our own indices as we encounter unique tool call IDs.
|
|
88
66
|
const toolCallMetadata = new Map<
|
|
89
67
|
string,
|
|
90
|
-
{
|
|
68
|
+
{
|
|
69
|
+
index: number
|
|
70
|
+
name: string
|
|
71
|
+
started: boolean
|
|
72
|
+
// Set once TOOL_CALL_END has been emitted (via args.done or the
|
|
73
|
+
// output_item.done backfill) so the two paths don't double-emit.
|
|
74
|
+
ended?: boolean
|
|
75
|
+
// Set when args.done arrives before TOOL_CALL_START could fire
|
|
76
|
+
// (output_item.added lacked a name). output_item.done picks these
|
|
77
|
+
// up to emit the missing END.
|
|
78
|
+
pendingArguments?: string
|
|
79
|
+
}
|
|
91
80
|
>()
|
|
92
|
-
const requestParams = this.mapOptionsToRequest(options)
|
|
93
|
-
const timestamp = Date.now()
|
|
94
81
|
|
|
95
82
|
// AG-UI lifecycle tracking
|
|
96
83
|
const aguiState = {
|
|
97
84
|
runId: generateId(this.name),
|
|
85
|
+
threadId: options.threadId ?? generateId(this.name),
|
|
98
86
|
messageId: generateId(this.name),
|
|
99
|
-
timestamp,
|
|
100
87
|
hasEmittedRunStarted: false,
|
|
101
88
|
}
|
|
102
89
|
|
|
103
90
|
try {
|
|
91
|
+
// mapOptionsToRequest can throw on caller-side validation failures
|
|
92
|
+
// (empty user content, unsupported parts, webSearchTool() rejection in
|
|
93
|
+
// the OpenRouter override). Keep it inside the try so those failures
|
|
94
|
+
// surface as RUN_ERROR events instead of iterator throws.
|
|
95
|
+
const requestParams = this.mapOptionsToRequest(options)
|
|
104
96
|
options.logger.request(
|
|
105
97
|
`activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`,
|
|
106
98
|
{ provider: this.name, model: this.model },
|
|
@@ -130,22 +122,24 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
130
122
|
// Emit RUN_STARTED if not yet emitted
|
|
131
123
|
if (!aguiState.hasEmittedRunStarted) {
|
|
132
124
|
aguiState.hasEmittedRunStarted = true
|
|
133
|
-
yield
|
|
134
|
-
type:
|
|
125
|
+
yield {
|
|
126
|
+
type: EventType.RUN_STARTED,
|
|
135
127
|
runId: aguiState.runId,
|
|
128
|
+
threadId: aguiState.threadId,
|
|
136
129
|
model: options.model,
|
|
137
|
-
timestamp,
|
|
138
|
-
}
|
|
130
|
+
timestamp: Date.now(),
|
|
131
|
+
}
|
|
139
132
|
}
|
|
140
133
|
|
|
141
134
|
// Emit AG-UI RUN_ERROR
|
|
142
|
-
yield
|
|
143
|
-
type:
|
|
144
|
-
runId: aguiState.runId,
|
|
135
|
+
yield {
|
|
136
|
+
type: EventType.RUN_ERROR,
|
|
145
137
|
model: options.model,
|
|
146
|
-
timestamp,
|
|
138
|
+
timestamp: Date.now(),
|
|
139
|
+
message: errorPayload.message,
|
|
140
|
+
code: errorPayload.code,
|
|
147
141
|
error: errorPayload,
|
|
148
|
-
}
|
|
142
|
+
}
|
|
149
143
|
|
|
150
144
|
options.logger.errors(`${this.name}.chatStream fatal`, {
|
|
151
145
|
error: errorPayload,
|
|
@@ -195,10 +189,7 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
195
189
|
)
|
|
196
190
|
const response = await this.client.responses.create(
|
|
197
191
|
{
|
|
198
|
-
...(cleanParams as Omit<
|
|
199
|
-
OpenAI_SDK.Responses.ResponseCreateParams,
|
|
200
|
-
'stream'
|
|
201
|
-
>),
|
|
192
|
+
...(cleanParams as Omit<ResponseCreateParams, 'stream'>),
|
|
202
193
|
stream: false,
|
|
203
194
|
// Configure structured output via text.format
|
|
204
195
|
text: {
|
|
@@ -217,9 +208,17 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
217
208
|
// SDK return type to `Response`, but the explicit annotation makes
|
|
218
209
|
// that contract local rather than relying on inference through the
|
|
219
210
|
// overloaded `client.responses.create` signature.
|
|
220
|
-
const rawText = this.extractTextFromResponse(
|
|
221
|
-
|
|
222
|
-
|
|
211
|
+
const rawText = this.extractTextFromResponse(response satisfies Response)
|
|
212
|
+
|
|
213
|
+
// Fail loud on empty content rather than letting it cascade into a
|
|
214
|
+
// confusing "Failed to parse JSON. Content: " error — the root cause
|
|
215
|
+
// (the model returned no text content for the structured request) is
|
|
216
|
+
// then visible in logs. Mirrors the chat-completions sibling.
|
|
217
|
+
if (rawText.length === 0) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`${this.name}.structuredOutput: response contained no content`,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
223
222
|
|
|
224
223
|
// Parse the JSON response
|
|
225
224
|
let parsed: unknown
|
|
@@ -231,9 +230,13 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
231
230
|
)
|
|
232
231
|
}
|
|
233
232
|
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
233
|
+
// Apply the provider-specific post-parse shaping (default: null →
|
|
234
|
+
// undefined to align with the original Zod schema's optional-field
|
|
235
|
+
// semantics; subclasses with different conventions can override
|
|
236
|
+
// `transformStructuredOutput`, mirroring the chat-completions base's
|
|
237
|
+
// hook so OpenRouter and other providers that preserve nulls in
|
|
238
|
+
// structured output can opt out without forking `structuredOutput`).
|
|
239
|
+
const transformed = this.transformStructuredOutput(parsed)
|
|
237
240
|
|
|
238
241
|
return {
|
|
239
242
|
data: transformed,
|
|
@@ -250,6 +253,387 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
250
253
|
}
|
|
251
254
|
}
|
|
252
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Stream structured output via the Responses API: single request with
|
|
258
|
+
* `text.format: json_schema` + `stream: true`. Consumes Responses-API
|
|
259
|
+
* events (`response.output_text.delta`, `response.reasoning_text.delta`,
|
|
260
|
+
* `response.reasoning_summary_text.delta`, `response.refusal.delta`,
|
|
261
|
+
* `response.completed`, `response.failed`) and re-emits the standard AG-UI
|
|
262
|
+
* lifecycle ending with `CUSTOM 'structured-output.complete'`.
|
|
263
|
+
*
|
|
264
|
+
* Tools are stripped (structured output is mutually exclusive with tool
|
|
265
|
+
* calls in this path). Reasoning text is accumulated and surfaced both as
|
|
266
|
+
* REASONING_* lifecycle events during the stream and on the terminal
|
|
267
|
+
* CUSTOM event's `value.reasoning`.
|
|
268
|
+
*/
|
|
269
|
+
async *structuredOutputStream(
|
|
270
|
+
options: StructuredOutputOptions<TProviderOptions>,
|
|
271
|
+
): AsyncIterable<StreamChunk> {
|
|
272
|
+
const { chatOptions, outputSchema } = options
|
|
273
|
+
const requestParams = this.mapOptionsToRequest(chatOptions)
|
|
274
|
+
|
|
275
|
+
const jsonSchema = this.makeStructuredOutputCompatible(
|
|
276
|
+
outputSchema,
|
|
277
|
+
outputSchema.required,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
const timestamp = Date.now()
|
|
281
|
+
const aguiState = {
|
|
282
|
+
runId: generateId(this.name),
|
|
283
|
+
threadId: chatOptions.threadId ?? generateId(this.name),
|
|
284
|
+
messageId: generateId(this.name),
|
|
285
|
+
timestamp,
|
|
286
|
+
hasEmittedRunStarted: false,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let accumulatedContent = ''
|
|
290
|
+
let accumulatedReasoning = ''
|
|
291
|
+
let hasEmittedTextMessageStart = false
|
|
292
|
+
let reasoningMessageId: string | undefined
|
|
293
|
+
let stepId: string | undefined
|
|
294
|
+
let hasClosedReasoning = false
|
|
295
|
+
let model: string = chatOptions.model
|
|
296
|
+
let usage: OpenAI.Responses.Response['usage'] | undefined
|
|
297
|
+
|
|
298
|
+
const closeReasoning = function* (this: {
|
|
299
|
+
name: string
|
|
300
|
+
}): Generator<StreamChunk> {
|
|
301
|
+
if (reasoningMessageId && !hasClosedReasoning) {
|
|
302
|
+
hasClosedReasoning = true
|
|
303
|
+
yield {
|
|
304
|
+
type: EventType.REASONING_MESSAGE_END,
|
|
305
|
+
messageId: reasoningMessageId,
|
|
306
|
+
model,
|
|
307
|
+
timestamp,
|
|
308
|
+
}
|
|
309
|
+
yield {
|
|
310
|
+
type: EventType.REASONING_END,
|
|
311
|
+
messageId: reasoningMessageId,
|
|
312
|
+
model,
|
|
313
|
+
timestamp,
|
|
314
|
+
}
|
|
315
|
+
if (stepId) {
|
|
316
|
+
yield {
|
|
317
|
+
type: EventType.STEP_FINISHED,
|
|
318
|
+
stepName: stepId,
|
|
319
|
+
stepId,
|
|
320
|
+
model,
|
|
321
|
+
timestamp,
|
|
322
|
+
content: accumulatedReasoning,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}.bind(this)
|
|
327
|
+
|
|
328
|
+
const openReasoning = function* (this: {
|
|
329
|
+
name: string
|
|
330
|
+
}): Generator<StreamChunk> {
|
|
331
|
+
if (reasoningMessageId) return
|
|
332
|
+
reasoningMessageId = generateId(this.name)
|
|
333
|
+
stepId = generateId(this.name)
|
|
334
|
+
yield {
|
|
335
|
+
type: EventType.REASONING_START,
|
|
336
|
+
messageId: reasoningMessageId,
|
|
337
|
+
model,
|
|
338
|
+
timestamp,
|
|
339
|
+
}
|
|
340
|
+
yield {
|
|
341
|
+
type: EventType.REASONING_MESSAGE_START,
|
|
342
|
+
messageId: reasoningMessageId,
|
|
343
|
+
role: 'reasoning' as const,
|
|
344
|
+
model,
|
|
345
|
+
timestamp,
|
|
346
|
+
}
|
|
347
|
+
yield {
|
|
348
|
+
type: EventType.STEP_STARTED,
|
|
349
|
+
stepName: stepId,
|
|
350
|
+
stepId,
|
|
351
|
+
model,
|
|
352
|
+
timestamp,
|
|
353
|
+
stepType: 'thinking',
|
|
354
|
+
}
|
|
355
|
+
}.bind(this)
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const { tools: _tools, ...cleanParams } = requestParams
|
|
359
|
+
void _tools
|
|
360
|
+
|
|
361
|
+
chatOptions.logger.request(
|
|
362
|
+
`activity=structuredOutputStream provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`,
|
|
363
|
+
{ provider: this.name, model: this.model },
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
const stream = await this.client.responses.create(
|
|
367
|
+
{
|
|
368
|
+
...cleanParams,
|
|
369
|
+
stream: true,
|
|
370
|
+
text: {
|
|
371
|
+
format: {
|
|
372
|
+
type: 'json_schema',
|
|
373
|
+
name: 'structured_output',
|
|
374
|
+
schema: jsonSchema,
|
|
375
|
+
strict: true,
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
extractRequestOptions(chatOptions.request),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
for await (const chunk of stream) {
|
|
383
|
+
chatOptions.logger.provider(
|
|
384
|
+
`provider=${this.name} type=${chunk.type}`,
|
|
385
|
+
{ provider: this.name, type: chunk.type },
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if (!aguiState.hasEmittedRunStarted) {
|
|
389
|
+
aguiState.hasEmittedRunStarted = true
|
|
390
|
+
yield {
|
|
391
|
+
type: EventType.RUN_STARTED,
|
|
392
|
+
runId: aguiState.runId,
|
|
393
|
+
threadId: aguiState.threadId,
|
|
394
|
+
model,
|
|
395
|
+
timestamp,
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (
|
|
400
|
+
chunk.type === 'response.created' ||
|
|
401
|
+
chunk.type === 'response.in_progress'
|
|
402
|
+
) {
|
|
403
|
+
const responseModel = (chunk as { response?: { model?: string } })
|
|
404
|
+
.response?.model
|
|
405
|
+
if (responseModel) model = responseModel
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (chunk.type === 'response.refusal.delta') {
|
|
410
|
+
const delta =
|
|
411
|
+
typeof (chunk as { delta?: unknown }).delta === 'string'
|
|
412
|
+
? (chunk as { delta: string }).delta
|
|
413
|
+
: ''
|
|
414
|
+
yield {
|
|
415
|
+
type: EventType.RUN_ERROR,
|
|
416
|
+
runId: aguiState.runId,
|
|
417
|
+
model,
|
|
418
|
+
timestamp,
|
|
419
|
+
message: `Model refused: ${delta}`,
|
|
420
|
+
code: 'refusal',
|
|
421
|
+
error: { message: `Model refused: ${delta}`, code: 'refusal' },
|
|
422
|
+
}
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (
|
|
427
|
+
chunk.type === 'response.reasoning_text.delta' ||
|
|
428
|
+
chunk.type === 'response.reasoning_summary_text.delta'
|
|
429
|
+
) {
|
|
430
|
+
const raw = (chunk as { delta?: unknown }).delta
|
|
431
|
+
const reasoningDelta = Array.isArray(raw)
|
|
432
|
+
? raw.join('')
|
|
433
|
+
: typeof raw === 'string'
|
|
434
|
+
? raw
|
|
435
|
+
: ''
|
|
436
|
+
if (!reasoningDelta) continue
|
|
437
|
+
yield* openReasoning()
|
|
438
|
+
// openReasoning() guarantees reasoningMessageId is set on first call;
|
|
439
|
+
// TS can't see through the generator side-effect.
|
|
440
|
+
const messageId = reasoningMessageId!
|
|
441
|
+
accumulatedReasoning += reasoningDelta
|
|
442
|
+
yield {
|
|
443
|
+
type: EventType.REASONING_MESSAGE_CONTENT,
|
|
444
|
+
messageId,
|
|
445
|
+
delta: reasoningDelta,
|
|
446
|
+
model,
|
|
447
|
+
timestamp,
|
|
448
|
+
}
|
|
449
|
+
continue
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (chunk.type === 'response.output_text.delta') {
|
|
453
|
+
const raw = (chunk as { delta?: unknown }).delta
|
|
454
|
+
const textDelta = Array.isArray(raw)
|
|
455
|
+
? raw.join('')
|
|
456
|
+
: typeof raw === 'string'
|
|
457
|
+
? raw
|
|
458
|
+
: ''
|
|
459
|
+
if (!textDelta) continue
|
|
460
|
+
|
|
461
|
+
yield* closeReasoning()
|
|
462
|
+
|
|
463
|
+
if (!hasEmittedTextMessageStart) {
|
|
464
|
+
hasEmittedTextMessageStart = true
|
|
465
|
+
yield {
|
|
466
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
467
|
+
messageId: aguiState.messageId,
|
|
468
|
+
model,
|
|
469
|
+
timestamp,
|
|
470
|
+
role: 'assistant',
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
accumulatedContent += textDelta
|
|
474
|
+
yield {
|
|
475
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
476
|
+
messageId: aguiState.messageId,
|
|
477
|
+
model,
|
|
478
|
+
timestamp,
|
|
479
|
+
delta: textDelta,
|
|
480
|
+
content: accumulatedContent,
|
|
481
|
+
}
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (chunk.type === 'response.completed') {
|
|
486
|
+
const response = chunk.response
|
|
487
|
+
if (response.usage) usage = response.usage
|
|
488
|
+
if (response.model) model = response.model
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (chunk.type === 'response.failed') {
|
|
493
|
+
const response = (
|
|
494
|
+
chunk as {
|
|
495
|
+
response?: { error?: { message?: string; code?: string } }
|
|
496
|
+
}
|
|
497
|
+
).response
|
|
498
|
+
const message =
|
|
499
|
+
response?.error?.message || 'Responses API stream failed'
|
|
500
|
+
yield {
|
|
501
|
+
type: EventType.RUN_ERROR,
|
|
502
|
+
runId: aguiState.runId,
|
|
503
|
+
model,
|
|
504
|
+
timestamp,
|
|
505
|
+
message,
|
|
506
|
+
code: response?.error?.code,
|
|
507
|
+
error: { message, code: response?.error?.code },
|
|
508
|
+
}
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
yield* closeReasoning()
|
|
514
|
+
|
|
515
|
+
if (hasEmittedTextMessageStart) {
|
|
516
|
+
yield {
|
|
517
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
518
|
+
messageId: aguiState.messageId,
|
|
519
|
+
model,
|
|
520
|
+
timestamp,
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (accumulatedContent.length === 0) {
|
|
525
|
+
yield {
|
|
526
|
+
type: EventType.RUN_ERROR,
|
|
527
|
+
runId: aguiState.runId,
|
|
528
|
+
model,
|
|
529
|
+
timestamp,
|
|
530
|
+
message: `${this.name}.structuredOutputStream: response contained no content`,
|
|
531
|
+
code: 'empty-response',
|
|
532
|
+
error: {
|
|
533
|
+
message: `${this.name}.structuredOutputStream: response contained no content`,
|
|
534
|
+
code: 'empty-response',
|
|
535
|
+
},
|
|
536
|
+
}
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
let parsed: unknown
|
|
541
|
+
try {
|
|
542
|
+
parsed = JSON.parse(accumulatedContent)
|
|
543
|
+
} catch {
|
|
544
|
+
yield {
|
|
545
|
+
type: EventType.RUN_ERROR,
|
|
546
|
+
runId: aguiState.runId,
|
|
547
|
+
model,
|
|
548
|
+
timestamp,
|
|
549
|
+
message: `Failed to parse structured output as JSON. Content: ${accumulatedContent.slice(0, 200)}${accumulatedContent.length > 200 ? '...' : ''}`,
|
|
550
|
+
code: 'parse-error',
|
|
551
|
+
error: {
|
|
552
|
+
message: 'Failed to parse structured output as JSON',
|
|
553
|
+
code: 'parse-error',
|
|
554
|
+
},
|
|
555
|
+
}
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const transformed = transformNullsToUndefined(parsed)
|
|
560
|
+
|
|
561
|
+
yield {
|
|
562
|
+
type: EventType.CUSTOM,
|
|
563
|
+
name: 'structured-output.complete',
|
|
564
|
+
value: {
|
|
565
|
+
object: transformed,
|
|
566
|
+
raw: accumulatedContent,
|
|
567
|
+
...(accumulatedReasoning ? { reasoning: accumulatedReasoning } : {}),
|
|
568
|
+
},
|
|
569
|
+
model,
|
|
570
|
+
timestamp,
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
yield {
|
|
574
|
+
type: EventType.RUN_FINISHED,
|
|
575
|
+
runId: aguiState.runId,
|
|
576
|
+
threadId: aguiState.threadId,
|
|
577
|
+
model,
|
|
578
|
+
timestamp,
|
|
579
|
+
finishReason: 'stop',
|
|
580
|
+
...(usage && {
|
|
581
|
+
usage: {
|
|
582
|
+
promptTokens: usage.input_tokens,
|
|
583
|
+
completionTokens: usage.output_tokens,
|
|
584
|
+
totalTokens: usage.total_tokens,
|
|
585
|
+
},
|
|
586
|
+
}),
|
|
587
|
+
}
|
|
588
|
+
} catch (error: unknown) {
|
|
589
|
+
if (!aguiState.hasEmittedRunStarted) {
|
|
590
|
+
aguiState.hasEmittedRunStarted = true
|
|
591
|
+
yield {
|
|
592
|
+
type: EventType.RUN_STARTED,
|
|
593
|
+
runId: aguiState.runId,
|
|
594
|
+
threadId: aguiState.threadId,
|
|
595
|
+
model,
|
|
596
|
+
timestamp,
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const isAbort = this.isAbortError(error)
|
|
601
|
+
const errorPayload = toRunErrorPayload(
|
|
602
|
+
error,
|
|
603
|
+
`${this.name}.structuredOutputStream failed`,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
yield {
|
|
607
|
+
type: EventType.RUN_ERROR,
|
|
608
|
+
runId: aguiState.runId,
|
|
609
|
+
model,
|
|
610
|
+
timestamp,
|
|
611
|
+
message: errorPayload.message,
|
|
612
|
+
code: isAbort ? 'aborted' : errorPayload.code,
|
|
613
|
+
error: { ...errorPayload, ...(isAbort && { code: 'aborted' }) },
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
chatOptions.logger.errors(`${this.name}.structuredOutputStream fatal`, {
|
|
617
|
+
error: errorPayload,
|
|
618
|
+
source: `${this.name}.structuredOutputStream`,
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Cross-SDK abort detection for `structuredOutputStream`. Mirrors the
|
|
625
|
+
* Chat Completions base; subclasses with proprietary error types override.
|
|
626
|
+
*/
|
|
627
|
+
protected isAbortError(error: unknown): boolean {
|
|
628
|
+
if (!error || typeof error !== 'object') return false
|
|
629
|
+
const e = error as { name?: unknown; code?: unknown }
|
|
630
|
+
return (
|
|
631
|
+
e.name === 'APIUserAbortError' ||
|
|
632
|
+
e.name === 'AbortError' ||
|
|
633
|
+
e.code === 'ERR_CANCELED'
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
|
|
253
637
|
/**
|
|
254
638
|
* Applies provider-specific transformations for structured output compatibility.
|
|
255
639
|
* Override this in subclasses to handle provider-specific quirks.
|
|
@@ -261,26 +645,49 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
261
645
|
return makeStructuredOutputCompatible(schema, originalRequired)
|
|
262
646
|
}
|
|
263
647
|
|
|
648
|
+
/**
|
|
649
|
+
* Final shaping pass applied to parsed structured-output JSON before it is
|
|
650
|
+
* returned to the caller. Default converts `null` values to `undefined` so
|
|
651
|
+
* the result aligns with the original Zod schema's optional-field
|
|
652
|
+
* semantics. Subclasses with different conventions (OpenRouter historically
|
|
653
|
+
* preserves nulls) can override — mirrors the chat-completions base's hook
|
|
654
|
+
* so a subclass that opts out of null-stripping doesn't have to fork the
|
|
655
|
+
* whole `structuredOutput` method.
|
|
656
|
+
*/
|
|
657
|
+
protected transformStructuredOutput(parsed: unknown): unknown {
|
|
658
|
+
return transformNullsToUndefined(parsed)
|
|
659
|
+
}
|
|
660
|
+
|
|
264
661
|
/**
|
|
265
662
|
* Extract text content from a non-streaming Responses API response.
|
|
266
663
|
* Override this in subclasses for provider-specific response shapes.
|
|
267
664
|
*/
|
|
268
|
-
protected extractTextFromResponse(
|
|
269
|
-
response: OpenAI_SDK.Responses.Response,
|
|
270
|
-
): string {
|
|
665
|
+
protected extractTextFromResponse(response: Response): string {
|
|
271
666
|
let textContent = ''
|
|
272
667
|
let refusal: string | undefined
|
|
668
|
+
let sawMessageItem = false
|
|
669
|
+
const observedItemTypes = new Set<string>()
|
|
273
670
|
|
|
274
671
|
for (const item of response.output) {
|
|
672
|
+
observedItemTypes.add(item.type)
|
|
275
673
|
if (item.type === 'message') {
|
|
674
|
+
sawMessageItem = true
|
|
276
675
|
for (const part of item.content) {
|
|
277
|
-
|
|
278
|
-
|
|
676
|
+
// Cast off the discriminated union before the type discrimination
|
|
677
|
+
// so future SDK variants (e.g. `output_audio`, `output_image`) hit
|
|
678
|
+
// the explicit error path rather than being misreported as refusals
|
|
679
|
+
// when they get added to the union. Mirrors the streaming side's
|
|
680
|
+
// handleContentPart.
|
|
681
|
+
const partType = (part as { type: string }).type
|
|
682
|
+
if (partType === 'output_text') {
|
|
683
|
+
textContent += (part as { text?: string }).text ?? ''
|
|
684
|
+
} else if (partType === 'refusal') {
|
|
685
|
+
const refusalText = (part as { refusal?: string }).refusal
|
|
686
|
+
refusal = refusalText || refusal || 'Refused without explanation'
|
|
279
687
|
} else {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
refusal = part.refusal || refusal || 'Refused without explanation'
|
|
688
|
+
throw new Error(
|
|
689
|
+
`${this.name}.extractTextFromResponse: unsupported message content part type "${partType}"`,
|
|
690
|
+
)
|
|
284
691
|
}
|
|
285
692
|
}
|
|
286
693
|
}
|
|
@@ -295,6 +702,16 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
295
702
|
throw err
|
|
296
703
|
}
|
|
297
704
|
|
|
705
|
+
// Response had items but none carried message text (e.g. only
|
|
706
|
+
// function_call or reasoning items). Surface that explicitly so a
|
|
707
|
+
// downstream structured-output caller doesn't see a misleading
|
|
708
|
+
// "Failed to parse JSON. Content: " from an empty string.
|
|
709
|
+
if (!textContent && response.output.length > 0 && !sawMessageItem) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
`${this.name}.extractTextFromResponse: response.output contained items of type(s) [${[...observedItemTypes].sort().join(', ')}] but no message text — the model returned a non-text response`,
|
|
712
|
+
)
|
|
713
|
+
}
|
|
714
|
+
|
|
298
715
|
return textContent
|
|
299
716
|
}
|
|
300
717
|
|
|
@@ -314,22 +731,27 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
314
731
|
* - error
|
|
315
732
|
*/
|
|
316
733
|
protected async *processStreamChunks(
|
|
317
|
-
stream: AsyncIterable<
|
|
734
|
+
stream: AsyncIterable<ResponseStreamEvent>,
|
|
318
735
|
toolCallMetadata: Map<
|
|
319
736
|
string,
|
|
320
|
-
{
|
|
737
|
+
{
|
|
738
|
+
index: number
|
|
739
|
+
name: string
|
|
740
|
+
started: boolean
|
|
741
|
+
ended?: boolean
|
|
742
|
+
pendingArguments?: string
|
|
743
|
+
}
|
|
321
744
|
>,
|
|
322
745
|
options: TextOptions<TProviderOptions>,
|
|
323
746
|
aguiState: {
|
|
324
747
|
runId: string
|
|
748
|
+
threadId: string
|
|
325
749
|
messageId: string
|
|
326
|
-
timestamp: number
|
|
327
750
|
hasEmittedRunStarted: boolean
|
|
328
751
|
},
|
|
329
752
|
): AsyncIterable<StreamChunk> {
|
|
330
753
|
let accumulatedContent = ''
|
|
331
754
|
let accumulatedReasoning = ''
|
|
332
|
-
const timestamp = aguiState.timestamp
|
|
333
755
|
|
|
334
756
|
// Track if we've been streaming deltas to avoid duplicating content from done events
|
|
335
757
|
let hasStreamedContentDeltas = false
|
|
@@ -357,12 +779,13 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
357
779
|
// Emit RUN_STARTED on first chunk
|
|
358
780
|
if (!aguiState.hasEmittedRunStarted) {
|
|
359
781
|
aguiState.hasEmittedRunStarted = true
|
|
360
|
-
yield
|
|
361
|
-
type:
|
|
782
|
+
yield {
|
|
783
|
+
type: EventType.RUN_STARTED,
|
|
362
784
|
runId: aguiState.runId,
|
|
785
|
+
threadId: aguiState.threadId,
|
|
363
786
|
model: model || options.model,
|
|
364
|
-
timestamp,
|
|
365
|
-
}
|
|
787
|
+
timestamp: Date.now(),
|
|
788
|
+
}
|
|
366
789
|
}
|
|
367
790
|
|
|
368
791
|
const handleContentPart = (contentPart: {
|
|
@@ -372,14 +795,14 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
372
795
|
}): StreamChunk => {
|
|
373
796
|
if (contentPart.type === 'output_text') {
|
|
374
797
|
accumulatedContent += contentPart.text || ''
|
|
375
|
-
return
|
|
376
|
-
type:
|
|
798
|
+
return {
|
|
799
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
377
800
|
messageId: aguiState.messageId,
|
|
378
801
|
model: model || options.model,
|
|
379
|
-
timestamp,
|
|
802
|
+
timestamp: Date.now(),
|
|
380
803
|
delta: contentPart.text || '',
|
|
381
804
|
content: accumulatedContent,
|
|
382
|
-
}
|
|
805
|
+
}
|
|
383
806
|
}
|
|
384
807
|
|
|
385
808
|
if (contentPart.type === 'reasoning_text') {
|
|
@@ -391,14 +814,15 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
391
814
|
if (!stepId) {
|
|
392
815
|
stepId = generateId(this.name)
|
|
393
816
|
}
|
|
394
|
-
return
|
|
395
|
-
type:
|
|
817
|
+
return {
|
|
818
|
+
type: EventType.STEP_FINISHED,
|
|
819
|
+
stepName: stepId,
|
|
396
820
|
stepId,
|
|
397
821
|
model: model || options.model,
|
|
398
|
-
timestamp,
|
|
822
|
+
timestamp: Date.now(),
|
|
399
823
|
delta: contentPart.text || '',
|
|
400
824
|
content: accumulatedReasoning,
|
|
401
|
-
}
|
|
825
|
+
}
|
|
402
826
|
}
|
|
403
827
|
// Either a real refusal or an unknown content_part type. Surface
|
|
404
828
|
// the part type in the error so unknown parts are debuggable
|
|
@@ -407,16 +831,15 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
407
831
|
const message = isRefusal
|
|
408
832
|
? contentPart.refusal || 'Refused without explanation'
|
|
409
833
|
: `Unsupported response content_part type: ${contentPart.type}`
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
834
|
+
const code = isRefusal ? 'refusal' : contentPart.type
|
|
835
|
+
return {
|
|
836
|
+
type: EventType.RUN_ERROR,
|
|
413
837
|
model: model || options.model,
|
|
414
|
-
timestamp,
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
})
|
|
838
|
+
timestamp: Date.now(),
|
|
839
|
+
message,
|
|
840
|
+
code,
|
|
841
|
+
error: { message, code },
|
|
842
|
+
}
|
|
420
843
|
}
|
|
421
844
|
|
|
422
845
|
// Capture model metadata from any of these events (created starts
|
|
@@ -451,12 +874,12 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
451
874
|
chunk.type === 'response.incomplete'
|
|
452
875
|
) {
|
|
453
876
|
if (hasEmittedTextMessageStart) {
|
|
454
|
-
yield
|
|
455
|
-
type:
|
|
877
|
+
yield {
|
|
878
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
456
879
|
messageId: aguiState.messageId,
|
|
457
880
|
model: chunk.response.model,
|
|
458
|
-
timestamp,
|
|
459
|
-
}
|
|
881
|
+
timestamp: Date.now(),
|
|
882
|
+
}
|
|
460
883
|
hasEmittedTextMessageStart = false
|
|
461
884
|
}
|
|
462
885
|
// Coalesce error + incomplete_details into a single RUN_ERROR
|
|
@@ -469,23 +892,25 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
469
892
|
? 'Response failed'
|
|
470
893
|
: 'Response ended incomplete')
|
|
471
894
|
const errorCode =
|
|
472
|
-
chunk.response.error?.code
|
|
473
|
-
(chunk.response.incomplete_details ? 'incomplete' : undefined)
|
|
895
|
+
chunk.response.error?.code ??
|
|
896
|
+
(chunk.response.incomplete_details ? 'incomplete' : undefined) ??
|
|
897
|
+
undefined
|
|
474
898
|
// Always emit RUN_ERROR for terminal failure events, even when the
|
|
475
899
|
// upstream omitted both `error` and `incomplete_details`. Skipping
|
|
476
900
|
// emission on a `response.incomplete` with no detail would let the
|
|
477
901
|
// post-loop synthetic block silently coerce the run to a clean
|
|
478
902
|
// `RUN_FINISHED { finishReason: 'stop' }` — masking the failure.
|
|
479
|
-
yield
|
|
480
|
-
type:
|
|
481
|
-
runId: aguiState.runId,
|
|
903
|
+
yield {
|
|
904
|
+
type: EventType.RUN_ERROR,
|
|
482
905
|
model: chunk.response.model,
|
|
483
|
-
timestamp,
|
|
906
|
+
timestamp: Date.now(),
|
|
907
|
+
message: errorMessage,
|
|
908
|
+
...(errorCode !== undefined && { code: errorCode }),
|
|
484
909
|
error: {
|
|
485
910
|
message: errorMessage,
|
|
486
911
|
...(errorCode !== undefined && { code: errorCode }),
|
|
487
912
|
},
|
|
488
|
-
}
|
|
913
|
+
}
|
|
489
914
|
// RUN_ERROR is the terminal event for this run; stop processing
|
|
490
915
|
// any further chunks the iterator might still deliver.
|
|
491
916
|
runFinishedEmitted = true
|
|
@@ -506,25 +931,25 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
506
931
|
// Emit TEXT_MESSAGE_START on first text content
|
|
507
932
|
if (!hasEmittedTextMessageStart) {
|
|
508
933
|
hasEmittedTextMessageStart = true
|
|
509
|
-
yield
|
|
510
|
-
type:
|
|
934
|
+
yield {
|
|
935
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
511
936
|
messageId: aguiState.messageId,
|
|
512
937
|
model: model || options.model,
|
|
513
|
-
timestamp,
|
|
938
|
+
timestamp: Date.now(),
|
|
514
939
|
role: 'assistant',
|
|
515
|
-
}
|
|
940
|
+
}
|
|
516
941
|
}
|
|
517
942
|
|
|
518
943
|
accumulatedContent += textDelta
|
|
519
944
|
hasStreamedContentDeltas = true
|
|
520
|
-
yield
|
|
521
|
-
type:
|
|
945
|
+
yield {
|
|
946
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
522
947
|
messageId: aguiState.messageId,
|
|
523
948
|
model: model || options.model,
|
|
524
|
-
timestamp,
|
|
949
|
+
timestamp: Date.now(),
|
|
525
950
|
delta: textDelta,
|
|
526
951
|
content: accumulatedContent,
|
|
527
|
-
}
|
|
952
|
+
}
|
|
528
953
|
}
|
|
529
954
|
}
|
|
530
955
|
|
|
@@ -543,25 +968,28 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
543
968
|
if (!hasEmittedStepStarted) {
|
|
544
969
|
hasEmittedStepStarted = true
|
|
545
970
|
stepId = generateId(this.name)
|
|
546
|
-
yield
|
|
547
|
-
type:
|
|
971
|
+
yield {
|
|
972
|
+
type: EventType.STEP_STARTED,
|
|
973
|
+
stepName: stepId,
|
|
548
974
|
stepId,
|
|
549
975
|
model: model || options.model,
|
|
550
|
-
timestamp,
|
|
976
|
+
timestamp: Date.now(),
|
|
551
977
|
stepType: 'thinking',
|
|
552
|
-
}
|
|
978
|
+
}
|
|
553
979
|
}
|
|
554
980
|
|
|
555
981
|
accumulatedReasoning += reasoningDelta
|
|
556
982
|
hasStreamedReasoningDeltas = true
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
983
|
+
const fallbackStepId = stepId || generateId(this.name)
|
|
984
|
+
yield {
|
|
985
|
+
type: EventType.STEP_FINISHED,
|
|
986
|
+
stepName: fallbackStepId,
|
|
987
|
+
stepId: fallbackStepId,
|
|
560
988
|
model: model || options.model,
|
|
561
|
-
timestamp,
|
|
989
|
+
timestamp: Date.now(),
|
|
562
990
|
delta: reasoningDelta,
|
|
563
991
|
content: accumulatedReasoning,
|
|
564
|
-
}
|
|
992
|
+
}
|
|
565
993
|
}
|
|
566
994
|
}
|
|
567
995
|
|
|
@@ -579,25 +1007,28 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
579
1007
|
if (!hasEmittedStepStarted) {
|
|
580
1008
|
hasEmittedStepStarted = true
|
|
581
1009
|
stepId = generateId(this.name)
|
|
582
|
-
yield
|
|
583
|
-
type:
|
|
1010
|
+
yield {
|
|
1011
|
+
type: EventType.STEP_STARTED,
|
|
1012
|
+
stepName: stepId,
|
|
584
1013
|
stepId,
|
|
585
1014
|
model: model || options.model,
|
|
586
|
-
timestamp,
|
|
1015
|
+
timestamp: Date.now(),
|
|
587
1016
|
stepType: 'thinking',
|
|
588
|
-
}
|
|
1017
|
+
}
|
|
589
1018
|
}
|
|
590
1019
|
|
|
591
1020
|
accumulatedReasoning += summaryDelta
|
|
592
1021
|
hasStreamedReasoningDeltas = true
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1022
|
+
const fallbackStepId = stepId || generateId(this.name)
|
|
1023
|
+
yield {
|
|
1024
|
+
type: EventType.STEP_FINISHED,
|
|
1025
|
+
stepName: fallbackStepId,
|
|
1026
|
+
stepId: fallbackStepId,
|
|
596
1027
|
model: model || options.model,
|
|
597
|
-
timestamp,
|
|
1028
|
+
timestamp: Date.now(),
|
|
598
1029
|
delta: summaryDelta,
|
|
599
1030
|
content: accumulatedReasoning,
|
|
600
|
-
}
|
|
1031
|
+
}
|
|
601
1032
|
}
|
|
602
1033
|
}
|
|
603
1034
|
|
|
@@ -610,25 +1041,26 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
610
1041
|
!hasEmittedTextMessageStart
|
|
611
1042
|
) {
|
|
612
1043
|
hasEmittedTextMessageStart = true
|
|
613
|
-
yield
|
|
614
|
-
type:
|
|
1044
|
+
yield {
|
|
1045
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
615
1046
|
messageId: aguiState.messageId,
|
|
616
1047
|
model: model || options.model,
|
|
617
|
-
timestamp,
|
|
1048
|
+
timestamp: Date.now(),
|
|
618
1049
|
role: 'assistant',
|
|
619
|
-
}
|
|
1050
|
+
}
|
|
620
1051
|
}
|
|
621
1052
|
// Emit STEP_STARTED if this is reasoning content
|
|
622
1053
|
if (contentPart.type === 'reasoning_text' && !hasEmittedStepStarted) {
|
|
623
1054
|
hasEmittedStepStarted = true
|
|
624
1055
|
stepId = generateId(this.name)
|
|
625
|
-
yield
|
|
626
|
-
type:
|
|
1056
|
+
yield {
|
|
1057
|
+
type: EventType.STEP_STARTED,
|
|
1058
|
+
stepName: stepId,
|
|
627
1059
|
stepId,
|
|
628
1060
|
model: model || options.model,
|
|
629
|
-
timestamp,
|
|
1061
|
+
timestamp: Date.now(),
|
|
630
1062
|
stepType: 'thinking',
|
|
631
|
-
}
|
|
1063
|
+
}
|
|
632
1064
|
}
|
|
633
1065
|
// Mark whichever stream we just emitted into so a subsequent
|
|
634
1066
|
// `content_part.done` doesn't duplicate the same text. Without
|
|
@@ -668,6 +1100,40 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
668
1100
|
continue
|
|
669
1101
|
}
|
|
670
1102
|
|
|
1103
|
+
// Upstreams that emit `content_part.done` without any preceding
|
|
1104
|
+
// deltas (or `content_part.added`) still need a START event before
|
|
1105
|
+
// CONTENT — otherwise consumers tracking start/end pairs see content
|
|
1106
|
+
// without a start and never see an end. Emit the lifecycle opener
|
|
1107
|
+
// for whichever stream this content_part belongs to before yielding
|
|
1108
|
+
// the CONTENT chunk; the post-loop block emits the matching END.
|
|
1109
|
+
if (
|
|
1110
|
+
contentPart.type === 'output_text' &&
|
|
1111
|
+
!hasEmittedTextMessageStart
|
|
1112
|
+
) {
|
|
1113
|
+
hasEmittedTextMessageStart = true
|
|
1114
|
+
yield {
|
|
1115
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
1116
|
+
messageId: aguiState.messageId,
|
|
1117
|
+
model: model || options.model,
|
|
1118
|
+
timestamp: Date.now(),
|
|
1119
|
+
role: 'assistant',
|
|
1120
|
+
}
|
|
1121
|
+
} else if (
|
|
1122
|
+
contentPart.type === 'reasoning_text' &&
|
|
1123
|
+
!hasEmittedStepStarted
|
|
1124
|
+
) {
|
|
1125
|
+
hasEmittedStepStarted = true
|
|
1126
|
+
stepId = generateId(this.name)
|
|
1127
|
+
yield {
|
|
1128
|
+
type: EventType.STEP_STARTED,
|
|
1129
|
+
stepName: stepId,
|
|
1130
|
+
stepId,
|
|
1131
|
+
model: model || options.model,
|
|
1132
|
+
timestamp: Date.now(),
|
|
1133
|
+
stepType: 'thinking',
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
671
1137
|
// Only emit if we haven't been streaming deltas (e.g., for non-streaming responses)
|
|
672
1138
|
const doneChunk = handleContentPart(contentPart)
|
|
673
1139
|
yield doneChunk
|
|
@@ -682,27 +1148,35 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
682
1148
|
const item = chunk.item
|
|
683
1149
|
if (item.type === 'function_call' && item.id) {
|
|
684
1150
|
const existing = toolCallMetadata.get(item.id)
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1151
|
+
// Track the item as soon as we see it so subsequent arg deltas
|
|
1152
|
+
// aren't logged as orphans, but only emit TOOL_CALL_START when
|
|
1153
|
+
// both id AND name are populated. Emitting START with an empty
|
|
1154
|
+
// name would propagate into TOOL_CALL_END (which reads the same
|
|
1155
|
+
// metadata) and route the tool call to whatever name happens to
|
|
1156
|
+
// match `''` downstream — a silent misroute.
|
|
1157
|
+
if (!existing) {
|
|
1158
|
+
toolCallMetadata.set(item.id, {
|
|
1159
|
+
index: chunk.output_index,
|
|
1160
|
+
name: item.name || '',
|
|
1161
|
+
started: false,
|
|
1162
|
+
})
|
|
1163
|
+
} else if (!existing.name && item.name) {
|
|
1164
|
+
// A later output_item.added for the same id finally carries
|
|
1165
|
+
// the name. Update so the gated emission below can fire.
|
|
1166
|
+
existing.name = item.name
|
|
1167
|
+
}
|
|
1168
|
+
const metadata = toolCallMetadata.get(item.id)!
|
|
1169
|
+
if (!metadata.started && metadata.name) {
|
|
1170
|
+
yield {
|
|
1171
|
+
type: EventType.TOOL_CALL_START,
|
|
698
1172
|
toolCallId: item.id,
|
|
699
|
-
toolCallName:
|
|
700
|
-
toolName:
|
|
1173
|
+
toolCallName: metadata.name,
|
|
1174
|
+
toolName: metadata.name,
|
|
701
1175
|
model: model || options.model,
|
|
702
|
-
timestamp,
|
|
1176
|
+
timestamp: Date.now(),
|
|
703
1177
|
index: chunk.output_index,
|
|
704
|
-
}
|
|
705
|
-
|
|
1178
|
+
}
|
|
1179
|
+
metadata.started = true
|
|
706
1180
|
}
|
|
707
1181
|
}
|
|
708
1182
|
}
|
|
@@ -735,13 +1209,13 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
735
1209
|
)
|
|
736
1210
|
continue
|
|
737
1211
|
}
|
|
738
|
-
yield
|
|
739
|
-
type:
|
|
1212
|
+
yield {
|
|
1213
|
+
type: EventType.TOOL_CALL_ARGS,
|
|
740
1214
|
toolCallId: chunk.item_id,
|
|
741
1215
|
model: model || options.model,
|
|
742
|
-
timestamp,
|
|
1216
|
+
timestamp: Date.now(),
|
|
743
1217
|
delta: chunk.delta,
|
|
744
|
-
}
|
|
1218
|
+
}
|
|
745
1219
|
}
|
|
746
1220
|
|
|
747
1221
|
if (chunk.type === 'response.function_call_arguments.done') {
|
|
@@ -749,13 +1223,19 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
749
1223
|
|
|
750
1224
|
// Get the function name from metadata (captured in output_item.added)
|
|
751
1225
|
const metadata = toolCallMetadata.get(item_id)
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
// can
|
|
1226
|
+
// If the matching START was never emitted (the upstream sent an
|
|
1227
|
+
// `output_item.added` without a name and no later event has filled
|
|
1228
|
+
// it in yet), defer END until `output_item.done` or
|
|
1229
|
+
// `response.completed` can backfill the name. We stash the raw
|
|
1230
|
+
// arguments so the late emission has them. Emitting END without
|
|
1231
|
+
// START would produce an unbalanced AG-UI lifecycle event
|
|
1232
|
+
// downstream consumers can't pair.
|
|
756
1233
|
if (!metadata?.started) {
|
|
1234
|
+
if (metadata) {
|
|
1235
|
+
metadata.pendingArguments = chunk.arguments
|
|
1236
|
+
}
|
|
757
1237
|
options.logger.errors(
|
|
758
|
-
`${this.name}.processStreamChunks
|
|
1238
|
+
`${this.name}.processStreamChunks deferring function_call_arguments.done — TOOL_CALL_START not yet emitted (waiting for name)`,
|
|
759
1239
|
{
|
|
760
1240
|
source: `${this.name}.processStreamChunks`,
|
|
761
1241
|
toolCallId: item_id,
|
|
@@ -764,7 +1244,12 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
764
1244
|
)
|
|
765
1245
|
continue
|
|
766
1246
|
}
|
|
1247
|
+
// The output_item.done backstop may have already emitted END (when
|
|
1248
|
+
// it arrived before args.done with a populated item.arguments).
|
|
1249
|
+
// Skip so we never produce a duplicate close for the same id.
|
|
1250
|
+
if (metadata.ended) continue
|
|
767
1251
|
const name = metadata.name || ''
|
|
1252
|
+
metadata.ended = true
|
|
768
1253
|
|
|
769
1254
|
// Parse arguments. Surface parse failures via the logger so a
|
|
770
1255
|
// model emitting malformed JSON is debuggable instead of silently
|
|
@@ -792,26 +1277,177 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
792
1277
|
}
|
|
793
1278
|
}
|
|
794
1279
|
|
|
795
|
-
yield
|
|
796
|
-
type:
|
|
1280
|
+
yield {
|
|
1281
|
+
type: EventType.TOOL_CALL_END,
|
|
797
1282
|
toolCallId: item_id,
|
|
798
1283
|
toolCallName: name,
|
|
799
1284
|
toolName: name,
|
|
800
1285
|
model: model || options.model,
|
|
801
|
-
timestamp,
|
|
1286
|
+
timestamp: Date.now(),
|
|
802
1287
|
input: parsedInput,
|
|
803
|
-
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// `output_item.done` is the last point at which a function_call's
|
|
1292
|
+
// name is guaranteed to be on the wire — it carries the fully-formed
|
|
1293
|
+
// ResponseFunctionToolCall. Use it as a backstop to recover any
|
|
1294
|
+
// tool call whose name was missing from `output_item.added` (and
|
|
1295
|
+
// whose START + END therefore never fired).
|
|
1296
|
+
if (chunk.type === 'response.output_item.done') {
|
|
1297
|
+
const item = chunk.item
|
|
1298
|
+
if (item.type === 'function_call' && item.id) {
|
|
1299
|
+
const metadata = toolCallMetadata.get(item.id) ?? {
|
|
1300
|
+
index: chunk.output_index,
|
|
1301
|
+
name: item.name || '',
|
|
1302
|
+
started: false,
|
|
1303
|
+
}
|
|
1304
|
+
if (!toolCallMetadata.has(item.id)) {
|
|
1305
|
+
toolCallMetadata.set(item.id, metadata)
|
|
1306
|
+
} else if (!metadata.name && item.name) {
|
|
1307
|
+
metadata.name = item.name
|
|
1308
|
+
}
|
|
1309
|
+
// Emit gated START if we now have a name and never started.
|
|
1310
|
+
if (!metadata.started && metadata.name) {
|
|
1311
|
+
yield {
|
|
1312
|
+
type: EventType.TOOL_CALL_START,
|
|
1313
|
+
toolCallId: item.id,
|
|
1314
|
+
toolCallName: metadata.name,
|
|
1315
|
+
toolName: metadata.name,
|
|
1316
|
+
model: model || options.model,
|
|
1317
|
+
timestamp: Date.now(),
|
|
1318
|
+
index: metadata.index,
|
|
1319
|
+
}
|
|
1320
|
+
metadata.started = true
|
|
1321
|
+
}
|
|
1322
|
+
// Emit END if we have args (either from a previously-deferred
|
|
1323
|
+
// args.done OR from item.arguments) and haven't already ended.
|
|
1324
|
+
const rawArgs =
|
|
1325
|
+
typeof item.arguments === 'string' && item.arguments.length > 0
|
|
1326
|
+
? item.arguments
|
|
1327
|
+
: metadata.pendingArguments
|
|
1328
|
+
if (metadata.started && !metadata.ended && rawArgs !== undefined) {
|
|
1329
|
+
const name = metadata.name || ''
|
|
1330
|
+
let parsedInput: unknown = {}
|
|
1331
|
+
if (rawArgs) {
|
|
1332
|
+
try {
|
|
1333
|
+
const parsed = JSON.parse(rawArgs)
|
|
1334
|
+
parsedInput =
|
|
1335
|
+
parsed && typeof parsed === 'object' ? parsed : {}
|
|
1336
|
+
} catch (parseError) {
|
|
1337
|
+
options.logger.errors(
|
|
1338
|
+
`${this.name}.processStreamChunks tool-args JSON parse failed (output_item.done backfill)`,
|
|
1339
|
+
{
|
|
1340
|
+
error: toRunErrorPayload(
|
|
1341
|
+
parseError,
|
|
1342
|
+
`tool ${name} (${item.id}) returned malformed JSON arguments`,
|
|
1343
|
+
),
|
|
1344
|
+
source: `${this.name}.processStreamChunks`,
|
|
1345
|
+
toolCallId: item.id,
|
|
1346
|
+
toolName: name,
|
|
1347
|
+
rawArguments: rawArgs,
|
|
1348
|
+
},
|
|
1349
|
+
)
|
|
1350
|
+
parsedInput = {}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
yield {
|
|
1354
|
+
type: EventType.TOOL_CALL_END,
|
|
1355
|
+
toolCallId: item.id,
|
|
1356
|
+
toolCallName: name,
|
|
1357
|
+
toolName: name,
|
|
1358
|
+
model: model || options.model,
|
|
1359
|
+
timestamp: Date.now(),
|
|
1360
|
+
input: parsedInput,
|
|
1361
|
+
}
|
|
1362
|
+
metadata.ended = true
|
|
1363
|
+
metadata.pendingArguments = undefined
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
804
1366
|
}
|
|
805
1367
|
|
|
806
1368
|
if (chunk.type === 'response.completed') {
|
|
1369
|
+
// Final backstop for function_call lifecycle: if a function_call
|
|
1370
|
+
// appears in `response.output[]` but was never matched by an
|
|
1371
|
+
// output_item.added/done with a name, recover the missing START
|
|
1372
|
+
// (and END if args were pending). Without this, a tool call could
|
|
1373
|
+
// be silently dropped from the AG-UI stream while `hasFunctionCalls`
|
|
1374
|
+
// below still routes the run's finishReason to 'tool_calls' —
|
|
1375
|
+
// leaving consumers waiting for tool results they never saw start.
|
|
1376
|
+
for (const item of chunk.response.output) {
|
|
1377
|
+
if (item.type !== 'function_call' || !item.id) continue
|
|
1378
|
+
const metadata = toolCallMetadata.get(item.id) ?? {
|
|
1379
|
+
index: 0,
|
|
1380
|
+
name: item.name || '',
|
|
1381
|
+
started: false,
|
|
1382
|
+
}
|
|
1383
|
+
if (!toolCallMetadata.has(item.id)) {
|
|
1384
|
+
toolCallMetadata.set(item.id, metadata)
|
|
1385
|
+
} else if (!metadata.name && item.name) {
|
|
1386
|
+
metadata.name = item.name
|
|
1387
|
+
}
|
|
1388
|
+
if (!metadata.started && metadata.name) {
|
|
1389
|
+
yield {
|
|
1390
|
+
type: EventType.TOOL_CALL_START,
|
|
1391
|
+
toolCallId: item.id,
|
|
1392
|
+
toolCallName: metadata.name,
|
|
1393
|
+
toolName: metadata.name,
|
|
1394
|
+
model: model || options.model,
|
|
1395
|
+
timestamp: Date.now(),
|
|
1396
|
+
index: metadata.index,
|
|
1397
|
+
}
|
|
1398
|
+
metadata.started = true
|
|
1399
|
+
}
|
|
1400
|
+
const rawArgs =
|
|
1401
|
+
typeof item.arguments === 'string' && item.arguments.length > 0
|
|
1402
|
+
? item.arguments
|
|
1403
|
+
: metadata.pendingArguments
|
|
1404
|
+
if (metadata.started && !metadata.ended) {
|
|
1405
|
+
const name = metadata.name || ''
|
|
1406
|
+
let parsedInput: unknown = {}
|
|
1407
|
+
if (rawArgs) {
|
|
1408
|
+
try {
|
|
1409
|
+
const parsed = JSON.parse(rawArgs)
|
|
1410
|
+
parsedInput =
|
|
1411
|
+
parsed && typeof parsed === 'object' ? parsed : {}
|
|
1412
|
+
} catch (parseError) {
|
|
1413
|
+
options.logger.errors(
|
|
1414
|
+
`${this.name}.processStreamChunks tool-args JSON parse failed (response.completed backfill)`,
|
|
1415
|
+
{
|
|
1416
|
+
error: toRunErrorPayload(
|
|
1417
|
+
parseError,
|
|
1418
|
+
`tool ${name} (${item.id}) returned malformed JSON arguments`,
|
|
1419
|
+
),
|
|
1420
|
+
source: `${this.name}.processStreamChunks`,
|
|
1421
|
+
toolCallId: item.id,
|
|
1422
|
+
toolName: name,
|
|
1423
|
+
rawArguments: rawArgs,
|
|
1424
|
+
},
|
|
1425
|
+
)
|
|
1426
|
+
parsedInput = {}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
yield {
|
|
1430
|
+
type: EventType.TOOL_CALL_END,
|
|
1431
|
+
toolCallId: item.id,
|
|
1432
|
+
toolCallName: name,
|
|
1433
|
+
toolName: name,
|
|
1434
|
+
model: model || options.model,
|
|
1435
|
+
timestamp: Date.now(),
|
|
1436
|
+
input: parsedInput,
|
|
1437
|
+
}
|
|
1438
|
+
metadata.ended = true
|
|
1439
|
+
metadata.pendingArguments = undefined
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
807
1443
|
// Emit TEXT_MESSAGE_END if we had text content
|
|
808
1444
|
if (hasEmittedTextMessageStart) {
|
|
809
|
-
yield
|
|
810
|
-
type:
|
|
1445
|
+
yield {
|
|
1446
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
811
1447
|
messageId: aguiState.messageId,
|
|
812
1448
|
model: model || options.model,
|
|
813
|
-
timestamp,
|
|
814
|
-
}
|
|
1449
|
+
timestamp: Date.now(),
|
|
1450
|
+
}
|
|
815
1451
|
hasEmittedTextMessageStart = false
|
|
816
1452
|
}
|
|
817
1453
|
|
|
@@ -819,43 +1455,62 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
819
1455
|
// Otherwise surface incomplete_details.reason when present so
|
|
820
1456
|
// callers can distinguish length-limit / content-filter cutoffs
|
|
821
1457
|
// from a clean stop, mirroring the chat-completions adapter.
|
|
1458
|
+
// The Responses API's incomplete_details.reason ('max_output_tokens'
|
|
1459
|
+
// | 'content_filter') maps to the AG-UI finishReason vocabulary:
|
|
1460
|
+
// max_output_tokens → 'length', content_filter → 'content_filter'.
|
|
822
1461
|
const hasFunctionCalls = chunk.response.output.some(
|
|
823
1462
|
(item: unknown) =>
|
|
824
1463
|
(item as { type: string }).type === 'function_call',
|
|
825
1464
|
)
|
|
826
|
-
const
|
|
1465
|
+
const incompleteReason = chunk.response.incomplete_details?.reason
|
|
1466
|
+
const finishReason:
|
|
1467
|
+
| 'tool_calls'
|
|
1468
|
+
| 'length'
|
|
1469
|
+
| 'content_filter'
|
|
1470
|
+
| 'stop' = hasFunctionCalls
|
|
827
1471
|
? 'tool_calls'
|
|
828
|
-
:
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1472
|
+
: incompleteReason === 'max_output_tokens'
|
|
1473
|
+
? 'length'
|
|
1474
|
+
: incompleteReason === 'content_filter'
|
|
1475
|
+
? 'content_filter'
|
|
1476
|
+
: 'stop'
|
|
1477
|
+
|
|
1478
|
+
yield {
|
|
1479
|
+
type: EventType.RUN_FINISHED,
|
|
832
1480
|
runId: aguiState.runId,
|
|
1481
|
+
threadId: aguiState.threadId,
|
|
833
1482
|
model: model || options.model,
|
|
834
|
-
timestamp,
|
|
1483
|
+
timestamp: Date.now(),
|
|
835
1484
|
usage: {
|
|
836
1485
|
promptTokens: chunk.response.usage?.input_tokens || 0,
|
|
837
1486
|
completionTokens: chunk.response.usage?.output_tokens || 0,
|
|
838
1487
|
totalTokens: chunk.response.usage?.total_tokens || 0,
|
|
839
1488
|
},
|
|
840
1489
|
finishReason,
|
|
841
|
-
}
|
|
1490
|
+
}
|
|
842
1491
|
runFinishedEmitted = true
|
|
843
1492
|
}
|
|
844
1493
|
|
|
845
1494
|
if (chunk.type === 'error') {
|
|
846
|
-
yield
|
|
847
|
-
type:
|
|
848
|
-
runId: aguiState.runId,
|
|
1495
|
+
yield {
|
|
1496
|
+
type: EventType.RUN_ERROR,
|
|
849
1497
|
model: model || options.model,
|
|
850
|
-
timestamp,
|
|
1498
|
+
timestamp: Date.now(),
|
|
1499
|
+
message: chunk.message,
|
|
1500
|
+
code: chunk.code ?? undefined,
|
|
851
1501
|
error: {
|
|
852
1502
|
message: chunk.message,
|
|
853
1503
|
code: chunk.code ?? undefined,
|
|
854
1504
|
},
|
|
855
|
-
}
|
|
1505
|
+
}
|
|
856
1506
|
// RUN_ERROR is terminal — don't let the synthetic RUN_FINISHED
|
|
857
|
-
// block fire after a top-level stream error event
|
|
1507
|
+
// block fire after a top-level stream error event, and stop
|
|
1508
|
+
// processing further chunks so no in-flight lifecycle events
|
|
1509
|
+
// (TEXT_MESSAGE_CONTENT, TOOL_CALL_*) leak past the terminal
|
|
1510
|
+
// error. Mirrors the `response.failed` / `response.incomplete`
|
|
1511
|
+
// branches above which return after their RUN_ERROR emission.
|
|
858
1512
|
runFinishedEmitted = true
|
|
1513
|
+
return
|
|
859
1514
|
}
|
|
860
1515
|
}
|
|
861
1516
|
|
|
@@ -865,21 +1520,22 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
865
1520
|
// see a terminal event for every started run.
|
|
866
1521
|
if (!runFinishedEmitted && aguiState.hasEmittedRunStarted) {
|
|
867
1522
|
if (hasEmittedTextMessageStart) {
|
|
868
|
-
yield
|
|
869
|
-
type:
|
|
1523
|
+
yield {
|
|
1524
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
870
1525
|
messageId: aguiState.messageId,
|
|
871
1526
|
model: model || options.model,
|
|
872
|
-
timestamp,
|
|
873
|
-
}
|
|
1527
|
+
timestamp: Date.now(),
|
|
1528
|
+
}
|
|
874
1529
|
}
|
|
875
|
-
yield
|
|
876
|
-
type:
|
|
1530
|
+
yield {
|
|
1531
|
+
type: EventType.RUN_FINISHED,
|
|
877
1532
|
runId: aguiState.runId,
|
|
1533
|
+
threadId: aguiState.threadId,
|
|
878
1534
|
model: model || options.model,
|
|
879
|
-
timestamp,
|
|
1535
|
+
timestamp: Date.now(),
|
|
880
1536
|
usage: undefined,
|
|
881
1537
|
finishReason: toolCallMetadata.size > 0 ? 'tool_calls' : 'stop',
|
|
882
|
-
}
|
|
1538
|
+
}
|
|
883
1539
|
}
|
|
884
1540
|
} catch (error: unknown) {
|
|
885
1541
|
// Narrow before logging: raw SDK errors can carry request metadata
|
|
@@ -892,13 +1548,14 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
892
1548
|
error: errorPayload,
|
|
893
1549
|
source: `${this.name}.processStreamChunks`,
|
|
894
1550
|
})
|
|
895
|
-
yield
|
|
896
|
-
type:
|
|
897
|
-
runId: aguiState.runId,
|
|
1551
|
+
yield {
|
|
1552
|
+
type: EventType.RUN_ERROR,
|
|
898
1553
|
model: options.model,
|
|
899
|
-
timestamp,
|
|
1554
|
+
timestamp: Date.now(),
|
|
1555
|
+
message: errorPayload.message,
|
|
1556
|
+
code: errorPayload.code,
|
|
900
1557
|
error: errorPayload,
|
|
901
|
-
}
|
|
1558
|
+
}
|
|
902
1559
|
}
|
|
903
1560
|
}
|
|
904
1561
|
|
|
@@ -908,7 +1565,7 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
908
1565
|
*/
|
|
909
1566
|
protected mapOptionsToRequest(
|
|
910
1567
|
options: TextOptions<TProviderOptions>,
|
|
911
|
-
): Omit<
|
|
1568
|
+
): Omit<ResponseCreateParams, 'stream'> {
|
|
912
1569
|
const input = this.convertMessagesToInput(options.messages)
|
|
913
1570
|
|
|
914
1571
|
const tools = options.tools
|
|
@@ -961,8 +1618,8 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
961
1618
|
*/
|
|
962
1619
|
protected convertMessagesToInput(
|
|
963
1620
|
messages: Array<ModelMessage>,
|
|
964
|
-
):
|
|
965
|
-
const result:
|
|
1621
|
+
): ResponseInput {
|
|
1622
|
+
const result: ResponseInput = []
|
|
966
1623
|
|
|
967
1624
|
for (const message of messages) {
|
|
968
1625
|
// Handle tool messages - convert to FunctionToolCallOutput
|
|
@@ -1016,7 +1673,7 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
1016
1673
|
|
|
1017
1674
|
// Handle user messages (default case) — support multimodal content
|
|
1018
1675
|
const contentParts = this.normalizeContent(message.content)
|
|
1019
|
-
const inputContent: Array<
|
|
1676
|
+
const inputContent: Array<ResponseInputContent> = []
|
|
1020
1677
|
|
|
1021
1678
|
for (const part of contentParts) {
|
|
1022
1679
|
inputContent.push(this.convertContentPartToInput(part))
|
|
@@ -1049,9 +1706,7 @@ export class OpenAICompatibleResponsesTextAdapter<
|
|
|
1049
1706
|
* Handles text, image, and audio content parts.
|
|
1050
1707
|
* Override this in subclasses for additional content types or provider-specific metadata.
|
|
1051
1708
|
*/
|
|
1052
|
-
protected convertContentPartToInput(
|
|
1053
|
-
part: ContentPart,
|
|
1054
|
-
): Responses.ResponseInputContent {
|
|
1709
|
+
protected convertContentPartToInput(part: ContentPart): ResponseInputContent {
|
|
1055
1710
|
switch (part.type) {
|
|
1056
1711
|
case 'text':
|
|
1057
1712
|
return {
|