@strav/brain 0.4.31 → 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.
Files changed (40) hide show
  1. package/package.json +18 -20
  2. package/src/agent.ts +50 -75
  3. package/src/agent_result.ts +32 -0
  4. package/src/agent_runner.ts +63 -0
  5. package/src/brain_config.ts +95 -0
  6. package/src/brain_error.ts +29 -0
  7. package/src/brain_manager.ts +186 -123
  8. package/src/brain_provider.ts +104 -6
  9. package/src/define_tool.ts +42 -0
  10. package/src/index.ts +44 -41
  11. package/src/mcp_server.ts +47 -0
  12. package/src/provider.ts +83 -0
  13. package/src/providers/anthropic_provider.ts +435 -232
  14. package/src/providers/openai_provider.ts +350 -503
  15. package/src/thread.ts +99 -0
  16. package/src/tool.ts +28 -44
  17. package/src/tool_execution_error.ts +26 -0
  18. package/src/types.ts +164 -237
  19. package/CHANGELOG.md +0 -44
  20. package/README.md +0 -121
  21. package/src/helpers.ts +0 -1082
  22. package/src/mcp_toolbox.ts +0 -62
  23. package/src/memory/context_budget.ts +0 -120
  24. package/src/memory/index.ts +0 -17
  25. package/src/memory/memory_manager.ts +0 -168
  26. package/src/memory/semantic_memory.ts +0 -89
  27. package/src/memory/strategies/sliding_window.ts +0 -20
  28. package/src/memory/strategies/summarize.ts +0 -157
  29. package/src/memory/thread_store.ts +0 -56
  30. package/src/memory/token_counter.ts +0 -101
  31. package/src/memory/types.ts +0 -68
  32. package/src/providers/google_provider.ts +0 -496
  33. package/src/providers/openai_responses_provider.ts +0 -321
  34. package/src/utils/error_scrub.ts +0 -5
  35. package/src/utils/prompt.ts +0 -65
  36. package/src/utils/retry.ts +0 -104
  37. package/src/utils/schema.ts +0 -27
  38. package/src/utils/sse_parser.ts +0 -62
  39. package/src/workflow.ts +0 -199
  40. package/tsconfig.json +0 -5
@@ -1,569 +1,416 @@
1
- import { parseSSE } from '../utils/sse_parser.ts'
2
- import { retryableFetch, type RetryOptions } from '../utils/retry.ts'
3
- import { ExternalServiceError } from '@strav/kernel'
4
- import type {
5
- AIProvider,
6
- CompletionRequest,
7
- CompletionResponse,
8
- StreamChunk,
9
- EmbeddingResponse,
10
- ProviderConfig,
11
- Message,
12
- ToolCall,
13
- TranscribeRequest,
14
- TranscriptionResponse,
15
- Usage,
16
- } from '../types.ts'
17
-
18
1
  /**
19
- * OpenAI Chat Completions API provider.
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.
20
33
  *
21
- * Also serves DeepSeek and any OpenAI-compatible API by setting `baseUrl`
22
- * in the provider config. Uses raw `fetch()`.
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.
23
45
  */
24
- export class OpenAIProvider implements AIProvider {
25
- readonly name: string
26
- private apiKey: string
27
- private baseUrl: string
28
- private defaultModel: string
29
- private defaultMaxTokens?: number
30
- private retryOptions: RetryOptions
31
46
 
32
- constructor(config: ProviderConfig, name?: string) {
33
- this.name = name ?? 'openai'
34
- this.apiKey = config.apiKey
35
- this.baseUrl = (config.baseUrl ?? 'https://api.openai.com').replace(/\/$/, '')
36
- this.defaultModel = config.model
37
- this.defaultMaxTokens = config.maxTokens
38
- this.retryOptions = {
39
- maxRetries: config.maxRetries ?? 3,
40
- baseDelay: config.retryBaseDelay ?? 1000,
41
- }
42
- }
43
-
44
- /** Whether this provider supports OpenAI's native json_schema response format. */
45
- private get supportsJsonSchema(): boolean {
46
- return this.baseUrl === 'https://api.openai.com'
47
- }
48
-
49
- async complete(request: CompletionRequest): Promise<CompletionResponse> {
50
- const body = this.buildRequestBody(request, false)
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'
51
66
 
52
- const response = await retryableFetch(
53
- 'OpenAI',
54
- `${this.baseUrl}/v1/chat/completions`,
55
- { method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body) },
56
- this.retryOptions
57
- )
67
+ const DEFAULT_OPENAI_MODEL = 'gpt-5'
58
68
 
59
- const data: any = await response.json()
60
- return this.parseResponse(data)
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
+ })
61
90
  }
