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

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,7 +1,7 @@
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.11",
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",
@@ -19,8 +19,9 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@strav/kernel": "1.0.0-alpha.10",
23
- "@anthropic-ai/sdk": "^0.100.0"
22
+ "@strav/kernel": "1.0.0-alpha.11",
23
+ "@anthropic-ai/sdk": "^0.100.0",
24
+ "openai": "^6.0.0"
24
25
  },
25
26
  "peerDependencies": {
26
27
  "@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,416 @@
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[]` → throws `BrainError`. OpenAI has no
26
+ * server-side MCP support; the local MCP client slice
27
+ * (`@strav/brain/mcp`) lands when this is needed.
28
+ *
29
+ * - `cache: true` is a no-op. OpenAI auto-caches; there's no
30
+ * per-block cache_control to set. The framework flag is
31
+ * accepted (so config that targets both providers still
32
+ * works) but doesn't emit anything to the wire.
33
+ *
34
+ * - `thinking: 'adaptive'` maps to `reasoning_effort: 'medium'`
35
+ * on reasoning models (o1, o3, o5, etc.); `'disabled'` maps
36
+ * to `reasoning_effort: 'minimal'`. Non-reasoning models
37
+ * silently ignore the field.
38
+ *
39
+ * - `effort` (when set) maps directly to `reasoning_effort`
40
+ * when supported by the model.
41
+ *
42
+ * - `countTokens` is NOT implemented — OpenAI has no dedicated
43
+ * count endpoint. `BrainManager.countTokens` returns `null`
44
+ * when the configured provider doesn't expose the method.
45
+ */
46
+
47
+ import OpenAI from 'openai'
48
+ import type { AgentResult } from '../agent_result.ts'
49
+ import { BrainError } from '../brain_error.ts'
50
+ import type { OpenAIProviderConfig } from '../brain_config.ts'
51
+ import type { Provider, RunWithToolsOptions } from '../provider.ts'
52
+ import type { Tool } from '../tool.ts'
53
+ import { ToolExecutionError } from '../tool_execution_error.ts'
54
+ import type {
55
+ ChatOptions,
56
+ ChatResult,
57
+ ChatUsage,
58
+ ContentBlock,
59
+ Message,
60
+ StreamEvent,
61
+ SystemPrompt,
62
+ TextBlock,
63
+ ToolResultBlock,
64
+ ToolUseBlock,
65
+ } from '../types.ts'
66
+
67
+ const DEFAULT_OPENAI_MODEL = 'gpt-5'
68
+
69
+ export class OpenAIProvider implements Provider {
70
+ readonly name: string
71
+ private readonly client: OpenAI
72
+ private readonly defaultModel: string
73
+ private readonly defaultMaxTokens: number
74
+
75
+ constructor(
76
+ name: string,
77
+ config: OpenAIProviderConfig,
78
+ options: { client?: OpenAI } = {},
79
+ ) {
80
+ this.name = name
81
+ this.defaultModel = config.defaultModel ?? DEFAULT_OPENAI_MODEL
82
+ this.defaultMaxTokens = config.defaultMaxTokens ?? 4096
83
+ this.client =
84
+ options.client ??
85
+ new OpenAI({
86
+ apiKey: config.apiKey,
87
+ ...(config.baseUrl !== undefined ? { baseURL: config.baseUrl } : {}),
88
+ ...(config.organization !== undefined ? { organization: config.organization } : {}),
89
+ })
90
+ }
91
+
92
+ async chat(messages: readonly Message[], options: ChatOptions = {}): Promise<ChatResult> {
93
+ const params = this.buildParams(messages, options, [])
94
+ const response = await this.client.chat.completions.create(params)
95
+ return this.toChatResult(response)
96
+ }
97
+
98
+ async *stream(
99
+ messages: readonly Message[],
100
+ options: ChatOptions = {},
101
+ ): AsyncIterable<StreamEvent> {
102
+ const params: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {
103
+ ...this.buildParams(messages, options, []),
104
+ stream: true,
105
+ stream_options: { include_usage: true },
106
+ }
107
+ const stream = await this.client.chat.completions.create(params)
108
+ let aggregatedUsage: OpenAI.CompletionUsage | undefined
109
+ let finishReason: string | null = null
110
+ for await (const chunk of stream) {
111
+ const delta = chunk.choices[0]?.delta?.content
112
+ if (typeof delta === 'string' && delta.length > 0) {
113
+ yield { type: 'text', delta }
114
+ }
115
+ if (chunk.choices[0]?.finish_reason) {
116
+ finishReason = chunk.choices[0].finish_reason
117
+ }
118
+ if (chunk.usage) aggregatedUsage = chunk.usage
119
+ }
120
+ yield {
121
+ type: 'stop',
122
+ stopReason: finishReason,
123
+ usage: toUsage(aggregatedUsage),
124
+ }
125
+ }
126
+
127
+ async runWithTools(
128
+ messages: readonly Message[],
129
+ tools: readonly Tool[],
130
+ options: RunWithToolsOptions = {},
131
+ ): Promise<AgentResult> {
132
+ if (options.mcpServers && options.mcpServers.length > 0) {
133
+ throw new BrainError(
134
+ 'OpenAIProvider.runWithTools: MCP servers are not supported by the OpenAI provider in V1. Use the Anthropic provider for server-side MCP, or wait for the local MCP client slice.',
135
+ { context: { provider: this.name } },
136
+ )
137
+ }
138
+ const maxIterations = options.maxIterations ?? 10
139
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
140
+ const workingMessages: Message[] = [...messages]
141
+ const aggregated: ChatUsage = {
142
+ inputTokens: 0,
143
+ outputTokens: 0,
144
+ cacheReadTokens: 0,
145
+ cacheCreationTokens: 0,
146
+ }
147
+ let iterations = 0
148
+
149
+ while (true) {
150
+ const params = this.buildParams(workingMessages, options, tools)
151
+ const response = await this.client.chat.completions.create(params)
152
+ addUsage(aggregated, response.usage)
153
+
154
+ const choice = response.choices[0]
155
+ if (!choice) {
156
+ throw new BrainError('OpenAIProvider: response had no choices.')
157
+ }
158
+ const assistantMessage = choice.message
159
+
160
+ // Append assistant turn to working messages so we send it back
161
+ // verbatim on the next round-trip.
162
+ workingMessages.push({
163
+ role: 'assistant',
164
+ content: fromOpenAIAssistantMessage(assistantMessage),
165
+ })
166
+
167
+ const toolCalls = assistantMessage.tool_calls ?? []
168
+ if (toolCalls.length === 0 || choice.finish_reason !== 'tool_calls') {
169
+ return {
170
+ text: assistantMessage.content ?? '',
171
+ messages: workingMessages,
172
+ iterations,
173
+ stopReason: choice.finish_reason ?? 'stop',
174
+ usage: aggregated,
175
+ }
176
+ }
177
+
178
+ const resultBlocks: ContentBlock[] = []
179
+ for (const call of toolCalls) {
180
+ if (call.type !== 'function') continue
181
+ const tool = toolMap.get(call.function.name)
182
+ if (!tool) {
183
+ throw new ToolExecutionError(
184
+ call.function.name,
185
+ call.id,
186
+ new Error(`Tool "${call.function.name}" is not registered.`),
187
+ )
188
+ }
189
+ let parsedInput: unknown
190
+ try {
191
+ parsedInput = call.function.arguments ? JSON.parse(call.function.arguments) : {}
192
+ } catch (err) {
193
+ throw new ToolExecutionError(
194
+ call.function.name,
195
+ call.id,
196
+ new Error(`Failed to parse tool input JSON: ${(err as Error).message}`),
197
+ )
198
+ }
199
+ let output: unknown
200
+ try {
201
+ output = await tool.execute(parsedInput, {
202
+ callId: call.id,
203
+ context: options.context ?? {},
204
+ })
205
+ } catch (cause) {
206
+ throw new ToolExecutionError(call.function.name, call.id, cause)
207
+ }
208
+ const resultBlock: ToolResultBlock = {
209
+ type: 'tool_result',
210
+ toolUseId: call.id,
211
+ content: typeof output === 'string' ? output : JSON.stringify(output),
212
+ }
213
+ resultBlocks.push(resultBlock)
214
+ }
215
+ workingMessages.push({ role: 'user', content: resultBlocks })
216
+
217
+ iterations++
218
+ if (iterations >= maxIterations) {
219
+ return {
220
+ text: assistantMessage.content ?? '',
221
+ messages: workingMessages,
222
+ iterations,
223
+ stopReason: 'max_iterations',
224
+ usage: aggregated,
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ // ─── Param translation ──────────────────────────────────────────────────
231
+
232
+ private buildParams(
233
+ messages: readonly Message[],
234
+ options: ChatOptions,
235
+ tools: readonly Tool[],
236
+ ): OpenAI.Chat.ChatCompletionCreateParamsNonStreaming {
237
+ const model = options.model ?? this.defaultModel
238
+ const params: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming = {
239
+ model,
240
+ max_completion_tokens: options.maxTokens ?? this.defaultMaxTokens,
241
+ messages: this.toMessages(options.system, messages),
242
+ }
243
+
244
+ if (tools.length > 0) {
245
+ params.tools = tools.map((t) => ({
246
+ type: 'function',
247
+ function: {
248
+ name: t.name,
249
+ description: t.description,
250
+ parameters: t.inputSchema as Record<string, unknown>,
251
+ },
252
+ }))
253
+ }
254
+
255
+ // Reasoning controls — only emitted when explicitly set so
256
+ // non-reasoning models don't get rejected.
257
+ if (options.effort !== undefined) {
258
+ params.reasoning_effort = options.effort as OpenAI.ReasoningEffort
259
+ } else if (options.thinking === 'adaptive') {
260
+ params.reasoning_effort = 'medium' as OpenAI.ReasoningEffort
261
+ } else if (options.thinking === 'disabled') {
262
+ params.reasoning_effort = 'minimal' as OpenAI.ReasoningEffort
263
+ }
264
+
265
+ // `cache` is a no-op on OpenAI — prompt caching is automatic.
266
+ // We accept the flag silently so apps that target both providers
267
+ // with the same options object don't have to special-case.
268
+
269
+ return params
270
+ }
271
+
272
+ private toMessages(
273
+ system: SystemPrompt | undefined,
274
+ messages: readonly Message[],
275
+ ): OpenAI.Chat.ChatCompletionMessageParam[] {
276
+ const out: OpenAI.Chat.ChatCompletionMessageParam[] = []
277
+ const systemText = systemPromptText(system)
278
+ if (systemText.length > 0) {
279
+ out.push({ role: 'system', content: systemText })
280
+ }
281
+ for (const message of messages) {
282
+ // User-role messages with tool results in their content fan
283
+ // out into one `tool`-role message per result — OpenAI's
284
+ // contract is "one tool_call_id per tool message," not a
285
+ // single user message carrying multiple results.
286
+ if (
287
+ message.role === 'user' &&
288
+ Array.isArray(message.content) &&
289
+ message.content.some((b) => b.type === 'tool_result')
290
+ ) {
291
+ const remainingText: string[] = []
292
+ for (const block of message.content) {
293
+ if (block.type === 'tool_result') {
294
+ out.push({
295
+ role: 'tool',
296
+ tool_call_id: block.toolUseId,
297
+ content: typeof block.content === 'string'
298
+ ? block.content
299
+ : block.content.map((t) => t.text).join(''),
300
+ })
301
+ } else if (block.type === 'text') {
302
+ remainingText.push(block.text)
303
+ }
304
+ }
305
+ if (remainingText.length > 0) {
306
+ out.push({ role: 'user', content: remainingText.join('') })
307
+ }
308
+ continue
309
+ }
310
+ out.push(toOpenAIMessage(message))
311
+ }
312
+ return out
313
+ }
314
+
315
+ private toChatResult(
316
+ response: OpenAI.Chat.ChatCompletion,
317
+ ): ChatResult<OpenAI.Chat.ChatCompletion> {
318
+ const choice = response.choices[0]
319
+ return {
320
+ text: choice?.message?.content ?? '',
321
+ model: response.model,
322
+ stopReason: choice?.finish_reason ?? null,
323
+ usage: toUsage(response.usage),
324
+ raw: response,
325
+ }
326
+ }
327
+ }
328
+
329
+ // ─── Shape converters ─────────────────────────────────────────────────────
330
+
331
+ function systemPromptText(system: SystemPrompt | undefined): string {
332
+ if (system === undefined) return ''
333
+ if (typeof system === 'string') return system
334
+ if (Array.isArray(system)) return system.map((b) => b.text).join('\n')
335
+ return system.text
336
+ }
337
+
338
+ function toOpenAIMessage(message: Message): OpenAI.Chat.ChatCompletionMessageParam {
339
+ if (typeof message.content === 'string') {
340
+ return { role: message.role, content: message.content } as OpenAI.Chat.ChatCompletionMessageParam
341
+ }
342
+
343
+ // Assistant turns may contain text + tool_use blocks; we need to
344
+ // split tool_use blocks into the `tool_calls` field and put the
345
+ // remaining text into `content`.
346
+ if (message.role === 'assistant') {
347
+ const text = message.content
348
+ .filter((b): b is TextBlock => b.type === 'text')
349
+ .map((b) => b.text)
350
+ .join('')
351
+ const toolUses = message.content.filter((b): b is ToolUseBlock => b.type === 'tool_use')
352
+ const param: OpenAI.Chat.ChatCompletionAssistantMessageParam = { role: 'assistant' }
353
+ if (text.length > 0) param.content = text
354
+ if (toolUses.length > 0) {
355
+ param.tool_calls = toolUses.map((b) => ({
356
+ id: b.id,
357
+ type: 'function',
358
+ function: {
359
+ name: b.name,
360
+ arguments: JSON.stringify(b.input ?? {}),
361
+ },
362
+ }))
363
+ }
364
+ return param
365
+ }
366
+
367
+ // User-role multi-block content — flatten text. MCP blocks (which
368
+ // are read-only and Anthropic-specific) are silently dropped.
369
+ const text = message.content
370
+ .filter((b): b is TextBlock => b.type === 'text')
371
+ .map((b) => b.text)
372
+ .join('')
373
+ return { role: 'user', content: text }
374
+ }
375
+
376
+ function fromOpenAIAssistantMessage(
377
+ msg: OpenAI.Chat.ChatCompletionMessage,
378
+ ): string | ContentBlock[] {
379
+ const blocks: ContentBlock[] = []
380
+ if (msg.content) blocks.push({ type: 'text', text: msg.content })
381
+ if (msg.tool_calls) {
382
+ for (const call of msg.tool_calls) {
383
+ if (call.type !== 'function') continue
384
+ let parsedInput: unknown = {}
385
+ try {
386
+ parsedInput = call.function.arguments ? JSON.parse(call.function.arguments) : {}
387
+ } catch {
388
+ parsedInput = call.function.arguments ?? {}
389
+ }
390
+ blocks.push({
391
+ type: 'tool_use',
392
+ id: call.id,
393
+ name: call.function.name,
394
+ input: parsedInput,
395
+ } satisfies ToolUseBlock)
396
+ }
397
+ }
398
+ if (blocks.length === 1 && blocks[0]?.type === 'text') return blocks[0].text
399
+ return blocks
400
+ }
401
+
402
+ function toUsage(u: OpenAI.CompletionUsage | undefined): ChatUsage {
403
+ return {
404
+ inputTokens: u?.prompt_tokens ?? 0,
405
+ outputTokens: u?.completion_tokens ?? 0,
406
+ cacheReadTokens: u?.prompt_tokens_details?.cached_tokens ?? 0,
407
+ cacheCreationTokens: 0,
408
+ }
409
+ }
410
+
411
+ function addUsage(acc: ChatUsage, u: OpenAI.CompletionUsage | undefined): void {
412
+ if (!u) return
413
+ acc.inputTokens += u.prompt_tokens
414
+ acc.outputTokens += u.completion_tokens
415
+ acc.cacheReadTokens += u.prompt_tokens_details?.cached_tokens ?? 0
416
+ }