@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +80 -2
- package/dist/assembleStream.d.ts +95 -0
- package/dist/assembleStream.d.ts.map +1 -0
- package/dist/assembleStream.js +180 -0
- package/dist/assembleStream.js.map +1 -0
- package/dist/capabilities.d.ts +43 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +77 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/createLlmEffect.d.ts +110 -0
- package/dist/createLlmEffect.d.ts.map +1 -0
- package/dist/createLlmEffect.js +177 -0
- package/dist/createLlmEffect.js.map +1 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +280 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -0
- package/dist/parts.d.ts +220 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +231 -0
- package/dist/parts.js.map +1 -0
- package/dist/providers/AnthropicProvider.d.ts +53 -0
- package/dist/providers/AnthropicProvider.d.ts.map +1 -0
- package/dist/providers/AnthropicProvider.js +443 -0
- package/dist/providers/AnthropicProvider.js.map +1 -0
- package/dist/providers/GeminiProvider.d.ts +66 -0
- package/dist/providers/GeminiProvider.d.ts.map +1 -0
- package/dist/providers/GeminiProvider.js +281 -0
- package/dist/providers/GeminiProvider.js.map +1 -0
- package/dist/providers/OpenAiProvider.d.ts +56 -0
- package/dist/providers/OpenAiProvider.d.ts.map +1 -0
- package/dist/providers/OpenAiProvider.js +402 -0
- package/dist/providers/OpenAiProvider.js.map +1 -0
- package/dist/providers/extraHeaders.d.ts +7 -0
- package/dist/providers/extraHeaders.d.ts.map +1 -0
- package/dist/providers/extraHeaders.js +40 -0
- package/dist/providers/extraHeaders.js.map +1 -0
- package/dist/providers/systemPrompt.d.ts +10 -0
- package/dist/providers/systemPrompt.d.ts.map +1 -0
- package/dist/providers/systemPrompt.js +22 -0
- package/dist/providers/systemPrompt.js.map +1 -0
- package/dist/reasoning.d.ts +154 -0
- package/dist/reasoning.d.ts.map +1 -0
- package/dist/reasoning.js +256 -0
- package/dist/reasoning.js.map +1 -0
- package/dist/resolveContent.d.ts +74 -0
- package/dist/resolveContent.d.ts.map +1 -0
- package/dist/resolveContent.js +335 -0
- package/dist/resolveContent.js.map +1 -0
- package/dist/types.d.ts +152 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/untrusted.d.ts +91 -0
- package/dist/untrusted.d.ts.map +1 -0
- package/dist/untrusted.js +385 -0
- package/dist/untrusted.js.map +1 -0
- package/package.json +54 -3
- package/src/assembleStream.ts +219 -0
- package/src/capabilities.ts +96 -0
- package/src/createLlmEffect.ts +287 -0
- package/src/errors.ts +269 -0
- package/src/index.ts +137 -0
- package/src/parts.ts +409 -0
- package/src/providers/AnthropicProvider.ts +550 -0
- package/src/providers/GeminiProvider.ts +380 -0
- package/src/providers/OpenAiProvider.ts +511 -0
- package/src/providers/extraHeaders.ts +41 -0
- package/src/providers/systemPrompt.ts +19 -0
- package/src/reasoning.ts +386 -0
- package/src/resolveContent.ts +425 -0
- package/src/types.ts +175 -0
- 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
|
+
}
|