@procedures/llm 0.3.0 → 0.5.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/CHANGELOG.md +46 -0
- package/README.md +114 -9
- package/dist/.tsbuildinfo +1 -1
- package/dist/anthropic.d.ts +64 -5
- package/dist/anthropic.d.ts.map +1 -1
- package/dist/anthropic.js +109 -50
- package/dist/anthropic.js.map +1 -1
- package/dist/errors.d.ts +15 -8
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -1
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +21 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +218 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +8 -2
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +127 -12
- package/dist/module.js.map +1 -1
- package/dist/procedures/llm/complete.d.ts +9 -3
- package/dist/procedures/llm/complete.d.ts.map +1 -1
- package/dist/procedures/llm/complete.js +50 -10
- package/dist/procedures/llm/complete.js.map +1 -1
- package/dist/procedures/llm/index.d.ts +1 -0
- package/dist/procedures/llm/index.d.ts.map +1 -1
- package/dist/procedures/llm/index.js +1 -0
- package/dist/procedures/llm/index.js.map +1 -1
- package/dist/procedures/llm/stream.d.ts +14 -0
- package/dist/procedures/llm/stream.d.ts.map +1 -0
- package/dist/procedures/llm/stream.js +20 -0
- package/dist/procedures/llm/stream.js.map +1 -0
- package/dist/procedures/llm/structured.d.ts +1 -1
- package/dist/procedures/llm/structured.d.ts.map +1 -1
- package/dist/procedures/llm/structured.js +6 -4
- package/dist/procedures/llm/structured.js.map +1 -1
- package/dist/request.d.ts +15 -4
- package/dist/request.d.ts.map +1 -1
- package/dist/request.js +47 -2
- package/dist/request.js.map +1 -1
- package/dist/retry.d.ts +45 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +106 -0
- package/dist/retry.js.map +1 -0
- package/dist/tools.d.ts +50 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +162 -0
- package/dist/tools.js.map +1 -0
- package/dist/types.d.ts +138 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -5
- package/src/anthropic.ts +149 -49
- package/src/errors.ts +17 -5
- package/src/events.ts +218 -0
- package/src/index.ts +5 -2
- package/src/module.ts +143 -12
- package/src/procedures/llm/complete.ts +53 -12
- package/src/procedures/llm/index.ts +1 -0
- package/src/procedures/llm/stream.ts +23 -0
- package/src/procedures/llm/structured.ts +6 -4
- package/src/request.ts +47 -4
- package/src/retry.ts +110 -0
- package/src/tools.ts +178 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
163
|
-
function
|
|
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
|
|
174
|
-
|
|
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
|
-
*
|
|
194
|
-
*
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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";
|