@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.
- package/package.json +18 -20
- package/src/agent.ts +50 -75
- package/src/agent_result.ts +32 -0
- package/src/agent_runner.ts +63 -0
- package/src/brain_config.ts +95 -0
- package/src/brain_error.ts +29 -0
- package/src/brain_manager.ts +186 -123
- package/src/brain_provider.ts +104 -6
- package/src/define_tool.ts +42 -0
- package/src/index.ts +44 -41
- package/src/mcp_server.ts +47 -0
- package/src/provider.ts +83 -0
- package/src/providers/anthropic_provider.ts +435 -232
- package/src/providers/openai_provider.ts +350 -503
- package/src/thread.ts +99 -0
- package/src/tool.ts +28 -44
- package/src/tool_execution_error.ts +26 -0
- package/src/types.ts +164 -237
- package/CHANGELOG.md +0 -44
- package/README.md +0 -121
- package/src/helpers.ts +0 -1082
- package/src/mcp_toolbox.ts +0 -62
- package/src/memory/context_budget.ts +0 -120
- package/src/memory/index.ts +0 -17
- package/src/memory/memory_manager.ts +0 -168
- package/src/memory/semantic_memory.ts +0 -89
- package/src/memory/strategies/sliding_window.ts +0 -20
- package/src/memory/strategies/summarize.ts +0 -157
- package/src/memory/thread_store.ts +0 -56
- package/src/memory/token_counter.ts +0 -101
- package/src/memory/types.ts +0 -68
- package/src/providers/google_provider.ts +0 -496
- package/src/providers/openai_responses_provider.ts +0 -321
- package/src/utils/error_scrub.ts +0 -5
- package/src/utils/prompt.ts +0 -65
- package/src/utils/retry.ts +0 -104
- package/src/utils/schema.ts +0 -27
- package/src/utils/sse_parser.ts +0 -62
- package/src/workflow.ts +0 -199
- 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
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
yield { type: '
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
if (
|
|
101
|
-
|
|
154
|
+
const choice = response.choices[0]
|
|
155
|
+
if (!choice) {
|
|
156
|
+
throw new BrainError('OpenAIProvider: response had no choices.')
|
|
102
157
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
this.
|
|
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 (
|
|
247
|
-
|
|
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.
|
|
250
|
+
parameters: t.inputSchema as Record<string, unknown>,
|
|
270
251
|
},
|
|
271
252
|
}))
|
|
272
253
|
}
|
|
273
254
|
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
'
|
|
467
|
-
'
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
}
|