62
91
 
63
- async *stream(request: CompletionRequest): AsyncIterable<StreamChunk> {
64
- const body = this.buildRequestBody(request, true)
65
-
66
- const response = await retryableFetch(
67
- 'OpenAI',
68
- `${this.baseUrl}/v1/chat/completions`,
69
- { method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body) },
70
- this.retryOptions
71
- )
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
+ }
72
97
 
73
- if (!response.body) {
74
- throw new ExternalServiceError('OpenAI', undefined, 'No stream body returned')
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 },
75
106
  }
76
-
77
- // Track in-progress tool calls for tool_start vs tool_delta distinction
78
- const seenTools = new Set<number>()
79
-
80
- for await (const sse of parseSSE(response.body)) {
81
- if (sse.data === '[DONE]') {
82
- yield { type: 'done' }
83
- break
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 }
84
114
  }
85
-
86
- let parsed: any
87
- try {
88
- parsed = JSON.parse(sse.data)
89
- } catch {
90
- continue
115
+ if (chunk.choices[0]?.finish_reason) {
116
+ finishReason = chunk.choices[0].finish_reason
91
117
  }
118
+ if (chunk.usage) aggregatedUsage = chunk.usage
119
+ }
120
+ yield {
121
+ type: 'stop',
122
+ stopReason: finishReason,
123
+ usage: toUsage(aggregatedUsage),
124
+ }
125
+ }
92
126
 
93
- const choice = parsed.choices?.[0]
94
- if (!choice) continue
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
95
148
 
96
- const delta = choice.delta
97
- if (!delta) continue
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)
98
153
 
99
- // Text content
100
- if (delta.content) {
101
- yield { type: 'text', text: delta.content }
154
+ const choice = response.choices[0]
155
+ if (!choice) {
156
+ throw new BrainError('OpenAIProvider: response had no choices.')
102
157
  }
103
-
104
- // Tool calls
105
- if (delta.tool_calls) {
106
- for (const tc of delta.tool_calls) {
107
- const index: number = tc.index ?? 0
108
-
109
- if (!seenTools.has(index)) {
110
- // First chunk for this tool — emit tool_start
111
- seenTools.add(index)
112
- yield {
113
- type: 'tool_start',
114
- toolCall: { id: tc.id, name: tc.function?.name },
115
- toolIndex: index,
116
- }
117
- }
118
-
119
- // Argument fragments
120
- if (tc.function?.arguments) {
121
- yield {
122
- type: 'tool_delta',
123
- text: tc.function.arguments,
124
- toolIndex: index,
125
- }
126
- }
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,
127
175
  }
128
176
  }
129
177
 
130
- // Finish reason
131
- if (choice.finish_reason) {
132
- if (choice.finish_reason === 'tool_calls') {
133
- // Emit tool_end for all tracked tools
134
- for (const idx of seenTools) {
135
- yield { type: 'tool_end', toolIndex: idx }
136
- }
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
+ )
137
188
  }
138
-
139
- // Usage in final chunk (if stream_options.include_usage is set)
140
- if (parsed.usage) {
141
- yield {
142
- type: 'usage',
143
- usage: {
144
- inputTokens: parsed.usage.prompt_tokens ?? 0,
145
- outputTokens: parsed.usage.completion_tokens ?? 0,
146
- totalTokens: parsed.usage.total_tokens ?? 0,
147
- },
148
- }
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,
149
225
  }
150
226
  }
