@strav/brain 1.0.0-alpha.10 → 1.0.0-alpha.12

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 CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@strav/brain",
3
- "version": "1.0.0-alpha.10",
4
- "description": "Strav AI module — unified Provider interface, BrainManager, threads, prompt caching. Anthropic provider in V1; OpenAI / Gemini / DeepSeek follow.",
3
+ "version": "1.0.0-alpha.12",
4
+ "description": "Strav AI module — unified Provider interface, BrainManager, threads, prompt caching, tools / agents / MCP. Anthropic + OpenAI providers; Gemini / DeepSeek follow.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": "./src/index.ts",
10
+ "./mcp": "./src/mcp/index.ts"
10
11
  },
11
12
  "files": [
12
13
  "src",
@@ -19,8 +20,10 @@
19
20
  "access": "public"
20
21
  },
21
22
  "dependencies": {
22
- "@strav/kernel": "1.0.0-alpha.10",
23
- "@anthropic-ai/sdk": "^0.100.0"
23
+ "@anthropic-ai/sdk": "^0.100.0",
24
+ "@modelcontextprotocol/sdk": "^1.29.0",
25
+ "@strav/kernel": "1.0.0-alpha.12",
26
+ "openai": "^6.0.0"
24
27
  },
25
28
  "peerDependencies": {
26
29
  "@types/bun": ">=1.3.14"
@@ -34,7 +34,22 @@ export interface AnthropicProviderConfig {
34
34
  betas?: readonly string[]
35
35
  }
36
36
 
37
- export type ProviderConfig = AnthropicProviderConfig // | OpenAIProviderConfig | … (later slices)
37
+ /** OpenAI-specific driver config. */
38
+ export interface OpenAIProviderConfig {
39
+ driver: 'openai'
40
+ /** API key. Required. Most apps source from `env('OPENAI_API_KEY')`. */
41
+ apiKey: string
42
+ /** Optional override of the SDK's base URL — useful for proxies, Azure OpenAI, or test doubles. */
43
+ baseUrl?: string
44
+ /** Optional organization id. */
45
+ organization?: string
46
+ /** Default model when neither `options.model` nor `options.tier` is passed. Defaults to `gpt-5`. */
47
+ defaultModel?: string
48
+ /** Default `max_tokens` for `chat()` calls that don't specify one. */
49
+ defaultMaxTokens?: number
50
+ }
51
+
52
+ export type ProviderConfig = AnthropicProviderConfig | OpenAIProviderConfig // | GoogleProviderConfig | DeepSeekProviderConfig (later slices)
38
53
 
39
54
  /** Cache-shape defaults applied when `ChatOptions.cache` is omitted. */
40
55
  export interface BrainCacheConfig {
@@ -28,6 +28,7 @@ import { type Application, ConfigError, ConfigRepository, ServiceProvider } from
28
28
  import { BrainManager } from './brain_manager.ts'
29
29
  import type { BrainConfigShape, ProviderConfig } from './brain_config.ts'
30
30
  import { AnthropicProvider } from './providers/anthropic_provider.ts'
31
+ import { OpenAIProvider } from './providers/openai_provider.ts'
31
32
  import type { Provider } from './provider.ts'
32
33
 
33
34
  export class BrainProvider extends ServiceProvider {
@@ -93,9 +94,21 @@ function buildProvider(name: string, config: ProviderConfig): Provider {
93
94
  )
94
95
  }
95
96
  return new AnthropicProvider(name, config)
96
- default:
97
+ case 'openai':
98
+ if (!config.apiKey) {
99
+ throw new ConfigError(
100
+ `BrainProvider: openai provider "${name}" is missing apiKey. Source from env('OPENAI_API_KEY').`,
101
+ )
102
+ }
103
+ return new OpenAIProvider(name, config)
104
+ default: {
105
+ const exhaustiveCheck: never = config
97
106
  throw new ConfigError(
98
- `BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic.`,
107
+ `BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic, openai.`,
99
108
  )
109
+ // (unreachable — kept for the exhaustive check to fire when a new driver lands)
110
+ // biome-ignore lint/correctness/noUnreachable: kept for the exhaustive-check above
111
+ return exhaustiveCheck
112
+ }
100
113
  }
101
114
  }
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  type BrainConfigShape,
17
17
  DEFAULT_MODEL,
