claude-sdk-proxy 2.3.1 → 3.0.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-sdk-proxy",
3
- "version": "2.3.1",
3
+ "version": "3.0.0",
4
4
  "description": "Anthropic Messages API proxy backed by Claude Agent SDK — use Claude Max with any API client",
5
5
  "type": "module",
6
6
  "main": "./src/proxy/server.ts",
package/src/mcpTools.ts CHANGED
@@ -1,237 +1,3 @@
1
- import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"
2
- import { z } from "zod"
3
- import { createPrivateKey, createPublicKey, sign, randomBytes } from "node:crypto"
4
- import { readFileSync } from "node:fs"
5
- import { homedir } from "node:os"
6
- import { execSync } from "node:child_process"
7
-
8
- // ── Gateway helpers ──────────────────────────────────────────────────────────
9
-
10
- function b64urlEncode(buf: Buffer): string {
11
- return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
12
- }
13
-
14
- function signPayload(privateKeyPem: string, payload: string): string {
15
- const key = createPrivateKey(privateKeyPem)
16
- return b64urlEncode(sign(null, Buffer.from(payload, "utf8"), key))
17
- }
18
-
19
- function pubKeyRawB64url(publicKeyPem: string): string {
20
- const pubKey = createPublicKey(publicKeyPem)
21
- const der = pubKey.export({ type: "spki", format: "der" }) as Buffer
22
- return b64urlEncode(der.slice(12)) // strip 12-byte ED25519 SPKI prefix
23
- }
24
-
25
- let _identity: { deviceId: string; privateKeyPem: string; publicKeyPem: string } | null = null
26
- let _gatewayToken: string | null = null
27
-
28
- function loadGatewayConfig(): { identity: typeof _identity; token: string } {
29
- if (!_identity || !_gatewayToken) {
30
- const identity = JSON.parse(readFileSync(`${homedir()}/.openclaw/identity/device.json`, "utf8"))
31
- const cfg = JSON.parse(readFileSync(`${homedir()}/.openclaw/openclaw.json`, "utf8"))
32
- const token: string = cfg?.gateway?.auth?.token
33
- if (!token) throw new Error("gateway token not found in openclaw.json")
34
- _identity = identity
35
- _gatewayToken = token
36
- }
37
- return { identity: _identity!, token: _gatewayToken! }
38
- }
39
-
40
- function invalidateGatewayConfig() {
41
- _identity = null
42
- _gatewayToken = null
43
- }
44
-
45
- async function sendViaGateway(
46
- to: string,
47
- message?: string,
48
- mediaUrl?: string
49
- ): Promise<{ ok: boolean; error?: string }> {
50
- let identity: ReturnType<typeof loadGatewayConfig>["identity"]
51
- let token: string
52
- try {
53
- const cfg = loadGatewayConfig()
54
- identity = cfg.identity
55
- token = cfg.token
56
- } catch (e) {
57
- invalidateGatewayConfig()
58
- return { ok: false, error: `config error: ${e instanceof Error ? e.message : String(e)}` }
59
- }
60
-
61
- return new Promise((resolve) => {
62
- const ws = new WebSocket("ws://127.0.0.1:18789")
63
- let settled = false
64
- let connected = false
65
-
66
- const finish = (result: { ok: boolean; error?: string }) => {
67
- if (settled) return
68
- settled = true
69
- clearTimeout(timer)
70
- try { ws.close() } catch {}
71
- resolve(result)
72
- }
73
-
74
- const timer = setTimeout(() => finish({ ok: false, error: "timeout waiting for gateway" }), 10_000)
75
-
76
- ws.onerror = () => finish({ ok: false, error: "gateway websocket error" })
77
-
78
- ws.onclose = (event: CloseEvent) => {
79
- if (!settled) finish({ ok: false, error: `gateway closed unexpectedly (code=${event.code})` })
80
- }
81
-
82
- ws.onmessage = (event: MessageEvent) => {
83
- try {
84
- const frame = JSON.parse(event.data as string)
85
-
86
- if (!connected && frame.type === "event" && frame.event === "connect.challenge") {
87
- const nonce: string = frame.payload.nonce
88
- const signedAtMs = Date.now()
89
- const SCOPES = ["operator.admin", "operator.write"]
90
- const authPayload = ["v2", identity!.deviceId, "cli", "cli", "operator",
91
- SCOPES.join(","), String(signedAtMs), token, nonce].join("|")
92
- ws.send(JSON.stringify({
93
- type: "req", id: "conn1", method: "connect",
94
- params: {
95
- minProtocol: 3, maxProtocol: 3,
96
- client: { id: "cli", version: "1.0.0", platform: "linux", mode: "cli" },
97
- caps: [],
98
- scopes: SCOPES,
99
- auth: { token },
100
- device: {
101
- id: identity!.deviceId,
102
- publicKey: pubKeyRawB64url(identity!.publicKeyPem),
103
- signature: signPayload(identity!.privateKeyPem, authPayload),
104
- signedAt: signedAtMs,
105
- nonce
106
- }
107
- }
108
- }))
109
-
110
- } else if (!connected && frame.type === "res" && frame.id === "conn1") {
111
- if (!frame.ok) {
112
- if (frame.error?.message?.includes("unauthorized") ||
113
- frame.error?.message?.includes("pairing")) {
114
- invalidateGatewayConfig()
115
- }
116
- finish({ ok: false, error: `gateway connect failed: ${frame.error?.message || "unknown"}` })
117
- return
118
- }
119
- connected = true
120
- const sendParams: Record<string, unknown> = {
121
- to,
122
- channel: "telegram",
123
- idempotencyKey: randomBytes(16).toString("hex")
124
- }
125
- if (message) sendParams.message = message
126
- if (mediaUrl) sendParams.mediaUrl = mediaUrl
127
- ws.send(JSON.stringify({
128
- type: "req", id: "send1", method: "send",
129
- params: sendParams
130
- }))
131
-
132
- } else if (connected && frame.type === "res" && frame.id === "send1") {
133
- if (frame.ok) {
134
- finish({ ok: true })
135
- } else {
136
- finish({ ok: false, error: frame.error?.message || "send failed" })
137
- }
138
- }
139
- } catch (e) {
140
- finish({ ok: false, error: `parse error: ${e instanceof Error ? e.message : String(e)}` })
141
- }
142
- }
143
- })
144
- }
145
-
146
- // ── MCP server factory ───────────────────────────────────────────────────────
147
- // Provides only the gateway message tool. File operations, bash, etc. use
148
- // Claude Code's built-in tools (which are more robust and don't need MCP).
149
- //
150
- // state.messageSent is set on successful delivery. The proxy uses this to
151
- // auto-suppress text responses when messages were sent via tool (prevents
152
- // double-delivery without requiring Claude to know about any sentinel value).
153
-
154
- export interface McpServerState { messageSent: boolean }
155
-
156
- export function createMcpServer(state: McpServerState = { messageSent: false }) {
157
- return createSdkMcpServer({
158
- name: "opencode",
159
- version: "1.0.0",
160
- tools: [
161
- // exec: fallback for callers whose system prompt references "exec" instead of
162
- // Claude Code's built-in "Bash" tool. Maps to child_process.execSync.
163
- tool(
164
- "exec",
165
- "Execute a shell command and return its output. Use this for running scripts, system commands, and file operations.",
166
- {
167
- command: z.string().describe("The shell command to execute"),
168
- timeout: z.number().optional().describe("Timeout in milliseconds (default 120000)"),
169
- },
170
- async (args) => {
171
- try {
172
- const output = execSync(args.command, {
173
- encoding: "utf-8",
174
- timeout: args.timeout ?? 120_000,
175
- maxBuffer: 10 * 1024 * 1024,
176
- cwd: "/tmp",
177
- })
178
- return { content: [{ type: "text", text: output || "(no output)" }] }
179
- } catch (error: any) {
180
- const stderr = error.stderr ? String(error.stderr) : ""
181
- const stdout = error.stdout ? String(error.stdout) : ""
182
- const msg = error.message ?? "Command failed"
183
- return {
184
- content: [{ type: "text", text: `Error: ${msg}\n${stderr}\n${stdout}`.trim() }],
185
- isError: true
186
- }
187
- }
188
- }
189
- ),
190
- tool(
191
- "message",
192
- "Send a message or file to a chat. Provide `to` (chat ID from conversation_label, e.g. '-1001426819337'), and either `message` (text) or `filePath`/`path`/`media` (absolute path to a file). Write files to /tmp/ before sending.",
193
- {
194
- action: z.string().optional().describe("Action to perform. Default: 'send'."),
195
- to: z.string().describe("Chat ID, extracted from conversation_label."),
196
- message: z.string().optional().describe("Text message to send."),
197
- filePath: z.string().optional().describe("Absolute path to a file to send as attachment."),
198
- path: z.string().optional().describe("Alias for filePath."),
199
- media: z.string().optional().describe("Alias for filePath."),
200
- caption: z.string().optional().describe("Caption for a media attachment."),
201
- },
202
- async (args) => {
203
- try {
204
- const rawMedia = args.media ?? args.path ?? args.filePath
205
- let mediaUrl: string | undefined
206
- if (rawMedia) {
207
- if (rawMedia.startsWith("http://") || rawMedia.startsWith("https://") || rawMedia.startsWith("file://")) {
208
- mediaUrl = rawMedia
209
- } else {
210
- const absPath = rawMedia.startsWith("/") ? rawMedia : `/tmp/${rawMedia}`
211
- mediaUrl = `file://${absPath}`
212
- }
213
- }
214
- const textMessage = args.message ?? args.caption
215
- if (!textMessage && !mediaUrl) {
216
- return { content: [{ type: "text", text: "Error: provide message or filePath/path/media" }], isError: true }
217
- }
218
- const result = await sendViaGateway(args.to, textMessage, mediaUrl)
219
- if (result.ok) {
220
- state.messageSent = true
221
- return { content: [{ type: "text", text: `Sent to ${args.to}` }] }
222
- }
223
- return {
224
- content: [{ type: "text", text: `Failed: ${result.error}` }],
225
- isError: true
226
- }
227
- } catch (error) {
228
- return {
229
- content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
230
- isError: true
231
- }
232
- }
233
- }
234
- )
235
- ]
236
- })
237
- }
1
+ // mcpTools.ts removed. The proxy no longer provides any MCP tools.
2
+ // Tool definitions come from the API caller (standard Anthropic tool loop).
3
+ export {}
@@ -11,8 +11,6 @@ import { tmpdir } from "os"
11
11
  import { randomBytes } from "crypto"
