@loomwork/llm 0.0.0-stage → 0.1.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.
Files changed (79) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +80 -2
  4. package/dist/assembleStream.d.ts +95 -0
  5. package/dist/assembleStream.d.ts.map +1 -0
  6. package/dist/assembleStream.js +180 -0
  7. package/dist/assembleStream.js.map +1 -0
  8. package/dist/capabilities.d.ts +43 -0
  9. package/dist/capabilities.d.ts.map +1 -0
  10. package/dist/capabilities.js +77 -0
  11. package/dist/capabilities.js.map +1 -0
  12. package/dist/createLlmEffect.d.ts +110 -0
  13. package/dist/createLlmEffect.d.ts.map +1 -0
  14. package/dist/createLlmEffect.js +177 -0
  15. package/dist/createLlmEffect.js.map +1 -0
  16. package/dist/errors.d.ts +96 -0
  17. package/dist/errors.d.ts.map +1 -0
  18. package/dist/errors.js +280 -0
  19. package/dist/errors.js.map +1 -0
  20. package/dist/index.d.ts +24 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +71 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/parts.d.ts +220 -0
  25. package/dist/parts.d.ts.map +1 -0
  26. package/dist/parts.js +231 -0
  27. package/dist/parts.js.map +1 -0
  28. package/dist/providers/AnthropicProvider.d.ts +53 -0
  29. package/dist/providers/AnthropicProvider.d.ts.map +1 -0
  30. package/dist/providers/AnthropicProvider.js +443 -0
  31. package/dist/providers/AnthropicProvider.js.map +1 -0
  32. package/dist/providers/GeminiProvider.d.ts +66 -0
  33. package/dist/providers/GeminiProvider.d.ts.map +1 -0
  34. package/dist/providers/GeminiProvider.js +281 -0
  35. package/dist/providers/GeminiProvider.js.map +1 -0
  36. package/dist/providers/OpenAiProvider.d.ts +56 -0
  37. package/dist/providers/OpenAiProvider.d.ts.map +1 -0
  38. package/dist/providers/OpenAiProvider.js +402 -0
  39. package/dist/providers/OpenAiProvider.js.map +1 -0
  40. package/dist/providers/extraHeaders.d.ts +7 -0
  41. package/dist/providers/extraHeaders.d.ts.map +1 -0
  42. package/dist/providers/extraHeaders.js +40 -0
  43. package/dist/providers/extraHeaders.js.map +1 -0
  44. package/dist/providers/systemPrompt.d.ts +10 -0
  45. package/dist/providers/systemPrompt.d.ts.map +1 -0
  46. package/dist/providers/systemPrompt.js +22 -0
  47. package/dist/providers/systemPrompt.js.map +1 -0
  48. package/dist/reasoning.d.ts +154 -0
  49. package/dist/reasoning.d.ts.map +1 -0
  50. package/dist/reasoning.js +256 -0
  51. package/dist/reasoning.js.map +1 -0
  52. package/dist/resolveContent.d.ts +74 -0
  53. package/dist/resolveContent.d.ts.map +1 -0
  54. package/dist/resolveContent.js +335 -0
  55. package/dist/resolveContent.js.map +1 -0
  56. package/dist/types.d.ts +152 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +3 -0
  59. package/dist/types.js.map +1 -0
  60. package/dist/untrusted.d.ts +91 -0
  61. package/dist/untrusted.d.ts.map +1 -0
  62. package/dist/untrusted.js +385 -0
  63. package/dist/untrusted.js.map +1 -0
  64. package/package.json +54 -3
  65. package/src/assembleStream.ts +219 -0
  66. package/src/capabilities.ts +96 -0
  67. package/src/createLlmEffect.ts +287 -0
  68. package/src/errors.ts +269 -0
  69. package/src/index.ts +137 -0
  70. package/src/parts.ts +409 -0
  71. package/src/providers/AnthropicProvider.ts +550 -0
  72. package/src/providers/GeminiProvider.ts +380 -0
  73. package/src/providers/OpenAiProvider.ts +511 -0
  74. package/src/providers/extraHeaders.ts +41 -0
  75. package/src/providers/systemPrompt.ts +19 -0
  76. package/src/reasoning.ts +386 -0
  77. package/src/resolveContent.ts +425 -0
  78. package/src/types.ts +175 -0
  79. package/src/untrusted.ts +440 -0