18
18
  DEFAULT_TIERS,
19
+ type OpenAIProviderConfig,
19
20
  type ProviderConfig,
20
21
  } from './brain_config.ts'
21
22
  export { BrainError } from './brain_error.ts'
@@ -28,6 +29,7 @@ export { BrainProvider } from './brain_provider.ts'
28
29
  export { defineTool, type DefineToolSpec } from './define_tool.ts'
29
30
  export type { MCPServer, MCPServerToolConfig } from './mcp_server.ts'
30
31
  export { AnthropicProvider } from './providers/anthropic_provider.ts'
32
+ export { OpenAIProvider } from './providers/openai_provider.ts'
31
33
  export type { Provider, RunWithToolsOptions } from './provider.ts'
32
34
  export { Thread, type ThreadOptions, type ThreadState } from './thread.ts'
33
35
  export type { Tool, ToolContext } from './tool.ts'
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `MCPClient` — local MCP client for providers that lack server-side
3
+ * MCP support (OpenAI, Gemini, DeepSeek, …).
4
+ *
5
+ * Wraps the official `@modelcontextprotocol/sdk` client. Connects to a
6
+ * single MCP server over Streamable HTTP, lists its tools, and invokes
7
+ * them. The agentic loop sees these as ordinary `Tool`s — translation
8
+ * happens in `resolveMcpTools`.
9
+ *
10
+ * Lifecycle:
11
+ *
12
+ * const client = new MCPClient(serverConfig)
13
+ * await client.connect()
14
+ * const tools = await client.listTools()
15
+ * const result = await client.callTool('name', {...})
16
+ * await client.close()
17
+ *
18
+ * Authentication:
19
+ * `MCPServer.authorizationToken` is forwarded as
20
+ * `Authorization: Bearer <token>`. OAuth-flow servers need
21
+ * out-of-band token exchange — same constraint as the server-side
22
+ * path. Full OAuth handshake is a later slice.
23
+ *
24
+ * Transport:
25
+ * V1 only does Streamable HTTP — the current MCP transport. Legacy
26
+ * SSE-only endpoints aren't supported; if a server URL ends with
27
+ * `/sse` and only speaks the legacy protocol, the connection will
28
+ * fail and apps should run against a Streamable-HTTP endpoint
29
+ * instead.
30
+ */
31
+
32
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
33
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
34
+ import { BrainError } from '../brain_error.ts'
35
+ import type { MCPServer } from '../mcp_server.ts'
36
+
37
+ /** Result of a single MCP tool invocation, as returned by `tools/call`. */
38
+ export interface MCPCallToolResult {
39
+ /** Stringified content — text blocks concatenated; image / resource blocks JSON-serialized. */
40
+ content: string
41
+ /** `true` when the MCP server reports the tool execution failed. */
42
+ isError: boolean
43
+ }
44
+
45
+ /** Tool descriptor surfaced by `tools/list`. */
46
+ export interface MCPToolDescriptor {
47
+ name: string
48
+ description: string
49
+ inputSchema: Record<string, unknown>
50
+ }
51
+
52
+ export interface MCPClientOptions {
53
+ /** Override the transport used to dial the server. Tests inject a mock here. */
54
+ client?: Client
55
+ }
56
+
57
+ export class MCPClient {
58
+ readonly server: MCPServer
59
+ private readonly _client: Client
60
+ private _connected = false
61
+
62
+ constructor(server: MCPServer, options: MCPClientOptions = {}) {
63
+ this.server = server
64
+ this._client =
65
+ options.client ??
66
+ new Client(
67
+ { name: `@strav/brain:${server.name}`, version: '1.0.0' },
68
+ { capabilities: {} },
69
+ )
70
+ }
71
+
72
+ async connect(): Promise<void> {
73
+ if (this._connected) return
74
+ const transport = this._buildTransport()
75
+ try {
76
+ await this._client.connect(transport)
77
+ this._connected = true
78
+ } catch (cause) {
79
+ throw new BrainError(
80
+ `MCPClient(${this.server.name}): failed to connect to ${this.server.url}.`,
81
+ { context: { server: this.server.name, url: this.server.url }, cause },
82
+ )
83
+ }
84
+ }
85
+
86
+ async listTools(): Promise<MCPToolDescriptor[]> {
87
+ await this.connect()
88
+ let response: Awaited<ReturnType<Client['listTools']>>
89
+ try {
90
+ response = await this._client.listTools()
91
+ } catch (cause) {
92
+ throw new BrainError(
93
+ `MCPClient(${this.server.name}): tools/list failed.`,
94
+ { context: { server: this.server.name }, cause },
95
+ )
96
+ }
97
+ return response.tools.map((t) => ({
98
+ name: t.name,
99
+ description: t.description ?? '',
100
+ inputSchema: (t.inputSchema ?? { type: 'object' }) as Record<string, unknown>,
101
+ }))
102
+ }
103
+
104
+ async callTool(name: string, input: unknown): Promise<MCPCallToolResult> {
105
+ await this.connect()
106
+ let response: Awaited<ReturnType<Client['callTool']>>
107
+ try {
108
+ response = await this._client.callTool({
109
+ name,
110
+ arguments: (input ?? {}) as Record<string, unknown>,
111
+ })
112
+ } catch (cause) {
113
+ throw new BrainError(
114
+ `MCPClient(${this.server.name}): tools/call ${name} failed.`,
115
+ { context: { server: this.server.name, tool: name }, cause },
116
+ )
117
+ }
118
+ return {
119
+ content: flattenContent(response.content),
120
+ isError: Boolean(response.isError),
121
+ }
122
+ }
123
+
124
+ async close(): Promise<void> {
125
+ if (!this._connected) return
126
+ try {
127
+ await this._client.close()
128
+ } finally {
129
+ this._connected = false
130
+ }
131
+ }
132
+
133
+ private _buildTransport(): StreamableHTTPClientTransport {
134
+ const headers: Record<string, string> = {}
135
+ if (this.server.authorizationToken !== undefined) {
136
+ headers.Authorization = `Bearer ${this.server.authorizationToken}`
137
+ }
138
+ return new StreamableHTTPClientTransport(new URL(this.server.url), {
139
+ requestInit: { headers },
140
+ })
141
+ }
142
+ }
143
+
144
+ function flattenContent(
145
+ content: Awaited<ReturnType<Client['callTool']>>['content'],
146
+ ): string {
147
+ if (!Array.isArray(content)) return ''
148
+ const parts: string[] = []
149
+ for (const block of content) {
150
+ if (block.type === 'text') {
151
+ parts.push(block.text)
152
+ } else {
153
+ parts.push(JSON.stringify(block))
154
+ }
155
+ }
156
+ return parts.join('')
157
+ }
@@ -0,0 +1,16 @@
1
+ // Public API of `@strav/brain/mcp` — local MCP client for providers
2
+ // without server-side MCP support (OpenAI, Gemini, DeepSeek). The
3
+ // Anthropic provider continues to use server-side MCP via the
4
+ // top-level `MCPServer` config; nothing here is needed for that path.
5
+
6
+ export {
7
+ MCPClient,
8
+ type MCPCallToolResult,
9
+ type MCPClientOptions,
10
+ type MCPToolDescriptor,
11
+ } from './client.ts'
12
+ export {
13
+ resolveMcpTools,
14
+ type ResolveMcpToolsOptions,
15
+ type ResolvedMcpTools,
16
+ } from './resolve_mcp_tools.ts'
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `resolveMcpTools` — connects to each `MCPServer`, discovers its
3
+ * tools, and surfaces them as framework `Tool`s the standard agentic
4
+ * loop already knows how to invoke.
5
+ *
6
+ * Honors the per-server config in `MCPServer.tools`:
7
+ * - `enabled: false` → server is skipped entirely.
8
+ * - `allowedTools` → only those tool names are exposed.
9
+ *
10
+ * Naming: discovered tools are namespaced as `<server>__<tool>` to
11
+ * keep names unique when multiple servers expose overlapping names.
12
+ * The `Tool.execute` then routes back to the correct server. The
13
+ * underscore separator (not `.` or `/`) is chosen because OpenAI's
14
+ * tool-name regex rejects `.` and `/`.
15
+ *
16
+ * Lifecycle: this helper returns `{ tools, close }`. `close` runs all
17
+ * client `close()` calls in parallel — providers must call it in a
18
+ * `finally` to avoid leaking transports.
19
+ */
20
+
21
+ import type { MCPServer } from '../mcp_server.ts'
22
+ import type { Tool, ToolContext } from '../tool.ts'
23
+ import { MCPClient } from './client.ts'
24
+
25
+ export interface ResolvedMcpTools {
26
+ tools: Tool[]
27
+ close(): Promise<void>
28
+ }
29
+
30
+ export interface ResolveMcpToolsOptions {
31
+ /** Override the client factory — tests inject mock clients per server here. */
32
+ clientFactory?(server: MCPServer): MCPClient
33
+ }
34
+
35
+ const NAME_SEPARATOR = '__'
36
+
37
+ export async function resolveMcpTools(
38
+ servers: readonly MCPServer[],
39
+ options: ResolveMcpToolsOptions = {},
40
+ ): Promise<ResolvedMcpTools> {
41
+ const clients: MCPClient[] = []
42
+ const tools: Tool[] = []
43
+
44
+ for (const server of servers) {
45
+ if (server.tools?.enabled === false) continue
46
+ const client = options.clientFactory
47
+ ? options.clientFactory(server)
48
+ : new MCPClient(server)
49
+ clients.push(client)
50
+
51
+ const allowed = server.tools?.allowedTools
52
+ const allowedSet = allowed ? new Set(allowed) : null
53
+
54
+ const descriptors = await client.listTools()
55
+ for (const descriptor of descriptors) {
56
+ if (allowedSet && !allowedSet.has(descriptor.name)) continue
57
+ tools.push(buildTool(server.name, client, descriptor))
58
+ }
59
+ }
60
+
61
+ return {
62
+ tools,
63
+ close: async () => {
64
+ await Promise.all(clients.map((c) => c.close()))
65
+ },
66
+ }
67
+ }
68
+
69
+ function buildTool(
70
+ serverName: string,
71
+ client: MCPClient,
72
+ descriptor: { name: string; description: string; inputSchema: Record<string, unknown> },
73
+ ): Tool {
74
+ return {
75
+ name: `${serverName}${NAME_SEPARATOR}${descriptor.name}`,
76
+ description: descriptor.description,
77
+ inputSchema: descriptor.inputSchema,
78
+ async execute(input: unknown, _ctx: ToolContext): Promise<string> {
79
+ const result = await client.callTool(descriptor.name, input)
80
+ if (result.isError) {
81
+ return `MCP tool error: ${result.content}`
82
+ }
83
+ return result.content
84
+ },
85
+ }
86
+ }
@@ -0,0 +1,446 @@
1
+ /**
2
+ * `OpenAIProvider` — implementation of `Provider` backed by the
3
+ * official `openai` SDK (chat completions API).
4
+ *
5
+ * Maps framework shapes to OpenAI's wire format:
6
+ *
7
+ * - `system` becomes the first message with `role: 'system'`.
8
+ * (OpenAI doesn't have a separate system field on chat
9
+ * completions; o1/o3 reasoning models accept `developer` as
10
+ * a synonym but `system` still works.)
11
+ *
12
+ * - `Message` with string content → `{role, content: string}`.
13
+ * `Message` with `ContentBlock[]`: text blocks concatenate into
14
+ * a single content string; `ToolUseBlock`s on assistant turns
15
+ * translate to `tool_calls`; `ToolResultBlock`s in user turns
16
+ * each become their own `{role: 'tool', tool_call_id, content}`
17
+ * message (OpenAI requires this layout, not a single user turn
18
+ * with mixed content like Anthropic's).
19
+ *
20
+ * - `Tool[]` → `[{type: 'function', function: {name, description,
21
+ * parameters: tool.inputSchema}}]`. OpenAI wraps every tool in
22
+ * a `function` namespace where Anthropic uses flat tool
23
+ * definitions.
24
+ *
25
+ * - `MCPServer[]` → resolved via the local MCP client
26
+ * (`@strav/brain/mcp`). Each server is dialed, its tools are
27
+ * discovered, and they're merged with locally-defined tools.
28
+ * The agentic loop then treats them uniformly. Tool names are
29
+ * namespaced `<server>__<tool>` to avoid collisions. Transports
30
+ * are closed in a `finally` once the loop exits.
31
+ *
32
+ * - `cache: true` is a no-op. OpenAI auto-caches; there's no
33
+ * per-block cache_control to set. The framework flag is
34
+ * accepted (so config that targets both providers still
35
+ * works) but doesn't emit anything to the wire.
36
+ *
37
+ * - `thinking: 'adaptive'` maps to `reasoning_effort: 'medium'`
38
+ * on reasoning models (o1, o3, o5, etc.); `'disabled'` maps
39
+ * to `reasoning_effort: 'minimal'`. Non-reasoning models
40
+ * silently ignore the field.
41
+ *
42
+ * - `effort` (when set) maps directly to `reasoning_effort`
43
+ * when supported by the model.
44
+ *
45
+ * - `countTokens` is NOT implemented — OpenAI has no dedicated
46
+ * count endpoint. `BrainManager.countTokens` returns `null`
47
+ * when the configured provider doesn't expose the method.
48
+ */
49
+
50
+ import OpenAI from 'openai'
51
+ import type { AgentResult } from '../agent_result.ts'
52
+ import { BrainError } from '../brain_error.ts'
53
+ import type { OpenAIProviderConfig } from '../brain_config.ts'
54
+ import type { MCPServer } from '../mcp_server.ts'
55
+ import { resolveMcpTools, type ResolveMcpToolsOptions } from '../mcp/resolve_mcp_tools.ts'
56
+ import type { Provider, RunWithToolsOptions } from '../provider.ts'
57
+ import type { Tool } from '../tool.ts'
58
+ import { ToolExecutionError } from '../tool_execution_error.ts'
59
+ import type {
60
+ ChatOptions,
61
+ ChatResult,
62
+ ChatUsage,
63
+ ContentBlock,
64
+ Message,
65
+ StreamEvent,
66
+ SystemPrompt,
67
+ TextBlock,
68
+ ToolResultBlock,
69
+ ToolUseBlock,
70
+ } from '../types.ts'
71
+
72
+ const DEFAULT_OPENAI_MODEL = 'gpt-5'
73
+
74
+ export interface OpenAIProviderOptions {
75
+ client?: OpenAI
76
+ /**
77
+ * Internal seam — tests inject a stub MCP client factory so MCP
78
+ * tool resolution doesn't dial the network. Real apps leave it
79
+ * unset; the provider uses the default `MCPClient`.
80
+ */
81
+ mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
82
+ }
83
+
84
+ export class OpenAIProvider implements Provider {
85
+ readonly name: string
86
+ private readonly client: OpenAI
87
+ private readonly defaultModel: string
88
+ private readonly defaultMaxTokens: number
89
+ private readonly mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
90
+
91
+ constructor(
92
+ name: string,
93
+ config: OpenAIProviderConfig,
94
+ options: OpenAIProviderOptions = {},
95
+ ) {
96
+ this.name = name
97
+ this.defaultModel = config.defaultModel ?? DEFAULT_OPENAI_MODEL
98
+ this.defaultMaxTokens = config.defaultMaxTokens ?? 4096
99
+ this.mcpClientFactory = options.mcpClientFactory
100
+ this.client =
101
+ options.client ??
102
+ new OpenAI({
103
+ apiKey: config.apiKey,
104
+ ...(config.baseUrl !== undefined ? { baseURL: config.baseUrl } : {}),
105
+ ...(config.organization !== undefined ? { organization: config.organization } : {}),
106
+ })
107
+ }
108
+
109
+ async chat(messages: readonly Message[], options: ChatOptions = {}): Promise<ChatResult> {
110
+ const params = this.buildParams(messages, options, [])
111
+ const response = await this.client.chat.completions.create(params)
112
+ return this.toChatResult(response)
113
+ }
114
+
115
+ async *stream(
116
+ messages: readonly Message[],
117
+ options: ChatOptions = {},
118
+ ): AsyncIterable<StreamEvent> {
119
+ const params: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {
120
+ ...this.buildParams(messages, options, []),
121
+ stream: true,
122
+ stream_options: { include_usage: true },
123
+ }
124
+ const stream = await this.client.chat.completions.create(params)
125
+ let aggregatedUsage: OpenAI.CompletionUsage | undefined
126
+ let finishReason: string | null = null
127
+ for await (const chunk of stream) {
128
+ const delta = chunk.choices[0]?.delta?.content
129
+ if (typeof delta === 'string' && delta.length > 0) {
130
+ yield { type: 'text', delta }
131
+ }
132
+ if (chunk.choices[0]?.finish_reason) {
133
+ finishReason = chunk.choices[0].finish_reason
134
+ }
135
+ if (chunk.usage) aggregatedUsage = chunk.usage
136
+ }
137
+ yield {
138
+ type: 'stop',
139
+ stopReason: finishReason,
140
+ usage: toUsage(aggregatedUsage),
141
+ }
142
+ }
143
+
144
+ async runWithTools(
145
+ messages: readonly Message[],
146
+ tools: readonly Tool[],
147
+ options: RunWithToolsOptions = {},
148
+ ): Promise<AgentResult> {
149
+ const mcpServers: readonly MCPServer[] = options.mcpServers ?? []
150
+ const resolved =
151
+ mcpServers.length > 0
152
+ ? await resolveMcpTools(mcpServers, {
153
+ ...(this.mcpClientFactory ? { clientFactory: this.mcpClientFactory } : {}),
154
+ })
155
+ : { tools: [] as Tool[], close: async () => {} }
156
+ try {
157
+ return await this._runLoop(messages, [...tools, ...resolved.tools], options)
158
+ } finally {
159
+ await resolved.close()
160
+ }
161
+ }
162
+
163
+ private async _runLoop(
164
+ messages: readonly Message[],
165
+ tools: readonly Tool[],
166
+ options: RunWithToolsOptions,
167
+ ): Promise<AgentResult> {
168
+ const maxIterations = options.maxIterations ?? 10
169
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
170
+ const workingMessages: Message[] = [...messages]
171
+ const aggregated: ChatUsage = {
172
+ inputTokens: 0,
173
+ outputTokens: 0,
174
+ cacheReadTokens: 0,
175
+ cacheCreationTokens: 0,
176
+ }
177
+ let iterations = 0
178
+
179
+ while (true) {
180
+ const params = this.buildParams(workingMessages, options, tools)
181
+ const response = await this.client.chat.completions.create(params)
182
+ addUsage(aggregated, response.usage)
183
+
184
+ const choice = response.choices[0]
185
+ if (!choice) {
186
+ throw new BrainError('OpenAIProvider: response had no choices.')
187
+ }
188
+ const assistantMessage = choice.message
189
+
190
+ // Append assistant turn to working messages so we send it back
191
+ // verbatim on the next round-trip.
192
+ workingMessages.push({
193
+ role: 'assistant',
194
+ content: fromOpenAIAssistantMessage(assistantMessage),
195
+ })
196
+
197
+ const toolCalls = assistantMessage.tool_calls ?? []
198
+ if (toolCalls.length === 0 || choice.finish_reason !== 'tool_calls') {
199
+ return {
200
+ text: assistantMessage.content ?? '',
201
+ messages: workingMessages,
202
+ iterations,
203
+ stopReason: choice.finish_reason ?? 'stop',
204
+ usage: aggregated,
205
+ }
206
+ }
207
+
208
+ const resultBlocks: ContentBlock[] = []
209
+ for (const call of toolCalls) {
210
+ if (call.type !== 'function') continue
211
+ const tool = toolMap.get(call.function.name)
212
+ if (!tool) {
213
+ throw new ToolExecutionError(
214
+ call.function.name,
215
+ call.id,
216
+ new Error(`Tool "${call.function.name}" is not registered.`),
217
+ )
218
+ }
219
+ let parsedInput: unknown
220
+ try {
221
+ parsedInput = call.function.arguments ? JSON.parse(call.function.arguments) : {}
222
+ } catch (err) {
223
+ throw new ToolExecutionError(
224
+ call.function.name,
225
+ call.id,
226
+ new Error(`Failed to parse tool input JSON: ${(err as Error).message}`),
227
+ )
228
+ }
229
+ let output: unknown
230
+ try {
231
+ output = await tool.execute(parsedInput, {
232
+ callId: call.id,
233
+ context: options.context ?? {},
234
+ })
235
+ } catch (cause) {
236
+ throw new ToolExecutionError(call.function.name, call.id, cause)
237
+ }
238
+ const resultBlock: ToolResultBlock = {
239
+ type: 'tool_result',
240
+ toolUseId: call.id,
241
+ content: typeof output === 'string' ? output : JSON.stringify(output),
242
+ }
243
+ resultBlocks.push(resultBlock)
244
+ }
245
+ workingMessages.push({ role: 'user', content: resultBlocks })
246
+
247
+ iterations++
248
+ if (iterations >= maxIterations) {
249
+ return {
250
+ text: assistantMessage.content ?? '',
251
+ messages: workingMessages,
252
+ iterations,
253
+ stopReason: 'max_iterations',
254
+ usage: aggregated,
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ // ─── Param translation ──────────────────────────────────────────────────
261
+
262
+ private buildParams(
263
+ messages: readonly Message[],
264
+ options: ChatOptions,
265
+ tools: readonly Tool[],
266
+ ): OpenAI.Chat.ChatCompletionCreateParamsNonStreaming {
267
+ const model = options.model ?? this.defaultModel
268
+ const params: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming = {
269
+ model,
270
+ max_completion_tokens: options.maxTokens ?? this.defaultMaxTokens,
271
+ messages: this.toMessages(options.system, messages),
272
+ }
273
+
274
+ if (tools.length > 0) {
275
+ params.tools = tools.map((t) => ({
276
+ type: 'function',
277
+ function: {
278
+ name: t.name,
279
+ description: t.description,
280
+ parameters: t.inputSchema as Record<string, unknown>,
281
+ },
282
+ }))
283
+ }
284
+
285
+ // Reasoning controls — only emitted when explicitly set so
286
+ // non-reasoning models don't get rejected.
287
+ if (options.effort !== undefined) {
288
+ params.reasoning_effort = options.effort as OpenAI.ReasoningEffort
289
+ } else if (options.thinking === 'adaptive') {
290
+ params.reasoning_effort = 'medium' as OpenAI.ReasoningEffort
291
+ } else if (options.thinking === 'disabled') {
292
+ params.reasoning_effort = 'minimal' as OpenAI.ReasoningEffort
293
+ }
294
+
295
+ // `cache` is a no-op on OpenAI — prompt caching is automatic.
296
+ // We accept the flag silently so apps that target both providers
297
+ // with the same options object don't have to special-case.
298
+
299
+ return params
300
+ }
301
+
302
+ private toMessages(
303
+ system: SystemPrompt | undefined,
304
+ messages: readonly Message[],
305
+ ): OpenAI.Chat.ChatCompletionMessageParam[] {
306
+ const out: OpenAI.Chat.ChatCompletionMessageParam[] = []
307
+ const systemText = systemPromptText(system)
308
+ if (systemText.length > 0) {
309
+ out.push({ role: 'system', content: systemText })
310
+ }
311
+ for (const message of messages) {
312
+ // User-role messages with tool results in their content fan
313
+ // out into one `tool`-role message per result — OpenAI's
314
+ // contract is "one tool_call_id per tool message," not a
315
+ // single user message carrying multiple results.
316
+ if (
317
+ message.role === 'user' &&
318
+ Array.isArray(message.content) &&
319
+ message.content.some((b) => b.type === 'tool_result')
320
+ ) {
321
+ const remainingText: string[] = []
322
+ for (const block of message.content) {
323
+ if (block.type === 'tool_result') {
324
+ out.push({
325
+ role: 'tool',
326
+ tool_call_id: block.toolUseId,
327
+ content: typeof block.content === 'string'
328
+ ? block.content
329
+ : block.content.map((t) => t.text).join(''),
330
+ })
331
+ } else if (block.type === 'text') {
332
+ remainingText.push(block.text)
333
+ }
334
+ }
335
+ if (remainingText.length > 0) {
336
+ out.push({ role: 'user', content: remainingText.join('') })
337
+ }
338
+ continue
339
+ }
340
+ out.push(toOpenAIMessage(message))
341
+ }
342
+ return out
343
+ }
344
+
345
+ private toChatResult(
346
+ response: OpenAI.Chat.ChatCompletion,
347
+ ): ChatResult<OpenAI.Chat.ChatCompletion> {
348
+ const choice = response.choices[0]
349
+ return {
350
+ text: choice?.message?.content ?? '',
351
+ model: response.model,
352
+ stopReason: choice?.finish_reason ?? null,
353
+ usage: toUsage(response.usage),
354
+ raw: response,
355
+ }
356
+ }
357
+ }
358
+
359
+ // ─── Shape converters ─────────────────────────────────────────────────────
360
+
361
+ function systemPromptText(system: SystemPrompt | undefined): string {
362
+ if (system === undefined) return ''
363
+ if (typeof system === 'string') return system
364
+ if (Array.isArray(system)) return system.map((b) => b.text).join('\n')
365
+ return system.text
366
+ }
367
+
368
+ function toOpenAIMessage(message: Message): OpenAI.Chat.ChatCompletionMessageParam {
369
+ if (typeof message.content === 'string') {
370
+ return { role: message.role, content: message.content } as OpenAI.Chat.ChatCompletionMessageParam
371
+ }
372
+
373
+ // Assistant turns may contain text + tool_use blocks; we need to
374
+ // split tool_use blocks into the `tool_calls` field and put the
375
+ // remaining text into `content`.
376
+ if (message.role === 'assistant') {
377
+ const text = message.content
378
+ .filter((b): b is TextBlock => b.type === 'text')
379
+ .map((b) => b.text)
380
+ .join('')
381
+ const toolUses = message.content.filter((b): b is ToolUseBlock => b.type === 'tool_use')
382
+ const param: OpenAI.Chat.ChatCompletionAssistantMessageParam = { role: 'assistant' }
383
+ if (text.length > 0) param.content = text
384
+ if (toolUses.length > 0) {
385
+ param.tool_calls = toolUses.map((b) => ({
386
+ id: b.id,
387
+ type: 'function',
388
+ function: {
389
+ name: b.name,
390
+ arguments: JSON.stringify(b.input ?? {}),
391
+ },
392
+ }))
393
+ }
394
+ return param
395
+ }
396
+
397
+ // User-role multi-block content — flatten text. MCP blocks (which
398
+ // are read-only and Anthropic-specific) are silently dropped.
399
+ const text = message.content
400
+ .filter((b): b is TextBlock => b.type === 'text')
401
+ .map((b) => b.text)
402
+ .join('')
403
+ return { role: 'user', content: text }
404
+ }
405
+
406
+ function fromOpenAIAssistantMessage(
407
+ msg: OpenAI.Chat.ChatCompletionMessage,
408
+ ): string | ContentBlock[] {
409
+ const blocks: ContentBlock[] = []
410
+ if (msg.content) blocks.push({ type: 'text', text: msg.content })
411
+ if (msg.tool_calls) {
412
+ for (const call of msg.tool_calls) {
413
+ if (call.type !== 'function') continue
414
+ let parsedInput: unknown = {}
415
+ try {
416
+ parsedInput = call.function.arguments ? JSON.parse(call.function.arguments) : {}
417
+ } catch {
418
+ parsedInput = call.function.arguments ?? {}
419
+ }
420
+ blocks.push({
421
+ type: 'tool_use',
422
+ id: call.id,
423
+ name: call.function.name,
424
+ input: parsedInput,
425
+ } satisfies ToolUseBlock)
426
+ }
427
+ }
428
+ if (blocks.length === 1 && blocks[0]?.type === 'text') return blocks[0].text
429
+ return blocks
430
+ }
431
+
432
+ function toUsage(u: OpenAI.CompletionUsage | undefined): ChatUsage {
433
+ return {
434
+ inputTokens: u?.prompt_tokens ?? 0,
435
+ outputTokens: u?.completion_tokens ?? 0,
436
+ cacheReadTokens: u?.prompt_tokens_details?.cached_tokens ?? 0,
437
+ cacheCreationTokens: 0,
438
+ }
439
+ }
440
+
441
+ function addUsage(acc: ChatUsage, u: OpenAI.CompletionUsage | undefined): void {
442
+ if (!u) return
443
+ acc.inputTokens += u.prompt_tokens
444
+ acc.outputTokens += u.completion_tokens
445
+ acc.cacheReadTokens += u.prompt_tokens_details?.cached_tokens ?? 0
446
+ }