@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 +8 -5
- package/src/brain_config.ts +16 -1
- package/src/brain_provider.ts +15 -2
- package/src/index.ts +2 -0
- package/src/mcp/client.ts +157 -0
- package/src/mcp/index.ts +16 -0
- package/src/mcp/resolve_mcp_tools.ts +86 -0
- package/src/providers/openai_provider.ts +446 -0
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/brain",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
4
|
-
"description": "Strav AI module — unified Provider interface, BrainManager, threads, prompt caching. Anthropic
|
|
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
|
-
"@
|
|
23
|
-
"@
|
|
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"
|
package/src/brain_config.ts
CHANGED
|
@@ -34,7 +34,22 @@ export interface AnthropicProviderConfig {
|
|
|
34
34
|
betas?: readonly string[]
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
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 {
|
package/src/brain_provider.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -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
|
+
}
|