151
227
  }
152
228
  }
153
229
 
154
- async embed(input: string | string[], model?: string): Promise<EmbeddingResponse> {
155
- const body = {
156
- input: Array.isArray(input) ? input : [input],
157
- model: model ?? 'text-embedding-3-small',
158
- }
159
-
160
- const response = await retryableFetch(
161
- 'OpenAI',
162
- `${this.baseUrl}/v1/embeddings`,
163
- { method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body) },
164
- this.retryOptions
165
- )
166
-
167
- const data: any = await response.json()
168
-
169
- return {
170
- embeddings: data.data.map((d: any) => d.embedding),
171
- model: data.model,
172
- usage: { totalTokens: data.usage?.total_tokens ?? 0 },
173
- }
174
- }
175
-
176
- /**
177
- * Speech-to-text via the OpenAI Whisper API (/v1/audio/transcriptions).
178
- *
179
- * Defaults to `whisper-1` — the long-standing, broadly supported model.
180
- * Override with `gpt-4o-transcribe` or `gpt-4o-mini-transcribe` for the
181
- * newer architecture (better noise/accent robustness, similar pricing).
182
- *
183
- * Requests `verbose_json` so we can surface `language` and `duration`
184
- * on the normalized response without a second round-trip.
185
- */
186
- async transcribe(request: TranscribeRequest): Promise<TranscriptionResponse> {
187
- const filename = request.filename ?? defaultFilename(request.contentType)
188
- const contentType = request.contentType ?? 'application/octet-stream'
189
- const blob =
190
- request.audio instanceof Blob
191
- ? request.audio
192
- : new Blob([request.audio], { type: contentType })
193
-
194
- const form = new FormData()
195
- form.append('file', blob, filename)
196
- form.append('model', request.model ?? 'whisper-1')
197
- form.append('response_format', 'verbose_json')
198
- if (request.language) form.append('language', request.language)
199
- if (request.prompt) form.append('prompt', request.prompt)
200
-
201
- const response = await retryableFetch(
202
- 'OpenAI',
203
- `${this.baseUrl}/v1/audio/transcriptions`,
204
- {
205
- method: 'POST',
206
- // Don't set Content-Type — the runtime sets it with the
207
- // multipart boundary derived from the FormData body.
208
- headers: { Authorization: `Bearer ${this.apiKey}` },
209
- body: form,
210
- },
211
- this.retryOptions
212
- )
213
-
214
- const data: any = await response.json()
215
- return {
216
- text: String(data.text ?? ''),
217
- language: typeof data.language === 'string' ? data.language : undefined,
218
- duration: typeof data.duration === 'number' ? data.duration : undefined,
219
- raw: data,
220
- }
221
- }
222
-
223
- // ── Private helpers ──────────────────────────────────────────────────────
224
-
225
- private isReasoningModel(model: string): boolean {
226
- return /^(o[1-9]|gpt-5)/.test(model)
227
- }
228
-
229
- private usesMaxCompletionTokens(model: string): boolean {
230
- return this.isReasoningModel(model) || /^gpt-4\.1|gpt-4o-mini-2024/.test(model)
231
- }
232
-
233
- private buildHeaders(): Record<string, string> {
234
- return {
235
- 'content-type': 'application/json',
236
- authorization: `Bearer ${this.apiKey}`,
237
- }
238
- }
239
-
240
- private buildRequestBody(request: CompletionRequest, stream: boolean): Record<string, unknown> {
241
- const body: Record<string, unknown> = {
242
- model: request.model ?? this.defaultModel,
243
- messages: this.mapMessages(request.messages, request.system),
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),
244
242
  }
245
243
 
