@procedures/llm 0.4.0 → 0.6.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 (67) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +114 -9
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/anthropic.d.ts +64 -5
  5. package/dist/anthropic.d.ts.map +1 -1
  6. package/dist/anthropic.js +109 -50
  7. package/dist/anthropic.js.map +1 -1
  8. package/dist/errors.d.ts +15 -8
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/errors.js +4 -1
  11. package/dist/errors.js.map +1 -1
  12. package/dist/events.d.ts +21 -0
  13. package/dist/events.d.ts.map +1 -0
  14. package/dist/events.js +218 -0
  15. package/dist/events.js.map +1 -0
  16. package/dist/index.d.ts +4 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/module.d.ts +8 -2
  21. package/dist/module.d.ts.map +1 -1
  22. package/dist/module.js +127 -12
  23. package/dist/module.js.map +1 -1
  24. package/dist/procedures/llm/complete.d.ts +9 -3
  25. package/dist/procedures/llm/complete.d.ts.map +1 -1
  26. package/dist/procedures/llm/complete.js +50 -10
  27. package/dist/procedures/llm/complete.js.map +1 -1
  28. package/dist/procedures/llm/index.d.ts +1 -0
  29. package/dist/procedures/llm/index.d.ts.map +1 -1
  30. package/dist/procedures/llm/index.js +1 -0
  31. package/dist/procedures/llm/index.js.map +1 -1
  32. package/dist/procedures/llm/stream.d.ts +14 -0
  33. package/dist/procedures/llm/stream.d.ts.map +1 -0
  34. package/dist/procedures/llm/stream.js +20 -0
  35. package/dist/procedures/llm/stream.js.map +1 -0
  36. package/dist/procedures/llm/structured.d.ts +1 -1
  37. package/dist/procedures/llm/structured.d.ts.map +1 -1
  38. package/dist/procedures/llm/structured.js +6 -4
  39. package/dist/procedures/llm/structured.js.map +1 -1
  40. package/dist/request.d.ts +15 -4
  41. package/dist/request.d.ts.map +1 -1
  42. package/dist/request.js +47 -2
  43. package/dist/request.js.map +1 -1
  44. package/dist/retry.d.ts +45 -0
  45. package/dist/retry.d.ts.map +1 -0
  46. package/dist/retry.js +106 -0
  47. package/dist/retry.js.map +1 -0
  48. package/dist/tools.d.ts +50 -0
  49. package/dist/tools.d.ts.map +1 -0
  50. package/dist/tools.js +162 -0
  51. package/dist/tools.js.map +1 -0
  52. package/dist/types.d.ts +138 -4
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +9 -5
  55. package/src/anthropic.ts +149 -49
  56. package/src/errors.ts +17 -5
  57. package/src/events.ts +218 -0
  58. package/src/index.ts +5 -2
  59. package/src/module.ts +143 -12
  60. package/src/procedures/llm/complete.ts +53 -12
  61. package/src/procedures/llm/index.ts +1 -0
  62. package/src/procedures/llm/stream.ts +23 -0
  63. package/src/procedures/llm/structured.ts +6 -4
  64. package/src/request.ts +47 -4
  65. package/src/retry.ts +110 -0
  66. package/src/tools.ts +178 -0
  67. package/src/types.ts +148 -4
package/src/anthropic.ts CHANGED
@@ -2,16 +2,19 @@
2
2
  * The Anthropic Messages API over `fetch` (roadmap R46).
3
3
  *
4
4
  * The procedures send each request with the `fetch` of the module settings, not through
5
- * `http.request`: the API key goes in a header, and the input of a nested call is visible to the
5
+ * `http.request`. The API key goes in a header. The input of a nested call is visible to the
6
6
  * interceptors and to the traces of the host. The key comes from an environment variable that the
7
7
  * module names. An input cannot give a key.
8
8
  *
9
- * A call stops at the earliest of its time limit, the deadline of its capability and the abort of
10
- * the caller. A stop is an `InvocationError` with the code `TIMEOUT` or `ABORTED`.
9
+ * A request stops at the earliest of its time limit, the deadline of its capability and the abort
10
+ * of the caller. A stop is an `InvocationError` with the code `TIMEOUT` or `ABORTED`. A request
11
+ * that fails with a retryable error gets new tries (`retry.ts`).
11
12
  */
