@strav/brain 1.0.0-alpha.12 → 1.0.0-alpha.14
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 +3 -2
- package/src/brain_config.ts +19 -1
- package/src/brain_manager.ts +28 -0
- package/src/brain_provider.ts +9 -1
- package/src/index.ts +4 -0
- package/src/output_schema.ts +72 -0
- package/src/provider.ts +19 -0
- package/src/providers/anthropic_provider.ts +25 -0
- package/src/providers/gemini_provider.ts +472 -0
- package/src/providers/openai_provider.ts +31 -0
- package/src/types.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/brain",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.14",
|
|
4
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",
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@anthropic-ai/sdk": "^0.100.0",
|
|
24
|
+
"@google/genai": "^2.7.0",
|
|
24
25
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
25
|
-
"@strav/kernel": "1.0.0-alpha.
|
|
26
|
+
"@strav/kernel": "1.0.0-alpha.14",
|
|
26
27
|
"openai": "^6.0.0"
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
package/src/brain_config.ts
CHANGED
|
@@ -49,7 +49,25 @@ export interface OpenAIProviderConfig {
|
|
|
49
49
|
defaultMaxTokens?: number
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
/** Google (Gemini) driver config — backed by `@google/genai`. */
|
|
53
|
+
export interface GeminiProviderConfig {
|
|
54
|
+
driver: 'google'
|
|
55
|
+
/** API key. Required. Most apps source from `env('GOOGLE_API_KEY')` or `env('GEMINI_API_KEY')`. */
|
|
56
|
+
apiKey: string
|
|
57
|
+
/** Optional override of the SDK's base URL — useful for proxies or test doubles. */
|
|
58
|
+
baseUrl?: string
|
|
59
|
+
/** Default model when neither `options.model` nor `options.tier` is passed. Defaults to `gemini-2.5-flash`. */
|
|
60
|
+
defaultModel?: string
|
|
61
|
+
/** Default `max_tokens` for `chat()` calls that don't specify one. */
|
|
62
|
+
defaultMaxTokens?: number
|
|
63
|
+
/** Optional API version pin (`v1` / `v1beta`). */
|
|
64
|
+
apiVersion?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type ProviderConfig =
|
|
68
|
+
| AnthropicProviderConfig
|
|
69
|
+
| OpenAIProviderConfig
|
|
70
|
+
| GeminiProviderConfig // | DeepSeekProviderConfig (later slice)
|
|
53
71
|
|
|
54
72
|
/** Cache-shape defaults applied when `ChatOptions.cache` is omitted. */
|
|
55
73
|
export interface BrainCacheConfig {
|
package/src/brain_manager.ts
CHANGED
|
@@ -22,12 +22,14 @@
|
|
|
22
22
|
import type { Agent } from './agent.ts'
|
|
23
23
|
import type { AgentResult } from './agent_result.ts'
|
|
24
24
|
import type { MCPServer } from './mcp_server.ts'
|
|
25
|
+
import type { OutputSchema } from './output_schema.ts'
|
|
25
26
|
import { AgentRunner } from './agent_runner.ts'
|
|
26
27
|
import { BrainError } from './brain_error.ts'
|
|
27
28
|
import type { ModelTier } from './types.ts'
|
|
28
29
|
import type {
|
|
29
30
|
ChatOptions,
|
|
30
31
|
ChatResult,
|
|
32
|
+
GenerateResult,
|
|
31
33
|
Message,
|
|
32
34
|
StreamEvent,
|
|
33
35
|
} from './types.ts'
|
|
@@ -166,6 +168,32 @@ export class BrainManager {
|
|
|
166
168
|
return provider.runWithTools(messages, tools, resolved)
|
|
167
169
|
}
|
|
168
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Structured output. Sends `input` to the configured (or
|
|
173
|
+
* `options.provider`-overridden) provider with the JSON-Schema
|
|
174
|
+
* constraint described by `schema`; returns the parsed object.
|
|
175
|
+
*
|
|
176
|
+
* Throws `BrainError` when the chosen provider doesn't implement
|
|
177
|
+
* `generate`. All three V1 providers (Anthropic, OpenAI, Gemini)
|
|
178
|
+
* do.
|
|
179
|
+
*/
|
|
180
|
+
async generate<T>(
|
|
181
|
+
input: string | readonly Message[],
|
|
182
|
+
schema: OutputSchema<T>,
|
|
183
|
+
options: ChatOptions = {},
|
|
184
|
+
): Promise<GenerateResult<T>> {
|
|
185
|
+
const provider = this.provider(options.provider)
|
|
186
|
+
if (!provider.generate) {
|
|
187
|
+
throw new BrainError(
|
|
188
|
+
`BrainManager.generate: provider "${provider.name}" does not implement generate.`,
|
|
189
|
+
{ context: { provider: provider.name } },
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
const messages = normalizeInput(input)
|
|
193
|
+
const resolved = this.applyDefaults(options)
|
|
194
|
+
return provider.generate<T>(messages, schema, resolved)
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
/**
|
|
170
198
|
* Resolve an `Agent` subclass from the container and return an
|
|
171
199
|
* `AgentRunner` ready to receive `input(...)` and `run()`. Apps
|
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 { GeminiProvider } from './providers/gemini_provider.ts'
|
|
31
32
|
import { OpenAIProvider } from './providers/openai_provider.ts'
|
|
32
33
|
import type { Provider } from './provider.ts'
|
|
33
34
|
|
|
@@ -101,10 +102,17 @@ function buildProvider(name: string, config: ProviderConfig): Provider {
|
|
|
101
102
|
)
|
|
102
103
|
}
|
|
103
104
|
return new OpenAIProvider(name, config)
|
|
105
|
+
case 'google':
|
|
106
|
+
if (!config.apiKey) {
|
|
107
|
+
throw new ConfigError(
|
|
108
|
+
`BrainProvider: google provider "${name}" is missing apiKey. Source from env('GOOGLE_API_KEY').`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return new GeminiProvider(name, config)
|
|
104
112
|
default: {
|
|
105
113
|
const exhaustiveCheck: never = config
|
|
106
114
|
throw new ConfigError(
|
|
107
|
-
`BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic, openai.`,
|
|
115
|
+
`BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic, openai, google.`,
|
|
108
116
|
)
|
|
109
117
|
// (unreachable — kept for the exhaustive check to fire when a new driver lands)
|
|
110
118
|
// biome-ignore lint/correctness/noUnreachable: kept for the exhaustive-check above
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export {
|
|
|
16
16
|
type BrainConfigShape,
|
|
17
17
|
DEFAULT_MODEL,
|
|
18
18
|
DEFAULT_TIERS,
|
|
19
|
+
type GeminiProviderConfig,
|
|
19
20
|
type OpenAIProviderConfig,
|
|
20
21
|
type ProviderConfig,
|
|
21
22
|
} from './brain_config.ts'
|
|
@@ -28,7 +29,9 @@ export {
|
|
|
28
29
|
export { BrainProvider } from './brain_provider.ts'
|
|
29
30
|
export { defineTool, type DefineToolSpec } from './define_tool.ts'
|
|
30
31
|
export type { MCPServer, MCPServerToolConfig } from './mcp_server.ts'
|
|
32
|
+
export type { OutputSchema } from './output_schema.ts'
|
|
31
33
|
export { AnthropicProvider } from './providers/anthropic_provider.ts'
|
|
34
|
+
export { GeminiProvider } from './providers/gemini_provider.ts'
|
|
32
35
|
export { OpenAIProvider } from './providers/openai_provider.ts'
|
|
33
36
|
export type { Provider, RunWithToolsOptions } from './provider.ts'
|
|
34
37
|
export { Thread, type ThreadOptions, type ThreadState } from './thread.ts'
|
|
@@ -39,6 +42,7 @@ export type {
|
|
|
39
42
|
ChatResult,
|
|
40
43
|
ChatUsage,
|
|
41
44
|
ContentBlock,
|
|
45
|
+
GenerateResult,
|
|
42
46
|
MCPToolResultBlock,
|
|
43
47
|
MCPToolUseBlock,
|
|
44
48
|
Message,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OutputSchema<T>` — declarative description of a structured-output
|
|
3
|
+
* target. Apps pass one of these to `BrainManager.generate(...)` and
|
|
4
|
+
* get back a `GenerateResult<T>` whose `value` is the parsed JSON
|
|
5
|
+
* the model produced.
|
|
6
|
+
*
|
|
7
|
+
* Schema shape:
|
|
8
|
+
* - `name` — short identifier; OpenAI requires it on
|
|
9
|
+
* `response_format.json_schema.name`. Other providers ignore it
|
|
10
|
+
* but apps should still set something stable and meaningful for
|
|
11
|
+
* logs / telemetry.
|
|
12
|
+
* - `description` — optional hint the model sees (some providers
|
|
13
|
+
* surface it next to the schema; others embed it in the prompt
|
|
14
|
+
* translation).
|
|
15
|
+
* - `jsonSchema` — plain JSON Schema (draft 2020-12 compatible).
|
|
16
|
+
* The framework deliberately doesn't depend on Zod; apps that
|
|
17
|
+
* want Zod use `zod-to-json-schema` (or similar) at the call
|
|
18
|
+
* site and feed the result here.
|
|
19
|
+
* - `parse` — optional runtime validator/parser. When set, the
|
|
20
|
+
* framework runs every model response through it before
|
|
21
|
+
* returning. Apps that just want type-level inference (and
|
|
22
|
+
* trust the model + provider validation) can omit it; the
|
|
23
|
+
* return type is then `T` by type assertion only.
|
|
24
|
+
*
|
|
25
|
+
* Why no built-in Zod dep:
|
|
26
|
+
* Same reason `Tool.inputSchema` is plain JSON Schema — Strav
|
|
27
|
+
* doesn't pin apps to a schema library. A thin `@strav/brain-zod`
|
|
28
|
+
* helper that produces `OutputSchema<T>` from a Zod schema is
|
|
29
|
+
* straightforward to ship later without touching this file.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export interface OutputSchema<T = unknown> {
|
|
33
|
+
/** Short identifier — provider-specific; some surface it, others ignore. */
|
|
34
|
+
name: string
|
|
35
|
+
/** Optional hint shown to the model alongside the schema. */
|
|
36
|
+
description?: string
|
|
37
|
+
/** JSON Schema describing the expected shape. */
|
|
38
|
+
jsonSchema: Record<string, unknown>
|
|
39
|
+
/** Optional runtime parser/validator. Apps that use Zod plug it in here. */
|
|
40
|
+
parse?(value: unknown): T
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Shared helper used by every provider's `generate` implementation:
|
|
45
|
+
* parse the raw JSON response, run the optional `parse` hook, and
|
|
46
|
+
* wrap parse failures in `BrainError` with the raw text on `.context`
|
|
47
|
+
* so apps can inspect what came back when validation rejects.
|
|
48
|
+
*/
|
|
49
|
+
import { BrainError } from './brain_error.ts'
|
|
50
|
+
|
|
51
|
+
export function parseGenerated<T>(text: string, schema: OutputSchema<T>): T {
|
|
52
|
+
let parsed: unknown
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(text)
|
|
55
|
+
} catch (cause) {
|
|
56
|
+
throw new BrainError(
|
|
57
|
+
`BrainProvider.generate: response was not valid JSON for schema "${schema.name}".`,
|
|
58
|
+
{ context: { schema: schema.name, text }, cause },
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
if (schema.parse) {
|
|
62
|
+
try {
|
|
63
|
+
return schema.parse(parsed)
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
throw new BrainError(
|
|
66
|
+
`BrainProvider.generate: response failed schema.parse for "${schema.name}".`,
|
|
67
|
+
{ context: { schema: schema.name, text }, cause },
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return parsed as T
|
|
72
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
import type { AgentResult } from './agent_result.ts'
|
|
16
16
|
import type { MCPServer } from './mcp_server.ts'
|
|
17
|
+
import type { OutputSchema } from './output_schema.ts'
|
|
17
18
|
import type { Tool } from './tool.ts'
|
|
18
19
|
import type {
|
|
19
20
|
ChatOptions,
|
|
20
21
|
ChatResult,
|
|
22
|
+
GenerateResult,
|
|
21
23
|
Message,
|
|
22
24
|
StreamEvent,
|
|
23
25
|
} from './types.ts'
|
|
@@ -80,4 +82,21 @@ export interface Provider {
|
|
|
80
82
|
tools: readonly Tool[],
|
|
81
83
|
options?: RunWithToolsOptions,
|
|
82
84
|
): Promise<AgentResult>
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Structured output. Sends `messages` to the model with a
|
|
88
|
+
* JSON-Schema constraint and returns the parsed object. Apps that
|
|
89
|
+
* supplied `schema.parse` get a runtime-validated value; otherwise
|
|
90
|
+
* the value is `T` by type assertion (the provider does its own
|
|
91
|
+
* upstream schema enforcement, but the framework doesn't validate).
|
|
92
|
+
*
|
|
93
|
+
* Optional on the interface so providers that lack a structured-
|
|
94
|
+
* output endpoint can omit it; `BrainManager.generate` throws a
|
|
95
|
+
* `BrainError` when the configured provider doesn't expose this.
|
|
96
|
+
*/
|
|
97
|
+
generate?<T>(
|
|
98
|
+
messages: readonly Message[],
|
|
99
|
+
schema: OutputSchema<T>,
|
|
100
|
+
options?: ChatOptions,
|
|
101
|
+
): Promise<GenerateResult<T>>
|
|
83
102
|
}
|
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
ChatResult,
|
|
36
36
|
ChatUsage,
|
|
37
37
|
ContentBlock,
|
|
38
|
+
GenerateResult,
|
|
38
39
|
MCPToolResultBlock,
|
|
39
40
|
MCPToolUseBlock,
|
|
40
41
|
Message,
|
|
@@ -44,6 +45,7 @@ import type {
|
|
|
44
45
|
ToolResultBlock,
|
|
45
46
|
ToolUseBlock,
|
|
46
47
|
} from '../types.ts'
|
|
48
|
+
import { parseGenerated, type OutputSchema } from '../output_schema.ts'
|
|
47
49
|
|
|
48
50
|
const EPHEMERAL_CACHE = { type: 'ephemeral' } as const
|
|
49
51
|
|
|
@@ -263,6 +265,29 @@ export class AnthropicProvider implements Provider {
|
|
|
263
265
|
}
|
|
264
266
|
}
|
|
265
267
|
|
|
268
|
+
async generate<T>(
|
|
269
|
+
messages: readonly Message[],
|
|
270
|
+
schema: OutputSchema<T>,
|
|
271
|
+
options: ChatOptions = {},
|
|
272
|
+
): Promise<GenerateResult<T>> {
|
|
273
|
+
const params = this.buildParams(messages, options) as Anthropic.MessageCreateParamsNonStreaming
|
|
274
|
+
params.output_config = {
|
|
275
|
+
...(params.output_config ?? {}),
|
|
276
|
+
format: { type: 'json_schema', schema: schema.jsonSchema },
|
|
277
|
+
}
|
|
278
|
+
const response = await this.client.messages.create(params)
|
|
279
|
+
const text = collectText(response.content)
|
|
280
|
+
const value = parseGenerated(text, schema)
|
|
281
|
+
return {
|
|
282
|
+
value,
|
|
283
|
+
text,
|
|
284
|
+
model: response.model,
|
|
285
|
+
stopReason: response.stop_reason,
|
|
286
|
+
usage: toUsage(response.usage),
|
|
287
|
+
raw: response,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
266
291
|
// ─── Param translation ──────────────────────────────────────────────────
|
|
267
292
|
|
|
268
293
|
private buildParams(
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GeminiProvider` — implementation of `Provider` backed by the
|
|
3
|
+
* official `@google/genai` SDK (Gemini Developer API / Vertex AI).
|
|
4
|
+
*
|
|
5
|
+
* Maps framework shapes to Gemini's wire format:
|
|
6
|
+
*
|
|
7
|
+
* - `system` → `config.systemInstruction` (string-joined when
|
|
8
|
+
* multi-block). Cache flags on the system prompt are ignored —
|
|
9
|
+
* Gemini's prompt caching uses an explicit Caches API rather
|
|
10
|
+
* than per-block flags, so `cache: true` becomes a no-op
|
|
11
|
+
* consistent with the OpenAI provider.
|
|
12
|
+
*
|
|
13
|
+
* - `Message[]` → `Content[]`. Framework `role: 'user' | 'assistant'`
|
|
14
|
+
* maps to Gemini's `role: 'user' | 'model'`. String content
|
|
15
|
+
* becomes a single `{text}` part; `ContentBlock[]` content fans
|
|
16
|
+
* out:
|
|
17
|
+
* - `TextBlock` → `{text}`
|
|
18
|
+
* - `ToolUseBlock` → `{functionCall: {id, name, args}}`
|
|
19
|
+
* - `ToolResultBlock` → `{functionResponse: {id, name,
|
|
20
|
+
* response: {result | error}}}`
|
|
21
|
+
* - `MCP*` blocks → silently dropped (Anthropic-only).
|
|
22
|
+
*
|
|
23
|
+
* - `Tool[]` → `[{functionDeclarations: [{name, description,
|
|
24
|
+
* parametersJsonSchema: inputSchema}]}]`. We use
|
|
25
|
+
* `parametersJsonSchema` (not `parameters`) so JSON-Schema-shaped
|
|
26
|
+
* tool inputs pass through verbatim without translation to
|
|
27
|
+
* Gemini's `Schema` form.
|
|
28
|
+
*
|
|
29
|
+
* - `MCPServer[]` → resolved via the local MCP client
|
|
30
|
+
* (`@strav/brain/mcp`). Discovered tools are namespaced
|
|
31
|
+
* `<server>__<tool>` and merged with caller-supplied tools.
|
|
32
|
+
* Transports are closed in a `finally` once the loop exits.
|
|
33
|
+
* Gemini has no first-party server-side MCP equivalent to
|
|
34
|
+
* Anthropic's connector.
|
|
35
|
+
*
|
|
36
|
+
* - `thinking: 'adaptive'` → `thinkingConfig: { thinkingBudget: -1 }`
|
|
37
|
+
* (auto). `'disabled'` → `thinkingConfig: { thinkingBudget: 0 }`.
|
|
38
|
+
* Explicit `effort` (`low`/`medium`/`high`/`xhigh`/`max`) maps to
|
|
39
|
+
* `thinkingConfig.thinkingLevel`. Non-thinking models ignore the
|
|
40
|
+
* field upstream — we always emit, the SDK rejects only for
|
|
41
|
+
* models that don't support it.
|
|
42
|
+
*
|
|
43
|
+
* - `cache: true` → no-op. Gemini's prompt cache lives behind the
|
|
44
|
+
* `Caches` API; same accepted-silently behavior as OpenAI.
|
|
45
|
+
*
|
|
46
|
+
* - `countTokens` IS implemented — `ai.models.countTokens` exists
|
|
47
|
+
* and is cheap. Returns `totalTokens`.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { GoogleGenAI, ThinkingLevel } from '@google/genai'
|
|
51
|
+
import type {
|
|
52
|
+
Content,
|
|
53
|
+
FunctionDeclaration,
|
|
54
|
+
GenerateContentConfig,
|
|
55
|
+
GenerateContentParameters,
|
|
56
|
+
GenerateContentResponse,
|
|
57
|
+
Part,
|
|
58
|
+
} from '@google/genai'
|
|
59
|
+
import type { AgentResult } from '../agent_result.ts'
|
|
60
|
+
import { BrainError } from '../brain_error.ts'
|
|
61
|
+
import type { GeminiProviderConfig } from '../brain_config.ts'
|
|
62
|
+
import type { MCPServer } from '../mcp_server.ts'
|
|
63
|
+
import { resolveMcpTools, type ResolveMcpToolsOptions } from '../mcp/resolve_mcp_tools.ts'
|
|
64
|
+
import { parseGenerated, type OutputSchema } from '../output_schema.ts'
|
|
65
|
+
import type { Provider, RunWithToolsOptions } from '../provider.ts'
|
|
66
|
+
import type { Tool } from '../tool.ts'
|
|
67
|
+
import { ToolExecutionError } from '../tool_execution_error.ts'
|
|
68
|
+
import type {
|
|
69
|
+
ChatOptions,
|
|
70
|
+
ChatResult,
|
|
71
|
+
ChatUsage,
|
|
72
|
+
ContentBlock,
|
|
73
|
+
GenerateResult,
|
|
74
|
+
Message,
|
|
75
|
+
StreamEvent,
|
|
76
|
+
SystemPrompt,
|
|
77
|
+
TextBlock,
|
|
78
|
+
ToolResultBlock,
|
|
79
|
+
ToolUseBlock,
|
|
80
|
+
} from '../types.ts'
|
|
81
|
+
|
|
82
|
+
const DEFAULT_GEMINI_MODEL = 'gemini-2.5-flash'
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The slice of `GoogleGenAI` the provider exercises. Narrowed so
|
|
86
|
+
* tests can inject a stub without satisfying the full SDK surface.
|
|
87
|
+
*/
|
|
88
|
+
export interface GeminiModelsClient {
|
|
89
|
+
generateContent(params: GenerateContentParameters): Promise<GenerateContentResponse>
|
|
90
|
+
generateContentStream(
|
|
91
|
+
params: GenerateContentParameters,
|
|
92
|
+
): Promise<AsyncIterable<GenerateContentResponse>>
|
|
93
|
+
countTokens(params: { model: string; contents: Content[] }): Promise<{ totalTokens?: number }>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface GeminiProviderOptions {
|
|
97
|
+
client?: { models: GeminiModelsClient }
|
|
98
|
+
/** Internal seam — tests inject a stub MCP client factory. */
|
|
99
|
+
mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class GeminiProvider implements Provider {
|
|
103
|
+
readonly name: string
|
|
104
|
+
private readonly models: GeminiModelsClient
|
|
105
|
+
private readonly defaultModel: string
|
|
106
|
+
private readonly defaultMaxTokens: number
|
|
107
|
+
private readonly mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
|
|
108
|
+
|
|
109
|
+
constructor(name: string, config: GeminiProviderConfig, options: GeminiProviderOptions = {}) {
|
|
110
|
+
this.name = name
|
|
111
|
+
this.defaultModel = config.defaultModel ?? DEFAULT_GEMINI_MODEL
|
|
112
|
+
this.defaultMaxTokens = config.defaultMaxTokens ?? 4096
|
|
113
|
+
this.mcpClientFactory = options.mcpClientFactory
|
|
114
|
+
if (options.client) {
|
|
115
|
+
this.models = options.client.models
|
|
116
|
+
} else {
|
|
117
|
+
const httpOpts =
|
|
118
|
+
config.baseUrl !== undefined || config.apiVersion !== undefined
|
|
119
|
+
? {
|
|
120
|
+
...(config.baseUrl !== undefined ? { baseUrl: config.baseUrl } : {}),
|
|
121
|
+
...(config.apiVersion !== undefined ? { apiVersion: config.apiVersion } : {}),
|
|
122
|
+
}
|
|
123
|
+
: undefined
|
|
124
|
+
const sdk = new GoogleGenAI({
|
|
125
|
+
apiKey: config.apiKey,
|
|
126
|
+
...(httpOpts ? { httpOptions: httpOpts } : {}),
|
|
127
|
+
})
|
|
128
|
+
this.models = sdk.models as unknown as GeminiModelsClient
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async chat(messages: readonly Message[], options: ChatOptions = {}): Promise<ChatResult> {
|
|
133
|
+
const params = this.buildParams(messages, options, [])
|
|
134
|
+
const response = await this.models.generateContent(params)
|
|
135
|
+
return this.toChatResult(response, params.model)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async *stream(
|
|
139
|
+
messages: readonly Message[],
|
|
140
|
+
options: ChatOptions = {},
|
|
141
|
+
): AsyncIterable<StreamEvent> {
|
|
142
|
+
const params = this.buildParams(messages, options, [])
|
|
143
|
+
const stream = await this.models.generateContentStream(params)
|
|
144
|
+
let finishReason: string | null = null
|
|
145
|
+
let lastUsage: ChatUsage | undefined
|
|
146
|
+
for await (const chunk of stream) {
|
|
147
|
+
const candidate = chunk.candidates?.[0]
|
|
148
|
+
const text = candidateText(candidate)
|
|
149
|
+
if (text.length > 0) yield { type: 'text', delta: text }
|
|
150
|
+
if (candidate?.finishReason) finishReason = String(candidate.finishReason)
|
|
151
|
+
if (chunk.usageMetadata) lastUsage = toUsage(chunk.usageMetadata)
|
|
152
|
+
}
|
|
153
|
+
yield {
|
|
154
|
+
type: 'stop',
|
|
155
|
+
stopReason: finishReason,
|
|
156
|
+
usage: lastUsage ?? {
|
|
157
|
+
inputTokens: 0,
|
|
158
|
+
outputTokens: 0,
|
|
159
|
+
cacheReadTokens: 0,
|
|
160
|
+
cacheCreationTokens: 0,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async countTokens(messages: readonly Message[], options: ChatOptions = {}): Promise<number> {
|
|
166
|
+
const contents = this.toContents(messages)
|
|
167
|
+
const model = options.model ?? this.defaultModel
|
|
168
|
+
const response = await this.models.countTokens({ model, contents })
|
|
169
|
+
return response.totalTokens ?? 0
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async runWithTools(
|
|
173
|
+
messages: readonly Message[],
|
|
174
|
+
tools: readonly Tool[],
|
|
175
|
+
options: RunWithToolsOptions = {},
|
|
176
|
+
): Promise<AgentResult> {
|
|
177
|
+
const mcpServers: readonly MCPServer[] = options.mcpServers ?? []
|
|
178
|
+
const resolved =
|
|
179
|
+
mcpServers.length > 0
|
|
180
|
+
? await resolveMcpTools(mcpServers, {
|
|
181
|
+
...(this.mcpClientFactory ? { clientFactory: this.mcpClientFactory } : {}),
|
|
182
|
+
})
|
|
183
|
+
: { tools: [] as Tool[], close: async () => {} }
|
|
184
|
+
try {
|
|
185
|
+
return await this._runLoop(messages, [...tools, ...resolved.tools], options)
|
|
186
|
+
} finally {
|
|
187
|
+
await resolved.close()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async _runLoop(
|
|
192
|
+
messages: readonly Message[],
|
|
193
|
+
tools: readonly Tool[],
|
|
194
|
+
options: RunWithToolsOptions,
|
|
195
|
+
): Promise<AgentResult> {
|
|
196
|
+
const maxIterations = options.maxIterations ?? 10
|
|
197
|
+
const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
|
|
198
|
+
const workingMessages: Message[] = [...messages]
|
|
199
|
+
const aggregated: ChatUsage = {
|
|
200
|
+
inputTokens: 0,
|
|
201
|
+
outputTokens: 0,
|
|
202
|
+
cacheReadTokens: 0,
|
|
203
|
+
cacheCreationTokens: 0,
|
|
204
|
+
}
|
|
205
|
+
let iterations = 0
|
|
206
|
+
|
|
207
|
+
while (true) {
|
|
208
|
+
const params = this.buildParams(workingMessages, options, tools)
|
|
209
|
+
const response = await this.models.generateContent(params)
|
|
210
|
+
addUsage(aggregated, response.usageMetadata)
|
|
211
|
+
|
|
212
|
+
const candidate = response.candidates?.[0]
|
|
213
|
+
if (!candidate) {
|
|
214
|
+
throw new BrainError('GeminiProvider: response had no candidates.')
|
|
215
|
+
}
|
|
216
|
+
const parts = candidate.content?.parts ?? []
|
|
217
|
+
const assistantContent = fromGeminiParts(parts)
|
|
218
|
+
workingMessages.push({ role: 'assistant', content: assistantContent })
|
|
219
|
+
|
|
220
|
+
const toolUses = (Array.isArray(assistantContent) ? assistantContent : []).filter(
|
|
221
|
+
(b): b is ToolUseBlock => b.type === 'tool_use',
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if (toolUses.length === 0) {
|
|
225
|
+
return {
|
|
226
|
+
text: typeof assistantContent === 'string'
|
|
227
|
+
? assistantContent
|
|
228
|
+
: candidateText(candidate),
|
|
229
|
+
messages: workingMessages,
|
|
230
|
+
iterations,
|
|
231
|
+
stopReason: candidate.finishReason ? String(candidate.finishReason) : 'stop',
|
|
232
|
+
usage: aggregated,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const resultBlocks: ContentBlock[] = []
|
|
237
|
+
for (const call of toolUses) {
|
|
238
|
+
const tool = toolMap.get(call.name)
|
|
239
|
+
if (!tool) {
|
|
240
|
+
throw new ToolExecutionError(
|
|
241
|
+
call.name,
|
|
242
|
+
call.id,
|
|
243
|
+
new Error(`Tool "${call.name}" is not registered.`),
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
let output: unknown
|
|
247
|
+
try {
|
|
248
|
+
output = await tool.execute(call.input, {
|
|
249
|
+
callId: call.id,
|
|
250
|
+
context: options.context ?? {},
|
|
251
|
+
})
|
|
252
|
+
} catch (cause) {
|
|
253
|
+
throw new ToolExecutionError(call.name, call.id, cause)
|
|
254
|
+
}
|
|
255
|
+
const resultBlock: ToolResultBlock = {
|
|
256
|
+
type: 'tool_result',
|
|
257
|
+
toolUseId: call.id,
|
|
258
|
+
content: typeof output === 'string' ? output : JSON.stringify(output),
|
|
259
|
+
}
|
|
260
|
+
resultBlocks.push(resultBlock)
|
|
261
|
+
}
|
|
262
|
+
workingMessages.push({ role: 'user', content: resultBlocks })
|
|
263
|
+
|
|
264
|
+
iterations++
|
|
265
|
+
if (iterations >= maxIterations) {
|
|
266
|
+
return {
|
|
267
|
+
text: candidateText(candidate),
|
|
268
|
+
messages: workingMessages,
|
|
269
|
+
iterations,
|
|
270
|
+
stopReason: 'max_iterations',
|
|
271
|
+
usage: aggregated,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async generate<T>(
|
|
278
|
+
messages: readonly Message[],
|
|
279
|
+
schema: OutputSchema<T>,
|
|
280
|
+
options: ChatOptions = {},
|
|
281
|
+
): Promise<GenerateResult<T>> {
|
|
282
|
+
const params = this.buildParams(messages, options, [])
|
|
283
|
+
params.config = {
|
|
284
|
+
...(params.config ?? {}),
|
|
285
|
+
responseMimeType: 'application/json',
|
|
286
|
+
responseJsonSchema: schema.jsonSchema,
|
|
287
|
+
}
|
|
288
|
+
const response = await this.models.generateContent(params)
|
|
289
|
+
const candidate = response.candidates?.[0]
|
|
290
|
+
const text = candidateText(candidate)
|
|
291
|
+
const value = parseGenerated(text, schema)
|
|
292
|
+
return {
|
|
293
|
+
value,
|
|
294
|
+
text,
|
|
295
|
+
model: response.modelVersion ?? params.model,
|
|
296
|
+
stopReason: candidate?.finishReason ? String(candidate.finishReason) : null,
|
|
297
|
+
usage: toUsage(response.usageMetadata),
|
|
298
|
+
raw: response,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── Param translation ──────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
private buildParams(
|
|
305
|
+
messages: readonly Message[],
|
|
306
|
+
options: ChatOptions,
|
|
307
|
+
tools: readonly Tool[],
|
|
308
|
+
): GenerateContentParameters {
|
|
309
|
+
const model = options.model ?? this.defaultModel
|
|
310
|
+
const contents = this.toContents(messages)
|
|
311
|
+
const config: GenerateContentConfig = {
|
|
312
|
+
maxOutputTokens: options.maxTokens ?? this.defaultMaxTokens,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const systemText = systemPromptText(options.system)
|
|
316
|
+
if (systemText.length > 0) {
|
|
317
|
+
config.systemInstruction = systemText
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (tools.length > 0) {
|
|
321
|
+
const functionDeclarations: FunctionDeclaration[] = tools.map((t) => ({
|
|
322
|
+
name: t.name,
|
|
323
|
+
description: t.description,
|
|
324
|
+
parametersJsonSchema: t.inputSchema,
|
|
325
|
+
}))
|
|
326
|
+
config.tools = [{ functionDeclarations }]
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const thinking = buildThinkingConfig(options)
|
|
330
|
+
if (thinking !== undefined) config.thinkingConfig = thinking
|
|
331
|
+
|
|
332
|
+
return { model, contents, config }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private toContents(messages: readonly Message[]): Content[] {
|
|
336
|
+
return messages.map((m) => ({
|
|
337
|
+
role: m.role === 'assistant' ? 'model' : 'user',
|
|
338
|
+
parts: toGeminiParts(m.content),
|
|
339
|
+
}))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private toChatResult(
|
|
343
|
+
response: GenerateContentResponse,
|
|
344
|
+
requestedModel: string,
|
|
345
|
+
): ChatResult<GenerateContentResponse> {
|
|
346
|
+
const candidate = response.candidates?.[0]
|
|
347
|
+
return {
|
|
348
|
+
text: candidateText(candidate),
|
|
349
|
+
model: response.modelVersion ?? requestedModel,
|
|
350
|
+
stopReason: candidate?.finishReason ? String(candidate.finishReason) : null,
|
|
351
|
+
usage: toUsage(response.usageMetadata),
|
|
352
|
+
raw: response,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ─── Shape converters ─────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
function systemPromptText(system: SystemPrompt | undefined): string {
|
|
360
|
+
if (system === undefined) return ''
|
|
361
|
+
if (typeof system === 'string') return system
|
|
362
|
+
if (Array.isArray(system)) return system.map((b) => b.text).join('\n')
|
|
363
|
+
return system.text
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function toGeminiParts(content: string | ContentBlock[]): Part[] {
|
|
367
|
+
if (typeof content === 'string') return [{ text: content }]
|
|
368
|
+
const parts: Part[] = []
|
|
369
|
+
for (const block of content) {
|
|
370
|
+
if (block.type === 'text') {
|
|
371
|
+
parts.push({ text: block.text })
|
|
372
|
+
} else if (block.type === 'tool_use') {
|
|
373
|
+
parts.push({
|
|
374
|
+
functionCall: {
|
|
375
|
+
id: block.id,
|
|
376
|
+
name: block.name,
|
|
377
|
+
args: (block.input ?? {}) as Record<string, unknown>,
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
} else if (block.type === 'tool_result') {
|
|
381
|
+
const text = typeof block.content === 'string'
|
|
382
|
+
? block.content
|
|
383
|
+
: block.content.map((t) => t.text).join('')
|
|
384
|
+
parts.push({
|
|
385
|
+
functionResponse: {
|
|
386
|
+
id: block.toolUseId,
|
|
387
|
+
name: '',
|
|
388
|
+
response: block.isError ? { error: text } : { result: text },
|
|
389
|
+
},
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
// MCP blocks (Anthropic-only) silently dropped.
|
|
393
|
+
}
|
|
394
|
+
return parts
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function fromGeminiParts(parts: readonly Part[]): string | ContentBlock[] {
|
|
398
|
+
const blocks: ContentBlock[] = []
|
|
399
|
+
for (const part of parts) {
|
|
400
|
+
if (typeof part.text === 'string' && part.text.length > 0) {
|
|
401
|
+
blocks.push({ type: 'text', text: part.text })
|
|
402
|
+
} else if (part.functionCall) {
|
|
403
|
+
const fc = part.functionCall
|
|
404
|
+
blocks.push({
|
|
405
|
+
type: 'tool_use',
|
|
406
|
+
id: fc.id ?? `gemini_${cryptoRandomId()}`,
|
|
407
|
+
name: fc.name ?? '',
|
|
408
|
+
input: fc.args ?? {},
|
|
409
|
+
} satisfies ToolUseBlock)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (blocks.length === 1 && blocks[0]?.type === 'text') return blocks[0].text
|
|
413
|
+
return blocks
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function candidateText(candidate: { content?: { parts?: Part[] } } | undefined): string {
|
|
417
|
+
const parts = candidate?.content?.parts ?? []
|
|
418
|
+
return parts
|
|
419
|
+
.filter((p) => typeof p.text === 'string' && p.text.length > 0)
|
|
420
|
+
.map((p) => p.text as string)
|
|
421
|
+
.join('')
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function buildThinkingConfig(options: ChatOptions): GenerateContentConfig['thinkingConfig'] {
|
|
425
|
+
if (options.effort !== undefined) {
|
|
426
|
+
const level = effortToThinkingLevel(options.effort)
|
|
427
|
+
return level !== undefined ? { thinkingLevel: level } : { thinkingBudget: -1 }
|
|
428
|
+
}
|
|
429
|
+
if (options.thinking === 'adaptive') return { thinkingBudget: -1 }
|
|
430
|
+
if (options.thinking === 'disabled') return { thinkingBudget: 0 }
|
|
431
|
+
return undefined
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function effortToThinkingLevel(
|
|
435
|
+
effort: NonNullable<ChatOptions['effort']>,
|
|
436
|
+
): ThinkingLevel | undefined {
|
|
437
|
+
switch (effort) {
|
|
438
|
+
case 'low': return ThinkingLevel.LOW
|
|
439
|
+
case 'medium': return ThinkingLevel.MEDIUM
|
|
440
|
+
case 'high':
|
|
441
|
+
case 'xhigh':
|
|
442
|
+
case 'max':
|
|
443
|
+
return ThinkingLevel.HIGH
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function toUsage(u: { promptTokenCount?: number; candidatesTokenCount?: number; cachedContentTokenCount?: number } | undefined): ChatUsage {
|
|
448
|
+
return {
|
|
449
|
+
inputTokens: u?.promptTokenCount ?? 0,
|
|
450
|
+
outputTokens: u?.candidatesTokenCount ?? 0,
|
|
451
|
+
cacheReadTokens: u?.cachedContentTokenCount ?? 0,
|
|
452
|
+
cacheCreationTokens: 0,
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function addUsage(
|
|
457
|
+
acc: ChatUsage,
|
|
458
|
+
u: { promptTokenCount?: number; candidatesTokenCount?: number; cachedContentTokenCount?: number } | undefined,
|
|
459
|
+
): void {
|
|
460
|
+
if (!u) return
|
|
461
|
+
acc.inputTokens += u.promptTokenCount ?? 0
|
|
462
|
+
acc.outputTokens += u.candidatesTokenCount ?? 0
|
|
463
|
+
acc.cacheReadTokens += u.cachedContentTokenCount ?? 0
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function cryptoRandomId(): string {
|
|
467
|
+
// Stable, low-entropy fallback for synthesizing tool-use ids when
|
|
468
|
+
// Gemini omits them. Uniqueness within a single response is all the
|
|
469
|
+
// loop requires — the id only travels back paired with its result
|
|
470
|
+
// and never escapes to the caller.
|
|
471
|
+
return Math.random().toString(36).slice(2, 12)
|
|
472
|
+
}
|
|
@@ -53,6 +53,7 @@ import { BrainError } from '../brain_error.ts'
|
|
|
53
53
|
import type { OpenAIProviderConfig } from '../brain_config.ts'
|
|
54
54
|
import type { MCPServer } from '../mcp_server.ts'
|
|
55
55
|
import { resolveMcpTools, type ResolveMcpToolsOptions } from '../mcp/resolve_mcp_tools.ts'
|
|
56
|
+
import { parseGenerated, type OutputSchema } from '../output_schema.ts'
|
|
56
57
|
import type { Provider, RunWithToolsOptions } from '../provider.ts'
|
|
57
58
|
import type { Tool } from '../tool.ts'
|
|
58
59
|
import { ToolExecutionError } from '../tool_execution_error.ts'
|
|
@@ -61,6 +62,7 @@ import type {
|
|
|
61
62
|
ChatResult,
|
|
62
63
|
ChatUsage,
|
|
63
64
|
ContentBlock,
|
|
65
|
+
GenerateResult,
|
|
64
66
|
Message,
|
|
65
67
|
StreamEvent,
|
|
66
68
|
SystemPrompt,
|
|
@@ -257,6 +259,35 @@ export class OpenAIProvider implements Provider {
|
|
|
257
259
|
}
|
|
258
260
|
}
|
|
259
261
|
|
|
262
|
+
async generate<T>(
|
|
263
|
+
messages: readonly Message[],
|
|
264
|
+
schema: OutputSchema<T>,
|
|
265
|
+
options: ChatOptions = {},
|
|
266
|
+
): Promise<GenerateResult<T>> {
|
|
267
|
+
const params = this.buildParams(messages, options, [])
|
|
268
|
+
params.response_format = {
|
|
269
|
+
type: 'json_schema',
|
|
270
|
+
json_schema: {
|
|
271
|
+
name: schema.name,
|
|
272
|
+
...(schema.description !== undefined ? { description: schema.description } : {}),
|
|
273
|
+
schema: schema.jsonSchema,
|
|
274
|
+
strict: true,
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
const response = await this.client.chat.completions.create(params)
|
|
278
|
+
const choice = response.choices[0]
|
|
279
|
+
const text = choice?.message?.content ?? ''
|
|
280
|
+
const value = parseGenerated(text, schema)
|
|
281
|
+
return {
|
|
282
|
+
value,
|
|
283
|
+
text,
|
|
284
|
+
model: response.model,
|
|
285
|
+
stopReason: choice?.finish_reason ?? null,
|
|
286
|
+
usage: toUsage(response.usage),
|
|
287
|
+
raw: response,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
260
291
|
// ─── Param translation ──────────────────────────────────────────────────
|
|
261
292
|
|
|
262
293
|
private buildParams(
|
package/src/types.ts
CHANGED
|
@@ -200,3 +200,18 @@ export interface ChatResult<Raw = unknown> {
|
|
|
200
200
|
export type StreamEvent =
|
|
201
201
|
| { type: 'text'; delta: string }
|
|
202
202
|
| { type: 'stop'; stopReason: string | null; usage: ChatUsage }
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Result of a structured-output call. `value` is the parsed JSON
|
|
206
|
+
* shaped to the `OutputSchema<T>` passed in. `text` is the raw JSON
|
|
207
|
+
* string the model produced (useful for logging / debugging when
|
|
208
|
+
* `parse` rejects). `raw` is the provider's full native response.
|
|
209
|
+
*/
|
|
210
|
+
export interface GenerateResult<T = unknown, Raw = unknown> {
|
|
211
|
+
value: T
|
|
212
|
+
text: string
|
|
213
|
+
model: string
|
|
214
|
+
stopReason: string | null
|
|
215
|
+
usage: ChatUsage
|
|
216
|
+
raw: Raw
|
|
217
|
+
}
|