@strav/brain 1.0.0-alpha.16 → 1.0.0-alpha.17

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.
@@ -0,0 +1,700 @@
1
+ /**
2
+ * `OpenAIResponsesProvider` — implementation of `Provider` backed
3
+ * by the `openai` SDK's Responses API
4
+ * (`client.responses.create`).
5
+ *
6
+ * Use when an app needs:
7
+ * - OpenAI's server-side tools: `web_search`, `code_interpreter`
8
+ * (via the framework's `ChatOptions.serverTools` union).
9
+ * - The Responses API's reasoning surfaces (gpt-5 / o-series).
10
+ *
11
+ * For everything else (plain chat, embeddings, transcription,
12
+ * function calling without server tools), the standard
13
+ * `OpenAIProvider` (driver `'openai'`) is simpler. Apps that
14
+ * use both register them as two separate providers and route
15
+ * per-call.
16
+ *
17
+ * Inherits `embed` + `transcribe` from `OpenAIProvider`
18
+ * (embeddings + Whisper live on different endpoints unchanged).
19
+ *
20
+ * V1 coverage:
21
+ * - `chat` / `stream` via `responses.create` (with `stream: true`
22
+ * for the streaming variant).
23
+ * - `runWithTools` / `streamWithTools` — function-calling loop
24
+ * against the Responses API. Local tools + MCP tools + server
25
+ * tools all combine.
26
+ * - `generate` / `runWithToolsAndSchema` /
27
+ * `streamWithToolsAndSchema` — throw `BrainError` with
28
+ * "structured output via Responses API is a follow-up slice"
29
+ * guidance. Apps that need structured output use
30
+ * `OpenAIProvider` (driver `'openai'`).
31
+ *
32
+ * The Responses API's message shape (`input_items`) is different
33
+ * from chat completions' `messages`, so this is a separate
34
+ * provider class rather than a strategy inside `OpenAIProvider`.
35
+ * Translation lives in this file.
36
+ */
37
+
38
+ import OpenAI from 'openai'
39
+ import type { AgentGenerateResult } from '../agent_generate_result.ts'
40
+ import type { AgentResult } from '../agent_result.ts'
41
+ import type { AgentStreamEvent } from '../agent_stream_event.ts'
42
+ import { BrainError } from '../brain_error.ts'
43
+ import type { OpenAIResponsesProviderConfig } from '../brain_config.ts'
44
+ import { resolveMcpTools, type ResolveMcpToolsOptions } from '../mcp/resolve_mcp_tools.ts'
45
+ import type { MCPServer } from '../mcp_server.ts'
46
+ import type { OutputSchema } from '../output_schema.ts'
47
+ import type { Provider, RunWithToolsOptions } from '../provider.ts'
48
+ import type { Tool } from '../tool.ts'
49
+ import { runToolWithRecovery } from '../tool_runner.ts'
50
+ import type {
51
+ ChatOptions,
52
+ ChatResult,
53
+ ChatUsage,
54
+ ContentBlock,
55
+ GenerateResult,
56
+ Message,
57
+ ServerTool,
58
+ StreamEvent,
59
+ SystemPrompt,
60
+ TextBlock,
61
+ ToolResultBlock,
62
+ ToolUseBlock,
63
+ } from '../types.ts'
64
+ import { OpenAIProvider } from './openai_provider.ts'
65
+
66
+ const DEFAULT_OPENAI_MODEL = 'gpt-5'
67
+ const DEFAULT_OPENAI_MAX_TOKENS = 4096
68
+
69
+ export interface OpenAIResponsesProviderOptions {
70
+ client?: OpenAI
71
+ /** Internal seam — tests inject a stub MCP client factory. */
72
+ mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
73
+ }
74
+
75
+ /** Translation: framework `ServerTool` → Responses API tool entry. */
76
+ type ResponsesTool = Record<string, unknown>
77
+
78
+ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider {
79
+ constructor(
80
+ name: string,
81
+ config: OpenAIResponsesProviderConfig,
82
+ options: OpenAIResponsesProviderOptions = {},
83
+ ) {
84
+ // Reuse OpenAIProvider's constructor for the SDK client + the
85
+ // chat / embed / transcribe model defaults. Inheritance keeps
86
+ // `client`, `defaultEmbedModel`, `defaultTranscribeModel`
87
+ // working unchanged.
88
+ super(
89
+ name,
90
+ {
91
+ driver: 'openai',
92
+ apiKey: config.apiKey,
93
+ ...(config.baseUrl !== undefined ? { baseUrl: config.baseUrl } : {}),
94
+ ...(config.organization !== undefined ? { organization: config.organization } : {}),
95
+ defaultModel: config.defaultModel ?? DEFAULT_OPENAI_MODEL,
96
+ defaultMaxTokens: config.defaultMaxTokens ?? DEFAULT_OPENAI_MAX_TOKENS,
97
+ ...(config.defaultEmbedModel !== undefined
98
+ ? { defaultEmbedModel: config.defaultEmbedModel }
99
+ : {}),
100
+ ...(config.defaultTranscribeModel !== undefined
101
+ ? { defaultTranscribeModel: config.defaultTranscribeModel }
102
+ : {}),
103
+ },
104
+ options,
105
+ )
106
+ }
107
+
108
+ // ─── chat / stream ──────────────────────────────────────────────────────
109
+
110
+ override async chat(
111
+ messages: readonly Message[],
112
+ options: ChatOptions = {},
113
+ ): Promise<ChatResult> {
114
+ const params = this.buildResponsesParams(messages, options, [])
115
+ const response = await this.client.responses.create(
116
+ params,
117
+ reqOpts(options),
118
+ )
119
+ return this.toChatResultFromResponse(response, params.model as string)
120
+ }
121
+
122
+ override async *stream(
123
+ messages: readonly Message[],
124
+ options: ChatOptions = {},
125
+ ): AsyncIterable<StreamEvent> {
126
+ const params: OpenAI.Responses.ResponseCreateParamsStreaming = {
127
+ ...this.buildResponsesParams(messages, options, []),
128
+ stream: true,
129
+ }
130
+ const stream = await this.client.responses.create(params, reqOpts(options))
131
+ let finishReason: string | null = null
132
+ let usage: OpenAI.Responses.ResponseUsage | undefined
133
+ for await (const event of stream) {
134
+ // Text deltas — `output_text.delta` is the streaming chunk
135
+ // for the model's text output.
136
+ if (event.type === 'response.output_text.delta') {
137
+ const delta = (event as { delta: string }).delta
138
+ if (delta && delta.length > 0) yield { type: 'text', delta }
139
+ } else if (event.type === 'response.completed') {
140
+ const completed = (event as { response: OpenAI.Responses.Response }).response
141
+ usage = completed.usage
142
+ // Responses API doesn't have a finish_reason field directly;
143
+ // the response.status === 'completed' is the signal.
144
+ finishReason = completed.status ?? null
145
+ }
146
+ }
147
+ yield {
148
+ type: 'stop',
149
+ stopReason: finishReason,
150
+ usage: toUsage(usage),
151
+ }
152
+ }
153
+
154
+ // ─── runWithTools / streamWithTools ─────────────────────────────────────
155
+
156
+ override async runWithTools(
157
+ messages: readonly Message[],
158
+ tools: readonly Tool[],
159
+ options: RunWithToolsOptions = {},
160
+ ): Promise<AgentResult> {
161
+ const mcpServers: readonly MCPServer[] = options.mcpServers ?? []
162
+ const resolved =
163
+ mcpServers.length > 0
164
+ ? await resolveMcpTools(mcpServers, {
165
+ ...(this.mcpClientFactory ? { clientFactory: this.mcpClientFactory } : {}),
166
+ })
167
+ : { tools: [] as Tool[], close: async () => {} }
168
+ try {
169
+ return await this._runResponsesLoop(messages, [...tools, ...resolved.tools], options)
170
+ } finally {
171
+ await resolved.close()
172
+ }
173
+ }
174
+
175
+ private async _runResponsesLoop(
176
+ messages: readonly Message[],
177
+ tools: readonly Tool[],
178
+ options: RunWithToolsOptions,
179
+ ): Promise<AgentResult> {
180
+ const maxIterations = options.maxIterations ?? 10
181
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
182
+ const workingMessages: Message[] = [...messages]
183
+ const aggregated: ChatUsage = {
184
+ inputTokens: 0,
185
+ outputTokens: 0,
186
+ cacheReadTokens: 0,
187
+ cacheCreationTokens: 0,
188
+ }
189
+ let iterations = 0
190
+
191
+ while (true) {
192
+ checkAborted(options.signal)
193
+ const params = this.buildResponsesParams(workingMessages, options, tools)
194
+ const response = await this.client.responses.create(params, reqOpts(options))
195
+ addUsage(aggregated, response.usage)
196
+
197
+ const assistantBlocks = fromResponsesOutput(response.output)
198
+ const toolCalls = response.output.filter(
199
+ (o): o is OpenAI.Responses.ResponseFunctionToolCall => o.type === 'function_call',
200
+ )
201
+ workingMessages.push({ role: 'assistant', content: assistantBlocks })
202
+
203
+ if (toolCalls.length === 0) {
204
+ const text = textFromOutput(response.output)
205
+ return {
206
+ text,
207
+ messages: workingMessages,
208
+ iterations,
209
+ stopReason: response.status ?? 'completed',
210
+ usage: aggregated,
211
+ }
212
+ }
213
+
214
+ const resultBlocks: ContentBlock[] = []
215
+ for (const call of toolCalls) {
216
+ let parsedInput: unknown = {}
217
+ let parseFailed: { content: string; isError: boolean } | undefined
218
+ try {
219
+ parsedInput = call.arguments ? JSON.parse(call.arguments) : {}
220
+ } catch (err) {
221
+ parseFailed = await tryRecoverParseError(
222
+ call.name,
223
+ call.call_id,
224
+ err as Error,
225
+ options,
226
+ )
227
+ }
228
+ const { content, isError } = parseFailed ?? await runToolWithRecovery(
229
+ toolMap.get(call.name),
230
+ call.name,
231
+ call.call_id,
232
+ parsedInput,
233
+ options,
234
+ )
235
+ resultBlocks.push({
236
+ type: 'tool_result',
237
+ toolUseId: call.call_id,
238
+ content,
239
+ ...(isError ? { isError: true } : {}),
240
+ } satisfies ToolResultBlock)
241
+ }
242
+ workingMessages.push({ role: 'user', content: resultBlocks })
243
+
244
+ iterations++
245
+ if (iterations >= maxIterations) {
246
+ const text = textFromOutput(response.output)
247
+ return {
248
+ text,
249
+ messages: workingMessages,
250
+ iterations,
251
+ stopReason: 'max_iterations',
252
+ usage: aggregated,
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ override async *streamWithTools(
259
+ messages: readonly Message[],
260
+ tools: readonly Tool[],
261
+ options: RunWithToolsOptions = {},
262
+ ): AsyncIterable<AgentStreamEvent> {
263
+ const mcpServers: readonly MCPServer[] = options.mcpServers ?? []
264
+ const resolved =
265
+ mcpServers.length > 0
266
+ ? await resolveMcpTools(mcpServers, {
267
+ ...(this.mcpClientFactory ? { clientFactory: this.mcpClientFactory } : {}),
268
+ })
269
+ : { tools: [] as Tool[], close: async () => {} }
270
+ try {
271
+ yield* this._streamResponsesLoop(messages, [...tools, ...resolved.tools], options)
272
+ } finally {
273
+ await resolved.close()
274
+ }
275
+ }
276
+
277
+ private async *_streamResponsesLoop(
278
+ messages: readonly Message[],
279
+ tools: readonly Tool[],
280
+ options: RunWithToolsOptions,
281
+ ): AsyncIterable<AgentStreamEvent> {
282
+ const maxIterations = options.maxIterations ?? 10
283
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
284
+ const workingMessages: Message[] = [...messages]
285
+ const aggregated: ChatUsage = {
286
+ inputTokens: 0,
287
+ outputTokens: 0,
288
+ cacheReadTokens: 0,
289
+ cacheCreationTokens: 0,
290
+ }
291
+ let iterations = 0
292
+
293
+ while (true) {
294
+ checkAborted(options.signal)
295
+ yield { type: 'iteration_start', iteration: iterations }
296
+
297
+ const params: OpenAI.Responses.ResponseCreateParamsStreaming = {
298
+ ...this.buildResponsesParams(workingMessages, options, tools),
299
+ stream: true,
300
+ }
301
+ const stream = await this.client.responses.create(params, reqOpts(options))
302
+ let finishReason: string | null = null
303
+ let finalResponse: OpenAI.Responses.Response | undefined
304
+
305
+ for await (const event of stream) {
306
+ if (event.type === 'response.output_text.delta') {
307
+ const delta = (event as { delta: string }).delta
308
+ if (delta && delta.length > 0) yield { type: 'text', delta }
309
+ } else if (event.type === 'response.completed') {
310
+ const completed = (event as { response: OpenAI.Responses.Response }).response
311
+ finalResponse = completed
312
+ finishReason = completed.status ?? null
313
+ }
314
+ }
315
+
316
+ yield { type: 'iteration_end', iteration: iterations, stopReason: finishReason }
317
+
318
+ if (!finalResponse) {
319
+ // The stream ended without a completion event — surface the
320
+ // best stop we have and bail.
321
+ yield {
322
+ type: 'stop',
323
+ stopReason: finishReason ?? 'incomplete',
324
+ iterations,
325
+ usage: aggregated,
326
+ messages: workingMessages,
327
+ }
328
+ return
329
+ }
330
+
331
+ addUsage(aggregated, finalResponse.usage)
332
+ const assistantBlocks = fromResponsesOutput(finalResponse.output)
333
+ workingMessages.push({ role: 'assistant', content: assistantBlocks })
334
+
335
+ const toolCalls = finalResponse.output.filter(
336
+ (o): o is OpenAI.Responses.ResponseFunctionToolCall => o.type === 'function_call',
337
+ )
338
+ if (toolCalls.length === 0) {
339
+ yield {
340
+ type: 'stop',
341
+ stopReason: finishReason ?? 'completed',
342
+ iterations,
343
+ usage: aggregated,
344
+ messages: workingMessages,
345
+ }
346
+ return
347
+ }
348
+
349
+ const resultBlocks: ContentBlock[] = []
350
+ for (const call of toolCalls) {
351
+ let parsedInput: unknown = {}
352
+ let parseFailed: { content: string; isError: boolean } | undefined
353
+ try {
354
+ parsedInput = call.arguments ? JSON.parse(call.arguments) : {}
355
+ } catch (err) {
356
+ parseFailed = await tryRecoverParseError(
357
+ call.name,
358
+ call.call_id,
359
+ err as Error,
360
+ options,
361
+ )
362
+ }
363
+ yield { type: 'tool_use', id: call.call_id, name: call.name, input: parsedInput }
364
+ const { content, isError } = parseFailed ?? await runToolWithRecovery(
365
+ toolMap.get(call.name),
366
+ call.name,
367
+ call.call_id,
368
+ parsedInput,
369
+ options,
370
+ )
371
+ resultBlocks.push({
372
+ type: 'tool_result',
373
+ toolUseId: call.call_id,
374
+ content,
375
+ ...(isError ? { isError: true } : {}),
376
+ } satisfies ToolResultBlock)
377
+ yield {
378
+ type: 'tool_result',
379
+ id: call.call_id,
380
+ name: call.name,
381
+ content,
382
+ isError,
383
+ }
384
+ }
385
+ workingMessages.push({ role: 'user', content: resultBlocks })
386
+
387
+ iterations++
388
+ if (iterations >= maxIterations) {
389
+ yield {
390
+ type: 'stop',
391
+ stopReason: 'max_iterations',
392
+ iterations,
393
+ usage: aggregated,
394
+ messages: workingMessages,
395
+ }
396
+ return
397
+ }
398
+ }
399
+ }
400
+
401
+ // ─── Schema variants throw — deferred ──────────────────────────────────
402
+
403
+ override async generate<T>(
404
+ _messages: readonly Message[],
405
+ _schema: OutputSchema<T>,
406
+ _options: ChatOptions = {},
407
+ ): Promise<GenerateResult<T>> {
408
+ throw new BrainError(
409
+ 'OpenAIResponsesProvider.generate: structured output via the Responses API is a follow-up slice. For json-schema structured output today, route the call to the chat completions provider (driver: "openai").',
410
+ { context: { provider: this.name } },
411
+ )
412
+ }
413
+
414
+ override async runWithToolsAndSchema<T>(
415
+ _messages: readonly Message[],
416
+ _tools: readonly Tool[],
417
+ _schema: OutputSchema<T>,
418
+ _options?: RunWithToolsOptions,
419
+ ): Promise<AgentGenerateResult<T>> {
420
+ throw new BrainError(
421
+ 'OpenAIResponsesProvider.runWithToolsAndSchema: combined tools + schema on the Responses API is a follow-up slice. Run runTools + generate as separate calls, or route to the chat completions provider for this combination.',
422
+ { context: { provider: this.name } },
423
+ )
424
+ }
425
+
426
+ override async *streamWithToolsAndSchema<T>(
427
+ _messages: readonly Message[],
428
+ _tools: readonly Tool[],
429
+ _schema: OutputSchema<T>,
430
+ _options?: RunWithToolsOptions,
431
+ ): AsyncIterable<AgentStreamEvent<T>> {
432
+ throw new BrainError(
433
+ 'OpenAIResponsesProvider.streamWithToolsAndSchema: streaming + tools + schema on the Responses API is a follow-up slice. Use streamTools without schema, or route to the chat completions provider.',
434
+ { context: { provider: this.name } },
435
+ )
436
+ }
437
+
438
+ // ─── Param translation ──────────────────────────────────────────────────
439
+
440
+ private buildResponsesParams(
441
+ messages: readonly Message[],
442
+ options: ChatOptions,
443
+ tools: readonly Tool[],
444
+ ): OpenAI.Responses.ResponseCreateParamsNonStreaming {
445
+ const model = options.model ?? this.defaultModel
446
+ const params: OpenAI.Responses.ResponseCreateParamsNonStreaming = {
447
+ model,
448
+ input: messages.flatMap((m) => {
449
+ const r = toResponsesInputItem(m)
450
+ return Array.isArray(r) ? r : [r]
451
+ }) as unknown as OpenAI.Responses.ResponseInput,
452
+ max_output_tokens: options.maxTokens ?? this.defaultMaxTokens,
453
+ }
454
+ const systemText = systemPromptText(options.system)
455
+ if (systemText.length > 0) params.instructions = systemText
456
+
457
+ const toolEntries: ResponsesTool[] = []
458
+ for (const t of tools) {
459
+ toolEntries.push({
460
+ type: 'function',
461
+ name: t.name,
462
+ description: t.description,
463
+ parameters: t.inputSchema,
464
+ strict: false,
465
+ })
466
+ }
467
+ if (options.serverTools && options.serverTools.length > 0) {
468
+ toolEntries.push(...responsesServerTools(options.serverTools))
469
+ }
470
+ if (toolEntries.length > 0) {
471
+ params.tools = toolEntries as unknown as OpenAI.Responses.ResponseCreateParams['tools']
472
+ }
473
+
474
+ // Reasoning controls — gpt-5 and o-series only. Emit when set;
475
+ // non-reasoning models reject.
476
+ if (options.effort !== undefined) {
477
+ params.reasoning = { effort: options.effort } as OpenAI.Responses.ResponseCreateParams['reasoning']
478
+ } else if (options.thinking === 'adaptive') {
479
+ params.reasoning = { effort: 'medium' } as OpenAI.Responses.ResponseCreateParams['reasoning']
480
+ } else if (options.thinking === 'disabled') {
481
+ params.reasoning = { effort: 'minimal' } as OpenAI.Responses.ResponseCreateParams['reasoning']
482
+ }
483
+
484
+ return params
485
+ }
486
+
487
+ private toChatResultFromResponse(
488
+ response: OpenAI.Responses.Response,
489
+ requestedModel: string,
490
+ ): ChatResult<OpenAI.Responses.Response> {
491
+ return {
492
+ text: textFromOutput(response.output),
493
+ model: response.model ?? requestedModel,
494
+ stopReason: response.status ?? null,
495
+ usage: toUsage(response.usage),
496
+ raw: response,
497
+ }
498
+ }
499
+ }
500
+
501
+ // ─── Translation helpers ──────────────────────────────────────────────────
502
+
503
+ function systemPromptText(system: SystemPrompt | undefined): string {
504
+ if (system === undefined) return ''
505
+ if (typeof system === 'string') return system
506
+ if (Array.isArray(system)) return system.map((b) => b.text).join('\n')
507
+ return system.text
508
+ }
509
+
510
+ /**
511
+ * Translate a framework `Message` into a Responses API input item.
512
+ * V1 covers text + tool_use + tool_result; other content blocks
513
+ * (image / document / audio) fall back to text concatenation until
514
+ * the Responses API multimodal slice ships.
515
+ */
516
+ function toResponsesInputItem(message: Message): unknown {
517
+ if (typeof message.content === 'string') {
518
+ return { role: message.role, content: message.content }
519
+ }
520
+ // For user-role tool results, emit one `function_call_output` per
521
+ // tool_result block. The Responses API wants each result as its
522
+ // own input item, NOT bundled in a message turn.
523
+ if (message.role === 'user') {
524
+ const toolResults = message.content.filter((b): b is ToolResultBlock => b.type === 'tool_result')
525
+ if (toolResults.length > 0) {
526
+ // Multi-item return — caller handles arrays in input.
527
+ const items: unknown[] = []
528
+ const remainingText: string[] = []
529
+ for (const block of message.content) {
530
+ if (block.type === 'tool_result') {
531
+ const content = typeof block.content === 'string'
532
+ ? block.content
533
+ : block.content.map((t) => t.text).join('')
534
+ items.push({
535
+ type: 'function_call_output',
536
+ call_id: block.toolUseId,
537
+ output: content,
538
+ })
539
+ } else if (block.type === 'text') {
540
+ remainingText.push(block.text)
541
+ }
542
+ }
543
+ if (remainingText.length > 0) {
544
+ items.unshift({ role: 'user', content: remainingText.join('') })
545
+ }
546
+ return items
547
+ }
548
+ // Plain user message with mixed blocks → flatten text.
549
+ return {
550
+ role: 'user',
551
+ content: message.content
552
+ .filter((b): b is TextBlock => b.type === 'text')
553
+ .map((b) => b.text)
554
+ .join(''),
555
+ }
556
+ }
557
+ // Assistant turn with tool_use blocks → emit function_call items.
558
+ const items: unknown[] = []
559
+ const textParts: string[] = []
560
+ for (const block of message.content) {
561
+ if (block.type === 'text') {
562
+ textParts.push(block.text)
563
+ } else if (block.type === 'tool_use') {
564
+ items.push({
565
+ type: 'function_call',
566
+ call_id: block.id,
567
+ name: block.name,
568
+ arguments: JSON.stringify(block.input ?? {}),
569
+ })
570
+ }
571
+ }
572
+ if (textParts.length > 0) {
573
+ items.unshift({ role: 'assistant', content: textParts.join('') })
574
+ }
575
+ return items.length === 1 ? items[0] : items
576
+ }
577
+
578
+ /**
579
+ * Extract framework `ContentBlock[]` from a Responses API output
580
+ * array — text from `output_message.content[].text`, tool calls
581
+ * from `function_call` items. Server-tool calls (web_search,
582
+ * code_interpreter) are not surfaced as blocks; they live on
583
+ * `response.output` and apps inspect via `raw` for now.
584
+ */
585
+ function fromResponsesOutput(
586
+ output: readonly OpenAI.Responses.ResponseOutputItem[],
587
+ ): string | ContentBlock[] {
588
+ const blocks: ContentBlock[] = []
589
+ for (const item of output) {
590
+ if (item.type === 'message' && item.role === 'assistant') {
591
+ for (const part of item.content) {
592
+ if (part.type === 'output_text') {
593
+ blocks.push({ type: 'text', text: part.text })
594
+ }
595
+ }
596
+ } else if (item.type === 'function_call') {
597
+ let parsed: unknown = {}
598
+ try {
599
+ parsed = item.arguments ? JSON.parse(item.arguments) : {}
600
+ } catch {
601
+ parsed = item.arguments ?? {}
602
+ }
603
+ blocks.push({
604
+ type: 'tool_use',
605
+ id: item.call_id,
606
+ name: item.name,
607
+ input: parsed,
608
+ } satisfies ToolUseBlock)
609
+ }
610
+ // Server-tool result items (web_search_call, code_interpreter_call,
611
+ // etc.) are surfaced on `raw` — V1 doesn't add framework blocks
612
+ // for them; apps inspect raw when they care.
613
+ }
614
+ if (blocks.length === 1 && blocks[0]?.type === 'text') return blocks[0].text
615
+ return blocks
616
+ }
617
+
618
+ function textFromOutput(output: readonly OpenAI.Responses.ResponseOutputItem[]): string {
619
+ const parts: string[] = []
620
+ for (const item of output) {
621
+ if (item.type === 'message' && item.role === 'assistant') {
622
+ for (const p of item.content) {
623
+ if (p.type === 'output_text') parts.push(p.text)
624
+ }
625
+ }
626
+ }
627
+ return parts.join('')
628
+ }
629
+
630
+ function responsesServerTools(serverTools: readonly ServerTool[]): ResponsesTool[] {
631
+ const out: ResponsesTool[] = []
632
+ for (const t of serverTools) {
633
+ if (t.type === 'web_search') {
634
+ out.push({ type: 'web_search' })
635
+ } else if (t.type === 'code_execution') {
636
+ out.push({ type: 'code_interpreter', container: { type: 'auto' } })
637
+ } else if (t.type === 'web_fetch') {
638
+ throw new BrainError(
639
+ 'OpenAIResponsesProvider: server tool `web_fetch` is Anthropic-only. Use `web_search` for OpenAI, or route to Anthropic.',
640
+ { context: { provider: 'openai-responses' } },
641
+ )
642
+ } else if (t.type === 'url_context') {
643
+ throw new BrainError(
644
+ 'OpenAIResponsesProvider: server tool `url_context` is Gemini-only. Route to Gemini, or include the URL in the prompt and use `web_search`.',
645
+ { context: { provider: 'openai-responses' } },
646
+ )
647
+ }
648
+ }
649
+ return out
650
+ }
651
+
652
+ function toUsage(u: OpenAI.Responses.ResponseUsage | undefined): ChatUsage {
653
+ return {
654
+ inputTokens: u?.input_tokens ?? 0,
655
+ outputTokens: u?.output_tokens ?? 0,
656
+ cacheReadTokens: u?.input_tokens_details?.cached_tokens ?? 0,
657
+ cacheCreationTokens: 0,
658
+ }
659
+ }
660
+
661
+ function addUsage(acc: ChatUsage, u: OpenAI.Responses.ResponseUsage | undefined): void {
662
+ if (!u) return
663
+ acc.inputTokens += u.input_tokens ?? 0
664
+ acc.outputTokens += u.output_tokens ?? 0
665
+ acc.cacheReadTokens += u.input_tokens_details?.cached_tokens ?? 0
666
+ }
667
+
668
+ function reqOpts(options: { signal?: AbortSignal }): { signal?: AbortSignal } | undefined {
669
+ return options.signal !== undefined ? { signal: options.signal } : undefined
670
+ }
671
+
672
+ function checkAborted(signal: AbortSignal | undefined): void {
673
+ if (signal?.aborted) {
674
+ throw signal.reason ?? new DOMException('Aborted', 'AbortError')
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Handle a JSON.parse failure on a `function_call.arguments` field
680
+ * through the standard `onToolError` recovery hook. Returns a
681
+ * recovery result or rethrows as ToolExecutionError. Kept inline
682
+ * (not in tool_runner.ts) because the call shape — error-only,
683
+ * pre-execute — differs from the standard path.
684
+ */
685
+ async function tryRecoverParseError(
686
+ toolName: string,
687
+ callId: string,
688
+ cause: Error,
689
+ options: RunWithToolsOptions,
690
+ ): Promise<{ content: string; isError: boolean }> {
691
+ const { ToolExecutionError } = await import('../tool_execution_error.ts')
692
+ const err = new ToolExecutionError(
693
+ toolName,
694
+ callId,
695
+ new Error(`Failed to parse tool input JSON: ${cause.message}`),
696
+ )
697
+ const recovered = options.onToolError?.(err)
698
+ if (typeof recovered !== 'string') throw err
699
+ return { content: recovered, isError: true }
700
+ }