12
12
  import { fileURLToPath } from "url"
13
13
  import { join, dirname } from "path"
14
- import { createMcpServer, type McpServerState } from "../mcpTools"
15
-
16
14
  // Base62 ID generator — matches Anthropic's real ID format (e.g. msg_01XFDUDYJgAACzvnptvVoYEL)
17
15
  const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
18
16
  function generateId(prefix: string, length = 24): string {
@@ -29,12 +27,6 @@ const PROXY_VERSION: string = (() => {
29
27
  } catch { return "unknown" }
30
28
  })()
31
29
 
32
- // Only block tools that add noise — everything else (Read, Write, Edit, Bash,
33
- // Glob, Grep, WebFetch, WebSearch) uses Claude Code's robust built-in implementations.
34
- const BLOCKED_BUILTIN_TOOLS = ["TodoWrite", "NotebookEdit"]
35
-
36
- const MCP_SERVER_NAME = "opencode"
37
-
38
30
  function resolveClaudeExecutable(): string {
39
31
  try {
40
32
  const sdkPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk"))
@@ -158,26 +150,9 @@ function cleanupTempFiles(tempFiles: string[]) {
158
150
  }
159
151
 
160
152
  // ── Client tool-use support ──────────────────────────────────────────────────
161
- // When the caller provides tool definitions (e.g. Claude Code, LangChain, etc.)
162
- // we switch to single-turn mode: inject tool defs into the system prompt, run
163
- // one LLM turn, parse <tool_use> blocks from the output, and return them as
164
- // proper Anthropic tool_use content blocks.
165
- //
166
- // We stay in agent mode (multi-turn, built-in + MCP tools) when:
167
- // - No tools in the request, OR
168
- // - The request has markers indicating the agent manages its own tool loop
169
-
170
- function isClientToolMode(body: any): boolean {
171
- if (!body.tools?.length) return false
172
- if (body.messages?.some((m: any) =>
173
- Array.isArray(m.content) && m.content.some((b: any) => b.type === "tool_result")
174
- )) return true
175
- const sysText = Array.isArray(body.system)
176
- ? body.system.filter((b: any) => b.type === "text").map((b: any) => b.text).join(" ")
177
- : String(body.system ?? "")
178
- if (sysText.includes("conversation_label") || sysText.includes("chat id:")) return false
179
- return true
180
- }
153
+ // The proxy never uses Claude Code's built-in tools. All tools come from the
154
+ // API caller. Tool definitions are injected into the system prompt; <tool_use>
155
+ // XML blocks in the output are parsed back into Anthropic tool_use content.
181
156
 
182
157
  function buildClientToolsPrompt(tools: any[]): string {
183
158
  const defs = tools.map((t: any) => {
@@ -237,46 +212,32 @@ function roughTokens(text: string): number {
237
212
  }
238
213
 
239
214
  // ── Query options builder ────────────────────────────────────────────────────
215
+ // Always runs with all built-in tools disabled (tools: []) and maxTurns: 1.
216
+ // The proxy is a pure API translation layer — tool definitions come from the
217
+ // caller and are injected into the system prompt. No MCP servers, no agent loop.
240
218
 
241
219
  function buildQueryOptions(
242
220
  model: "sonnet" | "opus" | "haiku",
243
221
  opts: {
244
222
  partial?: boolean
245
- clientToolMode?: boolean
246
223
  systemPrompt?: string
247
- mcpState?: McpServerState
248
224
  abortController?: AbortController
249
225
  thinking?: { type: "adaptive" } | { type: "enabled"; budgetTokens?: number } | { type: "disabled" }
250
226
  } = {}
251
227
  ) {
252
- const base = {
228
+ return {
253
229
  model,
254
230
  pathToClaudeCodeExecutable: claudeExecutable,
255
231
  permissionMode: "bypassPermissions" as const,
256
232
  allowDangerouslySkipPermissions: true,
257
233
  persistSession: false,
258
234
  settingSources: [],
235
+ tools: [] as string[],
236
+ maxTurns: 1,
259
237
  ...(opts.partial ? { includePartialMessages: true } : {}),
260
238
  ...(opts.abortController ? { abortController: opts.abortController } : {}),
261
239
  ...(opts.thinking ? { thinking: opts.thinking } : {}),
262
240
  ...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}),
263
- disallowedTools: [...BLOCKED_BUILTIN_TOOLS],
264
- }
265
-
266
- if (opts.clientToolMode) {
267
- // Disable ALL built-in tools — the caller manages its own tool loop.
268
- // Tool definitions are already baked into the systemPrompt.
269
- return {
270
- ...base,
271
- maxTurns: 1,
272
- tools: [] as string[],
273
- }
274
- }
275
-
276
- return {
277
- ...base,
278
- maxTurns: 200,
279
- mcpServers: { [MCP_SERVER_NAME]: createMcpServer(opts.mcpState) }
280
241
  }
281
242
  }
282
243
 
@@ -396,8 +357,7 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
396
357
 
397
358
  const model = mapModelToClaudeModel(body.model || "sonnet")
398
359
  const stream = body.stream ?? false
399
- const clientToolMode = isClientToolMode(body)
400
- const mcpState: McpServerState = { messageSent: false }
360
+ const hasTools = body.tools?.length > 0
401
361
  const abortController = new AbortController()
402
362
  const timeout = setTimeout(() => abortController.abort(), finalConfig.requestTimeoutMs)
403
363
 
@@ -423,34 +383,19 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
423
383
  }
424
384
 
425
385
  // Build the prompt from messages. The SDK's query() takes a single prompt
426
- // string. To avoid the model continuing a "Human:/Assistant:" format in its
427
- // response, we use neutral delimiters and only the last user message as the
428
- // primary prompt when there's minimal context.
386
+ // string, so multi-turn conversations are serialized with XML-delimited
387
+ // turns. Prior turns go into the system prompt as context, the last user
388
+ // message becomes the prompt.
429
389
  const messages = body.messages as Array<{ role: string; content: string | Array<any> }>
430
390
 
431
391
  let prompt: string
432
392
  let systemPrompt: string | undefined
393
+ const toolsSection = hasTools ? buildClientToolsPrompt(body.tools) : ""
433
394
 
434
- if (clientToolMode) {
435
- // Client tool mode: serialize all messages as context, inject tools
436
- const conversationParts = messages
437
- .map((m) => {
438
- const tag = m.role === "assistant" ? "assistant_message" : "user_message"
439
- return `<${tag}>\n${serializeContent(m.content, tempFiles)}\n</${tag}>`
440
- })
441
- .join("\n\n")
442
- const toolsSection = buildClientToolsPrompt(body.tools)
443
- systemPrompt = systemContext
444
- ? `${systemContext}${toolsSection}`
445
- : toolsSection
446
- prompt = conversationParts
447
- } else if (messages.length === 1) {
448
- // Single message: pass directly as prompt (most common case)
449
- systemPrompt = systemContext || undefined
395
+ if (messages.length === 1) {
396
+ systemPrompt = ((systemContext || "") + toolsSection).trim() || undefined
450
397
  prompt = serializeContent(messages[0]!.content, tempFiles)
451
398
  } else {
452
- // Multi-turn: build conversation context with XML-delimited turns.
453
- // Put prior turns in system prompt as context, last user message as prompt.
454
399
  const lastMsg = messages[messages.length - 1]!
455
400
  const priorMsgs = messages.slice(0, -1)
456
401
 
@@ -465,11 +410,11 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
465
410
  const contextSection = contextParts
466
411
  ? `\n\n<conversation_history>\n${contextParts}\n</conversation_history>`
467
412
  : ""
468
- systemPrompt = (baseSystem + contextSection).trim() || undefined
413
+ systemPrompt = (baseSystem + contextSection + toolsSection).trim() || undefined
469
414
  prompt = serializeContent(lastMsg.content, tempFiles)
470
415
  }
471
416
 
472
- claudeLog("proxy.request", { reqId, model, stream, msgs: body.messages?.length, clientToolMode, ...(thinking ? { thinking: thinking.type } : {}), queueActive: requestQueue.activeCount, queueWaiting: requestQueue.waitingCount })
417
+ claudeLog("proxy.request", { reqId, model, stream, msgs: body.messages?.length, hasTools, ...(thinking ? { thinking: thinking.type } : {}), queueActive: requestQueue.activeCount, queueWaiting: requestQueue.waitingCount })
473
418
 
474
419
  // Acquire a slot in the concurrency queue — all code after this MUST
475
420
  // release via the try/finally blocks in both streaming and non-streaming paths.
@@ -478,18 +423,12 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
478
423
  // ── Non-streaming ──────────────────────────────────────────────────────
479
424
  if (!stream) {
480
425
  let fullText = ""
481
- let lastCleanText = ""
482
426
  try {
483
- for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: false, clientToolMode, systemPrompt, mcpState, abortController, thinking }) })) {
427
+ for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: false, systemPrompt, abortController, thinking }) })) {
484
428
  if (message.type === "assistant") {
485
429
  let turnText = ""
486
- let hasToolUse = false
487
430
  for (const block of message.message.content) {
488
431
  if (block.type === "text") turnText += block.text
489
- if (block.type === "tool_use") hasToolUse = true
490
- }
491
- if (!hasToolUse && turnText) {
492
- lastCleanText = turnText
493
432
  }
494
433
  fullText = turnText
495
434
  }
@@ -499,10 +438,8 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
499
438
  cleanupTempFiles(tempFiles)
500
439
  requestQueue.release()
501
440
  }