@@ -0,0 +1,550 @@
1
+ import {
2
+ type LlmCapabilities,
3
+ assertRequestSupported,
4
+ capabilitiesForModel,
5
+ } from "../capabilities";
6
+ import {
7
+ DEFAULT_MAX_LINE_BYTES,
8
+ DEFAULT_MAX_RESPONSE_BYTES,
9
+ DEFAULT_MAX_STREAM_BYTES,
10
+ LlmHttpError,
11
+ LlmStreamError,
12
+ StreamLimiter,
13
+ drainBody,
14
+ readBoundedJson,
15
+ resolveLimit,
16
+ } from "../errors";
17
+ import {
18
+ type LlmContentPart,
19
+ type LlmLayoutEntry,
20
+ type LlmReasoning,
21
+ type LlmReasoningPart,
22
+ type LlmResultPart,
23
+ layoutOf,
24
+ } from "../parts";
25
+ import { inlineData } from "../resolveContent";
26
+ import type {
27
+ LlmMessage,
28
+ LlmProvider,
29
+ LlmRequest,
30
+ LlmResponse,
31
+ LlmStreamChunk,
32
+ LlmToolCall,
33
+ } from "../types";
34
+ import { fenceUntrustedMedia } from "../untrusted";
35
+ import { validateExtraHeaders } from "./extraHeaders";
36
+ import { resolveSystem } from "./systemPrompt";
37
+
38
+ /**
39
+ * Anthropic's `stop_reason` → the SPI's. ONE implementation, used by both
40
+ * `complete` and `stream`: a duplicated mapping is exactly where two paths to one
41
+ * outcome start disagreeing, which is what the conformance parity check exists to
42
+ * catch and what this makes structurally impossible.
43
+ */
44
+ function normalizeStopReason(raw: unknown): LlmResponse["stopReason"] {
45
+ return raw === "tool_use" ? "tool_use" : raw === "max_tokens" ? "max_tokens" : "end_turn";
46
+ }
47
+
48
+ export interface AnthropicProviderConfig {
49
+ /**
50
+ * Extra request headers sent on every request (for example `anthropic-workspace-id`). Header
51
+ * names and values must be strings without CR/LF; a header the provider sets itself (auth,
52
+ * protocol, content type) cannot be overridden and throws at construction. Unset, the request
53
+ * is unchanged.
54
+ */
55
+ headers?: Readonly<Record<string, string>>;
56
+ apiKey?: string;
57
+ model?: string;
58
+ baseUrl?: string;
59
+ /**
60
+ * Cap on a complete response body, in bytes after decompression (default 8 MiB). Over it the
61
+ * body is cancelled and `LlmResponseLimitError` (provider and reason only) is thrown.
62
+ */
63
+ maxResponseBytes?: number;
64
+ /** Cap on the total bytes read from a stream (default 128 MiB); same error. */
65
+ maxStreamBytes?: number;
66
+ /** Cap on one line of a stream, in UTF-8 bytes (default 1 MiB); same error. */
67
+ maxLineBytes?: number;
68
+ /** Override the declared modalities (e.g. a model without vision). */
69
+ capabilities?: LlmCapabilities;
70
+ /**
71
+ * Extended thinking, passed through as the request's `thinking` (ADR 0133). `display: "summarized"`
72
+ * asks for a readable summary; the default is the API's (omitted on current models, where only the
73
+ * signature is returned). Unset, no `thinking` is sent.
74
+ */
75
+ thinking?: { type: "adaptive"; display?: "summarized" | "omitted" };
76
+ }
77
+
78
+ /** Messages API: images (JPEG/PNG/GIF/WebP) and PDF documents; no audio. */
79
+ const ANTHROPIC_CAPABILITIES: LlmCapabilities = {
80
+ image: ["image/jpeg", "image/png", "image/gif", "image/webp"],
81
+ file: ["application/pdf"],
82
+ reasoning: true,
83
+ };
84
+
85
+ const PROVIDER_ID = "anthropic";
86
+
87
+ /** Model families that take images and PDFs: Claude 3 and later (2.x and Instant do not). */
88
+ const ANTHROPIC_MEDIA_MODELS = [/^claude-(3|[4-9]\d*|opus|sonnet|haiku)/];
89
+ const ANTHROPIC_DEFAULT_URL = "https://api.anthropic.com";
90
+
91
+ function mapResultPart(p: LlmResultPart): Record<string, unknown> {
92
+ return mapPart(p);
93
+ }
94
+
95
+ /** SPI part → Anthropic content block. Exported for tests. */
96
+ export function mapPart(p: LlmContentPart): Record<string, unknown> {
97
+ switch (p.type) {
98
+ case "text":
99
+ return { type: "text", text: p.text };
100
+ case "image":
101
+ return {
102
+ type: "image",
103
+ source: { type: "base64", media_type: p.mediaType, data: inlineData(p) },
104
+ };
105
+ case "file":
106
+ return {
107
+ type: "document",
108
+ source: { type: "base64", media_type: p.mediaType, data: inlineData(p) },
109
+ ...(p.name !== undefined && { title: p.name }),
110
+ };
111
+ case "audio":
112
+ throw new Error("Anthropic Messages does not accept audio content");
113
+ case "tool_use":
114
+ return { type: "tool_use", id: p.id, name: p.name, input: p.input };
115
+ case "reasoning":
116
+ return mapReasoningPart(p);
117
+ case "tool_result":
118
+ return {
119
+ type: "tool_result",
120
+ tool_use_id: p.toolUseId,
121
+ content:
122
+ typeof p.content === "string"
123
+ ? p.content
124
+ : fenceUntrustedMedia(p.content).map(mapResultPart),
125
+ ...(p.isError === true && { is_error: true }),
126
+ };
127
+ }
128
+ }
129
+
130
+ /**
131
+ * An assistant `reasoning` part -> the vendor block, UNCHANGED (ADR 0133 D5): a `thinking` block with
132
+ * its text (resolved by `resolveRequestContent`, "" when omitted) and signature, or a
133
+ * `redacted_thinking` block whose `data` is the recorded signature. Callers drop other providers'
134
+ * parts first ({@link mapContent}); this is only reached for ours.
135
+ */
136
+ function mapReasoningPart(p: LlmReasoningPart): Record<string, unknown> {
137
+ if (p.kind === "redacted") return { type: "redacted_thinking", data: p.signature ?? "" };
138
+ return {
139
+ type: "thinking",
140
+ thinking: p.text ?? "",
141
+ ...(p.signature !== undefined && { signature: p.signature }),
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Blocks go back in the order they were recorded (ADR 0133 D5): a signature binds the blocks before
147
+ * it, so `thinking, text, tool_use, thinking, tool_use` must not be regrouped. A part from another
148
+ * provider is omitted: its signature means nothing here.
149
+ */
150
+ function mapContent(content: LlmMessage["content"]): string | Record<string, unknown>[] {
151
+ if (typeof content === "string") return content;
152
+ const own = content.filter((p) => p.type !== "reasoning" || p.provider === PROVIDER_ID);
153
+ return fenceUntrustedMedia(own).map(mapPart);
154
+ }
155
+
156
+ /**
157
+ * ADR 0133 D2 - ONE rule, pinned by a test: a non-empty `thinking` text is `summary` (current models
158
+ * return only summaries; the raw chain of thought is never returned), an empty one is `text` with
159
+ * `text: ""` (an omitted-display block, the signature carries the reasoning), and `redacted_thinking`
160
+ * is `redacted` with its `data` as the signature. Shared by `complete` and `stream`.
161
+ */
162
+ function reasoningOf(block: {
163
+ type: string;
164
+ thinking?: unknown;
165
+ signature?: unknown;
166
+ data?: unknown;
167
+ }): LlmReasoning | undefined {
168
+ if (block.type === "redacted_thinking") {
169
+ return {
170
+ kind: "redacted",
171
+ ...(typeof block.data === "string" && { signature: block.data }),
172
+ provider: PROVIDER_ID,
173
+ };
174
+ }
175
+ if (block.type !== "thinking") return undefined;
176
+ const text = typeof block.thinking === "string" ? block.thinking : "";
177
+ return {
178
+ kind: text === "" ? "text" : "summary",
179
+ text,
180
+ ...(typeof block.signature === "string" && { signature: block.signature }),
181
+ provider: PROVIDER_ID,
182
+ };
183
+ }
184
+
185
+ interface AnthropicTextBlock {
186
+ type: "text";
187
+ text: string;
188
+ }
189
+ interface AnthropicToolUseBlock {
190
+ type: "tool_use";
191
+ id: string;
192
+ name: string;
193
+ input: unknown;
194
+ }
195
+ interface AnthropicThinkingBlock {
196
+ type: "thinking" | "redacted_thinking";
197
+ thinking?: string;
198
+ signature?: string;
199
+ data?: string;
200
+ }
201
+ type AnthropicContentBlock = AnthropicTextBlock | AnthropicToolUseBlock | AnthropicThinkingBlock;
202
+
203
+ interface AnthropicMessageResponse {
204
+ content: AnthropicContentBlock[];
205
+ stop_reason: string | null;
206
+ usage: { input_tokens: number; output_tokens: number };
207
+ }
208
+
209
+ export class AnthropicProvider implements LlmProvider {
210
+ private readonly apiKey: string;
211
+ private readonly model: string;
212
+ private readonly baseUrl: string;
213
+ private readonly maxResponseBytes: number;
214
+ private readonly maxStreamBytes: number;
215
+ private readonly maxLineBytes: number;
216
+ private readonly thinking: AnthropicProviderConfig["thinking"];
217
+ private readonly extraHeaders: Readonly<Record<string, string>>;
218
+ readonly capabilities: LlmCapabilities;
219
+
220
+ constructor(config: AnthropicProviderConfig = {}) {
221
+ this.extraHeaders = validateExtraHeaders(
222
+ config.headers,
223
+ ["x-api-key", "anthropic-version", "content-type", "authorization"],
224
+ "AnthropicProvider"
225
+ );
226
+ this.thinking = config.thinking;
227
+ const model = config.model ?? "claude-opus-4-8";
228
+ this.capabilities =
229
+ config.capabilities ??
230
+ (config.baseUrl !== undefined && config.baseUrl !== ANTHROPIC_DEFAULT_URL
231
+ ? {}
232
+ : capabilitiesForModel(model, ANTHROPIC_CAPABILITIES, ANTHROPIC_MEDIA_MODELS));
233
+ this.apiKey =
234
+ config.apiKey ??
235
+ (typeof process !== "undefined" ? (process.env.ANTHROPIC_API_KEY ?? "") : "");
236
+ this.model = config.model ?? "claude-opus-4-8";
237
+ this.baseUrl = config.baseUrl ?? ANTHROPIC_DEFAULT_URL;
238
+ this.maxResponseBytes = resolveLimit(
239
+ config.maxResponseBytes,
240
+ DEFAULT_MAX_RESPONSE_BYTES,
241
+ "maxResponseBytes"
242
+ );
243
+ this.maxStreamBytes = resolveLimit(
244
+ config.maxStreamBytes,
245
+ DEFAULT_MAX_STREAM_BYTES,
246
+ "maxStreamBytes"
247
+ );
248
+ this.maxLineBytes = resolveLimit(config.maxLineBytes, DEFAULT_MAX_LINE_BYTES, "maxLineBytes");
249
+ }
250
+
251
+ private buildBody(request: LlmRequest): Record<string, unknown> {
252
+ const system = resolveSystem(request);
253
+
254
+ const messages = request.messages
255
+ .filter((m) => m.role !== "system")
256
+ .map((m) => ({ role: m.role, content: mapContent(m.content) }));
257
+
258
+ const body: Record<string, unknown> = {
259
+ model: this.model,
260
+ max_tokens: request.maxTokens ?? 4096,
261
+ messages,
262
+ };
263
+
264
+ if (system !== undefined) body.system = system;
265
+ if (request.temperature !== undefined) body.temperature = request.temperature;
266
+ if (this.thinking !== undefined) body.thinking = { ...this.thinking };
267
+
268
+ if (request.tools !== undefined && request.tools.length > 0) {
269
+ body.tools = request.tools.map((t) => ({
270
+ name: t.name,
271
+ description: t.description,
272
+ input_schema: { type: "object", ...t.inputSchema },
273
+ }));
274
+ }
275
+
276
+ return body;
277
+ }
278
+
279
+ async complete(request: LlmRequest): Promise<LlmResponse> {
280
+ assertRequestSupported(request, this.capabilities, "AnthropicProvider");
281
+ const body = this.buildBody(request);
282
+ const resp = await fetch(`${this.baseUrl}/v1/messages`, {
283
+ method: "POST",
284
+ headers: {
285
+ ...this.extraHeaders,
286
+ "content-type": "application/json",
287
+ "x-api-key": this.apiKey,
288
+ "anthropic-version": "2023-06-01",
289
+ },
290
+ body: JSON.stringify(body),
291
+ ...(request.signal !== undefined && { signal: request.signal }),
292
+ // Never follow a redirect (it would re-send the body and credentials to another origin): a 3xx
293
+ // comes back as a response and becomes `LlmHttpError(provider, status)` below.
294
+ redirect: "manual",
295
+ });
296
+
297
+ if (!resp.ok) {
298
+ await drainBody(resp);
299
+ throw new LlmHttpError("anthropic", resp.status);
300
+ }
301
+
302
+ const data = (await readBoundedJson(
303
+ resp,
304
+ "anthropic",
305
+ this.maxResponseBytes
306
+ )) as AnthropicMessageResponse;
307
+
308
+ const content = data.content
309
+ .filter((b): b is AnthropicTextBlock => b.type === "text")
310
+ .map((b) => b.text)
311
+ .join("");
312
+
313
+ const toolCalls = data.content
314
+ .filter((b): b is AnthropicToolUseBlock => b.type === "tool_use")
315
+ .map((b) => ({ id: b.id, name: b.name, input: b.input }));
316
+
317
+ const stopReason = normalizeStopReason(data.stop_reason);
318
+ const reasoning: LlmReasoning[] = [];
319
+ const entries: LlmLayoutEntry[] = [];
320
+ for (const b of data.content) {
321
+ const r = reasoningOf(b);
322
+ if (r !== undefined) {
323
+ reasoning.push(r);
324
+ entries.push("reasoning");
325
+ } else if (b.type === "text") entries.push({ text: (b as AnthropicTextBlock).text.length });
326
+ else if (b.type === "tool_use") entries.push({ call: (b as AnthropicToolUseBlock).id });
327
+ }
328
+ const layout = layoutOf(entries);
329
+
330
+ return {
331
+ content,
332
+ ...(toolCalls.length > 0 && { toolCalls }),
333
+ stopReason,
334
+ usage: { inputTokens: data.usage.input_tokens, outputTokens: data.usage.output_tokens },
335
+ ...(reasoning.length > 0 && { reasoning }),
336
+ ...(layout !== undefined && { layout }),
337
+ };
338
+ }
339
+
340
+ async *stream(request: LlmRequest): AsyncIterable<LlmStreamChunk> {
341
+ assertRequestSupported(request, this.capabilities, "AnthropicProvider");
342
+ const body = { ...this.buildBody(request), stream: true };
343
+
344
+ const resp = await fetch(`${this.baseUrl}/v1/messages`, {
345
+ method: "POST",
346
+ headers: {
347
+ ...this.extraHeaders,
348
+ "content-type": "application/json",
349
+ "x-api-key": this.apiKey,
350
+ "anthropic-version": "2023-06-01",
351
+ },
352
+ body: JSON.stringify(body),
353
+ ...(request.signal !== undefined && { signal: request.signal }),
354
+ // Never follow a redirect (it would re-send the body and credentials to another origin): a 3xx
355
+ // comes back as a response and becomes `LlmHttpError(provider, status)` below.
356
+ redirect: "manual",
357
+ });
358
+
359
+ if (!resp.ok) {
360
+ await drainBody(resp);
361
+ throw new LlmHttpError("anthropic", resp.status);
362
+ }
363
+
364
+ if (!resp.body) return;
365
+
366
+ const reader = resp.body.getReader();
367
+ const limiter = new StreamLimiter("anthropic", this.maxStreamBytes, this.maxLineBytes);
368
+ const decoder = new TextDecoder();
369
+ let buffer = "";
370
+
371
+ // Accumulated across the stream so the TERMINAL chunk can report the same
372
+ // outcome `complete` would have. Tool-call arguments arrive as `input_json_delta`
373
+ // fragments that are only valid JSON once concatenated, so a block is parsed at
374
+ // `content_block_stop`, not per delta.
375
+ const toolCalls: LlmToolCall[] = [];
376
+ const openBlocks = new Map<number, { id: string; name: string; argsJson: string }>();
377
+ // ADR 0133 - thinking blocks assemble from `thinking_delta` / `signature_delta`; a block is
378
+ // closed (and mapped through the same {@link reasoningOf} as `complete`) at `content_block_stop`.
379
+ const openThinking = new Map<
380
+ number,
381
+ {
382
+ type: "thinking" | "redacted_thinking";
383
+ thinking: string;
384
+ signature?: string;
385
+ data?: string;
386
+ }
387
+ >();
388
+ const reasoning: LlmReasoning[] = [];
389
+ // ADR 0133 D5 - block order, by `content_block_start`; a text block's entry grows with its deltas.
390
+ const entries: LlmLayoutEntry[] = [];
391
+ const textEntries = new Map<number, { text: number }>();
392
+ let stopReason: LlmResponse["stopReason"] | undefined;
393
+ let inputTokens: number | undefined;
394
+ let outputTokens: number | undefined;
395
+
396
+ const terminal = (): LlmStreamChunk => {
397
+ const layout = layoutOf(entries);
398
+ return {
399
+ delta: "",
400
+ done: true,
401
+ ...(toolCalls.length > 0 && { toolCalls }),
402
+ ...(stopReason !== undefined && { stopReason }),
403
+ ...(inputTokens !== undefined &&
404
+ outputTokens !== undefined && { usage: { inputTokens, outputTokens } }),
405
+ ...(reasoning.length > 0 && { reasoning }),
406
+ ...(layout !== undefined && { layout }),
407
+ };
408
+ };
409
+
410
+ try {
411
+ while (true) {
412
+ const { done, value } = await reader.read();
413
+ if (done) break;
414
+ limiter.chunk(value);
415
+ buffer += decoder.decode(value, { stream: true });
416
+
417
+ const lines = buffer.split("\n");
418
+ buffer = lines.pop() ?? "";
419
+ limiter.lines(lines, buffer);
420
+
421
+ for (const line of lines) {
422
+ if (!line.startsWith("data: ")) continue;
423
+ const json = line.slice(6).trim();
424
+ if (json === "[DONE]") {
425
+ yield terminal();
426
+ return;
427
+ }
428
+ try {
429
+ const event = JSON.parse(json) as Record<string, unknown>;
430
+ if (event.type === "content_block_start") {
431
+ const block = event.content_block as Record<string, unknown> | undefined;
432
+ if (block?.type === "text") {
433
+ const entry = { text: 0 };
434
+ textEntries.set(Number(event.index), entry);
435
+ entries.push(entry);
436
+ } else if (block?.type === "thinking" || block?.type === "redacted_thinking") {
437
+ entries.push("reasoning");
438
+ openThinking.set(Number(event.index), {
439
+ type: block.type,
440
+ thinking: typeof block.thinking === "string" ? block.thinking : "",
441
+ ...(typeof block.signature === "string" && { signature: block.signature }),
442
+ ...(typeof block.data === "string" && { data: block.data }),
443
+ });
444
+ } else if (block?.type === "tool_use") {
445
+ entries.push({ call: String(block.id ?? "") });
446
+ openBlocks.set(Number(event.index), {
447
+ id: String(block.id ?? ""),
448
+ name: String(block.name ?? ""),
449
+ argsJson: "",
450
+ });
451
+ yield {
452
+ delta: "",
453
+ done: false,
454
+ toolDelta: {
455
+ id: String(block.id ?? ""),
456
+ name: String(block.name ?? ""),
457
+ inputDelta: "",
458
+ },
459
+ };
460
+ }
461
+ } else if (event.type === "content_block_delta") {
462
+ const delta = event.delta as Record<string, unknown> | undefined;
463
+ if (delta?.type === "text_delta") {
464
+ const text = String(delta.text ?? "");
465
+ let entry = textEntries.get(Number(event.index));
466
+ if (entry === undefined) {
467
+ entry = { text: 0 };
468
+ textEntries.set(Number(event.index), entry);
469
+ entries.push(entry);
470
+ }
471
+ entry.text += text.length;
472
+ yield { delta: text, done: false };
473
+ } else if (delta?.type === "thinking_delta") {
474
+ const open = openThinking.get(Number(event.index));
475
+ const fragment = String(delta.thinking ?? "");
476
+ if (open !== undefined) open.thinking += fragment;
477
+ if (fragment !== "") yield { delta: "", done: false, reasoningDelta: fragment };
478
+ } else if (delta?.type === "signature_delta") {
479
+ const open = openThinking.get(Number(event.index));
480
+ if (open !== undefined)
481
+ open.signature = (open.signature ?? "") + String(delta.signature ?? "");
482
+ } else if (delta?.type === "input_json_delta") {
483
+ const open = openBlocks.get(Number(event.index));
484
+ if (open !== undefined) {
485
+ const fragment = String(delta.partial_json ?? "");
486
+ open.argsJson += fragment;
487
+ yield {
488
+ delta: "",
489
+ done: false,
490
+ toolDelta: { id: open.id, inputDelta: fragment },
491
+ };
492
+ }
493
+ }
494
+ } else if (event.type === "content_block_stop") {
495
+ const thinking = openThinking.get(Number(event.index));
496
+ if (thinking !== undefined) {
497
+ openThinking.delete(Number(event.index));
498
+ const r = reasoningOf(thinking);
499
+ if (r !== undefined) reasoning.push(r);
500
+ }
501
+ const open = openBlocks.get(Number(event.index));
502
+ if (open !== undefined) {
503
+ openBlocks.delete(Number(event.index));
504
+ // An empty argument object streams as no deltas at all, which is `{}`,
505
+ // not a parse error.
506
+ let input: unknown = {};
507
+ try {
508
+ input = open.argsJson === "" ? {} : JSON.parse(open.argsJson);
509
+ } catch {
510
+ // Malformed arguments are reported as-is rather than dropped: the
511
+ // agent loop must be able to see WHY a call could not be made.
512
+ input = { __unparsedArguments: open.argsJson };
513
+ }
514
+ toolCalls.push({ id: open.id, name: open.name, input });
515
+ }
516
+ } else if (event.type === "message_delta") {
517
+ // Carries the stop reason and the OUTPUT token count (input tokens came
518
+ // with message_start).
519
+ const delta = event.delta as Record<string, unknown> | undefined;
520
+ stopReason = normalizeStopReason(delta?.stop_reason);
521
+ const usage = event.usage as Record<string, unknown> | undefined;
522
+ if (usage?.output_tokens !== undefined) outputTokens = Number(usage.output_tokens);
523
+ } else if (event.type === "message_start") {
524
+ const message = event.message as Record<string, unknown> | undefined;
525
+ const usage = message?.usage as Record<string, unknown> | undefined;
526
+ if (usage?.input_tokens !== undefined) inputTokens = Number(usage.input_tokens);
527
+ if (usage?.output_tokens !== undefined) outputTokens = Number(usage.output_tokens);
528
+ } else if (event.type === "message_stop") {
529
+ yield terminal();
530
+ return;
531
+ } else if (event.type === "error") {
532
+ // The message text can echo the prompt: only the error type is kept.
533
+ const err = event.error as Record<string, unknown> | undefined;
534
+ throw new LlmStreamError("anthropic", err?.type);
535
+ }
536
+ } catch (e) {
537
+ if (e instanceof LlmStreamError) throw e;
538
+ // skip malformed SSE lines
539
+ }
540
+ }
541
+ }
542
+ } finally {
543
+ try {
544
+ await reader.cancel();
545
+ } catch {
546
+ // already closed or released: nothing to free
547
+ }
548
+ }
549
+ }
550
+ }