@strav/brain 1.0.0-alpha.15 → 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.
- package/package.json +2 -2
- package/src/agent.ts +34 -5
- package/src/agent_generate_result.ts +30 -0
- package/src/agent_runner.ts +140 -14
- package/src/agent_stream_event.ts +100 -0
- package/src/brain_config.ts +91 -1
- package/src/brain_manager.ts +168 -4
- package/src/brain_provider.ts +25 -1
- package/src/index.ts +19 -1
- package/src/mcp/client.ts +82 -13
- package/src/mcp/index.ts +6 -0
- package/src/mcp/oauth.ts +227 -0
- package/src/mcp/resolve_mcp_tools.ts +6 -2
- package/src/mcp_server.ts +16 -0
- package/src/provider.ts +109 -0
- package/src/providers/anthropic_provider.ts +596 -28
- package/src/providers/deepseek_provider.ts +117 -0
- package/src/providers/gemini_provider.ts +590 -21
- package/src/providers/ollama_provider.ts +86 -0
- package/src/providers/openai_compat_provider.ts +187 -0
- package/src/providers/openai_provider.ts +735 -32
- package/src/providers/openai_responses_provider.ts +700 -0
- package/src/tool.ts +7 -0
- package/src/tool_runner.ts +81 -0
- package/src/types.ts +233 -0
|
@@ -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
|
+
}
|