12
13
 
14
+ import type { Backoff } from "@mark1russell7/service/backoff";
13
15
  import { InvocationError, type ProcedureContext, type ProcedurePath } from "@procedures/client";
14
16
  import { LlmError } from "./errors.js";
17
+ import { DEFAULT_BACKOFF, DEFAULT_REQUEST_RETRIES, deadlineLeft, retryAfterMs, withRetries } from "./retry.js";
15
18
  import type { LlmEffort, LlmUsage } from "./types.js";
16
19
 
17
20
  /** The variables that the module reads: a name and a value, or undefined for a variable that is not set. */
@@ -28,7 +31,7 @@ export const DEFAULT_API_KEY_ENV = "ANTHROPIC_API_KEY";
28
31
  export const DEFAULT_BASE_URL = "https://api.anthropic.com";
29
32
  /** The default of `maxTokens`. */
30
33
  export const DEFAULT_MAX_TOKENS = 16_000;
31
- /** The default time limit of a call: 10 minutes. */
34
+ /** The default time limit of a request: 10 minutes. */
32
35
  export const DEFAULT_TIMEOUT_MS = 600_000;
33
36
  /** The version header of the Messages API. */
34
37
  export const ANTHROPIC_VERSION = "2023-06-01";
@@ -47,6 +50,10 @@ export interface LlmSettings {
47
50
  readonly defaultModel: string;
48
51
  /** The default of the `fallback` input. */
49
52
  readonly fallback: boolean;
53
+ /** The default number of new tries after a retryable error. */
54
+ readonly retries: number;
55
+ /** The backoff before each new try, when the API does not give `retry-after`. */
56
+ readonly backoff: Backoff;
50
57
  }
51
58
 
52
59
  /** The settings of the default module. */
@@ -57,9 +64,11 @@ export const DEFAULT_LLM_SETTINGS: LlmSettings = Object.freeze({
57
64
  baseUrl: DEFAULT_BASE_URL,
58
65
  defaultModel: DEFAULT_MODEL,
59
66
  fallback: true,
67
+ retries: DEFAULT_REQUEST_RETRIES,
68
+ backoff: DEFAULT_BACKOFF,
60
69
  });
61
70
 
