@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/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 or another `fetch` (roadmap R46).
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 type { LlmCompleteInput, LlmCompleteOutput, LlmStructuredInput, LlmStructuredOutput } from "./types.js";
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 the call, in milliseconds (default 600000)"),
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 LlmCompleteInputSchema = z.object(requestFields);
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 two procedures, with handlers that use the settings. */
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
- return [llmCompleteProcedure, llmStructuredProcedure];
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` that a bundle requires (the first module of a name counts). A bad option throws here.
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, sendMessages, textOf, type CallLimits, type LlmSettings } from "../../anthropic.js";
9
- import { baseRequest, timeoutOf } from "../../request.js";
10
- import type { LlmCompleteInput, LlmCompleteOutput } from "../../types.js";
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?: CallLimits["ctx"], settings: LlmSettings = DEFAULT_LLM_SETTINGS): Promise<LlmCompleteOutput> {
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 answer = await sendMessages(request, settings, { path: PATH, ctx, timeoutMs: timeoutOf(input) });
20
- return {
21
- text: textOf(answer.content),
22
- model: answer.model || request.model,
23
- stopReason: answer.stopReason,
24
- usage: answer.usage,
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
  }
@@ -1,2 +1,3 @@
1
1
  export { llmComplete } from "./complete.js";
2
2
  export { llmStructured, MAX_RETRIES } from "./structured.js";
3
+ export { llmStream } from "./stream.js";
@@ -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: the request then has
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, sendMessages, textOf, type CallLimits, type LlmSettings, type MessagesRequest } from "../../anthropic.js";
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
- const answer = await sendMessages(request, settings, { path: PATH, ctx, timeoutMs: timeoutOf(input) });
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 both procedures take (`LlmRequestInput`).
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 type { LlmRequestInput } from "./types.js";
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 call. */
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
+ }