@procedures/llm 0.0.0-stage → 0.3.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 (60) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +109 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/anthropic.d.ts +102 -0
  6. package/dist/anthropic.d.ts.map +1 -0
  7. package/dist/anthropic.js +195 -0
  8. package/dist/anthropic.js.map +1 -0
  9. package/dist/errors.d.ts +34 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +20 -0
  12. package/dist/errors.js.map +1 -0
  13. package/dist/index.d.ts +7 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +9 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/module.d.ts +48 -0
  18. package/dist/module.d.ts.map +1 -0
  19. package/dist/module.js +145 -0
  20. package/dist/module.js.map +1 -0
  21. package/dist/procedures/llm/complete.d.ts +13 -0
  22. package/dist/procedures/llm/complete.d.ts.map +1 -0
  23. package/dist/procedures/llm/complete.js +23 -0
  24. package/dist/procedures/llm/complete.js.map +1 -0
  25. package/dist/procedures/llm/index.d.ts +3 -0
  26. package/dist/procedures/llm/index.d.ts.map +1 -0
  27. package/dist/procedures/llm/index.js +3 -0
  28. package/dist/procedures/llm/index.js.map +1 -0
  29. package/dist/procedures/llm/structured.d.ts +16 -0
  30. package/dist/procedures/llm/structured.d.ts.map +1 -0
  31. package/dist/procedures/llm/structured.js +75 -0
  32. package/dist/procedures/llm/structured.js.map +1 -0
  33. package/dist/register.d.ts +6 -0
  34. package/dist/register.d.ts.map +1 -0
  35. package/dist/register.js +8 -0
  36. package/dist/register.js.map +1 -0
  37. package/dist/request.d.ts +10 -0
  38. package/dist/request.d.ts.map +1 -0
  39. package/dist/request.js +26 -0
  40. package/dist/request.js.map +1 -0
  41. package/dist/schema.d.ts +31 -0
  42. package/dist/schema.d.ts.map +1 -0
  43. package/dist/schema.js +86 -0
  44. package/dist/schema.js.map +1 -0
  45. package/dist/types.d.ts +76 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +8 -0
  48. package/dist/types.js.map +1 -0
  49. package/package.json +77 -3
  50. package/src/anthropic.ts +255 -0
  51. package/src/errors.ts +38 -0
  52. package/src/index.ts +20 -0
  53. package/src/module.ts +186 -0
  54. package/src/procedures/llm/complete.ts +26 -0
  55. package/src/procedures/llm/index.ts +2 -0
  56. package/src/procedures/llm/structured.ts +79 -0
  57. package/src/register.ts +10 -0
  58. package/src/request.ts +25 -0
  59. package/src/schema.ts +85 -0
  60. package/src/types.ts +90 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * The Anthropic Messages API over `fetch` (roadmap R46).
