@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/module.ts
CHANGED
|
@@ -5,26 +5,32 @@
|
|
|
5
5
|
*
|
|
6
6
|
* `llmModule` sends each request to the Anthropic Messages API with the key of the environment
|
|
7
7
|
* variable `ANTHROPIC_API_KEY`. `createLlmModule()` gives the same procedures with another
|
|
8
|
-
* variable, another default model, another base URL
|
|
8
|
+
* variable, another default model, another base URL, another `fetch`, another number of new
|
|
9
|
+
* tries or another backoff (roadmap R46).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { z } from "zod";
|
|
13
|
+
import { backoffProblems, type Backoff } from "@mark1russell7/service/backoff";
|
|
12
14
|
import {
|
|
13
15
|
defineModule,
|
|
14
16
|
registerModules,
|
|
15
17
|
PROCEDURE_REGISTRY,
|
|
16
18
|
createProcedure,
|
|
19
|
+
markDataDriven,
|
|
17
20
|
zodAdapter,
|
|
18
21
|
type AnyProcedure,
|
|
19
22
|
type Effects,
|
|
20
23
|
type ProcedureContext,
|
|
24
|
+
type ProcedureHandler,
|
|
21
25
|
type ProcedureModule,
|
|
22
26
|
type ProcedureRegistry,
|
|
23
27
|
} from "@procedures/client";
|
|
24
28
|
import { llmComplete } from "./procedures/llm/complete.js";
|
|
25
29
|
import { llmStructured, MAX_RETRIES } from "./procedures/llm/structured.js";
|
|
30
|
+
import { llmStream } from "./procedures/llm/stream.js";
|
|
26
31
|
import { DEFAULT_LLM_SETTINGS, type EnvSource, type FetchFunction, type LlmSettings } from "./anthropic.js";
|
|
27
|
-
import
|
|
32
|
+
import { MAX_REQUEST_RETRIES } from "./retry.js";
|
|
33
|
+
import type { LlmCompleteInput, LlmCompleteOutput, LlmStreamInput, LlmStreamOutput, LlmStructuredInput, LlmStructuredOutput } from "./types.js";
|
|
28
34
|
|
|
29
35
|
// =============================================================================
|
|
30
36
|
// Schemas
|
|
@@ -43,7 +49,7 @@ const requestFields = {
|
|
|
43
49
|
maxTokens: z.number().int().min(1).max(64_000).optional().describe("The most tokens of the output, with the thinking (default 16000)"),
|
|
44
50
|
temperature: z.number().min(0).max(1).optional().describe("The temperature, 0 to 1. Some models accept only the default"),
|
|
45
51
|
effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("The effort of the model (default: the default of the model)"),
|
|
46
|
-
timeoutMs: z.number().int().positive().max(3_600_000).optional().describe("The time limit of
|
|
52
|
+
timeoutMs: z.number().int().positive().max(3_600_000).optional().describe("The time limit of each request, in milliseconds (default 600000)"),
|
|
47
53
|
fallback: z.boolean().optional().describe("True: the API runs a declined request again on its fallback model (default: the option of the module, true)"),
|
|
48
54
|
};
|
|
49
55
|
|
|
@@ -56,13 +62,74 @@ const usage = z
|
|
|
56
62
|
})
|
|
57
63
|
.describe("The tokens of the call");
|
|
58
64
|
|
|
59
|
-
const
|
|
65
|
+
const TOOL_NAME = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
66
|
+
|
|
67
|
+
const tool = z.object({
|
|
68
|
+
name: z.string().regex(TOOL_NAME, "1 to 128 letters, digits, _ and -").describe("The name of the tool"),
|
|
69
|
+
description: z.string().max(100_000).optional().describe("What the tool does and when the model uses it"),
|
|
70
|
+
inputSchema: z.record(z.string(), z.unknown()).describe("The JSON Schema of the input of the tool, with type object"),
|
|
71
|
+
strict: z.boolean().optional().describe("True: the API makes each input agree with the schema (strict tool use)"),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const parallel = z.boolean().optional().describe("True: the model uses at most one tool in an answer");
|
|
75
|
+
|
|
76
|
+
const toolChoice = z
|
|
77
|
+
.discriminatedUnion("type", [
|
|
78
|
+
z.object({ type: z.literal("auto"), disableParallelToolUse: parallel }),
|
|
79
|
+
z.object({ type: z.literal("any"), disableParallelToolUse: parallel }),
|
|
80
|
+
z.object({ type: z.literal("tool"), name: z.string().regex(TOOL_NAME).describe("The tool that the model uses"), disableParallelToolUse: parallel }),
|
|
81
|
+
z.object({ type: z.literal("none") }),
|
|
82
|
+
])
|
|
83
|
+
.describe("How the model uses the tools: auto (default), any, tool or none. Some models refuse any and tool");
|
|
84
|
+
|
|
85
|
+
const toolFields = {
|
|
86
|
+
tools: z.array(tool).max(128).optional().describe("The tools that the model can use. The output gives each tool_use block"),
|
|
87
|
+
toolChoice: toolChoice.optional(),
|
|
88
|
+
retries: z
|
|
89
|
+
.number()
|
|
90
|
+
.int()
|
|
91
|
+
.min(0)
|
|
92
|
+
.max(MAX_REQUEST_RETRIES)
|
|
93
|
+
.optional()
|
|
94
|
+
.describe("The new tries after a retryable error (429, 529, 5xx, a connection error), 0 to 10 (default: the option of the module, 2)"),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const toolUse = z.object({
|
|
98
|
+
id: z.string().describe("The ID of the tool_use block"),
|
|
99
|
+
name: z.string().describe("The name of the tool"),
|
|
100
|
+
input: z.unknown().describe("The input of the call"),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const toolCall = z.object({
|
|
104
|
+
id: z.string().describe("The ID of the tool_use block"),
|
|
105
|
+
name: z.string().describe("The name of the tool"),
|
|
106
|
+
procedure: z.string().describe("The key of the procedure"),
|
|
107
|
+
input: z.unknown().describe("The input that the model gave"),
|
|
108
|
+
isError: z.boolean().describe("True when the procedure failed: the model got the error"),
|
|
109
|
+
output: z.unknown().optional().describe("The output of the procedure"),
|
|
110
|
+
error: z.string().optional().describe("The error of the procedure"),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const LlmCompleteInputSchema = z.object({
|
|
114
|
+
...requestFields,
|
|
115
|
+
...toolFields,
|
|
116
|
+
procedures: z
|
|
117
|
+
.array(z.string().regex(/^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/, "A procedure key, for example weather.current"))
|
|
118
|
+
.max(64)
|
|
119
|
+
.optional()
|
|
120
|
+
.describe("The procedures that the model can use as tools: llm.complete calls them and sends the results back"),
|
|
121
|
+
maxRounds: z.number().int().min(1).max(64).optional().describe("The most requests to the model in one call, 1 to 64 (default 8)"),
|
|
122
|
+
});
|
|
60
123
|
|
|
61
124
|
const LlmCompleteOutputSchema = z.object({
|
|
62
|
-
text: z.string().describe("The text of the answer"),
|
|
125
|
+
text: z.string().describe("The text of the last answer"),
|
|
63
126
|
model: z.string().describe("The model that answered"),
|
|
64
|
-
stopReason: z.string().describe("Why the model stopped: end_turn, max_tokens, stop_sequence or another reason"),
|
|
127
|
+
stopReason: z.string().describe("Why the model stopped: end_turn, max_tokens, stop_sequence, tool_use or another reason"),
|
|
65
128
|
usage,
|
|
129
|
+
toolUses: z.array(toolUse).describe("The tool_use blocks of the last answer that no procedure ran"),
|
|
130
|
+
toolCalls: z.array(toolCall).describe("The calls of the procedure tools, in their sequence"),
|
|
131
|
+
rounds: z.number().int().describe("The number of requests to the model"),
|
|
132
|
+
roundLimit: z.boolean().describe("True when maxRounds stopped the loop before the model stopped"),
|
|
66
133
|
});
|
|
67
134
|
|
|
68
135
|
const LlmStructuredInputSchema = z.object({
|
|
@@ -70,6 +137,7 @@ const LlmStructuredInputSchema = z.object({
|
|
|
70
137
|
schema: z.record(z.string(), z.unknown()).describe("The JSON Schema of the value"),
|
|
71
138
|
constrained: z.boolean().optional().describe("True (default): the API constrains the output to the schema. False: the system text gives the schema"),
|
|
72
139
|
retries: z.number().int().min(0).max(MAX_RETRIES).optional().describe("The new tries after an answer that is not valid, 0 to 2 (default 2)"),
|
|
140
|
+
requestRetries: toolFields.retries,
|
|
73
141
|
});
|
|
74
142
|
|
|
75
143
|
const LlmStructuredOutputSchema = z.object({
|
|
@@ -80,6 +148,33 @@ const LlmStructuredOutputSchema = z.object({
|
|
|
80
148
|
unchecked: z.array(z.string()).describe("The keywords of the schema that the check does not examine fully"),
|
|
81
149
|
});
|
|
82
150
|
|
|
151
|
+
const LlmStreamInputSchema = z.object({ ...requestFields, ...toolFields });
|
|
152
|
+
|
|
153
|
+
const index = z.number().int().describe("The index of the content block");
|
|
154
|
+
|
|
155
|
+
const LlmStreamOutputSchema = z
|
|
156
|
+
.discriminatedUnion("type", [
|
|
157
|
+
z.object({ type: z.literal("start"), id: z.string().describe("The ID of the message"), model: z.string().describe("The model that answers"), usage }),
|
|
158
|
+
z.object({ type: z.literal("text"), index, text: z.string().describe("The new text") }),
|
|
159
|
+
z.object({
|
|
160
|
+
type: z.literal("tool_use"),
|
|
161
|
+
index,
|
|
162
|
+
id: z.string().describe("The ID of the tool_use block"),
|
|
163
|
+
name: z.string().describe("The name of the tool"),
|
|
164
|
+
input: z.unknown().describe("The input of the call, or null when its JSON is not complete"),
|
|
165
|
+
partialJson: z.string().optional().describe("The JSON text of an input that does not parse"),
|
|
166
|
+
}),
|
|
167
|
+
z.object({
|
|
168
|
+
type: z.literal("stop"),
|
|
169
|
+
model: z.string().describe("The model that answered"),
|
|
170
|
+
stopReason: z.string().describe("Why the model stopped"),
|
|
171
|
+
usage,
|
|
172
|
+
text: z.string().describe("The text of the answer: all the text deltas, joined"),
|
|
173
|
+
toolUses: z.array(toolUse).describe("The tool_use blocks of the answer"),
|
|
174
|
+
}),
|
|
175
|
+
])
|
|
176
|
+
.describe("One item of the stream: start, text, tool_use or stop");
|
|
177
|
+
|
|
83
178
|
// =============================================================================
|
|
84
179
|
// Procedures
|
|
85
180
|
// =============================================================================
|
|
@@ -87,19 +182,20 @@ const LlmStructuredOutputSchema = z.object({
|
|
|
87
182
|
/**
|
|
88
183
|
* The effects of each procedure: it sends data to a system outside the host and gets an answer.
|
|
89
184
|
* A call costs money and can change the state of an account, so it declares a write. It deletes
|
|
90
|
-
* nothing and a second call is a new call.
|
|
185
|
+
* nothing and a second call is a new call. The procedure tools of `llm.complete` are nested calls:
|
|
186
|
+
* the admission of the host examines each one.
|
|
91
187
|
*/
|
|
92
188
|
const SENDS_TO_MODEL: Effects = Object.freeze({ reads: Object.freeze(["network"]), writes: Object.freeze(["network"]), destructive: false, idempotent: false });
|
|
93
189
|
|
|
94
|
-
/** The
|
|
190
|
+
/** The three procedures, with handlers that use the settings. */
|
|
95
191
|
function llmProcedures(settings: LlmSettings): AnyProcedure[] {
|
|
96
192
|
const llmCompleteProcedure = createProcedure()
|
|
97
193
|
.path(["llm", "complete"])
|
|
98
194
|
.input(zodAdapter<LlmCompleteInput>(LlmCompleteInputSchema))
|
|
99
195
|
.output(zodAdapter<LlmCompleteOutput>(LlmCompleteOutputSchema))
|
|
100
196
|
.meta({
|
|
101
|
-
description: "Send a prompt to a language model and give the text and the usage",
|
|
102
|
-
tags: ["llm", "anthropic"],
|
|
197
|
+
description: "Send a prompt to a language model and give the text and the usage. The model can use tools and procedures",
|
|
198
|
+
tags: ["llm", "anthropic", "tools"],
|
|
103
199
|
args: ["prompt"],
|
|
104
200
|
shorts: { model: "m", system: "s" },
|
|
105
201
|
output: "json",
|
|
@@ -107,6 +203,10 @@ function llmProcedures(settings: LlmSettings): AnyProcedure[] {
|
|
|
107
203
|
})
|
|
108
204
|
.handler(async (input: LlmCompleteInput, ctx: ProcedureContext): Promise<LlmCompleteOutput> => llmComplete(input, ctx, settings))
|
|
109
205
|
.build();
|
|
206
|
+
// The input names the procedures that the model can call, so the procedure is data-driven: the
|
|
207
|
+
// host examines each nested call with its capability, and permits only the procedures that it
|
|
208
|
+
// exposes. It is not "runs-refs": the refs of its input still run before the call.
|
|
209
|
+
markDataDriven(llmCompleteProcedure);
|
|
110
210
|
|
|
111
211
|
const llmStructuredProcedure = createProcedure()
|
|
112
212
|
.path(["llm", "structured"])
|
|
@@ -123,7 +223,25 @@ function llmProcedures(settings: LlmSettings): AnyProcedure[] {
|
|
|
123
223
|
.handler(async (input: LlmStructuredInput, ctx: ProcedureContext): Promise<LlmStructuredOutput> => llmStructured(input, ctx, settings))
|
|
124
224
|
.build();
|
|
125
225
|
|
|
126
|
-
|
|
226
|
+
const llmStreamProcedure = createProcedure()
|
|
227
|
+
.path(["llm", "stream"])
|
|
228
|
+
.input(zodAdapter<LlmStreamInput>(LlmStreamInputSchema))
|
|
229
|
+
.output(zodAdapter<LlmStreamOutput>(LlmStreamOutputSchema))
|
|
230
|
+
.meta({
|
|
231
|
+
description: "Send a prompt to a language model and stream the events of the answer",
|
|
232
|
+
tags: ["llm", "anthropic", "stream"],
|
|
233
|
+
args: ["prompt"],
|
|
234
|
+
shorts: { model: "m", system: "s" },
|
|
235
|
+
output: "json",
|
|
236
|
+
streaming: true,
|
|
237
|
+
// The stream ends with the answer: maxTokens and timeoutMs limit it (roadmap R15)
|
|
238
|
+
stream: { unbounded: false },
|
|
239
|
+
effects: SENDS_TO_MODEL,
|
|
240
|
+
})
|
|
241
|
+
.handler(((input: LlmStreamInput, ctx: ProcedureContext) => llmStream(input, ctx, settings)) as ProcedureHandler<LlmStreamInput, LlmStreamOutput>)
|
|
242
|
+
.build();
|
|
243
|
+
|
|
244
|
+
return [llmCompleteProcedure, llmStructuredProcedure, llmStreamProcedure];
|
|
127
245
|
}
|
|
128
246
|
|
|
129
247
|
/**
|
|
@@ -148,12 +266,16 @@ export interface LlmModuleOptions {
|
|
|
148
266
|
readonly fetch?: FetchFunction | undefined;
|
|
149
267
|
/** The variables: a record, or a function that gives them at each call. The default is `process.env`. */
|
|
150
268
|
readonly env?: EnvSource | (() => EnvSource) | undefined;
|
|
269
|
+
/** The default number of new tries after a retryable error: 0 to 10. The default is 2. */
|
|
270
|
+
readonly retries?: number | undefined;
|
|
271
|
+
/** The backoff before a new try, in milliseconds. The default starts at 500 and stops at 8000. */
|
|
272
|
+
readonly backoff?: Backoff | undefined;
|
|
151
273
|
}
|
|
152
274
|
|
|
153
275
|
/**
|
|
154
276
|
* This function gives the module of this package with other settings. Without options, it gives
|
|
155
277
|
* `llmModule`. A host gives it to `createRegistry()` before its bundles, so it replaces the
|
|
156
|
-
* `llmModule`
|
|
278
|
+
* `llmModule` of a bundle (the first module of a name counts). A bad option throws here.
|
|
157
279
|
*
|
|
158
280
|
* ```ts
|
|
159
281
|
* createRegistry(createLlmModule({ apiKeyEnv: "WORK_ANTHROPIC_KEY", defaultModel: "claude-haiku-5-5" }));
|
|
@@ -165,6 +287,13 @@ export function createLlmModule(options: LlmModuleOptions = {}): ProcedureModule
|
|
|
165
287
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) throw new TypeError(`createLlmModule: apiKeyEnv must be the name of a variable, not ${JSON.stringify(apiKeyEnv)}`);
|
|
166
288
|
const baseUrl = options.baseUrl ?? DEFAULT_LLM_SETTINGS.baseUrl;
|
|
167
289
|
if (!/^https?:\/\//.test(baseUrl)) throw new TypeError("createLlmModule: baseUrl must be an http or https URL");
|
|
290
|
+
const retries = options.retries ?? DEFAULT_LLM_SETTINGS.retries;
|
|
291
|
+
if (!Number.isInteger(retries) || retries < 0 || retries > MAX_REQUEST_RETRIES) {
|
|
292
|
+
throw new TypeError(`createLlmModule: retries must be a whole number from 0 to ${MAX_REQUEST_RETRIES}`);
|
|
293
|
+
}
|
|
294
|
+
const backoff = options.backoff ?? DEFAULT_LLM_SETTINGS.backoff;
|
|
295
|
+
const problems = backoffProblems(backoff);
|
|
296
|
+
if (problems.length > 0) throw new TypeError(`createLlmModule: the backoff is not valid: ${problems.join("; ")}`);
|
|
168
297
|
const env = options.env;
|
|
169
298
|
const settings: LlmSettings = {
|
|
170
299
|
apiKeyEnv,
|
|
@@ -173,6 +302,8 @@ export function createLlmModule(options: LlmModuleOptions = {}): ProcedureModule
|
|
|
173
302
|
baseUrl,
|
|
174
303
|
defaultModel: options.defaultModel ?? DEFAULT_LLM_SETTINGS.defaultModel,
|
|
175
304
|
fallback: options.fallback ?? DEFAULT_LLM_SETTINGS.fallback,
|
|
305
|
+
retries,
|
|
306
|
+
backoff,
|
|
176
307
|
};
|
|
177
308
|
return defineModule("@procedures/llm", llmProcedures(settings));
|
|
178
309
|
}
|
|
@@ -2,25 +2,66 @@
|
|
|
2
2
|
* llm.complete procedure
|
|
3
3
|
*
|
|
4
4
|
* Send a prompt to a language model and give the text and the usage. The text is the text
|
|
5
|
-
* blocks of the answer, joined: the thinking blocks are not in it.
|
|
5
|
+
* blocks of the last answer, joined: the thinking blocks are not in it.
|
|
6
|
+
*
|
|
7
|
+
* With `tools`, the output gives each `tool_use` block of the answer, and the caller starts them.
|
|
8
|
+
* With `procedures`, the procedure starts the tools. It calls each procedure that the model asks
|
|
9
|
+
* for through `ctx.client` and sends the results back. Then it asks again, until the model stops
|
|
10
|
+
* or `maxRounds` requests went out.
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
|
-
import { DEFAULT_LLM_SETTINGS,
|
|
9
|
-
import { baseRequest, timeoutOf } from "../../request.js";
|
|
10
|
-
import
|
|
13
|
+
import { addUsage, DEFAULT_LLM_SETTINGS, NO_USAGE, sendWithRetries, textOf, type CallLimits, type ContentBlock, type LlmSettings } from "../../anthropic.js";
|
|
14
|
+
import { addTools, baseRequest, retriesOf, timeoutOf, toolParam } from "../../request.js";
|
|
15
|
+
import { DEFAULT_MAX_ROUNDS, procedureTools, runToolUse, toolUsesOf, type ToolContext } from "../../tools.js";
|
|
16
|
+
import type { LlmCompleteInput, LlmCompleteOutput, LlmToolCall, LlmUsage } from "../../types.js";
|
|
11
17
|
|
|
12
18
|
const PATH = ["llm", "complete"];
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* Send a prompt to a language model and give the text and the usage
|
|
16
22
|
*/
|
|
17
|
-
export async function llmComplete(input: LlmCompleteInput, ctx?:
|
|
23
|
+
export async function llmComplete(input: LlmCompleteInput, ctx?: ToolContext, settings: LlmSettings = DEFAULT_LLM_SETTINGS): Promise<LlmCompleteOutput> {
|
|
18
24
|
const request = baseRequest(input, settings, input.system);
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const procedures = procedureTools(input.procedures ?? [], ctx, PATH);
|
|
26
|
+
addTools(request, [...(input.tools ?? []).map(toolParam), ...procedures.map((tool) => tool.param)], input.toolChoice);
|
|
27
|
+
const byName = new Map(procedures.map((tool) => [tool.param.name, tool]));
|
|
28
|
+
const limits: CallLimits = { path: PATH, ctx, timeoutMs: timeoutOf(input) };
|
|
29
|
+
const retries = retriesOf(input.retries, settings);
|
|
30
|
+
const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;
|
|
31
|
+
|
|
32
|
+
let usage: LlmUsage = NO_USAGE;
|
|
33
|
+
const toolCalls: LlmToolCall[] = [];
|
|
34
|
+
for (let round = 1; ; round++) {
|
|
35
|
+
const answer = await sendWithRetries(request, settings, limits, retries);
|
|
36
|
+
usage = addUsage(usage, answer.usage);
|
|
37
|
+
const uses = toolUsesOf(answer.content);
|
|
38
|
+
// The procedure runs the tools only when each tool of the answer is a procedure tool
|
|
39
|
+
const runnable = answer.stopReason === "tool_use" && uses.length > 0 && uses.every((use) => byName.has(use.name));
|
|
40
|
+
if (!runnable || round >= maxRounds) {
|
|
41
|
+
return {
|
|
42
|
+
text: textOf(answer.content),
|
|
43
|
+
model: answer.model || request.model,
|
|
44
|
+
stopReason: answer.stopReason,
|
|
45
|
+
usage,
|
|
46
|
+
toolUses: uses,
|
|
47
|
+
toolCalls,
|
|
48
|
+
rounds: round,
|
|
49
|
+
roundLimit: runnable,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// One at a time, in the sequence of the answer: a procedure can change data that the next one reads
|
|
53
|
+
const results: ContentBlock[] = [];
|
|
54
|
+
for (const use of uses) {
|
|
55
|
+
const { call, result } = await runToolUse(use, byName.get(use.name)!, ctx!);
|
|
56
|
+
toolCalls.push(call);
|
|
57
|
+
results.push(result);
|
|
58
|
+
}
|
|
59
|
+
// The next request continues the conversation: the answer as it came, then the results
|
|
60
|
+
request.messages = [...request.messages, { role: "assistant", content: answer.content }, { role: "user", content: results }];
|
|
61
|
+
// A forced tool_choice applies to the first request only: then the model can stop
|
|
62
|
+
if (request.tool_choice !== undefined && (request.tool_choice.type === "any" || request.tool_choice.type === "tool")) {
|
|
63
|
+
const { disable_parallel_tool_use } = request.tool_choice;
|
|
64
|
+
request.tool_choice = { type: "auto", ...(disable_parallel_tool_use !== undefined ? { disable_parallel_tool_use } : {}) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
26
67
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llm.stream procedure
|
|
3
|
+
*
|
|
4
|
+
* Send a prompt to a language model and stream the events of the answer. The request has
|
|
5
|
+
* `"stream": true`. The items are the start of the message, the text deltas and each complete
|
|
6
|
+
* `tool_use` block. The last item has the stop reason, the usage and the full text.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DEFAULT_LLM_SETTINGS, type CallLimits, type LlmSettings } from "../../anthropic.js";
|
|
10
|
+
import { streamMessages } from "../../events.js";
|
|
11
|
+
import { addTools, baseRequest, retriesOf, timeoutOf, toolParam } from "../../request.js";
|
|
12
|
+
import type { LlmStreamInput, LlmStreamOutput } from "../../types.js";
|
|
13
|
+
|
|
14
|
+
const PATH = ["llm", "stream"];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Send a prompt to a language model and stream the events of the answer
|
|
18
|
+
*/
|
|
19
|
+
export async function* llmStream(input: LlmStreamInput, ctx?: CallLimits["ctx"], settings: LlmSettings = DEFAULT_LLM_SETTINGS): AsyncGenerator<LlmStreamOutput, void, undefined> {
|
|
20
|
+
const request = baseRequest(input, settings, input.system);
|
|
21
|
+
addTools(request, (input.tools ?? []).map(toolParam), input.toolChoice);
|
|
22
|
+
yield* streamMessages(request, settings, { path: PATH, ctx, timeoutMs: timeoutOf(input) }, retriesOf(input.retries, settings));
|
|
23
|
+
}
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* llm.structured procedure
|
|
3
3
|
*
|
|
4
4
|
* Send a prompt and a JSON Schema to a language model and give a value that the schema accepts.
|
|
5
|
-
* An answer that is not JSON, or that the schema refuses, gets a new try
|
|
5
|
+
* An answer that is not JSON, or that the schema refuses, gets a new try. The next request has
|
|
6
6
|
* the answer (its content, unchanged) and a user turn with the problems. At most 2 new tries.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { validateJson, type JsonValue } from "@procedures/json";
|
|
10
10
|
import { LlmError } from "../../errors.js";
|
|
11
|
-
import { addUsage, DEFAULT_LLM_SETTINGS, NO_USAGE,
|
|
12
|
-
import { baseRequest, timeoutOf } from "../../request.js";
|
|
11
|
+
import { addUsage, DEFAULT_LLM_SETTINGS, NO_USAGE, sendWithRetries, textOf, type CallLimits, type LlmSettings, type MessagesRequest } from "../../anthropic.js";
|
|
12
|
+
import { baseRequest, retriesOf, timeoutOf } from "../../request.js";
|
|
13
13
|
import { constrainedSchema, parseAnswer } from "../../schema.js";
|
|
14
14
|
import type { LlmStructuredInput, LlmStructuredOutput, LlmUsage } from "../../types.js";
|
|
15
15
|
|
|
@@ -42,6 +42,7 @@ function problemsOf(value: unknown, schema: Record<string, unknown>): { problems
|
|
|
42
42
|
export async function llmStructured(input: LlmStructuredInput, ctx?: CallLimits["ctx"], settings: LlmSettings = DEFAULT_LLM_SETTINGS): Promise<LlmStructuredOutput> {
|
|
43
43
|
const constrained = input.constrained ?? true;
|
|
44
44
|
const retries = Math.min(Math.max(input.retries ?? MAX_RETRIES, 0), MAX_RETRIES);
|
|
45
|
+
const requestRetries = retriesOf(input.requestRetries, settings);
|
|
45
46
|
const system = [input.system, instruction(input, constrained)].filter((part) => part !== undefined && part !== "").join("\n\n");
|
|
46
47
|
const request: MessagesRequest = baseRequest(input, settings, system);
|
|
47
48
|
if (constrained) {
|
|
@@ -51,7 +52,8 @@ export async function llmStructured(input: LlmStructuredInput, ctx?: CallLimits[
|
|
|
51
52
|
let usage: LlmUsage = NO_USAGE;
|
|
52
53
|
let lastProblem = "";
|
|
53
54
|
for (let attempt = 1; attempt <= retries + 1; attempt++) {
|
|
54
|
-
|
|
55
|
+
// A retryable error of the API gets new tries here: they do not count as attempts
|
|
56
|
+
const answer = await sendWithRetries(request, settings, { path: PATH, ctx, timeoutMs: timeoutOf(input) }, requestRetries);
|
|
55
57
|
usage = addUsage(usage, answer.usage);
|
|
56
58
|
if (answer.stopReason === "max_tokens") {
|
|
57
59
|
throw new LlmError("MAX_TOKENS", `llm.structured: the answer stopped at maxTokens (${request.max_tokens}), so the JSON is not complete`);
|
package/src/request.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The request of the fields that
|
|
2
|
+
* The request of the fields that the procedures take (`LlmRequestInput`), and the tool fields.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { DEFAULT_MAX_TOKENS, DEFAULT_TIMEOUT_MS, type LlmSettings, type MessagesRequest } from "./anthropic.js";
|
|
6
|
-
import
|
|
5
|
+
import { DEFAULT_MAX_TOKENS, DEFAULT_TIMEOUT_MS, type LlmSettings, type MessagesRequest, type ToolChoiceParam, type ToolParam } from "./anthropic.js";
|
|
6
|
+
import { LlmError } from "./errors.js";
|
|
7
|
+
import { MAX_REQUEST_RETRIES } from "./retry.js";
|
|
8
|
+
import type { LlmRequestInput, LlmTool, LlmToolChoice } from "./types.js";
|
|
7
9
|
|
|
8
10
|
/** This function gives the body of a request: the model, the limits, the system text and the user text. */
|
|
9
11
|
export function baseRequest(input: LlmRequestInput, settings: LlmSettings, system: string | undefined): MessagesRequest {
|
|
@@ -19,7 +21,48 @@ export function baseRequest(input: LlmRequestInput, settings: LlmSettings, syste
|
|
|
19
21
|
return request;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
/** The time limit of a
|
|
24
|
+
/** The time limit of a request. */
|
|
23
25
|
export function timeoutOf(input: LlmRequestInput): number {
|
|
24
26
|
return input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
25
27
|
}
|
|
28
|
+
|
|
29
|
+
/** The number of new tries after a retryable error: the input, or the default of the module. */
|
|
30
|
+
export function retriesOf(given: number | undefined, settings: LlmSettings): number {
|
|
31
|
+
return Math.min(Math.max(given ?? settings.retries, 0), MAX_REQUEST_RETRIES);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The definition of a tool of the input, as the API gets it. */
|
|
35
|
+
export function toolParam(tool: LlmTool): ToolParam {
|
|
36
|
+
return {
|
|
37
|
+
name: tool.name,
|
|
38
|
+
...(tool.description !== undefined ? { description: tool.description } : {}),
|
|
39
|
+
input_schema: tool.inputSchema,
|
|
40
|
+
...(tool.strict !== undefined ? { strict: tool.strict } : {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The `tool_choice` of the input, as the API gets it. */
|
|
45
|
+
export function toolChoiceParam(choice: LlmToolChoice): ToolChoiceParam {
|
|
46
|
+
if (choice.type === "none") return { type: "none" };
|
|
47
|
+
return {
|
|
48
|
+
type: choice.type,
|
|
49
|
+
...(choice.type === "tool" ? { name: choice.name } : {}),
|
|
50
|
+
...(choice.disableParallelToolUse !== undefined ? { disable_parallel_tool_use: choice.disableParallelToolUse } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* This function puts the tools and the `tool_choice` into a request. It throws `BAD_TOOL` for two
|
|
56
|
+
* tools with the same name, and for a `toolChoice` without tools.
|
|
57
|
+
*/
|
|
58
|
+
export function addTools(request: MessagesRequest, tools: readonly ToolParam[], choice: LlmToolChoice | undefined): void {
|
|
59
|
+
const names = new Set<string>();
|
|
60
|
+
for (const tool of tools) {
|
|
61
|
+
if (names.has(tool.name)) throw new LlmError("BAD_TOOL", `llm: two tools have the name ${tool.name}`);
|
|
62
|
+
names.add(tool.name);
|
|
63
|
+
}
|
|
64
|
+
if (choice !== undefined && tools.length === 0) throw new LlmError("BAD_TOOL", "llm: toolChoice needs tools");
|
|
65
|
+
if (choice?.type === "tool" && !names.has(choice.name)) throw new LlmError("BAD_TOOL", `llm: toolChoice names the tool ${choice.name}, and no tool has that name`);
|
|
66
|
+
if (tools.length > 0) request.tools = [...tools];
|
|
67
|
+
if (choice !== undefined) request.tool_choice = toolChoiceParam(choice);
|
|
68
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The automatic retry of a request to the Messages API.
|
|
3
|
+
*
|
|
4
|
+
* A request that fails with an `LlmError` with `retryable: true` (429, 529 and the other 5xx
|
|
5
|
+
* statuses, 408, 409, a connection error, an error event at the start of a stream) gets a new
|
|
6
|
+
* try. The wait before a try is the `retry-after-ms` or `retry-after` header of the API, or the
|
|
7
|
+
* backoff of `@mark1russell7/service/backoff`. A wait that ends after the deadline of the call
|
|
8
|
+
* gives the error at once. The abort of the caller stops a wait with `ABORTED`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { backoffDelay, type Backoff } from "@mark1russell7/service/backoff";
|
|
12
|
+
import { InvocationError } from "@procedures/client";
|
|
13
|
+
import { LlmError } from "./errors.js";
|
|
14
|
+
import type { CallLimits } from "./anthropic.js";
|
|
15
|
+
|
|
16
|
+
/** The default number of new tries after a retryable error, as in the Anthropic SDKs. */
|
|
17
|
+
export const DEFAULT_REQUEST_RETRIES = 2;
|
|
18
|
+
/** The most new tries that an input or the module can ask for. */
|
|
19
|
+
export const MAX_REQUEST_RETRIES = 10;
|
|
20
|
+
/** The longest wait that a `retry-after` header can ask for: 60 seconds. A longer wait gives the error at once. */
|
|
21
|
+
export const MAX_RETRY_AFTER_MS = 60_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The default backoff: 500 ms before the first new try, then two times more each time, at most
|
|
25
|
+
* 8 seconds. A jitter removes up to a quarter of each wait.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_BACKOFF: Backoff = Object.freeze({
|
|
28
|
+
base: 500,
|
|
29
|
+
factor: 2,
|
|
30
|
+
cap: 8_000,
|
|
31
|
+
jitter: Object.freeze({ kind: "subtract", fraction: 0.25 }),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** What a retry uses: the backoff policy and the number of new tries. */
|
|
35
|
+
export interface RetryPolicy {
|
|
36
|
+
readonly backoff: Backoff;
|
|
37
|
+
readonly retries: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* This function gives the wait that the headers of an answer ask for, in milliseconds, or
|
|
42
|
+
* undefined. It reads `retry-after-ms`, then `retry-after` as seconds or as an HTTP date.
|
|
43
|
+
*/
|
|
44
|
+
export function retryAfterMs(headers: Pick<Headers, "get">, now: number = Date.now()): number | undefined {
|
|
45
|
+
const milliseconds = headers.get("retry-after-ms");
|
|
46
|
+
if (milliseconds !== null && milliseconds.trim() !== "") {
|
|
47
|
+
const value = Number(milliseconds);
|
|
48
|
+
if (Number.isFinite(value) && value >= 0) return value;
|
|
49
|
+
}
|
|
50
|
+
const after = headers.get("retry-after");
|
|
51
|
+
if (after === null || after.trim() === "") return undefined;
|
|
52
|
+
const seconds = Number(after);
|
|
53
|
+
if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1000 : undefined;
|
|
54
|
+
const date = Date.parse(after);
|
|
55
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - now);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The time that is left before the deadline of the capability, or undefined. */
|
|
59
|
+
export function deadlineLeft(ctx: CallLimits["ctx"]): number | undefined {
|
|
60
|
+
const deadline = ctx?.capability?.deadline;
|
|
61
|
+
return typeof deadline === "number" ? deadline - Date.now() : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** This function waits `ms` milliseconds. The abort of the signal stops the wait with `ABORTED`. */
|
|
65
|
+
function sleep(ms: number, limits: CallLimits): Promise<void> {
|
|
66
|
+
const signal = limits.ctx?.signal;
|
|
67
|
+
const aborted = (): InvocationError => new InvocationError("ABORTED", `Procedure was aborted: ${limits.path.join(".")}`, limits.path);
|
|
68
|
+
if (signal?.aborted) return Promise.reject(aborted());
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const onAbort = (): void => {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
reject(aborted());
|
|
73
|
+
};
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
signal?.removeEventListener("abort", onAbort);
|
|
76
|
+
resolve();
|
|
77
|
+
}, ms);
|
|
78
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* This function decides about a new try after an error. It waits and gives true, or it gives
|
|
84
|
+
* false at once. `retry` is the number of the new try: the first new try is 1.
|
|
85
|
+
*/
|
|
86
|
+
export async function waitToRetry(error: unknown, retry: number, policy: RetryPolicy, limits: CallLimits): Promise<boolean> {
|
|
87
|
+
if (retry > policy.retries) return false;
|
|
88
|
+
if (!(error instanceof LlmError) || !error.retryable) return false;
|
|
89
|
+
const after = error.retryAfterMs;
|
|
90
|
+
if (after !== undefined && after > MAX_RETRY_AFTER_MS) return false;
|
|
91
|
+
const wait = after ?? backoffDelay(policy.backoff, retry);
|
|
92
|
+
const left = deadlineLeft(limits.ctx);
|
|
93
|
+
if (left !== undefined && wait >= left) return false;
|
|
94
|
+
await sleep(wait, limits);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* This function runs `attempt` and gives its result. After a retryable error, it waits and
|
|
100
|
+
* runs `attempt` again, at most `policy.retries` more times. The last error goes to the caller.
|
|
101
|
+
*/
|
|
102
|
+
export async function withRetries<T>(attempt: () => Promise<T>, policy: RetryPolicy, limits: CallLimits): Promise<T> {
|
|
103
|
+
for (let retry = 1; ; retry++) {
|
|
104
|
+
try {
|
|
105
|
+
return await attempt();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!(await waitToRetry(error, retry, policy, limits))) throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|