62
- /** One content block of a message. The procedures read only the text blocks. */
71
+ /** One content block of a message. The procedures read the text blocks and the `tool_use` blocks. */
63
72
  export interface ContentBlock {
64
73
  readonly type: string;
65
74
  readonly text?: string;
@@ -72,6 +81,21 @@ export interface MessageParam {
72
81
  readonly content: string | readonly ContentBlock[];
73
82
  }
74
83
 
84
+ /** A tool definition as the API gets it. */
85
+ export interface ToolParam {
86
+ readonly name: string;
87
+ readonly description?: string;
88
+ readonly input_schema: Record<string, unknown>;
89
+ readonly strict?: boolean;
90
+ }
91
+
92
+ /** The `tool_choice` field as the API gets it. */
93
+ export interface ToolChoiceParam {
94
+ readonly type: "auto" | "any" | "tool" | "none";
95
+ readonly name?: string;
96
+ readonly disable_parallel_tool_use?: boolean;
97
+ }
98
+
75
99
  /** The request body of the Messages API, as the procedures send it. */
76
100
  export interface MessagesRequest {
77
101
  model: string;
@@ -81,6 +105,9 @@ export interface MessagesRequest {
81
105
  temperature?: number;
82
106
  output_config?: { effort?: LlmEffort; format?: { type: "json_schema"; schema: unknown } };
83
107
  fallbacks?: "default";
108
+ tools?: ToolParam[];
109
+ tool_choice?: ToolChoiceParam;
110
+ stream?: true;
84
111
  }
85
112
 
86
113
  /** The parts of an answer that the procedures use. */
@@ -131,6 +158,27 @@ export const NO_USAGE: LlmUsage = Object.freeze({ inputTokens: 0, outputTokens:
131
158
 
132
159
  const count = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0);
133
160
 
161
+ /** The four fields of the usage of the API and their names in `LlmUsage`. */
162
+ const USAGE_FIELDS = [
163
+ ["input_tokens", "inputTokens"],
164
+ ["output_tokens", "outputTokens"],
165
+ ["cache_read_input_tokens", "cacheReadInputTokens"],
166
+ ["cache_creation_input_tokens", "cacheCreationInputTokens"],
167
+ ] as const;
168
+
169
+ /**
170
+ * This function gives the usage of a `usage` object of the API. A field that the object does not
171
+ * have keeps its value of `base`: the usage of a `message_delta` event changes only some fields.
172
+ */
173
+ export function usageOf(raw: unknown, base: LlmUsage = NO_USAGE): LlmUsage {
174
+ const record = raw !== null && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
175
+ const usage = { ...base };
176
+ for (const [from, to] of USAGE_FIELDS) {
177
+ if (typeof record[from] === "number") usage[to] = count(record[from]);
178
+ }
179
+ return usage;
180
+ }
181
+
134
182
  /** The answer of the API, checked: an answer of another form is a `BAD_RESPONSE`. */
135
183
  function parseResponse(text: string): MessagesResponse {
136
184
  let body: unknown;
@@ -143,24 +191,27 @@ function parseResponse(text: string): MessagesResponse {
143
191
  if (record === null || typeof record !== "object" || !Array.isArray(record["content"]) || typeof record["stop_reason"] !== "string") {
144
192
  throw new LlmError("BAD_RESPONSE", "llm: the answer of the API is not a message with content and stop_reason");
145
193
  }
146
- const usage = (record["usage"] ?? {}) as Record<string, unknown>;
147
194
  const details = record["stop_details"];
148
195
  return {
149
196
  model: typeof record["model"] === "string" ? record["model"] : "",
150
197
  content: (record["content"] as unknown[]).filter((block): block is ContentBlock => block !== null && typeof block === "object" && typeof (block as ContentBlock).type === "string"),
151
198
  stopReason: record["stop_reason"],
152
199
  stopDetails: details !== null && typeof details === "object" ? (details as MessagesResponse["stopDetails"]) : undefined,
153
- usage: {
154
- inputTokens: count(usage["input_tokens"]),
155
- outputTokens: count(usage["output_tokens"]),
156
- cacheReadInputTokens: count(usage["cache_read_input_tokens"]),
157
- cacheCreationInputTokens: count(usage["cache_creation_input_tokens"]),
158
- },
200
+ usage: usageOf(record["usage"]),
159
201
  };
160
202
  }
161
203
 
162
- /** The error of an answer with an error status. The message has the type and the message of the API. */
163
- function apiError(status: number, text: string, key: string): LlmError {
204
+ /** True for a status that the same request can pass later: 408, 409, 429 and 5xx, as in the Anthropic SDKs. */
205
+ function retryableStatus(status: number): boolean {
206
+ return status === 408 || status === 409 || status === 429 || status >= 500;
207
+ }
208
+
209
+ /**
210
+ * The error of an answer with an error status. The message has the type and the message of the
211
+ * API. The header `x-should-retry` of the API overrides the rule of the status.
212
+ */
213
+ function apiError(response: Response, text: string, key: string): LlmError {
214
+ const { status } = response;
164
215
  let detail = text.slice(0, 500);
165
216
  try {
166
217
  const error = (JSON.parse(text) as { error?: { type?: unknown; message?: unknown } }).error;
@@ -170,14 +221,9 @@ function apiError(status: number, text: string, key: string): LlmError {
170
221
  }
171
222
  // The API does not repeat the key, but the message must not carry it in any case
172
223
  const safe = detail.split(key).join("[key]");
173
- const retryable = status === 408 || status === 409 || status === 429 || status >= 500;
174
- return new LlmError("API_ERROR", `llm: the API answered ${status}: ${safe}`, { status, retryable });
175
- }
176
-
177
- /** The time that is left before the deadline of the capability, or undefined. */
178
- function deadlineLeft(ctx: CallLimits["ctx"]): number | undefined {
179
- const deadline = ctx?.capability?.deadline;
180
- return typeof deadline === "number" ? deadline - Date.now() : undefined;
224
+ const should = response.headers.get("x-should-retry");
225
+ const retryable = should === "true" ? true : should === "false" ? false : retryableStatus(status);
226
+ return new LlmError("API_ERROR", `llm: the API answered ${status}: ${safe}`, { status, retryable, retryAfterMs: retryAfterMs(response.headers) });
181
227
  }
182
228
 
183
229
  /** The body of an answer as text, with a size limit. */
@@ -190,10 +236,25 @@ async function readText(response: Response): Promise<string> {
190
236
  }
191
237
 
192
238
  /**
193
- * This function sends one request to the Messages API and gives the checked answer. It throws a
194
- * `REFUSAL` error when a safety classifier declined the request.
239
+ * The scope of one request: the key, the signal of the `fetch` and the stop. The signal aborts at
240
+ * the time limit, at the deadline and at the abort of the caller.
195
241
  */
196
- export async function sendMessages(request: MessagesRequest, settings: LlmSettings, limits: CallLimits): Promise<MessagesResponse> {
242
+ export interface RequestScope {
243
+ readonly key: string;
244
+ readonly signal: AbortSignal;
245
+ /** The reason of a stop, or undefined while the request runs. */
246
+ readonly stopped: () => "timeout" | "caller" | undefined;
247
+ /** This function gives the error of a stop for an error of the request, or the error unchanged. */
248
+ readonly translate: (error: unknown) => unknown;
249
+ /** This function stops the timer and removes the listener of the abort. */
250
+ readonly close: () => void;
251
+ }
252
+
253
+ /**
254
+ * This function opens the scope of one request. It throws `NO_API_KEY` without a key, `TIMEOUT`
255
+ * when the deadline passed and `ABORTED` when the caller aborted, before a request goes out.
256
+ */
257
+ export function openScope(settings: LlmSettings, limits: CallLimits): RequestScope {
197
258
  const key = apiKey(settings);
198
259
  const { path, ctx } = limits;
199
260
  const left = deadlineLeft(ctx);
@@ -212,44 +273,83 @@ export async function sendMessages(request: MessagesRequest, settings: LlmSettin
212
273
  controller.abort();
213
274
  };
214
275
  ctx?.signal?.addEventListener("abort", onAbort, { once: true });
276
+ return {
277
+ key,
278
+ signal: controller.signal,
279
+ stopped: () => stop,
280
+ translate: (error) => {
281
+ if (stop === "timeout") return new InvocationError("TIMEOUT", `${path.join(".")} passed its time limit of ${timeoutMs} ms`, path);
282
+ if (stop === "caller") return new InvocationError("ABORTED", `Procedure was aborted: ${path.join(".")}`, path);
283
+ return error;
284
+ },
285
+ close: () => {
286
+ clearTimeout(timer);
287
+ ctx?.signal?.removeEventListener("abort", onAbort);
288
+ },
289
+ };
290
+ }
215
291
 
292
+ /** The message of an error, without the key. */
293
+ export function safeMessage(error: unknown, key: string): string {
294
+ const message = error instanceof Error ? error.message : String(error);
295
+ return message.split(key).join("[key]");
296
+ }
297
+
298
+ /**
299
+ * This function sends a request to the Messages API in a scope, and gives the answer with a
300
+ * status from 200 to 299. It throws `CONNECTION` for a failed connection and `API_ERROR` for an
301
+ * error status.
302
+ */
303
+ export async function postMessages(request: MessagesRequest, settings: LlmSettings, scope: RequestScope): Promise<Response> {
216
304
  const headers: Record<string, string> = {
217
305
  "content-type": "application/json",
218
- "x-api-key": key,
306
+ "x-api-key": scope.key,
219
307
  "anthropic-version": ANTHROPIC_VERSION,
220
308
  };
221
309
  if (request.fallbacks !== undefined) headers["anthropic-beta"] = FALLBACK_BETA;
222
-
310
+ let response: Response;
223
311
  try {
224
- let response: Response;
225
- try {
226
- response = await settings.fetch(`${settings.baseUrl.replace(/\/+$/, "")}/v1/messages`, {
227
- method: "POST",
228
- headers,
229
- body: JSON.stringify(request),
230
- signal: controller.signal,
231
- });
232
- } catch (error) {
233
- if (stop === undefined) {
234
- const message = error instanceof Error ? error.message : String(error);
235
- throw new LlmError("CONNECTION", `llm: the connection to the API failed: ${message.split(key).join("[key]")}`, { retryable: true });
236
- }
237
- throw error;
312
+ response = await settings.fetch(`${settings.baseUrl.replace(/\/+$/, "")}/v1/messages`, {
313
+ method: "POST",
314
+ headers,
315
+ body: JSON.stringify(request),
316
+ signal: scope.signal,
317
+ });
318
+ } catch (error) {
319
+ if (scope.stopped() === undefined) {
320
+ throw new LlmError("CONNECTION", `llm: the connection to the API failed: ${safeMessage(error, scope.key)}`, { retryable: true });
238
321
  }
239
- const text = await readText(response);
240
- if (!response.ok) throw apiError(response.status, text, key);
241
- const answer = parseResponse(text);
322
+ throw error;
323
+ }
324
+ if (!response.ok) throw apiError(response, await readText(response), scope.key);
325
+ return response;
326
+ }
327
+
328
+ /**
329
+ * This function sends one request to the Messages API and gives the checked answer. It throws a
330
+ * `REFUSAL` error when a safety classifier declined the request.
331
+ */
332
+ export async function sendMessages(request: MessagesRequest, settings: LlmSettings, limits: CallLimits): Promise<MessagesResponse> {
333
+ const scope = openScope(settings, limits);
334
+ try {
335
+ const response = await postMessages(request, settings, scope);
336
+ const answer = parseResponse(await readText(response));
242
337
  if (answer.stopReason === "refusal") {
243
338
  const category = answer.stopDetails?.category ?? "not given";
244
339
  throw new LlmError("REFUSAL", `llm: a safety classifier declined the request (category: ${category})`);
245
340
  }
246
341
  return answer;
247
342
  } catch (error) {
248
- if (stop === "timeout") throw new InvocationError("TIMEOUT", `${path.join(".")} passed its time limit of ${timeoutMs} ms`, path);
249
- if (stop === "caller") throw new InvocationError("ABORTED", `Procedure was aborted: ${path.join(".")}`, path);
250
- throw error;
343
+ throw scope.translate(error);
251
344
  } finally {
252
- clearTimeout(timer);
253
- ctx?.signal?.removeEventListener("abort", onAbort);
345
+ scope.close();
254
346
  }
255
347
  }
348
+
349
+ /**
350
+ * This function sends a request with `sendMessages`, and sends it again after a retryable
351
+ * error, at most `retries` more times.
352
+ */
353
+ export function sendWithRetries(request: MessagesRequest, settings: LlmSettings, limits: CallLimits, retries: number): Promise<MessagesResponse> {
354
+ return withRetries(() => sendMessages(request, settings, limits), { backoff: settings.backoff, retries }, limits);
355
+ }
package/src/errors.ts CHANGED
@@ -7,32 +7,44 @@
7
7
  export type LlmErrorCode =
8
8
  /** The environment variable of the API key has no value. */
9
9
  | "NO_API_KEY"
10
- /** The API answered with an error status. `status` has it. */
10
+ /** The API answered with an error status, or sent an error event in a stream. */
11
11
  | "API_ERROR"
12
- /** The connection to the API failed. */
12
+ /** The connection to the API failed, or a stream ended before its end event. */
13
13
  | "CONNECTION"
14
- /** The answer of the API does not have the form of a message. */
14
+ /** The answer of the API does not have the form of a message or of a stream of events. */
15
15
  | "BAD_RESPONSE"
16
16
  /** A safety classifier declined the request (`stop_reason: "refusal"`). */
17
17
  | "REFUSAL"
18
+ /** A tool definition is not valid: a name that two tools have, or a procedure without an object input. */
19
+ | "BAD_TOOL"
18
20
  /** `llm.structured`: the output stopped at `maxTokens`, so the JSON is not complete. */
19
21
  | "MAX_TOKENS"
20
22
  /** `llm.structured`: no answer gave a value that the schema accepts. */
21
23
  | "INVALID_OUTPUT";
22
24
 
25
+ /** The options of an `LlmError`. */
26
+ export interface LlmErrorOptions {
27
+ readonly status?: number | undefined;
28
+ readonly retryable?: boolean | undefined;
29
+ readonly retryAfterMs?: number | undefined;
30
+ }
31
+
23
32
  /** An error of a call to the model. */
24
33
  export class LlmError extends Error {
25
34
  override readonly name = "LlmError";
26
35
  readonly code: LlmErrorCode;
27
- /** The HTTP status of an `API_ERROR`. */
36
+ /** The HTTP status of an `API_ERROR`. An error event of a stream has no status. */
28
37
  readonly status: number | undefined;
29
38
  /** True when the same call can succeed later: a rate limit, an overload, a server error or a connection error. */
30
39
  readonly retryable: boolean;
40
+ /** The wait that the API asked for with `retry-after-ms` or `retry-after`, in milliseconds. */
41
+ readonly retryAfterMs: number | undefined;
31
42
 
32
- constructor(code: LlmErrorCode, message: string, options: { status?: number | undefined; retryable?: boolean | undefined } = {}) {
43
+ constructor(code: LlmErrorCode, message: string, options: LlmErrorOptions = {}) {
33
44
  super(message);
34
45
  this.code = code;
35
46
  this.status = options.status;
36
47
  this.retryable = options.retryable ?? false;
48
+ this.retryAfterMs = options.retryAfterMs;
37
49
  }
38
50
  }
package/src/events.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The streaming Messages API (`"stream": true`): the server-sent events of an answer as stream items.
3
+ *
4
+ * `sseEvents` of `@procedures/http` parses the event stream. The first event of the API is
5
+ * `message_start`. Then each content block comes (`content_block_start`, its
6
+ * `content_block_delta` events, `content_block_stop`), then one or more `message_delta` events, and
7
+ * at the end `message_stop`. A `ping` event can come at each time, and an `error` event stops the
8
+ * stream. The usage of a `message_delta` event is cumulative. Other event types are ignored, as
9
+ * the versioning policy of the API asks.
10
+ *
11
+ * A stream gets a new try only before its first item. After the first item, an error goes to the
12
+ * caller.
13
+ */
14
+
15
+ import { HttpError, sseEvents, type ServerSentEvent } from "@procedures/http";
16
+ import { InvocationError } from "@procedures/client";
17
+ import { openScope, postMessages, safeMessage, usageOf, NO_USAGE, type CallLimits, type LlmSettings, type MessagesRequest } from "./anthropic.js";
18
+ import { LlmError } from "./errors.js";
19
+ import { waitToRetry } from "./retry.js";
20
+ import type { LlmStreamOutput, LlmToolUse, LlmUsage } from "./types.js";
21
+
22
+ /** The largest event of a stream, in bytes. */
23
+ const MAX_EVENT_BYTES = 8 * 1024 * 1024;
24
+
25
+ /** The error types of an `error` event that the same request can pass later. */
26
+ const RETRYABLE_ERROR_TYPES: ReadonlySet<string> = new Set(["overloaded_error", "api_error", "rate_limit_error", "timeout_error"]);
27
+
28
+ /** The chunks of a body. A stop of the reader cancels the body. */
29
+ async function* chunksOf(body: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array, void, undefined> {
30
+ const reader = body.getReader();
31
+ let done = false;
32
+ try {
33
+ for (;;) {
34
+ const step = await reader.read();
35
+ if (step.done) {
36
+ done = true;
37
+ return;
38
+ }
39
+ yield step.value;
40
+ }
41
+ } finally {
42
+ if (!done) reader.cancel().catch(() => undefined);
43
+ }
44
+ }
45
+
46
+ /** The data of an event as an object, or a `BAD_RESPONSE` error. */
47
+ function eventData(event: ServerSentEvent): Record<string, unknown> {
48
+ let data: unknown;
49
+ try {
50
+ data = JSON.parse(event.data);
51
+ } catch {
52
+ throw new LlmError("BAD_RESPONSE", `llm: the data of the event ${event.event} is not JSON: ${event.data.slice(0, 200)}`);
53
+ }
54
+ if (data === null || typeof data !== "object") throw new LlmError("BAD_RESPONSE", `llm: the data of the event ${event.event} is not an object`);
55
+ return data as Record<string, unknown>;
56
+ }
57
+
58
+ const recordOf = (value: unknown): Record<string, unknown> => (value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {});
59
+ const stringOf = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
60
+
61
+ /** The error of an `error` event. An overload, a rate limit or a server error is retryable. */
62
+ function streamError(data: Record<string, unknown>): LlmError {
63
+ const error = recordOf(data["error"]);
64
+ const type = stringOf(error["type"]) ?? "error";
65
+ const message = stringOf(error["message"]) ?? "no message";
66
+ return new LlmError("API_ERROR", `llm: the API sent an error event: ${type}: ${message}`, { retryable: RETRYABLE_ERROR_TYPES.has(type) });
67
+ }
68
+
69
+ /** One content block of a stream while its deltas come. */
70
+ interface OpenBlock {
71
+ readonly type: string;
72
+ readonly id: string;
73
+ readonly name: string;
74
+ json: string;
75
+ }
76
+
77
+ /** The state of one stream: it turns each event into the items of the procedure. */
78
+ class StreamState {
79
+ private model: string;
80
+ private id = "";
81
+ private usage: LlmUsage = NO_USAGE;
82
+ private stopReason: string | undefined;
83
+ private category: string | undefined;
84
+ private text = "";
85
+ private readonly blocks = new Map<number, OpenBlock>();
86
+ private readonly toolUses: LlmToolUse[] = [];
87
+ /** True after the event `message_stop`. */
88
+ stopped = false;
89
+
90
+ constructor(model: string) {
91
+ this.model = model;
92
+ }
93
+
94
+ /** The items of one event. The event `error` and a refusal at the end throw. */
95
+ apply(event: ServerSentEvent): LlmStreamOutput[] {
96
+ if (event.event === "ping") return [];
97
+ const data = eventData(event);
98
+ const index = typeof data["index"] === "number" ? data["index"] : -1;
99
+ switch (stringOf(data["type"]) ?? event.event) {
100
+ case "message_start": {
101
+ const message = recordOf(data["message"]);
102
+ this.id = stringOf(message["id"]) ?? "";
103
+ this.model = stringOf(message["model"]) || this.model;
104
+ this.usage = usageOf(message["usage"]);
105
+ return [{ type: "start", id: this.id, model: this.model, usage: this.usage }];
106
+ }
107
+ case "content_block_start": {
108
+ const block = recordOf(data["content_block"]);
109
+ const type = stringOf(block["type"]) ?? "";
110
+ // A server-side fallback puts a fallback block at the change of the model
111
+ const to = stringOf(recordOf(block["to"])["model"]);
112
+ if (type === "fallback" && to) this.model = to;
113
+ this.blocks.set(index, { type, id: stringOf(block["id"]) ?? "", name: stringOf(block["name"]) ?? "", json: "" });
114
+ const text = stringOf(block["text"]);
115
+ if (type === "text" && text) {
116
+ this.text += text;
117
+ return [{ type: "text", index, text }];
118
+ }
119
+ return [];
120
+ }
121
+ case "content_block_delta": {
122
+ const delta = recordOf(data["delta"]);
123
+ const deltaType = stringOf(delta["type"]);
124
+ if (deltaType === "text_delta") {
125
+ const text = stringOf(delta["text"]) ?? "";
126
+ this.text += text;
127
+ return text === "" ? [] : [{ type: "text", index, text }];
128
+ }
129
+ if (deltaType === "input_json_delta") {
130
+ const block = this.blocks.get(index);
131
+ if (block) block.json += stringOf(delta["partial_json"]) ?? "";
132
+ }
133
+ return [];
134
+ }
135
+ case "content_block_stop": {
136
+ const block = this.blocks.get(index);
137
+ this.blocks.delete(index);
138
+ if (block?.type !== "tool_use") return [];
139
+ let input: unknown = {};
140
+ let partialJson: string | undefined;
141
+ if (block.json.trim() !== "") {
142
+ try {
143
+ input = JSON.parse(block.json);
144
+ } catch {
145
+ input = null;
146
+ partialJson = block.json;
147
+ }
148
+ }
149
+ this.toolUses.push({ id: block.id, name: block.name, input });
150
+ return [{ type: "tool_use", index, id: block.id, name: block.name, input, ...(partialJson !== undefined ? { partialJson } : {}) }];
151
+ }
152
+ case "message_delta": {
153
+ const delta = recordOf(data["delta"]);
154
+ this.stopReason = stringOf(delta["stop_reason"]) ?? this.stopReason;
155
+ this.category = stringOf(recordOf(delta["stop_details"] ?? data["stop_details"])["category"]) ?? this.category;
156
+ this.usage = usageOf(data["usage"], this.usage);
157
+ return [];
158
+ }
159
+ case "message_stop": {
160
+ if (this.stopReason === undefined) throw new LlmError("BAD_RESPONSE", "llm: the stream stopped without a stop reason");
161
+ if (this.stopReason === "refusal") {
162
+ throw new LlmError("REFUSAL", `llm: a safety classifier declined the request (category: ${this.category ?? "not given"})`);
163
+ }
164
+ this.stopped = true;
165
+ return [{ type: "stop", model: this.model, stopReason: this.stopReason, usage: this.usage, text: this.text, toolUses: [...this.toolUses] }];
166
+ }
167
+ case "error":
168
+ throw streamError(data);
169
+ default:
170
+ return [];
171
+ }
172
+ }
173
+ }
174
+
175
+ /** The items of one try of a stream, in one request scope. */
176
+ async function* streamOnce(request: MessagesRequest, settings: LlmSettings, limits: CallLimits): AsyncGenerator<LlmStreamOutput, void, undefined> {
177
+ const scope = openScope(settings, limits);
178
+ try {
179
+ const response = await postMessages(request, settings, scope);
180
+ if (response.body === null) throw new LlmError("BAD_RESPONSE", "llm: the answer of the API has no body");
181
+ const state = new StreamState(request.model);
182
+ try {
183
+ for await (const event of sseEvents(chunksOf(response.body), MAX_EVENT_BYTES, limits.path.join("."))) {
184
+ for (const item of state.apply(event)) yield item;
185
+ if (state.stopped) return;
186
+ }
187
+ } catch (error) {
188
+ if (error instanceof LlmError || error instanceof InvocationError || scope.stopped() !== undefined) throw error;
189
+ if (error instanceof HttpError) throw new LlmError("BAD_RESPONSE", `llm: ${error.message}`);
190
+ throw new LlmError("CONNECTION", `llm: the stream of the API failed: ${safeMessage(error, scope.key)}`, { retryable: true });
191
+ }
192
+ throw new LlmError("CONNECTION", "llm: the stream of the API ended before the event message_stop", { retryable: true });
193
+ } catch (error) {
194
+ throw scope.translate(error);
195
+ } finally {
196
+ scope.close();
197
+ }
198
+ }
199
+
200
+ /**
201
+ * This function sends a request with `"stream": true` and gives each item of the answer. Before
202
+ * the first item, a retryable error gets a new try, at most `retries` times.
203
+ */
204
+ export async function* streamMessages(request: MessagesRequest, settings: LlmSettings, limits: CallLimits, retries: number): AsyncGenerator<LlmStreamOutput, void, undefined> {
205
+ const body: MessagesRequest = { ...request, stream: true };
206
+ for (let retry = 1; ; retry++) {
207
+ let started = false;
208
+ try {
209
+ for await (const item of streamOnce(body, settings, limits)) {
210
+ started = true;
211
+ yield item;
212
+ }
213
+ return;
214
+ } catch (error) {
215
+ if (started || !(await waitToRetry(error, retry, { backoff: settings.backoff, retries }, limits))) throw error;
216
+ }
217
+ }
218
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // The root registers nothing: import "<package>/register", or give the module to createRegistry()
2
2
  export * from "./module.js";
3
3
  export * from "./types.js";
4
- export { llmComplete, llmStructured, MAX_RETRIES } from "./procedures/llm/index.js";
4
+ export { llmComplete, llmStream, llmStructured, MAX_RETRIES } from "./procedures/llm/index.js";
5
5
  // The provider (roadmap R46)
6
6
  export {
7
7
  ANTHROPIC_VERSION,
@@ -16,5 +16,8 @@ export {
16
16
  type FetchFunction,
17
17
  type LlmSettings,
18
18
  } from "./anthropic.js";
19
- export { LlmError, type LlmErrorCode } from "./errors.js";
19
+ export { LlmError, type LlmErrorCode, type LlmErrorOptions } from "./errors.js";
20
20
  export { constrainedSchema, parseAnswer, UNSUPPORTED_KEYWORDS } from "./schema.js";
21
+ // The retry of a request, the procedure tools
22
+ export { DEFAULT_BACKOFF, DEFAULT_REQUEST_RETRIES, MAX_REQUEST_RETRIES, MAX_RETRY_AFTER_MS, retryAfterMs } from "./retry.js";
23
+ export { DEFAULT_MAX_ROUNDS, MAX_TOOL_RESULT_CHARS, toolNameOf, type ToolContext } from "./tools.js";