@strav/brain 0.4.31 → 1.0.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +18 -20
  2. package/src/agent.ts +50 -75
  3. package/src/agent_result.ts +32 -0
  4. package/src/agent_runner.ts +63 -0
  5. package/src/brain_config.ts +95 -0
  6. package/src/brain_error.ts +29 -0
  7. package/src/brain_manager.ts +186 -123
  8. package/src/brain_provider.ts +104 -6
  9. package/src/define_tool.ts +42 -0
  10. package/src/index.ts +44 -41
  11. package/src/mcp_server.ts +47 -0
  12. package/src/provider.ts +83 -0
  13. package/src/providers/anthropic_provider.ts +435 -232
  14. package/src/providers/openai_provider.ts +350 -503
  15. package/src/thread.ts +99 -0
  16. package/src/tool.ts +28 -44
  17. package/src/tool_execution_error.ts +26 -0
  18. package/src/types.ts +164 -237
  19. package/CHANGELOG.md +0 -44
  20. package/README.md +0 -121
  21. package/src/helpers.ts +0 -1082
  22. package/src/mcp_toolbox.ts +0 -62
  23. package/src/memory/context_budget.ts +0 -120
  24. package/src/memory/index.ts +0 -17
  25. package/src/memory/memory_manager.ts +0 -168
  26. package/src/memory/semantic_memory.ts +0 -89
  27. package/src/memory/strategies/sliding_window.ts +0 -20
  28. package/src/memory/strategies/summarize.ts +0 -157
  29. package/src/memory/thread_store.ts +0 -56
  30. package/src/memory/token_counter.ts +0 -101
  31. package/src/memory/types.ts +0 -68
  32. package/src/providers/google_provider.ts +0 -496
  33. package/src/providers/openai_responses_provider.ts +0 -321
  34. package/src/utils/error_scrub.ts +0 -5
  35. package/src/utils/prompt.ts +0 -65
  36. package/src/utils/retry.ts +0 -104
  37. package/src/utils/schema.ts +0 -27
  38. package/src/utils/sse_parser.ts +0 -62
  39. package/src/workflow.ts +0 -199
  40. package/tsconfig.json +0 -5
@@ -1,281 +1,484 @@
1
- import { parseSSE } from '../utils/sse_parser.ts'
2
- import { retryableFetch, type RetryOptions } from '../utils/retry.ts'
3
- import { ExternalServiceError } from '@strav/kernel'
1
+ /**
2
+ * `AnthropicProvider` implementation of `Provider` backed by the
3
+ * official `@anthropic-ai/sdk`.
4
+ *
5
+ * Responsibilities:
6
+ * 1. Hold a singleton `Anthropic` client instance for the
7
+ * configured API key + base URL.
8
+ * 2. Translate the framework's `ChatOptions` / `Message` shapes
9
+ * into Anthropic's `MessageCreateParams` (system as `TextBlock[]`
10
+ * with `cache_control` when requested; messages with per-block
11
+ * cache flags translated likewise; `thinking` mapped to
12
+ * `ThinkingConfigParam`; `effort` placed under `output_config`).
13
+ * 3. Translate the response back to `ChatResult` — flatten the
14
+ * content blocks into a single `text` string, surface usage with
15
+ * cache-hit counters, and pass the raw `Message` through on `.raw`.
16
+ * 4. Stream via `client.messages.stream()` and yield the framework
17
+ * `StreamEvent` union — `text` deltas plus a terminal `stop`
18
+ * event with usage + stop reason.
19
+ *
20
+ * Errors from the SDK propagate; apps that want provider-specific
21
+ * recovery can `instanceof Anthropic.RateLimitError` etc. The brain
22
+ * facade wraps the call site in `BrainError` only for invariants the
23
+ * facade owns (e.g. "no provider configured").
24
+ */
25
+
26
+ import Anthropic from '@anthropic-ai/sdk'
27
+ import type { AgentResult } from '../agent_result.ts'
28
+ import type { AnthropicProviderConfig } from '../brain_config.ts'
29
+ import { DEFAULT_MODEL } from '../brain_config.ts'
30
+ import type { Provider, RunWithToolsOptions } from '../provider.ts'
31
+ import type { Tool } from '../tool.ts'
32
+ import { ToolExecutionError } from '../tool_execution_error.ts'
4
33
  import type {
5
- AIProvider,
6
- CompletionRequest,
7
- CompletionResponse,
8
- StreamChunk,
9
- ProviderConfig,
34
+ ChatOptions,
35
+ ChatResult,
36
+ ChatUsage,
37
+ ContentBlock,
38
+ MCPToolResultBlock,
39
+ MCPToolUseBlock,
10
40
  Message,
11
- ToolCall,
12
- Usage,
41
+ StreamEvent,
42
+ SystemPrompt,
43
+ TextBlock,
44
+ ToolResultBlock,
45
+ ToolUseBlock,
13
46
  } from '../types.ts'