246
- if (stream) body.stream = true
247
- if (request.maxTokens ?? this.defaultMaxTokens) {
248
- const tokens = request.maxTokens ?? this.defaultMaxTokens
249
- const model = (body.model as string) ?? ''
250
-
251
- if (this.usesMaxCompletionTokens(model)) {
252
- body.max_completion_tokens = tokens
253
- } else {
254
- body.max_tokens = tokens
255
- }
256
- }
257
- if (request.temperature !== undefined && !this.isReasoningModel((body.model as string) ?? '')) {
258
- body.temperature = request.temperature
259
- }
260
- if (request.stopSequences?.length) body.stop = request.stopSequences
261
-
262
- // Tools
263
- if (request.tools?.length) {
264
- body.tools = request.tools.map(t => ({
244
+ if (tools.length > 0) {
245
+ params.tools = tools.map((t) => ({
265
246
  type: 'function',
266
247
  function: {
267
248
  name: t.name,
268
249
  description: t.description,
269
- parameters: t.parameters,
250
+ parameters: t.inputSchema as Record<string, unknown>,
270
251
  },
271
252
  }))
272
253
  }
273
254
 
274
- // Tool choice
275
- if (request.toolChoice) {
276
- if (typeof request.toolChoice === 'string') {
277
- body.tool_choice = request.toolChoice
278
- } else {
279
- body.tool_choice = {
280
- type: 'function',
281
- function: { name: request.toolChoice.name },
282
- }
283
- }
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
284
263
  }
285
264
 
286
- // Structured output
287
- if (request.schema) {
288
- const useStrict = this.supportsJsonSchema && this.isStrictCompatible(request.schema)
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.
289
268
 
290
- if (useStrict) {
291
- body.response_format = {
292
- type: 'json_schema',
293
- json_schema: {
294
- name: 'response',
295
- schema: this.normalizeSchemaForOpenAI(request.schema),
296
- strict: true,
297
- },
298
- }
299
- } else {
300
- // Fallback: json_object mode with schema injected into system prompt
301
- body.response_format = { type: 'json_object' }
302
- const schemaHint = `\n\nYou MUST respond with valid JSON matching this schema:\n${JSON.stringify(request.schema, null, 2)}`
303
- const messages = body.messages as any[]
304
- if (messages[0]?.role === 'system') {
305
- messages[0].content += schemaHint
306
- } else {
307
- messages.unshift({ role: 'system', content: `Respond with valid JSON.${schemaHint}` })
308
- }
309
- }
310
- }
311
-
312
- return body
269
+ return params
313
270
  }
314
271
 
315
- private mapMessages(messages: Message[], system?: string): any[] {
316
- const result: any[] = []
317
-
318
- // System prompt as first message
319
- if (system) {
320
- result.push({ role: 'system', content: system })
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 })
321
280
  }
322
-
323
- for (const msg of messages) {
324
- if (msg.role === 'tool') {
325
- result.push({
326
- role: 'tool',
327
- tool_call_id: msg.toolCallId,
328
- content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
329
- })
330
- } else if (msg.role === 'assistant') {
331
- const mapped: any = {
332
- role: 'assistant',
333
- content: typeof msg.content === 'string' ? msg.content : null,
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
+ }
334
304
  }
335
-
336
- if (msg.toolCalls?.length) {
337
- mapped.tool_calls = msg.toolCalls.map(tc => ({
338
- id: tc.id,
339
- type: 'function',
340
- function: {
341
- name: tc.name,
342
- arguments: JSON.stringify(tc.arguments),
343
- },
344
- }))
305
+ if (remainingText.length > 0) {
306
+ out.push({ role: 'user', content: remainingText.join('') })
345
307
  }
346
-
347
- result.push(mapped)
348
- } else {
349
- result.push({
350
- role: 'user',
351
- content: typeof msg.content === 'string' ? msg.content : msg.content,
352
- })
308
+ continue
353
309
  }
310
+ out.push(toOpenAIMessage(message))
354
311
  }
355
-
356
- return result
312
+ return out
357
313
  }
358
314
 
359
- private parseResponse(data: any): CompletionResponse {
360
- const choice = data.choices?.[0]
361
- const message = choice?.message
362
-
363
- const content: string = message?.content ?? ''
364
- const toolCalls: ToolCall[] = []
365
-
366
- if (message?.tool_calls) {
367
- for (const tc of message.tool_calls) {
368
- let args: Record<string, unknown> = {}
369
- try {
370
- args = JSON.parse(tc.function.arguments)
371
- } catch {
372
- // Invalid JSON from the model — pass as-is in a wrapper
373
- args = { _raw: tc.function.arguments }
374
- }
375
-
376
- toolCalls.push({
377
- id: tc.id,
378
- name: tc.function.name,
379
- arguments: args,
380
- })
381
- }
382
- }
383
-
384
- const usage: Usage = {
385
- inputTokens: data.usage?.prompt_tokens ?? 0,
386
- outputTokens: data.usage?.completion_tokens ?? 0,
387
- totalTokens: data.usage?.total_tokens ?? 0,
388
- }
389
-
390
- let stopReason: CompletionResponse['stopReason'] = 'end'
391
- switch (choice?.finish_reason) {
392
- case 'tool_calls':
393
- stopReason = 'tool_use'
394
- break
395
- case 'length':
396
- stopReason = 'max_tokens'
397
- break
398
- case 'stop':
399
- stopReason = 'end'
400
- break
401
- }
402
-
315
+ private toChatResult(
316
+ response: OpenAI.Chat.ChatCompletion,
317
+ ): ChatResult<OpenAI.Chat.ChatCompletion> {
318
+ const choice = response.choices[0]
403
319
  return {
404
- id: data.id ?? '',
405
- content,
406
- toolCalls,
407
- stopReason,
408
- usage,
409
- raw: data,
320
+ text: choice?.message?.content ?? '',
321
+ model: response.model,
322
+ stopReason: choice?.finish_reason ?? null,
323
+ usage: toUsage(response.usage),
324
+ raw: response,
410
325
  }
411
326
  }
327
+ }
412
328
 
413
- /**
414
- * OpenAI's strict structured output requires:
415
- * - All properties listed in `required`
416
- * - Optional properties use nullable types instead
417
- * - `additionalProperties: false` on every object
418
- */
419
- /**
420
- * Check if a schema is compatible with OpenAI's strict structured output.
421
- * Record types (object with additionalProperties != false) are not supported.
422
- */
423
- private isStrictCompatible(schema: Record<string, unknown>): boolean {
424
- if (schema == null || typeof schema !== 'object') return true
425
-
426
- // Record type: object with additionalProperties that isn't false
427
- if (
428
- schema.type === 'object' &&
429
- schema.additionalProperties !== undefined &&
430
- schema.additionalProperties !== false
431
- ) {
432
- return false
433
- }
434
-
435
- // Check nested properties
436
- if (schema.properties) {
437
- for (const prop of Object.values(schema.properties as Record<string, any>)) {
438
- if (!this.isStrictCompatible(prop)) return false
439
- }
440
- }
441
-
442
- // Check array items
443
- if (schema.items && !this.isStrictCompatible(schema.items as Record<string, unknown>))
444
- return false
329
+ // ─── Shape converters ─────────────────────────────────────────────────────
445
330
 
446
- // Check anyOf / oneOf
447
- for (const key of ['anyOf', 'oneOf'] as const) {
448
- if (Array.isArray(schema[key])) {
449
- for (const s of schema[key] as any[]) {
450
- if (!this.isStrictCompatible(s)) return false
451
- }
452
- }
453
- }
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
+ }
454
337
 
455
- return true
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
456
341
  }