502
- // In agent mode, prefer the last turn that had no tool_use
503
- if (!clientToolMode && lastCleanText) fullText = lastCleanText
504
441
 
505
- if (clientToolMode) {
442
+ if (hasTools) {
506
443
  const { toolCalls, textBefore } = parseToolUse(fullText)
507
444
  const content: any[] = []
508
445
  if (textBefore) content.push({ type: "text", text: textBefore })
@@ -518,11 +455,8 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
518
455
  })
519
456
  }
520
457
 
521
- // If the MCP message tool delivered anything, suppress the proxy's
522
- // own text response so the client doesn't double-deliver.
523
- if (mcpState.messageSent) fullText = "NO_REPLY"
524
458
  if (!fullText || !fullText.trim()) fullText = "..."
525
- claudeLog("proxy.response", { reqId, len: fullText.length, messageSent: mcpState.messageSent })
459
+ claudeLog("proxy.response", { reqId, len: fullText.length })
526
460
  return c.json({
527
461
  id: generateId("msg_"),
528
462
  type: "message", role: "assistant",
@@ -564,11 +498,11 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
564
498
  }
565
499
  })
566
500
 
567
- // ── Client tool mode: buffer → emit blocks at end ─────────────
568
- if (clientToolMode) {
501
+ if (hasTools) {
502
+ // ── With tools: buffer output, parse tool_use blocks at end ──
569
503
  let fullText = ""
570
504
  try {
571
- for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true, clientToolMode: true, systemPrompt, abortController, thinking }) })) {
505
+ for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true, systemPrompt, abortController, thinking }) })) {
572
506
  if (message.type === "stream_event") {
573
507
  const ev = message.event as any
574
508
  if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta") {
@@ -613,17 +547,13 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
613
547
  return
614
548
  }
615
549
 
616
- // ── Agent mode: real-time streaming ─────────────────────────
617
- // Forward text deltas to the client as they arrive from the SDK.
618
- // For single-turn (most chat requests), this gives true token-by-
619
- // token streaming. For multi-turn (agent tool use), the client
620
- // sees all turns' text streamed in real-time.
550
+ // ── No tools: stream text deltas directly ─────────────────────
621
551
  sse("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })
622
552
 
623
553
  let fullText = ""
624
554
  let hasStreamed = false
625
555
  try {
626
- for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true, systemPrompt, mcpState, abortController, thinking }) })) {
556
+ for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true, systemPrompt, abortController, thinking }) })) {
627
557
  if (message.type === "stream_event") {
628
558
  const ev = message.event as any
629
559
  if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta") {
@@ -643,11 +573,9 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
643
573
  releaseQueue()
644
574
  }
645
575
 
646
- claudeLog("proxy.stream.done", { reqId, len: fullText.length, messageSent: mcpState.messageSent })
576
+ claudeLog("proxy.stream.done", { reqId, len: fullText.length })
647
577
 
648
- if (mcpState.messageSent) {
649
- sse("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "\nNO_REPLY" } })
650
- } else if (!hasStreamed) {
578
+ if (!hasStreamed) {
651
579
  sse("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "..." } })
652
580
  }
653
581