14
47
 
15
- /**
16
- * Anthropic Messages API provider.
17
- *
18
- * Translates the framework's normalized CompletionRequest/Response
19
- * to/from the Anthropic wire format. Uses raw `fetch()`.
20
- */
21
- export class AnthropicProvider implements AIProvider {
48
+ const EPHEMERAL_CACHE = { type: 'ephemeral' } as const
49
+
50
+ export class AnthropicProvider implements Provider {
22
51
  readonly name: string
23
- private apiKey: string
24
- private baseUrl: string
25
- private defaultModel: string
26
- private defaultMaxTokens: number
27
- private retryOptions: RetryOptions
28
-
29
- constructor(config: ProviderConfig) {
30
- this.name = 'anthropic'
31
- this.apiKey = config.apiKey
32
- this.baseUrl = (config.baseUrl ?? 'https://api.anthropic.com').replace(/\/$/, '')
33
- this.defaultModel = config.model
34
- this.defaultMaxTokens = config.maxTokens ?? 4096
35
- this.retryOptions = {
36
- maxRetries: config.maxRetries ?? 3,
37
- baseDelay: config.retryBaseDelay ?? 1000,
38
- }
52
+ private readonly client: Anthropic
53
+ private readonly defaultModel: string
54
+ private readonly defaultMaxTokens: number
55
+ private readonly betas: readonly string[]
56
+
57
+ constructor(
58
+ name: string,
59
+ config: AnthropicProviderConfig,
60
+ options: { client?: Anthropic } = {},
61
+ ) {
62
+ this.name = name
63
+ this.defaultModel = config.defaultModel ?? DEFAULT_MODEL
64
+ this.defaultMaxTokens = config.defaultMaxTokens ?? 4096
65
+ this.betas = config.betas ?? []
66
+ // `client` injection point — tests pass a stub; apps that want a
67
+ // pre-configured SDK instance (custom retry, fetch transport, etc.)
68
+ // build their own and hand it over here.
69
+ this.client =
70
+ options.client ??
71
+ new Anthropic({
72
+ apiKey: config.apiKey,
73
+ ...(config.baseUrl !== undefined ? { baseURL: config.baseUrl } : {}),
74
+ })
39
75
  }
40
76
 
41
- async complete(request: CompletionRequest): Promise<CompletionResponse> {
42
- const body = this.buildRequestBody(request, false)
43
-
44
- const response = await retryableFetch(
45
- 'Anthropic',
46
- `${this.baseUrl}/v1/messages`,
47
- { method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body) },
48
- this.retryOptions
49
- )
50
-
51
- const data: any = await response.json()
52
- return this.parseResponse(data)
77
+ async chat(messages: readonly Message[], options: ChatOptions = {}): Promise<ChatResult> {
78
+ const params = this.buildParams(messages, options)
79
+ const response = await this.client.messages.create(params)
80
+ return this.toChatResult(response)
53
81
  }
54
82
 
55
- async *stream(request: CompletionRequest): AsyncIterable<StreamChunk> {
56
- const body = this.buildRequestBody(request, true)
83
+ async *stream(
84
+ messages: readonly Message[],
85
+ options: ChatOptions = {},
86
+ ): AsyncIterable<StreamEvent> {
87
+ const params = this.buildParams(messages, options)
88
+ const stream = this.client.messages.stream(params)
89
+ for await (const event of stream) {
90
+ if (
91
+ event.type === 'content_block_delta' &&
92
+ event.delta.type === 'text_delta'
93
+ ) {
94
+ yield { type: 'text', delta: event.delta.text }
95
+ }
96
+ }
97
+ const final = await stream.finalMessage()
98
+ yield {
99
+ type: 'stop',
100
+ stopReason: final.stop_reason,
101
+ usage: toUsage(final.usage),
102
+ }
103
+ }
57
104
 
58
- const response = await retryableFetch(
59
- 'Anthropic',
60
- `${this.baseUrl}/v1/messages`,
61
- { method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body) },
62
- this.retryOptions
63
- )
105
+ async countTokens(
106
+ messages: readonly Message[],
107
+ options: ChatOptions = {},
108
+ ): Promise<number> {
109
+ const base = this.buildParams(messages, options)
110
+ // count_tokens only accepts a subset of MessageCreateParams; build
111
+ // a focused payload that matches what apps actually need to budget.
112
+ const result = await this.client.messages.countTokens({
113
+ model: base.model,
114
+ messages: base.messages,
115
+ ...(base.system !== undefined ? { system: base.system } : {}),
116
+ ...(base.thinking !== undefined ? { thinking: base.thinking } : {}),
117
+ })
118
+ return result.input_tokens
119
+ }
64
120
 
65
- if (!response.body) {
66
- throw new ExternalServiceError('Anthropic', undefined, 'No stream body returned')
121
+ /**
122
+ * Agentic loop. Send detect tool_use blocks → execute → append
123
+ * tool_result → re-send, until the model returns `end_turn` or
124
+ * the iteration ceiling is hit.
125
+ *
126
+ * Tools are passed once on every call — Anthropic doesn't carry
127
+ * tool state across requests; the model rediscovers them from the
128
+ * `tools` array each turn. Apps that care about cache hits keep
129
+ * the tool list stable across runs.
130
+ */
131
+ async runWithTools(
132
+ messages: readonly Message[],
133
+ tools: readonly Tool[],
134
+ options: RunWithToolsOptions = {},
135
+ ): Promise<AgentResult> {
136
+ const maxIterations = options.maxIterations ?? 10
137
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
138
+ const workingMessages: Message[] = [...messages]
139
+ const aggregated: ChatUsage = {
140
+ inputTokens: 0,
141
+ outputTokens: 0,
142
+ cacheReadTokens: 0,
143
+ cacheCreationTokens: 0,
67
144
  }
145
+ let iterations = 0
146
+ let lastStopReason: string | null = null
68
147
 
69
- let currentBlockIndex = -1
70
-
71
- for await (const sse of parseSSE(response.body)) {
72
- if (sse.data === '[DONE]') break
148
+ const mcpServers = options.mcpServers ?? []
149
+ const useMcpBeta = mcpServers.length > 0
73
150
 
74
- let parsed: any
75
- try {
76
- parsed = JSON.parse(sse.data)
77
- } catch {
78
- continue
151
+ while (true) {
152
+ const params = this.buildParams(workingMessages, options) as Anthropic.MessageCreateParamsNonStreaming & {
153
+ mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
79
154
  }
80
-
81
- const type = parsed.type ?? sse.event
82
-
83
- if (type === 'content_block_start') {
84
- currentBlockIndex = parsed.index ?? currentBlockIndex + 1
85
- const block = parsed.content_block
86
- if (block?.type === 'tool_use') {
87
- yield {
88
- type: 'tool_start',
89
- toolCall: { id: block.id, name: block.name },
90
- toolIndex: currentBlockIndex,
155
+ params.tools = [
156
+ ...tools.map((t) => ({
157
+ name: t.name,
158
+ description: t.description,
159
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
160
+ })),
161
+ // MCP toolsets one per declared server. The model sees the
162
+ // server's tools via Anthropic's connector, not via our local
163
+ // `tools` list.
164
+ ...mcpServers
165
+ .filter((s) => s.tools?.enabled !== false)
166
+ .map((s) => ({
167
+ type: 'mcp_toolset' as const,
168
+ mcp_server_name: s.name,
169
+ ...(s.tools?.allowedTools
170
+ ? { allowed_tools: [...s.tools.allowedTools] }
171
+ : {}),
172
+ })),
173
+ ] as unknown as Anthropic.MessageCreateParams['tools']
174
+
175
+ // Declare MCP servers + flip to the beta surface when in use.
176
+ // Anthropic's MCP connector requires `mcp-client-2025-11-20`.
177
+ let response: Anthropic.Message
178
+ if (useMcpBeta) {
179
+ params.mcp_servers = mcpServers.map((s) => {
180
+ const def: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition = {
181
+ type: 'url',
182
+ name: s.name,
183
+ url: s.url,
91
184
  }
185
+ if (s.authorizationToken !== undefined) def.authorization_token = s.authorizationToken
186
+ return def
187
+ })
188
+ const baseBetas = (params as { betas?: readonly string[] }).betas ?? []
189
+ ;(params as { betas?: string[] }).betas = baseBetas.includes('mcp-client-2025-11-20')
190
+ ? [...baseBetas]
191
+ : [...baseBetas, 'mcp-client-2025-11-20']
192
+ response = (await this.client.beta.messages.create(
193
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsNonStreaming,
194
+ )) as unknown as Anthropic.Message
195
+ } else {
196
+ response = await this.client.messages.create(params)
197
+ }
198
+ addUsage(aggregated, response.usage)
199
+ lastStopReason = response.stop_reason ?? null
200
+
201
+ // Append the assistant turn verbatim from the SDK shape so
202
+ // tool_use blocks survive to the next request unchanged.
203
+ workingMessages.push({
204
+ role: 'assistant',
205
+ content: fromAnthropicContent(response.content),
206
+ })
207
+
208
+ if (response.stop_reason !== 'tool_use') {
209
+ return {
210
+ text: collectText(response.content),
211
+ messages: workingMessages,
212
+ iterations,
213
+ stopReason: lastStopReason ?? 'end_turn',
214
+ usage: aggregated,
92
215
  }
93
- } else if (type === 'content_block_delta') {
94
- const delta = parsed.delta
95
- if (delta?.type === 'text_delta') {
96
- yield { type: 'text', text: delta.text }
97
- } else if (delta?.type === 'input_json_delta') {
98
- yield {
99
- type: 'tool_delta',
100
- text: delta.partial_json,
101
- toolIndex: parsed.index ?? currentBlockIndex,
102
- }
216
+ }
217
+
218
+ // Execute every tool_use block in the response and append the
219
+ // results in a single user-role turn. The SDK's API expects all
220
+ // tool_result blocks for a given assistant turn to land in the
221
+ // same user message.
222
+ const toolUseBlocks = response.content.filter(
223
+ (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
224
+ )
225
+ const resultBlocks: ContentBlock[] = []
226
+ for (const block of toolUseBlocks) {
227
+ const tool = toolMap.get(block.name)
228
+ if (!tool) {
229
+ throw new ToolExecutionError(
230
+ block.name,
231
+ block.id,
232
+ new Error(`Tool "${block.name}" is not registered.`),
233
+ )
103
234
  }
104
- } else if (type === 'content_block_stop') {
105
- // If we were accumulating a tool call, signal end
106
- if (currentBlockIndex >= 0) {
107
- yield { type: 'tool_end', toolIndex: parsed.index ?? currentBlockIndex }
235
+ let output: unknown
236
+ try {
237
+ output = await tool.execute(block.input, {
238
+ callId: block.id,
239
+ context: options.context ?? {},
240
+ })
241
+ } catch (cause) {
242
+ throw new ToolExecutionError(block.name, block.id, cause)
108
243
  }
109
- } else if (type === 'message_delta') {
110
- const usage = parsed.usage
111
- if (usage) {
112
- yield {
113
- type: 'usage',
114
- usage: {
115
- inputTokens: usage.input_tokens ?? 0,
116
- outputTokens: usage.output_tokens ?? 0,
117
- totalTokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0),
118
- },
119
- }
244
+ const resultBlock: ToolResultBlock = {
245
+ type: 'tool_result',
246
+ toolUseId: block.id,
247
+ content: typeof output === 'string' ? output : JSON.stringify(output),
248
+ }
249
+ resultBlocks.push(resultBlock)
250
+ }
251
+ workingMessages.push({ role: 'user', content: resultBlocks })
252
+
253
+ iterations++
254
+ if (iterations >= maxIterations) {
255
+ return {
256
+ text: collectText(response.content),
257
+ messages: workingMessages,
258
+ iterations,
259
+ stopReason: 'max_iterations',
260
+ usage: aggregated,
120
261
  }
121
- } else if (type === 'message_stop') {
122
- yield { type: 'done' }
123
262
  }
124
263
  }
125
264
  }
126
265
 
127
- // ── Private helpers ──────────────────────────────────────────────────────
128
-
129
- private buildHeaders(): Record<string, string> {
130
- return {
131
- 'content-type': 'application/json',
132
- 'x-api-key': this.apiKey,
133
- 'anthropic-version': '2023-06-01',
266
+ // ─── Param translation ──────────────────────────────────────────────────
267
+
268
+ private buildParams(
269
+ messages: readonly Message[],
270
+ options: ChatOptions,
271
+ ): Anthropic.MessageCreateParamsNonStreaming {
272
+ const model = options.model ?? this.defaultModel
273
+ const params: Anthropic.MessageCreateParamsNonStreaming = {
274
+ model,
275
+ max_tokens: options.maxTokens ?? this.defaultMaxTokens,
276
+ messages: messages.map(toMessageParam),
134
277
  }
135
- }
136
278
 
137
- private buildRequestBody(request: CompletionRequest, stream: boolean): Record<string, unknown> {
138
- const body: Record<string, unknown> = {
139
- model: request.model ?? this.defaultModel,
140
- max_tokens: request.maxTokens ?? this.defaultMaxTokens,
141
- messages: this.mapMessages(request.messages),
279
+ const system = toSystemParam(options.system)
280
+ if (system !== undefined) params.system = system
281
+
282
+ if (options.thinking === 'adaptive') {
283
+ params.thinking = { type: 'adaptive' }
284
+ } else if (options.thinking === 'disabled') {
285
+ params.thinking = { type: 'disabled' }
142
286
  }
143
287
 
144
- if (stream) body.stream = true
145
- if (request.system) body.system = request.system
146
- if (request.temperature !== undefined) body.temperature = request.temperature
147
- if (request.stopSequences?.length) body.stop_sequences = request.stopSequences
148
-
149
- // Tools
150
- if (request.tools?.length) {
151
- body.tools = request.tools.map(t => ({
152
- name: t.name,
153
- description: t.description,
154
- input_schema: t.parameters,
155
- }))
288
+ if (options.effort !== undefined) {
289
+ params.output_config = { effort: options.effort }
156
290
  }
157
291
 
158
- // Tool choice
159
- if (request.toolChoice) {
160
- if (request.toolChoice === 'auto') {
161
- body.tool_choice = { type: 'auto' }
162
- } else if (request.toolChoice === 'required') {
163
- body.tool_choice = { type: 'any' }
164
- } else {
165
- body.tool_choice = { type: 'tool', name: request.toolChoice.name }
166
- }
292
+ if (options.cache === true) {
293
+ // Top-level auto-cache the last cacheable block. Maps to the
294
+ // SDK's `cache_control` shorthand on the request body.
295
+ ;(params as { cache_control?: { type: 'ephemeral' } }).cache_control = EPHEMERAL_CACHE
167
296
  }
168
297
 
169
- // Structured output (using GA API with output_config)
170
- if (request.schema) {
171
- body.output_config = {
172
- format: {
173
- type: 'json_schema',
174
- schema: request.schema
175
- }
176
- }
298
+ const betas = mergeBetas(this.betas, options.betas)
299
+ if (betas.length > 0) {
300
+ ;(params as { betas?: readonly string[] }).betas = betas
177
301
  }
178
302
 
179
- return body
303
+ return params
180
304
  }
181
305
 
182
- private mapMessages(messages: Message[]): any[] {
183
- const result: any[] = []
184
-
185
- for (const msg of messages) {
186
- if (msg.role === 'tool') {
187
- // Tool results go as user messages with tool_result content blocks
188
- result.push({
189
- role: 'user',
190
- content: [
191
- {
192
- type: 'tool_result',
193
- tool_use_id: msg.toolCallId,
194
- content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
195
- },
196
- ],
197
- })
198
- } else if (msg.role === 'assistant') {
199
- const content: any[] = []
200
-
201
- // Add text content if present
202
- const text = typeof msg.content === 'string' ? msg.content : ''
203
- if (text) {
204
- content.push({ type: 'text', text })
205
- }
206
-
207
- // Add tool use blocks
208
- if (msg.toolCalls?.length) {
209
- for (const tc of msg.toolCalls) {
210
- content.push({
211
- type: 'tool_use',
212
- id: tc.id,
213
- name: tc.name,
214
- input: tc.arguments,
215
- })
216
- }
217
- }
218
-
219
- result.push({
220
- role: 'assistant',
221
- content: content.length === 1 && content[0].type === 'text' ? content[0].text : content,
222
- })
223
- } else {
224
- // User messages
225
- result.push({
226
- role: 'user',
227
- content: typeof msg.content === 'string' ? msg.content : msg.content,
228
- })
229
- }
306
+ private toChatResult(message: Anthropic.Message): ChatResult<Anthropic.Message> {
307
+ const text = message.content
308
+ .filter((b): b is Anthropic.TextBlock => b.type === 'text')
309
+ .map((b) => b.text)
310
+ .join('')
311
+ return {
312
+ text,
313
+ model: message.model,
314
+ stopReason: message.stop_reason,
315
+ usage: toUsage(message.usage),
316
+ raw: message,
230
317
  }
231
-
232
- return result
233
318
  }
319
+ }
234
320
 
235
- private parseResponse(data: any): CompletionResponse {
236
- let content = ''
237
- const toolCalls: ToolCall[] = []
321
+ // ─── Shape converters ─────────────────────────────────────────────────────
238
322
 
239
- if (Array.isArray(data.content)) {
240
- for (const block of data.content) {
241
- if (block.type === 'text') {
242
- content += block.text
243
- } else if (block.type === 'tool_use') {
244
- toolCalls.push({
323
+ function toUsage(u: Anthropic.Usage): ChatUsage {
324
+ return {
325
+ inputTokens: u.input_tokens,
326
+ outputTokens: u.output_tokens,
327
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
328
+ cacheCreationTokens: u.cache_creation_input_tokens ?? 0,
329
+ }
330
+ }
331
+
332
+ function toMessageParam(message: Message): Anthropic.MessageParam {
333
+ if (typeof message.content === 'string') {
334
+ return { role: message.role, content: message.content }
335
+ }
336
+ return {
337
+ role: message.role,
338
+ content: message.content
339
+ // MCP blocks are inbound-only — Anthropic produces them, we
340
+ // surface them on `result.messages` for observability, but we
341
+ // never echo them back to the model. The backend tracks MCP
342
+ // tool state on its side.
343
+ .filter(
344
+ (b): b is Exclude<ContentBlock, MCPToolUseBlock | MCPToolResultBlock> =>
345
+ b.type !== 'mcp_tool_use' && b.type !== 'mcp_tool_result',
346
+ )
347
+ .map((block): Anthropic.ContentBlockParam => {
348
+ if (block.type === 'tool_use') {
349
+ return {
350
+ type: 'tool_use',
245
351
  id: block.id,
246
352
  name: block.name,
247
- arguments: block.input ?? {},
248
- })
353
+ input: block.input as Record<string, unknown>,
354
+ }
249
355
  }
250
- }
251
- }
356
+ if (block.type === 'tool_result') {
357
+ const param: Anthropic.ToolResultBlockParam = {
358
+ type: 'tool_result',
359
+ tool_use_id: block.toolUseId,
360
+ content:
361
+ typeof block.content === 'string'
362
+ ? block.content
363
+ : block.content.map(
364
+ (b) => ({ type: 'text', text: b.text }) as Anthropic.TextBlockParam,
365
+ ),
366
+ }
367
+ if (block.isError) param.is_error = true
368
+ return param
369
+ }
370
+ const text: Anthropic.TextBlockParam = { type: 'text', text: block.text }
371
+ if (block.cache) text.cache_control = EPHEMERAL_CACHE
372
+ return text
373
+ }),
374
+ }
375
+ }
252
376
 
253
- const usage: Usage = {
254
- inputTokens: data.usage?.input_tokens ?? 0,
255
- outputTokens: data.usage?.output_tokens ?? 0,
256
- totalTokens: (data.usage?.input_tokens ?? 0) + (data.usage?.output_tokens ?? 0),
257
- }
377
+ function toSystemParam(
378
+ system: SystemPrompt | undefined,
379
+ ): string | Anthropic.TextBlockParam[] | undefined {
380
+ if (system === undefined) return undefined
381
+ if (typeof system === 'string') return system
382
+ if (Array.isArray(system)) {
383
+ return system.map((block) => {
384
+ const param: Anthropic.TextBlockParam = { type: 'text', text: block.text }
385
+ if (block.cache) param.cache_control = EPHEMERAL_CACHE
386
+ return param
387
+ })
388
+ }
389
+ const param: Anthropic.TextBlockParam = { type: 'text', text: system.text }
390
+ if (system.cache) param.cache_control = EPHEMERAL_CACHE
391
+ return [param]
392
+ }
258
393
 
259
- let stopReason: CompletionResponse['stopReason'] = 'end'
260
- switch (data.stop_reason) {
261
- case 'tool_use':
262
- stopReason = 'tool_use'
263
- break
264
- case 'max_tokens':
265
- stopReason = 'max_tokens'
266
- break
267
- case 'stop_sequence':
268
- stopReason = 'stop_sequence'
269
- break
270
- }
394
+ function mergeBetas(
395
+ providerBetas: readonly string[],
396
+ callBetas: readonly string[] | undefined,
397
+ ): readonly string[] {
398
+ if (!callBetas || callBetas.length === 0) return providerBetas
399
+ const seen = new Set<string>()
400
+ const out: string[] = []
401
+ for (const b of providerBetas) {
402
+ if (seen.has(b)) continue
403
+ seen.add(b)
404
+ out.push(b)
405
+ }
406
+ for (const b of callBetas) {
407
+ if (seen.has(b)) continue
408
+ seen.add(b)
409
+ out.push(b)
410
+ }
411
+ return out
412
+ }
271
413
 
272
- return {
273
- id: data.id ?? '',
274
- content,
275
- toolCalls,
276
- stopReason,
277
- usage,
278
- raw: data,
414
+ function addUsage(acc: ChatUsage, u: Anthropic.Usage): void {
415
+ acc.inputTokens += u.input_tokens
416
+ acc.outputTokens += u.output_tokens
417
+ acc.cacheReadTokens += u.cache_read_input_tokens ?? 0
418
+ acc.cacheCreationTokens += u.cache_creation_input_tokens ?? 0
419
+ }
420
+
421
+ function collectText(content: Anthropic.ContentBlock[]): string {
422
+ return content
423
+ .filter((b): b is Anthropic.TextBlock => b.type === 'text')
424
+ .map((b) => b.text)
425
+ .join('')
426
+ }
427
+
428
+ /**
429
+ * Translate the SDK's response content blocks back into framework
430
+ * `ContentBlock`s for storage in `workingMessages`. We preserve
431
+ * `text` and `tool_use` blocks verbatim; other server-side block
432
+ * types (thinking, server tool blocks) are dropped — V1 doesn't
433
+ * surface them, and re-sending them as part of the assistant turn
434
+ * could confuse the model.
435
+ */
436
+ function fromAnthropicContent(
437
+ content: ReadonlyArray<Anthropic.ContentBlock | { type: string; [k: string]: unknown }>,
438
+ ): ContentBlock[] {
439
+ const out: ContentBlock[] = []
440
+ for (const block of content) {
441
+ if (block.type === 'text') {
442
+ out.push({ type: 'text', text: (block as { text: string }).text } satisfies TextBlock)
443
+ } else if (block.type === 'tool_use') {
444
+ const u = block as { id: string; name: string; input: unknown }
445
+ out.push({
446
+ type: 'tool_use',
447
+ id: u.id,
448
+ name: u.name,
449
+ input: u.input,
450
+ } satisfies ToolUseBlock)
451
+ } else if (block.type === 'mcp_tool_use') {
452
+ const m = block as unknown as {
453
+ id: string
454
+ server_name: string
455
+ name: string
456
+ input: unknown
457
+ }
458
+ out.push({
459
+ type: 'mcp_tool_use',
460
+ id: m.id,
461
+ serverName: m.server_name,
462
+ name: m.name,
463
+ input: m.input,
464
+ } satisfies MCPToolUseBlock)
465
+ } else if (block.type === 'mcp_tool_result') {
466
+ const r = block as unknown as {
467
+ tool_use_id: string
468
+ content: string | Array<{ type: 'text'; text: string }>
469
+ is_error?: boolean
470
+ }
471
+ const result: MCPToolResultBlock = {
472
+ type: 'mcp_tool_result',
473
+ toolUseId: r.tool_use_id,
474
+ content:
475
+ typeof r.content === 'string'
476
+ ? r.content
477
+ : r.content.map((c) => ({ type: 'text', text: c.text }) satisfies TextBlock),
478
+ }
479
+ if (r.is_error) result.isError = true
480
+ out.push(result)
279
481
  }
280
482
  }
483
+ return out
281
484
  }