457
342
 
458
- /** Keywords OpenAI strict mode does NOT support. */
459
- private static UNSUPPORTED_KEYWORDS = new Set([
460
- 'propertyNames',
461
- 'patternProperties',
462
- 'if',
463
- 'then',
464
- 'else',
465
- 'not',
466
- 'contains',
467
- 'minItems',
468
- 'maxItems',
469
- 'minProperties',
470
- 'maxProperties',
471
- 'minLength',
472
- 'maxLength',
473
- 'minimum',
474
- 'maximum',
475
- 'exclusiveMinimum',
476
- 'exclusiveMaximum',
477
- 'multipleOf',
478
- 'pattern',
479
- 'format',
480
- 'contentEncoding',
481
- 'contentMediaType',
482
- 'unevaluatedProperties',
483
- '$schema',
484
- ])
485
-
486
- private normalizeSchemaForOpenAI(schema: Record<string, unknown>): Record<string, unknown> {
487
- if (schema == null || typeof schema !== 'object') return schema
488
-
489
- // Strip unsupported keywords
490
- const result: Record<string, unknown> = {}
491
- for (const [k, v] of Object.entries(schema)) {
492
- if (!OpenAIProvider.UNSUPPORTED_KEYWORDS.has(k)) {
493
- result[k] = v
494
- }
495
- }
496
-
497
- // Handle object types with explicit properties
498
- if (result.type === 'object' && result.properties) {
499
- const props = result.properties as Record<string, any>
500
- const currentRequired = new Set(
501
- Array.isArray(result.required) ? (result.required as string[]) : []
502
- )
503
-
504
- const normalizedProps: Record<string, any> = {}
505
-
506
- for (const [key, prop] of Object.entries(props)) {
507
- let normalizedProp = this.normalizeSchemaForOpenAI(prop)
508
-
509
- // If property is not required, make it nullable and add to required
510
- if (!currentRequired.has(key)) {
511
- normalizedProp = this.makeNullable(normalizedProp)
512
- }
513
-
514
- normalizedProps[key] = normalizedProp
515
- }
516
-
517
- result.properties = normalizedProps
518
- result.required = Object.keys(normalizedProps)
519
- result.additionalProperties = false
520
- }
521
-
522
- // Handle arrays
523
- if (result.type === 'array' && result.items) {
524
- result.items = this.normalizeSchemaForOpenAI(result.items as Record<string, unknown>)
525
- }
526
-
527
- // Handle anyOf / oneOf
528
- for (const key of ['anyOf', 'oneOf'] as const) {
529
- if (Array.isArray(result[key])) {
530
- result[key] = (result[key] as any[]).map((s: any) => this.normalizeSchemaForOpenAI(s))
531
- }
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
+ }))
532
363
  }