3
+ *
4
+ * The procedures send each request with the `fetch` of the module settings, not through
5
+ * `http.request`: the API key goes in a header, and the input of a nested call is visible to the
6
+ * interceptors and to the traces of the host. The key comes from an environment variable that the
7
+ * module names. An input cannot give a key.
8
+ *
9
+ * A call stops at the earliest of its time limit, the deadline of its capability and the abort of
10
+ * the caller. A stop is an `InvocationError` with the code `TIMEOUT` or `ABORTED`.
11
+ */
12
+
13
+ import { InvocationError, type ProcedureContext, type ProcedurePath } from "@procedures/client";
14
+ import { LlmError } from "./errors.js";
15
+ import type { LlmEffort, LlmUsage } from "./types.js";
16
+
17
+ /** The variables that the module reads: a name and a value, or undefined for a variable that is not set. */
18
+ export type EnvSource = Readonly<Record<string, string | undefined>>;
19
+
20
+ /** The `fetch` that the procedures use. A test gives a fake one. */
21
+ export type FetchFunction = (url: string, init: RequestInit) => Promise<Response>;
22
+
23
+ /** The default model of the module. */
24
+ export const DEFAULT_MODEL = "claude-sonnet-5-5";
25
+ /** The default environment variable of the API key. */
26
+ export const DEFAULT_API_KEY_ENV = "ANTHROPIC_API_KEY";
27
+ /** The default base URL of the API. */
28
+ export const DEFAULT_BASE_URL = "https://api.anthropic.com";
29
+ /** The default of `maxTokens`. */
30
+ export const DEFAULT_MAX_TOKENS = 16_000;
31
+ /** The default time limit of a call: 10 minutes. */
32
+ export const DEFAULT_TIMEOUT_MS = 600_000;
33
+ /** The version header of the Messages API. */
34
+ export const ANTHROPIC_VERSION = "2023-06-01";
35
+ /** The beta header of the server-side fallback with `fallbacks: "default"`. */
36
+ export const FALLBACK_BETA = "server-side-fallback-2026-07-01";
37
+ /** The largest answer that the procedures read, in bytes. */
38
+ const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
39
+
40
+ /** What the procedures of a module use. */
41
+ export interface LlmSettings {
42
+ readonly apiKeyEnv: string;
43
+ /** This function gives the variables at the time of a call. */
44
+ readonly env: () => EnvSource;
45
+ readonly fetch: FetchFunction;
46
+ readonly baseUrl: string;
47
+ readonly defaultModel: string;
48
+ /** The default of the `fallback` input. */
49
+ readonly fallback: boolean;
50
+ }
51
+
52
+ /** The settings of the default module. */
53
+ export const DEFAULT_LLM_SETTINGS: LlmSettings = Object.freeze({
54
+ apiKeyEnv: DEFAULT_API_KEY_ENV,
55
+ env: () => process.env,
56
+ fetch: (url: string, init: RequestInit) => globalThis.fetch(url, init),
57
+ baseUrl: DEFAULT_BASE_URL,
58
+ defaultModel: DEFAULT_MODEL,
59
+ fallback: true,
60
+ });
61
+
62
+ /** One content block of a message. The procedures read only the text blocks. */
63
+ export interface ContentBlock {
64
+ readonly type: string;
65
+ readonly text?: string;
66
+ readonly [key: string]: unknown;
67
+ }
68
+
69
+ /** One message of a request. The content of an assistant turn is the content of the answer, unchanged. */
70
+ export interface MessageParam {
71
+ readonly role: "user" | "assistant";
72
+ readonly content: string | readonly ContentBlock[];
73
+ }
74
+
75
+ /** The request body of the Messages API, as the procedures send it. */
76
+ export interface MessagesRequest {
77
+ model: string;
78
+ max_tokens: number;
79
+ messages: MessageParam[];
80
+ system?: string;
81
+ temperature?: number;
82
+ output_config?: { effort?: LlmEffort; format?: { type: "json_schema"; schema: unknown } };
83
+ fallbacks?: "default";
84
+ }
85
+
86
+ /** The parts of an answer that the procedures use. */
87
+ export interface MessagesResponse {
88
+ readonly model: string;
89
+ readonly content: readonly ContentBlock[];
90
+ readonly stopReason: string;
91
+ readonly stopDetails: { readonly category?: string | null; readonly explanation?: string | null } | undefined;
92
+ readonly usage: LlmUsage;
93
+ }
94
+
95
+ /** The time limit and the stop of one procedure call. */
96
+ export interface CallLimits {
97
+ readonly path: ProcedurePath;
98
+ readonly ctx: Pick<ProcedureContext, "signal" | "capability"> | undefined;
99
+ readonly timeoutMs: number;
100
+ }
101
+
102
+ /** The API key, or a `NO_API_KEY` error that names the variable. */
103
+ export function apiKey(settings: LlmSettings): string {
104
+ const value = settings.env()[settings.apiKeyEnv];
105
+ if (typeof value !== "string" || value.trim() === "") {
106
+ throw new LlmError("NO_API_KEY", `llm: the environment variable ${settings.apiKeyEnv} has no API key`);
107
+ }
108
+ return value.trim();
109
+ }
110
+
111
+ /** The text blocks of an answer, joined. */
112
+ export function textOf(content: readonly ContentBlock[]): string {
113
+ return content
114
+ .filter((block) => block.type === "text" && typeof block.text === "string")
115
+ .map((block) => block.text)
116
+ .join("");
117
+ }
118
+
119
+ /** The sum of two usages. */
120
+ export function addUsage(a: LlmUsage, b: LlmUsage): LlmUsage {
121
+ return {
122
+ inputTokens: a.inputTokens + b.inputTokens,
123
+ outputTokens: a.outputTokens + b.outputTokens,
124
+ cacheReadInputTokens: a.cacheReadInputTokens + b.cacheReadInputTokens,
125
+ cacheCreationInputTokens: a.cacheCreationInputTokens + b.cacheCreationInputTokens,
126
+ };
127
+ }
128
+
129
+ /** A usage of zero tokens. */
130
+ export const NO_USAGE: LlmUsage = Object.freeze({ inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 });
131
+
132
+ const count = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0);
133
+
134
+ /** The answer of the API, checked: an answer of another form is a `BAD_RESPONSE`. */
135
+ function parseResponse(text: string): MessagesResponse {
136
+ let body: unknown;
137
+ try {
138
+ body = JSON.parse(text);
139
+ } catch {
140
+ throw new LlmError("BAD_RESPONSE", `llm: the answer of the API is not JSON: ${text.slice(0, 200)}`);
141
+ }
142
+ const record = body as Record<string, unknown> | null;
143
+ if (record === null || typeof record !== "object" || !Array.isArray(record["content"]) || typeof record["stop_reason"] !== "string") {
144
+ throw new LlmError("BAD_RESPONSE", "llm: the answer of the API is not a message with content and stop_reason");
145
+ }
146
+ const usage = (record["usage"] ?? {}) as Record<string, unknown>;
147
+ const details = record["stop_details"];
148
+ return {
149
+ model: typeof record["model"] === "string" ? record["model"] : "",
150
+ content: (record["content"] as unknown[]).filter((block): block is ContentBlock => block !== null && typeof block === "object" && typeof (block as ContentBlock).type === "string"),
151
+ stopReason: record["stop_reason"],
152
+ 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
+ },
159
+ };
160
+ }
161
+
162
+ /** The error of an answer with an error status. The message has the type and the message of the API. */
163
+ function apiError(status: number, text: string, key: string): LlmError {
164
+ let detail = text.slice(0, 500);
165
+ try {
166
+ const error = (JSON.parse(text) as { error?: { type?: unknown; message?: unknown } }).error;
167
+ if (error && typeof error.message === "string") detail = `${typeof error.type === "string" ? `${error.type}: ` : ""}${error.message}`;
168
+ } catch {
169
+ // The text of the answer stays the detail
170
+ }
171
+ // The API does not repeat the key, but the message must not carry it in any case
172
+ const safe = detail.split(key).join("[key]");
173
+ const retryable = status === 408 || status === 409 || status === 429 || status >= 500;
174
+ return new LlmError("API_ERROR", `llm: the API answered ${status}: ${safe}`, { status, retryable });
175
+ }
176
+
177
+ /** The time that is left before the deadline of the capability, or undefined. */
178
+ function deadlineLeft(ctx: CallLimits["ctx"]): number | undefined {
179
+ const deadline = ctx?.capability?.deadline;
180
+ return typeof deadline === "number" ? deadline - Date.now() : undefined;
181
+ }
182
+
183
+ /** The body of an answer as text, with a size limit. */
184
+ async function readText(response: Response): Promise<string> {
185
+ const declared = Number(response.headers.get("content-length") ?? "0");
186
+ if (declared > MAX_RESPONSE_BYTES) throw new LlmError("BAD_RESPONSE", `llm: the answer of the API is larger than ${MAX_RESPONSE_BYTES} bytes`);
187
+ const text = await response.text();
188
+ if (text.length > MAX_RESPONSE_BYTES) throw new LlmError("BAD_RESPONSE", `llm: the answer of the API is larger than ${MAX_RESPONSE_BYTES} bytes`);
189
+ return text;
190
+ }
191
+
192
+ /**
193
+ * This function sends one request to the Messages API and gives the checked answer. It throws a
194
+ * `REFUSAL` error when a safety classifier declined the request.
195
+ */
196
+ export async function sendMessages(request: MessagesRequest, settings: LlmSettings, limits: CallLimits): Promise<MessagesResponse> {
197
+ const key = apiKey(settings);
198
+ const { path, ctx } = limits;
199
+ const left = deadlineLeft(ctx);
200
+ if (left !== undefined && left <= 0) throw new InvocationError("TIMEOUT", `The deadline of ${path.join(".")} passed before the call`, path);
201
+ const timeoutMs = left === undefined ? limits.timeoutMs : Math.min(limits.timeoutMs, left);
202
+ if (ctx?.signal?.aborted) throw new InvocationError("ABORTED", `Procedure was aborted: ${path.join(".")}`, path);
203
+
204
+ const controller = new AbortController();
205
+ let stop: "timeout" | "caller" | undefined;
206
+ const timer = setTimeout(() => {
207
+ stop ??= "timeout";
208
+ controller.abort();
209
+ }, timeoutMs);
210
+ const onAbort = (): void => {
211
+ stop ??= "caller";
212
+ controller.abort();
213
+ };
214
+ ctx?.signal?.addEventListener("abort", onAbort, { once: true });
215
+
216
+ const headers: Record<string, string> = {
217
+ "content-type": "application/json",
218
+ "x-api-key": key,
219
+ "anthropic-version": ANTHROPIC_VERSION,
220
+ };
221
+ if (request.fallbacks !== undefined) headers["anthropic-beta"] = FALLBACK_BETA;
222
+
223
+ try {
224
+ let response: Response;
225
+ try {
226
+ response = await settings.fetch(`${settings.baseUrl.replace(/\/+$/, "")}/v1/messages`, {
227
+ method: "POST",
228
+ headers,
229
+ body: JSON.stringify(request),
230
+ signal: controller.signal,
231
+ });
232
+ } catch (error) {
233
+ if (stop === undefined) {
234
+ const message = error instanceof Error ? error.message : String(error);
235
+ throw new LlmError("CONNECTION", `llm: the connection to the API failed: ${message.split(key).join("[key]")}`, { retryable: true });
236
+ }
237
+ throw error;
238
+ }
239
+ const text = await readText(response);
240
+ if (!response.ok) throw apiError(response.status, text, key);
241
+ const answer = parseResponse(text);
242
+ if (answer.stopReason === "refusal") {
243
+ const category = answer.stopDetails?.category ?? "not given";
244
+ throw new LlmError("REFUSAL", `llm: a safety classifier declined the request (category: ${category})`);
245
+ }
246
+ return answer;
247
+ } catch (error) {
248
+ if (stop === "timeout") throw new InvocationError("TIMEOUT", `${path.join(".")} passed its time limit of ${timeoutMs} ms`, path);
249
+ if (stop === "caller") throw new InvocationError("ABORTED", `Procedure was aborted: ${path.join(".")}`, path);
250
+ throw error;
251
+ } finally {
252
+ clearTimeout(timer);
253
+ ctx?.signal?.removeEventListener("abort", onAbort);
254
+ }
255
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The errors of the llm procedures. A stop of the call (its time limit, its deadline, the abort of
3
+ * the caller) is an `InvocationError` with the code `TIMEOUT` or `ABORTED`.
4
+ */
5
+
6
+ /** The reason of an `LlmError`. */
7
+ export type LlmErrorCode =
8
+ /** The environment variable of the API key has no value. */
9
+ | "NO_API_KEY"
10
+ /** The API answered with an error status. `status` has it. */
11
+ | "API_ERROR"
12
+ /** The connection to the API failed. */
13
+ | "CONNECTION"
14
+ /** The answer of the API does not have the form of a message. */
15
+ | "BAD_RESPONSE"
16
+ /** A safety classifier declined the request (`stop_reason: "refusal"`). */
17
+ | "REFUSAL"
18
+ /** `llm.structured`: the output stopped at `maxTokens`, so the JSON is not complete. */
19
+ | "MAX_TOKENS"
20
+ /** `llm.structured`: no answer gave a value that the schema accepts. */
21
+ | "INVALID_OUTPUT";
22
+
23
+ /** An error of a call to the model. */
24
+ export class LlmError extends Error {
25
+ override readonly name = "LlmError";
26
+ readonly code: LlmErrorCode;
27
+ /** The HTTP status of an `API_ERROR`. */
28
+ readonly status: number | undefined;
29
+ /** True when the same call can succeed later: a rate limit, an overload, a server error or a connection error. */
30
+ readonly retryable: boolean;
31
+
32
+ constructor(code: LlmErrorCode, message: string, options: { status?: number | undefined; retryable?: boolean | undefined } = {}) {
33
+ super(message);
34
+ this.code = code;
35
+ this.status = options.status;
36
+ this.retryable = options.retryable ?? false;
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ // The root registers nothing: import "<package>/register", or give the module to createRegistry()
2
+ export * from "./module.js";
3
+ export * from "./types.js";
4
+ export { llmComplete, llmStructured, MAX_RETRIES } from "./procedures/llm/index.js";
5
+ // The provider (roadmap R46)
6
+ export {
7
+ ANTHROPIC_VERSION,
8
+ DEFAULT_API_KEY_ENV,
9
+ DEFAULT_BASE_URL,
10
+ DEFAULT_LLM_SETTINGS,
11
+ DEFAULT_MAX_TOKENS,
12
+ DEFAULT_MODEL,
13
+ DEFAULT_TIMEOUT_MS,
14
+ FALLBACK_BETA,
15
+ type EnvSource,
16
+ type FetchFunction,
17
+ type LlmSettings,
18
+ } from "./anthropic.js";
19
+ export { LlmError, type LlmErrorCode } from "./errors.js";
20
+ export { constrainedSchema, parseAnswer, UNSUPPORTED_KEYWORDS } from "./schema.js";
package/src/module.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The procedures of @procedures/llm as data. Importing this file registers nothing.
3
+ *
4
+ * Scaffold a procedure with: mark procedure new <name> --path packages/llm
5
+ *
6
+ * `llmModule` sends each request to the Anthropic Messages API with the key of the environment
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).
9
+ */
10
+
11
+ import { z } from "zod";
12
+ import {
13
+ defineModule,
14
+ registerModules,
15
+ PROCEDURE_REGISTRY,
16
+ createProcedure,
17
+ zodAdapter,
18
+ type AnyProcedure,
19
+ type Effects,
20
+ type ProcedureContext,
21
+ type ProcedureModule,
22
+ type ProcedureRegistry,
23
+ } from "@procedures/client";
24
+ import { llmComplete } from "./procedures/llm/complete.js";
25
+ import { llmStructured, MAX_RETRIES } from "./procedures/llm/structured.js";
26
+ import { DEFAULT_LLM_SETTINGS, type EnvSource, type FetchFunction, type LlmSettings } from "./anthropic.js";
27
+ import type { LlmCompleteInput, LlmCompleteOutput, LlmStructuredInput, LlmStructuredOutput } from "./types.js";
28
+
29
+ // =============================================================================
30
+ // Schemas
31
+ // =============================================================================
32
+
33
+ const requestFields = {
34
+ prompt: z.string().min(1).max(4_000_000).describe("The text of the user"),
35
+ system: z.string().max(4_000_000).optional().describe("The system text: the instructions to the model"),
36
+ model: z
37
+ .string()
38
+ .min(1)
39
+ .max(200)
40
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/, "A model ID")
41
+ .optional()
42
+ .describe("The model (default: the model of the module, claude-sonnet-5-5)"),
43
+ maxTokens: z.number().int().min(1).max(64_000).optional().describe("The most tokens of the output, with the thinking (default 16000)"),
44
+ temperature: z.number().min(0).max(1).optional().describe("The temperature, 0 to 1. Some models accept only the default"),
45
+ 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)"),
47
+ 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
+ };
49
+
50
+ const usage = z
51
+ .object({
52
+ inputTokens: z.number().int().describe("The tokens of the input that the cache did not have"),
53
+ outputTokens: z.number().int().describe("The tokens of the output, with the thinking"),
54
+ cacheReadInputTokens: z.number().int().describe("The tokens of the input from the cache"),
55
+ cacheCreationInputTokens: z.number().int().describe("The tokens of the input that went into the cache"),
56
+ })
57
+ .describe("The tokens of the call");
58
+
59
+ const LlmCompleteInputSchema = z.object(requestFields);
60
+
61
+ const LlmCompleteOutputSchema = z.object({
62
+ text: z.string().describe("The text of the answer"),
63
+ 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"),
65
+ usage,
66
+ });
67
+
68
+ const LlmStructuredInputSchema = z.object({
69
+ ...requestFields,
70
+ schema: z.record(z.string(), z.unknown()).describe("The JSON Schema of the value"),
71
+ constrained: z.boolean().optional().describe("True (default): the API constrains the output to the schema. False: the system text gives the schema"),
72
+ 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)"),
73
+ });
74
+
75
+ const LlmStructuredOutputSchema = z.object({
76
+ value: z.unknown().describe("The value: the schema accepts it"),
77
+ model: z.string().describe("The model that gave the value"),
78
+ attempts: z.number().int().describe("The number of calls to the model"),
79
+ usage,
80
+ unchecked: z.array(z.string()).describe("The keywords of the schema that the check does not examine fully"),
81
+ });
82
+
83
+ // =============================================================================
84
+ // Procedures
85
+ // =============================================================================
86
+
87
+ /**
88
+ * The effects of each procedure: it sends data to a system outside the host and gets an answer.
89
+ * 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.
91
+ */
92
+ const SENDS_TO_MODEL: Effects = Object.freeze({ reads: Object.freeze(["network"]), writes: Object.freeze(["network"]), destructive: false, idempotent: false });
93
+
94
+ /** The two procedures, with handlers that use the settings. */
95
+ function llmProcedures(settings: LlmSettings): AnyProcedure[] {
96
+ const llmCompleteProcedure = createProcedure()
97
+ .path(["llm", "complete"])
98
+ .input(zodAdapter<LlmCompleteInput>(LlmCompleteInputSchema))
99
+ .output(zodAdapter<LlmCompleteOutput>(LlmCompleteOutputSchema))
100
+ .meta({
101
+ description: "Send a prompt to a language model and give the text and the usage",
102
+ tags: ["llm", "anthropic"],
103
+ args: ["prompt"],
104
+ shorts: { model: "m", system: "s" },
105
+ output: "json",
106
+ effects: SENDS_TO_MODEL,
107
+ })
108
+ .handler(async (input: LlmCompleteInput, ctx: ProcedureContext): Promise<LlmCompleteOutput> => llmComplete(input, ctx, settings))
109
+ .build();
110
+
111
+ const llmStructuredProcedure = createProcedure()
112
+ .path(["llm", "structured"])
113
+ .input(zodAdapter<LlmStructuredInput>(LlmStructuredInputSchema))
114
+ .output(zodAdapter<LlmStructuredOutput>(LlmStructuredOutputSchema))
115
+ .meta({
116
+ description: "Send a prompt and a JSON Schema to a language model and give a value that the schema accepts",
117
+ tags: ["llm", "anthropic", "json-schema"],
118
+ args: ["prompt"],
119
+ shorts: { model: "m", system: "s" },
120
+ output: "json",
121
+ effects: SENDS_TO_MODEL,
122
+ })
123
+ .handler(async (input: LlmStructuredInput, ctx: ProcedureContext): Promise<LlmStructuredOutput> => llmStructured(input, ctx, settings))
124
+ .build();
125
+
126
+ return [llmCompleteProcedure, llmStructuredProcedure];
127
+ }
128
+
129
+ /**
130
+ * The module of this package, for `createRegistry()`: its procedures as data. Each call reads the
131
+ * key from `ANTHROPIC_API_KEY`. Importing this file registers nothing.
132
+ */
133
+ export const llmModule: ProcedureModule = defineModule("@procedures/llm", llmProcedures(DEFAULT_LLM_SETTINGS));
134
+
135
+ export default llmModule;
136
+
137
+ /** The options of `createLlmModule()`. */
138
+ export interface LlmModuleOptions {
139
+ /** The environment variable of the API key. The default is `ANTHROPIC_API_KEY`. */
140
+ readonly apiKeyEnv?: string | undefined;
141
+ /** The model of an input without `model`. The default is `claude-sonnet-5-5`. */
142
+ readonly defaultModel?: string | undefined;
143
+ /** The base URL of the API. The default is `https://api.anthropic.com`. */
144
+ readonly baseUrl?: string | undefined;
145
+ /** The default of the `fallback` input. The default is true. */
146
+ readonly fallback?: boolean | undefined;
147
+ /** The `fetch` of the requests. The default is the global `fetch`. A test gives a fake one. */
148
+ readonly fetch?: FetchFunction | undefined;
149
+ /** The variables: a record, or a function that gives them at each call. The default is `process.env`. */
150
+ readonly env?: EnvSource | (() => EnvSource) | undefined;
151
+ }
152
+
153
+ /**
154
+ * This function gives the module of this package with other settings. Without options, it gives
155
+ * `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.
157
+ *
158
+ * ```ts
159
+ * createRegistry(createLlmModule({ apiKeyEnv: "WORK_ANTHROPIC_KEY", defaultModel: "claude-haiku-5-5" }));
160
+ * ```
161
+ */
162
+ export function createLlmModule(options: LlmModuleOptions = {}): ProcedureModule {
163
+ if (Object.values(options).every((value) => value === undefined)) return llmModule;
164
+ const apiKeyEnv = options.apiKeyEnv ?? DEFAULT_LLM_SETTINGS.apiKeyEnv;
165
+ 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
+ const baseUrl = options.baseUrl ?? DEFAULT_LLM_SETTINGS.baseUrl;
167
+ if (!/^https?:\/\//.test(baseUrl)) throw new TypeError("createLlmModule: baseUrl must be an http or https URL");
168
+ const env = options.env;
169
+ const settings: LlmSettings = {
170
+ apiKeyEnv,
171
+ env: env === undefined ? DEFAULT_LLM_SETTINGS.env : typeof env === "function" ? env : () => env,
172
+ fetch: options.fetch ?? DEFAULT_LLM_SETTINGS.fetch,
173
+ baseUrl,
174
+ defaultModel: options.defaultModel ?? DEFAULT_LLM_SETTINGS.defaultModel,
175
+ fallback: options.fallback ?? DEFAULT_LLM_SETTINGS.fallback,
176
+ };
177
+ return defineModule("@procedures/llm", llmProcedures(settings));
178
+ }
179
+
180
+ /**
181
+ * This function registers the procedures of @procedures/llm in a registry. The default is the
182
+ * global registry. The options are the options of `createLlmModule()`.
183
+ */
184
+ export function registerLlmProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY, options: LlmModuleOptions = {}): void {
185
+ registerModules(registry, [createLlmModule(options)]);
186
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * llm.complete procedure
3
+ *
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.
6
+ */
7
+
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";
11
+
12
+ const PATH = ["llm", "complete"];
13
+
14
+ /**
15
+ * Send a prompt to a language model and give the text and the usage
16
+ */
17
+ export async function llmComplete(input: LlmCompleteInput, ctx?: CallLimits["ctx"], settings: LlmSettings = DEFAULT_LLM_SETTINGS): Promise<LlmCompleteOutput> {
18
+ 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
+ };
26
+ }
@@ -0,0 +1,2 @@
1
+ export { llmComplete } from "./complete.js";
2
+ export { llmStructured, MAX_RETRIES } from "./structured.js";
@@ -0,0 +1,79 @@
1
+ /**
2
+ * llm.structured procedure
3
+ *
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
6
+ * the answer (its content, unchanged) and a user turn with the problems. At most 2 new tries.
7
+ */
8
+
9
+ import { validateJson, type JsonValue } from "@procedures/json";
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";
13
+ import { constrainedSchema, parseAnswer } from "../../schema.js";
14
+ import type { LlmStructuredInput, LlmStructuredOutput, LlmUsage } from "../../types.js";
15
+
16
+ const PATH = ["llm", "structured"];
17
+
18
+ /** The most new tries after an answer that is not valid. */
19
+ export const MAX_RETRIES = 2;
20
+
21
+ /** The instruction of the system text: one JSON value and nothing else. */
22
+ function instruction(input: LlmStructuredInput, constrained: boolean): string {
23
+ const lines = ["Answer with one JSON value that the JSON Schema below accepts. Give only the JSON value, with no other text and no code fence."];
24
+ if (!constrained) lines.push("", "JSON Schema:", JSON.stringify(input.schema));
25
+ return lines.join("\n");
26
+ }
27
+
28
+ /** The problems of a value against the schema, as one text, or undefined for a valid value. */
29
+ function problemsOf(value: unknown, schema: Record<string, unknown>): { problems: string | undefined; unchecked: string[] } {
30
+ const result = validateJson(value as JsonValue, schema);
31
+ if (result.valid) return { problems: undefined, unchecked: [...result.unchecked] };
32
+ const problems = result.errors
33
+ .slice(0, 20)
34
+ .map((issue) => `${issue.pointer === "" ? "(root)" : issue.pointer}: ${issue.message}`)
35
+ .join("; ");
36
+ return { problems, unchecked: [...result.unchecked] };
37
+ }
38
+
39
+ /**
40
+ * Send a prompt and a JSON Schema to a language model and give a value that the schema accepts
41
+ */
42
+ export async function llmStructured(input: LlmStructuredInput, ctx?: CallLimits["ctx"], settings: LlmSettings = DEFAULT_LLM_SETTINGS): Promise<LlmStructuredOutput> {
43
+ const constrained = input.constrained ?? true;
44
+ const retries = Math.min(Math.max(input.retries ?? MAX_RETRIES, 0), MAX_RETRIES);
45
+ const system = [input.system, instruction(input, constrained)].filter((part) => part !== undefined && part !== "").join("\n\n");
46
+ const request: MessagesRequest = baseRequest(input, settings, system);
47
+ if (constrained) {
48
+ request.output_config = { ...request.output_config, format: { type: "json_schema", schema: constrainedSchema(input.schema) } };
49
+ }
50
+
51
+ let usage: LlmUsage = NO_USAGE;
52
+ let lastProblem = "";
53
+ for (let attempt = 1; attempt <= retries + 1; attempt++) {
54
+ const answer = await sendMessages(request, settings, { path: PATH, ctx, timeoutMs: timeoutOf(input) });
55
+ usage = addUsage(usage, answer.usage);
56
+ if (answer.stopReason === "max_tokens") {
57
+ throw new LlmError("MAX_TOKENS", `llm.structured: the answer stopped at maxTokens (${request.max_tokens}), so the JSON is not complete`);
58
+ }
59
+ const parsed = parseAnswer(textOf(answer.content));
60
+ let problem: string;
61
+ if (parsed.ok) {
62
+ const { problems, unchecked } = problemsOf(parsed.value, input.schema);
63
+ if (problems === undefined) {
64
+ return { value: parsed.value, model: answer.model || request.model, attempts: attempt, usage, unchecked };
65
+ }
66
+ problem = `the schema refuses the value: ${problems}`;
67
+ } else {
68
+ problem = parsed.problem;
69
+ }
70
+ lastProblem = problem;
71
+ // The next request continues the conversation: the answer as it came, then the problems
72
+ request.messages = [
73
+ ...request.messages,
74
+ { role: "assistant", content: answer.content },
75
+ { role: "user", content: `That answer is not valid: ${problem}. Answer again with only the JSON value.` },
76
+ ];
77
+ }
78
+ throw new LlmError("INVALID_OUTPUT", `llm.structured: no answer of ${retries + 1} gave a valid value. The last problem: ${lastProblem}`);
79
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Importing this file registers the procedures of @procedures/llm in the global registry:
3
+ * `import "@procedures/llm/register"`. The package root registers nothing.
4
+ */
5
+
6
+ import { registerLlmProcedures } from "./module.js";
7
+
8
+ export * from "./module.js";
9
+
10
+ registerLlmProcedures();
package/src/request.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The request of the fields that both procedures take (`LlmRequestInput`).
3
+ */
4
+
5
+ import { DEFAULT_MAX_TOKENS, DEFAULT_TIMEOUT_MS, type LlmSettings, type MessagesRequest } from "./anthropic.js";
6
+ import type { LlmRequestInput } from "./types.js";
7
+
8
+ /** This function gives the body of a request: the model, the limits, the system text and the user text. */
9
+ export function baseRequest(input: LlmRequestInput, settings: LlmSettings, system: string | undefined): MessagesRequest {
10
+ const request: MessagesRequest = {
11
+ model: input.model ?? settings.defaultModel,
12
+ max_tokens: input.maxTokens ?? DEFAULT_MAX_TOKENS,
13
+ messages: [{ role: "user", content: input.prompt }],
14
+ };
15
+ if (system !== undefined && system !== "") request.system = system;
16
+ if (input.temperature !== undefined) request.temperature = input.temperature;
17
+ if (input.effort !== undefined) request.output_config = { effort: input.effort };
18
+ if (input.fallback ?? settings.fallback) request.fallbacks = "default";
19
+ return request;
20
+ }
21
+
22
+ /** The time limit of a call. */
23
+ export function timeoutOf(input: LlmRequestInput): number {
24
+ return input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
25
+ }