@strav/brain 1.0.0-alpha.9 → 1.0.2
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 +23 -7
- package/src/agent.ts +43 -5
- package/src/agent_generate_result.ts +32 -0
- package/src/agent_result.ts +7 -0
- package/src/agent_runner.ts +218 -14
- package/src/agent_stream_event.ts +100 -0
- package/src/brain_config.ts +218 -1
- package/src/brain_driver.ts +247 -0
- package/src/brain_error.ts +86 -10
- package/src/brain_manager.ts +359 -11
- package/src/brain_provider.ts +79 -9
- package/src/drivers/anthropic/anthropic_brain_driver.ts +641 -0
- package/src/drivers/anthropic/anthropic_helpers.ts +65 -0
- package/src/drivers/anthropic/anthropic_message_builder.ts +258 -0
- package/src/drivers/anthropic/anthropic_response_mapper.ts +123 -0
- package/src/drivers/anthropic/anthropic_tool_loop.ts +246 -0
- package/src/drivers/anthropic/index.ts +1 -0
- package/src/drivers/deepseek/deepseek_brain_driver.ts +117 -0
- package/src/drivers/deepseek/index.ts +1 -0
- package/src/drivers/gemini/gemini_brain_driver.ts +1064 -0
- package/src/drivers/gemini/index.ts +1 -0
- package/src/drivers/minimax/index.ts +1 -0
- package/src/drivers/minimax/minimax_brain_driver.ts +84 -0
- package/src/drivers/ollama/index.ts +1 -0
- package/src/drivers/ollama/ollama_brain_driver.ts +86 -0
- package/src/drivers/openai/index.ts +1 -0
- package/src/drivers/openai/openai_brain_driver.ts +796 -0
- package/src/drivers/openai/openai_helpers.ts +58 -0
- package/src/drivers/openai/openai_message_builder.ts +187 -0
- package/src/drivers/openai/openai_response_mapper.ts +70 -0
- package/src/drivers/openai/openai_tool_dispatch.ts +127 -0
- package/src/drivers/openai/openai_tool_loop.ts +191 -0
- package/src/drivers/openai_compat/index.ts +1 -0
- package/src/drivers/openai_compat/openai_compat_brain_driver.ts +616 -0
- package/src/drivers/openai_responses/index.ts +1 -0
- package/src/drivers/openai_responses/openai_responses_brain_driver.ts +1015 -0
- package/src/drivers/openrouter/index.ts +1 -0
- package/src/drivers/openrouter/openrouter_brain_driver.ts +137 -0
- package/src/drivers/qwen/index.ts +1 -0
- package/src/drivers/qwen/qwen_brain_driver.ts +103 -0
- package/src/index.ts +75 -11
- package/src/mcp/client.ts +243 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/oauth.ts +227 -0
- package/src/mcp/pool.ts +106 -0
- package/src/mcp/resolve_mcp_tools.ts +108 -0
- package/src/mcp_server.ts +63 -0
- package/src/output_schema.ts +72 -0
- package/src/persistence/brain_message.ts +34 -0
- package/src/persistence/brain_message_repository.ts +98 -0
- package/src/persistence/brain_store.ts +166 -0
- package/src/persistence/brain_suspended_run.ts +30 -0
- package/src/persistence/brain_suspended_run_repository.ts +59 -0
- package/src/persistence/brain_thread.ts +30 -0
- package/src/persistence/brain_thread_repository.ts +56 -0
- package/src/persistence/database_brain_store.ts +190 -0
- package/src/persistence/index.ts +48 -0
- package/src/persistence/schemas/brain_message_schema.ts +61 -0
- package/src/persistence/schemas/brain_suspended_run_schema.ts +58 -0
- package/src/persistence/schemas/brain_thread_schema.ts +50 -0
- package/src/persistence/schemas/index.ts +3 -0
- package/src/suspended_run.ts +153 -0
- package/src/thread.ts +40 -1
- package/src/tool.ts +7 -0
- package/src/tool_runner.ts +81 -0
- package/src/translate/index.ts +19 -0
- package/src/translate/translate_cache.ts +78 -0
- package/src/translate/translate_provider.ts +46 -0
- package/src/translate/translator.ts +271 -0
- package/src/types.ts +398 -1
- package/src/zod/index.ts +121 -0
- package/src/provider.ts +0 -74
- package/src/providers/anthropic_provider.ts +0 -397
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OpenRouterBrainDriver } from './openrouter_brain_driver.ts'
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OpenRouterBrainDriver` — `OpenAICompatBrainDriver` pointed at
|
|
3
|
+
* OpenRouter's OpenAI-compatible endpoint at
|
|
4
|
+
* `https://openrouter.ai/api/v1`.
|
|
5
|
+
*
|
|
6
|
+
* OpenRouter multiplexes 300+ hosted models (Anthropic, OpenAI,
|
|
7
|
+
* Google, Meta, Mistral, DeepSeek, Qwen, ...) behind one
|
|
8
|
+
* Chat-Completions-shaped API. The driver does NOT try to predict
|
|
9
|
+
* which model supports what — capabilities vary per slug. The
|
|
10
|
+
* supported approach is:
|
|
11
|
+
*
|
|
12
|
+
* - Pin an explicit model slug per call (e.g.
|
|
13
|
+
* `anthropic/claude-sonnet-4`, `meta-llama/llama-3.3-70b`).
|
|
14
|
+
* - If the chosen model doesn't support a primitive (tools,
|
|
15
|
+
* JSON mode, schema-forcing), the upstream OpenRouter error
|
|
16
|
+
* bubbles up as a `BrainError` from the inherited helpers.
|
|
17
|
+
* - Consult `https://openrouter.ai/api/v1/models` and filter on
|
|
18
|
+
* the `supported_parameters` field to pick a model that
|
|
19
|
+
* handles the feature you need.
|
|
20
|
+
*
|
|
21
|
+
* Two OpenRouter-specific niceties:
|
|
22
|
+
*
|
|
23
|
+
* - Optional `appUrl` (→ `HTTP-Referer`) and `appTitle`
|
|
24
|
+
* (→ `X-Title`) forwarded as default headers on every request.
|
|
25
|
+
* OpenRouter uses these to attribute traffic on their public
|
|
26
|
+
* leaderboard / rankings.
|
|
27
|
+
*
|
|
28
|
+
* - No `defaultModel` default — OpenRouter has no canonical pick.
|
|
29
|
+
* Apps must set one, or pass `options.model` per call. The
|
|
30
|
+
* inherited OpenAI default `gpt-5` would silently route through
|
|
31
|
+
* OpenAI's slug namespace and surprise users.
|
|
32
|
+
*
|
|
33
|
+
* Inherits all OpenAI-compat overrides: `buildParams` strips
|
|
34
|
+
* `reasoning_effort` (re-add it in a subclass / per-call `extraBody`
|
|
35
|
+
* for models that take it), `generate` uses `json_object`-mode +
|
|
36
|
+
* schema-in-system-prompt, `runWithToolsAndSchema` /
|
|
37
|
+
* `streamWithToolsAndSchema` use the tool-forcing pattern.
|
|
38
|
+
*
|
|
39
|
+
* `embed` / `transcribe` are not exposed via OpenRouter — replaced
|
|
40
|
+
* with explicit `BrainError`s.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import OpenAI from 'openai'
|
|
44
|
+
import type { OpenRouterProviderConfig } from '../../brain_config.ts'
|
|
45
|
+
import { BrainError } from '../../brain_error.ts'
|
|
46
|
+
import type { ResolveMcpToolsOptions } from '../../mcp/resolve_mcp_tools.ts'
|
|
47
|
+
import type {
|
|
48
|
+
AudioSource,
|
|
49
|
+
EmbedOptions,
|
|
50
|
+
EmbedResult,
|
|
51
|
+
TranscribeOptions,
|
|
52
|
+
TranscribeResult,
|
|
53
|
+
} from '../../types.ts'
|
|
54
|
+
import { OpenAICompatBrainDriver } from '../openai_compat/openai_compat_brain_driver.ts'
|
|
55
|
+
|
|
56
|
+
const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
|
|
57
|
+
/**
|
|
58
|
+
* Fallback when neither `config.defaultModel` nor a per-call
|
|
59
|
+
* `options.model` is supplied. Picked as a broad-purpose,
|
|
60
|
+
* tool-capable, well-priced default; apps that want something else
|
|
61
|
+
* just pass `defaultModel`.
|
|
62
|
+
*/
|
|
63
|
+
const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-4o-mini'
|
|
64
|
+
|
|
65
|
+
export interface OpenRouterProviderOptions {
|
|
66
|
+
client?: OpenAI
|
|
67
|
+
/**
|
|
68
|
+
* Internal seam — tests inject a stub MCP client factory so MCP
|
|
69
|
+
* tool resolution doesn't dial the network. Real apps leave it
|
|
70
|
+
* unset; the provider uses the default `MCPClient`.
|
|
71
|
+
*/
|
|
72
|
+
mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class OpenRouterBrainDriver extends OpenAICompatBrainDriver {
|
|
76
|
+
constructor(
|
|
77
|
+
name: string,
|
|
78
|
+
config: OpenRouterProviderConfig,
|
|
79
|
+
options: OpenRouterProviderOptions = {},
|
|
80
|
+
) {
|
|
81
|
+
const baseURL = config.baseUrl ?? DEFAULT_OPENROUTER_BASE_URL
|
|
82
|
+
const defaultHeaders = buildOpenRouterHeaders(config)
|
|
83
|
+
// Construct the OpenAI client locally so we can carry OpenRouter's
|
|
84
|
+
// attribution headers (HTTP-Referer / X-Title). The base
|
|
85
|
+
// OpenAIBrainDriver only exposes apiKey / baseUrl / organization,
|
|
86
|
+
// so we hand a fully-built client through `options.client`.
|
|
87
|
+
const client =
|
|
88
|
+
options.client ??
|
|
89
|
+
new OpenAI({
|
|
90
|
+
apiKey: config.apiKey,
|
|
91
|
+
baseURL,
|
|
92
|
+
...(defaultHeaders ? { defaultHeaders } : {}),
|
|
93
|
+
})
|
|
94
|
+
super(
|
|
95
|
+
name,
|
|
96
|
+
{
|
|
97
|
+
driver: 'openai',
|
|
98
|
+
apiKey: config.apiKey,
|
|
99
|
+
baseUrl: baseURL,
|
|
100
|
+
defaultModel: config.defaultModel ?? DEFAULT_OPENROUTER_MODEL,
|
|
101
|
+
...(config.defaultMaxTokens !== undefined
|
|
102
|
+
? { defaultMaxTokens: config.defaultMaxTokens }
|
|
103
|
+
: {}),
|
|
104
|
+
},
|
|
105
|
+
{ ...options, client },
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
override async embed(
|
|
110
|
+
_texts: readonly string[],
|
|
111
|
+
_options?: EmbedOptions,
|
|
112
|
+
): Promise<EmbedResult<OpenAI.CreateEmbeddingResponse>> {
|
|
113
|
+
throw new BrainError(
|
|
114
|
+
`OpenRouterBrainDriver.embed: OpenRouter's API does not expose embeddings. Route embed calls to a provider with native support — OpenAI / Gemini / Ollama.`,
|
|
115
|
+
{ context: { provider: this.name } },
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
override async transcribe(
|
|
120
|
+
_audio: AudioSource,
|
|
121
|
+
_options?: TranscribeOptions,
|
|
122
|
+
): Promise<TranscribeResult<OpenAI.Audio.TranscriptionCreateResponse>> {
|
|
123
|
+
throw new BrainError(
|
|
124
|
+
"OpenRouterBrainDriver.transcribe: OpenRouter's API does not expose audio transcription. Route transcribe calls to a provider with native support — OpenAI / Ollama / Gemini.",
|
|
125
|
+
{ context: { provider: this.name } },
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildOpenRouterHeaders(
|
|
131
|
+
config: OpenRouterProviderConfig,
|
|
132
|
+
): Record<string, string> | undefined {
|
|
133
|
+
const headers: Record<string, string> = {}
|
|
134
|
+
if (config.appUrl !== undefined) headers['HTTP-Referer'] = config.appUrl
|
|
135
|
+
if (config.appTitle !== undefined) headers['X-Title'] = config.appTitle
|
|
136
|
+
return Object.keys(headers).length > 0 ? headers : undefined
|
|
137
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { QwenBrainDriver } from './qwen_brain_driver.ts'
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `QwenBrainDriver` — `OpenAICompatBrainDriver` pointed at Alibaba
|
|
3
|
+
* DashScope's OpenAI-compatible `/compatible-mode/v1` endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Why ship Qwen: deep coverage of Chinese + SEA languages, with the
|
|
6
|
+
* `qwen-plus` / `qwen-max` line punching at the frontier on those
|
|
7
|
+
* locales. Fits Strav's SEA-first positioning.
|
|
8
|
+
*
|
|
9
|
+
* Inherits the OpenAI-compat overrides (strip `reasoning_effort`,
|
|
10
|
+
* `json_object`-mode generate with schema-in-system-prompt,
|
|
11
|
+
* tool-forcing pattern for combined tools + schema) from the base
|
|
12
|
+
* class. Only adds:
|
|
13
|
+
*
|
|
14
|
+
* - Constructor with DashScope defaults — base URL
|
|
15
|
+
* `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` (the
|
|
16
|
+
* Singapore endpoint), default model `qwen-plus`.
|
|
17
|
+
*
|
|
18
|
+
* - `mapUsage` override — Qwen reports prompt-cache hits on
|
|
19
|
+
* OpenAI's standard `prompt_tokens_details.cached_tokens`
|
|
20
|
+
* field; the inherited mapping is already correct. Kept the
|
|
21
|
+
* hook here for vendor-specific extension fields if they
|
|
22
|
+
* surface later.
|
|
23
|
+
*
|
|
24
|
+
* `embed` / `transcribe` are not implemented for V1 — DashScope
|
|
25
|
+
* exposes both via separate endpoints, but the framework's seam
|
|
26
|
+
* is per-driver and we'd rather route those calls to a provider
|
|
27
|
+
* with first-class support. Override the inherited stubs to throw
|
|
28
|
+
* with a clear message instead of letting the SDK 404.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type OpenAI from 'openai'
|
|
32
|
+
import type { QwenProviderConfig } from '../../brain_config.ts'
|
|
33
|
+
import { BrainError } from '../../brain_error.ts'
|
|
34
|
+
import type { ResolveMcpToolsOptions } from '../../mcp/resolve_mcp_tools.ts'
|
|
35
|
+
import type {
|
|
36
|
+
AudioSource,
|
|
37
|
+
EmbedOptions,
|
|
38
|
+
EmbedResult,
|
|
39
|
+
TranscribeOptions,
|
|
40
|
+
TranscribeResult,
|
|
41
|
+
} from '../../types.ts'
|
|
42
|
+
import { OpenAICompatBrainDriver } from '../openai_compat/openai_compat_brain_driver.ts'
|
|
43
|
+
|
|
44
|
+
const DEFAULT_QWEN_MODEL = 'qwen-plus'
|
|
45
|
+
const DEFAULT_QWEN_BASE_URL = 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1'
|
|
46
|
+
|
|
47
|
+
export interface QwenProviderOptions {
|
|
48
|
+
client?: OpenAI
|
|
49
|
+
/**
|
|
50
|
+
* Internal seam — tests inject a stub MCP client factory so MCP
|
|
51
|
+
* tool resolution doesn't dial the network. Real apps leave it
|
|
52
|
+
* unset; the provider uses the default `MCPClient`.
|
|
53
|
+
*/
|
|
54
|
+
mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class QwenBrainDriver extends OpenAICompatBrainDriver {
|
|
58
|
+
constructor(name: string, config: QwenProviderConfig, options: QwenProviderOptions = {}) {
|
|
59
|
+
super(
|
|
60
|
+
name,
|
|
61
|
+
{
|
|
62
|
+
driver: 'openai',
|
|
63
|
+
apiKey: config.apiKey,
|
|
64
|
+
baseUrl: config.baseUrl ?? DEFAULT_QWEN_BASE_URL,
|
|
65
|
+
defaultModel: config.defaultModel ?? DEFAULT_QWEN_MODEL,
|
|
66
|
+
...(config.defaultMaxTokens !== undefined
|
|
67
|
+
? { defaultMaxTokens: config.defaultMaxTokens }
|
|
68
|
+
: {}),
|
|
69
|
+
},
|
|
70
|
+
options,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* DashScope's compatibility endpoint does not expose embeddings
|
|
76
|
+
* via `/compatible-mode/v1/embeddings`. Override the inherited
|
|
77
|
+
* `embed` to throw clearly rather than 404 at the wire.
|
|
78
|
+
*/
|
|
79
|
+
override async embed(
|
|
80
|
+
_texts: readonly string[],
|
|
81
|
+
_options?: EmbedOptions,
|
|
82
|
+
): Promise<EmbedResult<OpenAI.CreateEmbeddingResponse>> {
|
|
83
|
+
throw new BrainError(
|
|
84
|
+
`QwenBrainDriver.embed: Qwen's OpenAI-compatibility endpoint does not expose embeddings. Route embed calls to a provider with native support — OpenAI / Gemini / Ollama.`,
|
|
85
|
+
{ context: { provider: this.name } },
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* DashScope's compatibility endpoint does not expose audio
|
|
91
|
+
* transcription. Override the inherited `transcribe` to throw
|
|
92
|
+
* clearly.
|
|
93
|
+
*/
|
|
94
|
+
override async transcribe(
|
|
95
|
+
_audio: AudioSource,
|
|
96
|
+
_options?: TranscribeOptions,
|
|
97
|
+
): Promise<TranscribeResult<OpenAI.Audio.TranscriptionCreateResponse>> {
|
|
98
|
+
throw new BrainError(
|
|
99
|
+
"QwenBrainDriver.transcribe: Qwen's OpenAI-compatibility endpoint does not expose audio transcription. Route transcribe calls to a provider with native support — OpenAI / Ollama / Gemini.",
|
|
100
|
+
{ context: { provider: this.name } },
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,46 +1,110 @@
|
|
|
1
1
|
// Public API of @strav/brain.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Shipped:
|
|
4
|
+
// - `BrainDriver` contract + concrete drivers — Anthropic, OpenAI
|
|
5
|
+
// (Chat + Responses), Gemini, DeepSeek, Ollama, Qwen, MiniMax,
|
|
6
|
+
// OpenRouter, openai-compat.
|
|
7
|
+
// - `BrainManager` + `Thread` (persisted history, compaction) + the
|
|
8
|
+
// `BrainProvider` service wiring + prompt-cache plumbing.
|
|
9
|
+
// - Tools — `defineTool`, `Agent` base + `AgentRunner`,
|
|
10
|
+
// `runWithTools` + `streamWithTools` (agentic + streaming loops).
|
|
11
|
+
// - Structured outputs — `generate`, `runWithToolsAndSchema`,
|
|
12
|
+
// `streamWithToolsAndSchema` (JSON-Schema constrained replies).
|
|
13
|
+
// - MCP — server-side (Anthropic) + local client (`@strav/brain/mcp`).
|
|
14
|
+
// - Embeddings (`embed`), audio transcription (`transcribe`),
|
|
15
|
+
// human-in-the-loop suspend/resume.
|
|
9
16
|
|
|
10
17
|
export { Agent } from './agent.ts'
|
|
18
|
+
export type { AgentGenerateResult } from './agent_generate_result.ts'
|
|
11
19
|
export type { AgentResult } from './agent_result.ts'
|
|
12
|
-
export {
|
|
20
|
+
export {
|
|
21
|
+
type AgentRunMaybeSuspended,
|
|
22
|
+
AgentRunner,
|
|
23
|
+
type AgentRunResult,
|
|
24
|
+
} from './agent_runner.ts'
|
|
25
|
+
export type { AgentStreamEvent } from './agent_stream_event.ts'
|
|
13
26
|
export {
|
|
14
27
|
type AnthropicProviderConfig,
|
|
15
28
|
type BrainCacheConfig,
|
|
16
29
|
type BrainConfigShape,
|
|
17
30
|
DEFAULT_MODEL,
|
|
18
31
|
DEFAULT_TIERS,
|
|
32
|
+
type DeepSeekProviderConfig,
|
|
33
|
+
type GeminiProviderConfig,
|
|
34
|
+
type MiniMaxProviderConfig,
|
|
35
|
+
type OllamaProviderConfig,
|
|
36
|
+
type OpenAIProviderConfig,
|
|
37
|
+
type OpenAIResponsesProviderConfig,
|
|
38
|
+
type OpenRouterProviderConfig,
|
|
19
39
|
type ProviderConfig,
|
|
40
|
+
type QwenProviderConfig,
|
|
20
41
|
} from './brain_config.ts'
|
|
21
|
-
export {
|
|
42
|
+
export type {
|
|
43
|
+
BrainDriver,
|
|
44
|
+
RunWithToolsOptions,
|
|
45
|
+
RunWithToolsOptionsWithSuspend,
|
|
46
|
+
} from './brain_driver.ts'
|
|
47
|
+
export {
|
|
48
|
+
BrainConfigError,
|
|
49
|
+
BrainError,
|
|
50
|
+
BrainProviderError,
|
|
51
|
+
BrainUsageError,
|
|
52
|
+
UnknownProviderError,
|
|
53
|
+
} from './brain_error.ts'
|
|
22
54
|
export {
|
|
23
55
|
type AgentResolver,
|
|
24
56
|
BrainManager,
|
|
25
57
|
type BrainManagerOptions,
|
|
26
58
|
} from './brain_manager.ts'
|
|
27
59
|
export { BrainProvider } from './brain_provider.ts'
|
|
28
|
-
export {
|
|
29
|
-
export {
|
|
30
|
-
export
|
|
60
|
+
export { type DefineToolSpec, defineTool } from './define_tool.ts'
|
|
61
|
+
export { AnthropicBrainDriver } from './drivers/anthropic/anthropic_brain_driver.ts'
|
|
62
|
+
export { DeepSeekBrainDriver } from './drivers/deepseek/deepseek_brain_driver.ts'
|
|
63
|
+
export { GeminiBrainDriver } from './drivers/gemini/gemini_brain_driver.ts'
|
|
64
|
+
export { MiniMaxBrainDriver } from './drivers/minimax/minimax_brain_driver.ts'
|
|
65
|
+
export { OllamaBrainDriver } from './drivers/ollama/ollama_brain_driver.ts'
|
|
66
|
+
export { OpenAIBrainDriver } from './drivers/openai/openai_brain_driver.ts'
|
|
67
|
+
export { OpenAICompatBrainDriver } from './drivers/openai_compat/openai_compat_brain_driver.ts'
|
|
68
|
+
export { OpenAIResponsesBrainDriver } from './drivers/openai_responses/openai_responses_brain_driver.ts'
|
|
69
|
+
export { OpenRouterBrainDriver } from './drivers/openrouter/openrouter_brain_driver.ts'
|
|
70
|
+
export { QwenBrainDriver } from './drivers/qwen/qwen_brain_driver.ts'
|
|
71
|
+
export { type MCPClientFactory, MCPClientPool } from './mcp/pool.ts'
|
|
72
|
+
export type { MCPServer, MCPServerToolConfig } from './mcp_server.ts'
|
|
73
|
+
export type { OutputSchema } from './output_schema.ts'
|
|
74
|
+
export {
|
|
75
|
+
appendResumeResults,
|
|
76
|
+
isSuspended,
|
|
77
|
+
type SuspendedRun,
|
|
78
|
+
type SuspendedState,
|
|
79
|
+
type ToolResultInput,
|
|
80
|
+
} from './suspended_run.ts'
|
|
31
81
|
export { Thread, type ThreadOptions, type ThreadState } from './thread.ts'
|
|
32
82
|
export type { Tool, ToolContext } from './tool.ts'
|
|
33
83
|
export { ToolExecutionError } from './tool_execution_error.ts'
|
|
34
84
|
export type {
|
|
85
|
+
AudioBlock,
|
|
86
|
+
AudioSource,
|
|
35
87
|
ChatOptions,
|
|
36
88
|
ChatResult,
|
|
37
89
|
ChatUsage,
|
|
90
|
+
CompactConfig,
|
|
91
|
+
CompactionBlock,
|
|
38
92
|
ContentBlock,
|
|
93
|
+
DocumentBlock,
|
|
94
|
+
EmbedOptions,
|
|
95
|
+
EmbedResult,
|
|
96
|
+
GenerateResult,
|
|
97
|
+
ImageBlock,
|
|
98
|
+
MCPToolResultBlock,
|
|
99
|
+
MCPToolUseBlock,
|
|
39
100
|
Message,
|
|
40
101
|
ModelTier,
|
|
102
|
+
ServerTool,
|
|
41
103
|
StreamEvent,
|
|
42
104
|
SystemPrompt,
|
|
43
105
|
TextBlock,
|
|
44
106
|
ToolResultBlock,
|
|
45
107
|
ToolUseBlock,
|
|
108
|
+
TranscribeOptions,
|
|
109
|
+
TranscribeResult,
|
|
46
110
|
} from './types.ts'
|
|
@@ -0,0 +1,243 @@
|
|
|
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` → static bearer; fine for
|
|
20
|
+
* self-hosted servers where the app controls the token.
|
|
21
|
+
* - `MCPServer.oauth` → drives the authorization-code-with-PKCE
|
|
22
|
+
* flow against the server's OAuth endpoints. On
|
|
23
|
+
* `connect()` against an un-authorized server,
|
|
24
|
+
* `MCPAuthRequiredError` is thrown carrying the URL the user
|
|
25
|
+
* should be redirected to. After the user authorizes and is
|
|
26
|
+
* redirected back, the app's callback handler calls
|
|
27
|
+
* `completeAuthorization(code)` to finish the exchange.
|
|
28
|
+
* The two are mutually exclusive — passing both throws.
|
|
29
|
+
*
|
|
30
|
+
* Transport:
|
|
31
|
+
* V1 only does Streamable HTTP — the current MCP transport. Legacy
|
|
32
|
+
* SSE-only endpoints aren't supported; if a server URL ends with
|
|
33
|
+
* `/sse` and only speaks the legacy protocol, the connection will
|
|
34
|
+
* fail and apps should run against a Streamable-HTTP endpoint
|
|
35
|
+
* instead.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
39
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
40
|
+
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
|
41
|
+
import { BrainError } from '../brain_error.ts'
|
|
42
|
+
import type { MCPServer } from '../mcp_server.ts'
|
|
43
|
+
import { MCPAuthRequiredError, StoreBackedOAuthProvider } from './oauth.ts'
|
|
44
|
+
|
|
45
|
+
/** Result of a single MCP tool invocation, as returned by `tools/call`. */
|
|
46
|
+
export interface MCPCallToolResult {
|
|
47
|
+
/** Stringified content — text blocks concatenated; image / resource blocks JSON-serialized. */
|
|
48
|
+
content: string
|
|
49
|
+
/** `true` when the MCP server reports the tool execution failed. */
|
|
50
|
+
isError: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Tool descriptor surfaced by `tools/list`. */
|
|
54
|
+
export interface MCPToolDescriptor {
|
|
55
|
+
name: string
|
|
56
|
+
description: string
|
|
57
|
+
inputSchema: Record<string, unknown>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MCPClientOptions {
|
|
61
|
+
/** Override the transport used to dial the server. Tests inject a mock here. */
|
|
62
|
+
client?: Client
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class MCPClient {
|
|
66
|
+
readonly server: MCPServer
|
|
67
|
+
private readonly _client: Client
|
|
68
|
+
private _connected = false
|
|
69
|
+
/**
|
|
70
|
+
* In-flight connect promise — set on the first concurrent
|
|
71
|
+
* `connect()` and cleared on settle. Subsequent callers that
|
|
72
|
+
* race against the first one await the same promise instead of
|
|
73
|
+
* each kicking off their own transport handshake. Necessary for
|
|
74
|
+
* pooled clients: a fresh `borrow()` followed by parallel
|
|
75
|
+
* `listTools()` + `callTool()` calls both hit the same connect.
|
|
76
|
+
*/
|
|
77
|
+
private _connecting: Promise<void> | undefined
|
|
78
|
+
private _transport: StreamableHTTPClientTransport | undefined
|
|
79
|
+
private _authProvider: StoreBackedOAuthProvider | undefined
|
|
80
|
+
|
|
81
|
+
constructor(server: MCPServer, options: MCPClientOptions = {}) {
|
|
82
|
+
if (server.authorizationToken !== undefined && server.oauth !== undefined) {
|
|
83
|
+
throw new BrainError(
|
|
84
|
+
`MCPClient(${server.name}): \`authorizationToken\` and \`oauth\` are mutually exclusive — set one.`,
|
|
85
|
+
{ context: { server: server.name } },
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
this.server = server
|
|
89
|
+
this._client =
|
|
90
|
+
options.client ??
|
|
91
|
+
new Client(
|
|
92
|
+
{ name: `@strav/brain:${server.name}`, version: '1.0.0' },
|
|
93
|
+
{ capabilities: {} },
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async connect(): Promise<void> {
|
|
98
|
+
if (this._connected) return
|
|
99
|
+
if (this._connecting) return this._connecting
|
|
100
|
+
this._connecting = this._doConnect().finally(() => {
|
|
101
|
+
this._connecting = undefined
|
|
102
|
+
})
|
|
103
|
+
return this._connecting
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async _doConnect(): Promise<void> {
|
|
107
|
+
const transport = this._buildTransport()
|
|
108
|
+
this._transport = transport
|
|
109
|
+
try {
|
|
110
|
+
await this._client.connect(transport)
|
|
111
|
+
this._connected = true
|
|
112
|
+
} catch (cause) {
|
|
113
|
+
if (cause instanceof UnauthorizedError && this._authProvider?.capturedAuthorizationUrl) {
|
|
114
|
+
throw new MCPAuthRequiredError(
|
|
115
|
+
this.server.name,
|
|
116
|
+
this._authProvider.capturedAuthorizationUrl.toString(),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
throw new BrainError(
|
|
120
|
+
`MCPClient(${this.server.name}): failed to connect to ${this.server.url}.`,
|
|
121
|
+
{ context: { server: this.server.name, url: this.server.url }, cause },
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Finish the OAuth authorization-code flow after the user
|
|
128
|
+
* authorized and was redirected back to the app's callback URL.
|
|
129
|
+
* Exchanges `code` for tokens, persists them via the configured
|
|
130
|
+
* `MCPOAuthStore`, then connects.
|
|
131
|
+
*
|
|
132
|
+
* Throws `BrainError` when called on a server without OAuth
|
|
133
|
+
* configured.
|
|
134
|
+
*/
|
|
135
|
+
async completeAuthorization(code: string): Promise<void> {
|
|
136
|
+
if (this.server.oauth === undefined) {
|
|
137
|
+
throw new BrainError(
|
|
138
|
+
`MCPClient(${this.server.name}): completeAuthorization() called on a server without \`oauth\` configured.`,
|
|
139
|
+
{ context: { server: this.server.name } },
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
if (this._transport === undefined) {
|
|
143
|
+
// A previous `connect()` attempt builds the transport + captures
|
|
144
|
+
// the auth URL on the provider. Apps that lost the transport
|
|
145
|
+
// reference (typical for stateless callback handlers) can
|
|
146
|
+
// construct a fresh transport here — the store carries every
|
|
147
|
+
// bit of state the exchange needs.
|
|
148
|
+
this._transport = this._buildTransport()
|
|
149
|
+
}
|
|
150
|
+
await this._transport.finishAuth(code)
|
|
151
|
+
// Now that tokens are saved, re-attempt the connection.
|
|
152
|
+
this._connected = false
|
|
153
|
+
await this.connect()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async listTools(opts: { signal?: AbortSignal } = {}): Promise<MCPToolDescriptor[]> {
|
|
157
|
+
await this.connect()
|
|
158
|
+
let response: Awaited<ReturnType<Client['listTools']>>
|
|
159
|
+
try {
|
|
160
|
+
response = await this._client.listTools(
|
|
161
|
+
undefined,
|
|
162
|
+
opts.signal !== undefined ? { signal: opts.signal } : undefined,
|
|
163
|
+
)
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
throw new BrainError(
|
|
166
|
+
`MCPClient(${this.server.name}): tools/list failed.`,
|
|
167
|
+
{ context: { server: this.server.name }, cause },
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
return response.tools.map((t) => ({
|
|
171
|
+
name: t.name,
|
|
172
|
+
description: t.description ?? '',
|
|
173
|
+
inputSchema: (t.inputSchema ?? { type: 'object' }) as Record<string, unknown>,
|
|
174
|
+
}))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async callTool(
|
|
178
|
+
name: string,
|
|
179
|
+
input: unknown,
|
|
180
|
+
opts: { signal?: AbortSignal } = {},
|
|
181
|
+
): Promise<MCPCallToolResult> {
|
|
182
|
+
await this.connect()
|
|
183
|
+
let response: Awaited<ReturnType<Client['callTool']>>
|
|
184
|
+
try {
|
|
185
|
+
response = await this._client.callTool(
|
|
186
|
+
{
|
|
187
|
+
name,
|
|
188
|
+
arguments: (input ?? {}) as Record<string, unknown>,
|
|
189
|
+
},
|
|
190
|
+
undefined,
|
|
191
|
+
opts.signal !== undefined ? { signal: opts.signal } : undefined,
|
|
192
|
+
)
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
throw new BrainError(
|
|
195
|
+
`MCPClient(${this.server.name}): tools/call ${name} failed.`,
|
|
196
|
+
{ context: { server: this.server.name, tool: name }, cause },
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
content: flattenContent(response.content),
|
|
201
|
+
isError: Boolean(response.isError),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async close(): Promise<void> {
|
|
206
|
+
if (!this._connected) return
|
|
207
|
+
try {
|
|
208
|
+
await this._client.close()
|
|
209
|
+
} finally {
|
|
210
|
+
this._connected = false
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private _buildTransport(): StreamableHTTPClientTransport {
|
|
215
|
+
const headers: Record<string, string> = {}
|
|
216
|
+
if (this.server.authorizationToken !== undefined) {
|
|
217
|
+
headers.Authorization = `Bearer ${this.server.authorizationToken}`
|
|
218
|
+
}
|
|
219
|
+
const transportOpts: ConstructorParameters<typeof StreamableHTTPClientTransport>[1] = {
|
|
220
|
+
requestInit: { headers },
|
|
221
|
+
}
|
|
222
|
+
if (this.server.oauth !== undefined) {
|
|
223
|
+
this._authProvider = new StoreBackedOAuthProvider(this.server.oauth)
|
|
224
|
+
transportOpts.authProvider = this._authProvider
|
|
225
|
+
}
|
|
226
|
+
return new StreamableHTTPClientTransport(new URL(this.server.url), transportOpts)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function flattenContent(
|
|
231
|
+
content: Awaited<ReturnType<Client['callTool']>>['content'],
|
|
232
|
+
): string {
|
|
233
|
+
if (!Array.isArray(content)) return ''
|
|
234
|
+
const parts: string[] = []
|
|
235
|
+
for (const block of content) {
|
|
236
|
+
if (block.type === 'text') {
|
|
237
|
+
parts.push(block.text)
|
|
238
|
+
} else {
|
|
239
|
+
parts.push(JSON.stringify(block))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return parts.join('')
|
|
243
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
MCPAuthRequiredError,
|
|
14
|
+
type MCPOAuthConfig,
|
|
15
|
+
type MCPOAuthStore,
|
|
16
|
+
MemoryOAuthStore,
|
|
17
|
+
} from './oauth.ts'
|
|
18
|
+
export { MCPClientPool, type MCPClientFactory } from './pool.ts'
|
|
19
|
+
export {
|
|
20
|
+
resolveMcpTools,
|
|
21
|
+
type ResolveMcpToolsOptions,
|
|
22
|
+
type ResolvedMcpTools,
|
|
23
|
+
} from './resolve_mcp_tools.ts'
|