533
-
534
- return result
364
+ return param
535
365
  }
536
366
 
537
- private makeNullable(schema: Record<string, unknown>): Record<string, unknown> {
538
- // Already nullable
539
- if (Array.isArray(schema.type) && schema.type.includes('null')) return schema
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
+ }
540
375
 
541
- // Has anyOf — add null variant
542
- if (Array.isArray(schema.anyOf)) {
543
- const hasNull = schema.anyOf.some((s: any) => s.type === 'null')
544
- if (!hasNull) {
545
- return { ...schema, anyOf: [...schema.anyOf, { type: 'null' }] }
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 ?? {}
546
389
  }
547
- return schema
548
- }
549
-
550
- // Simple type — wrap in anyOf with null
551
- if (schema.type) {
552
- const { type, ...rest } = schema
553
- return { anyOf: [{ type, ...rest }, { type: 'null' }] }
390
+ blocks.push({
391
+ type: 'tool_use',
392
+ id: call.id,
393
+ name: call.function.name,
394
+ input: parsedInput,
395
+ } satisfies ToolUseBlock)
554
396
  }
397
+ }
398
+ if (blocks.length === 1 && blocks[0]?.type === 'text') return blocks[0].text
399
+ return blocks
400
+ }
555
401
 
556
- return schema
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,
557
408
  }
558
409
  }
559
410
 
560
- /**
561
- * Choose a multipart filename for Whisper based on the content type.
562
- * Whisper sniffs the extension when no MIME is supplied; sending a name
563
- * that matches the actual format avoids "unsupported file" 400s.
564
- */
565
- function defaultFilename(contentType?: string): string {
566
- if (!contentType) return 'audio.bin'
567
- const ext = contentType.split('/')[1]?.split(';')[0]?.trim()
568
- return ext ? `audio.${ext}` : 'audio.bin'
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
569
416
  }