@argentic/chest-sdk 0.1.1 → 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 (57) hide show
  1. package/README.md +573 -37
  2. package/client/index.ts +12 -5
  3. package/client/src/ai.ts +374 -0
  4. package/client/src/api.ts +85 -0
  5. package/client/src/chest.ts +80 -0
  6. package/client/src/errors.ts +58 -3
  7. package/client/src/events.ts +228 -0
  8. package/client/src/files.ts +93 -88
  9. package/client/src/member.ts +61 -17
  10. package/client/src/members.ts +167 -0
  11. package/client/src/notifications.ts +148 -0
  12. package/client/src/testing.ts +564 -0
  13. package/dist/index.d.ts +5 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +12 -5
  16. package/dist/index.js.map +1 -1
  17. package/dist/src/ai.d.ts +131 -0
  18. package/dist/src/ai.d.ts.map +1 -0
  19. package/dist/src/ai.js +290 -0
  20. package/dist/src/ai.js.map +1 -0
  21. package/dist/src/api.d.ts +11 -0
  22. package/dist/src/api.d.ts.map +1 -0
  23. package/dist/src/api.js +93 -0
  24. package/dist/src/api.js.map +1 -0
  25. package/dist/src/chest.d.ts +10 -0
  26. package/dist/src/chest.d.ts.map +1 -0
  27. package/dist/src/chest.js +49 -0
  28. package/dist/src/chest.js.map +1 -0
  29. package/dist/src/errors.d.ts +19 -0
  30. package/dist/src/errors.d.ts.map +1 -1
  31. package/dist/src/errors.js +49 -3
  32. package/dist/src/errors.js.map +1 -1
  33. package/dist/src/events.d.ts +56 -0
  34. package/dist/src/events.d.ts.map +1 -0
  35. package/dist/src/events.js +193 -0
  36. package/dist/src/events.js.map +1 -0
  37. package/dist/src/files.d.ts +17 -1
  38. package/dist/src/files.d.ts.map +1 -1
  39. package/dist/src/files.js +95 -92
  40. package/dist/src/files.js.map +1 -1
  41. package/dist/src/member.d.ts +10 -3
  42. package/dist/src/member.d.ts.map +1 -1
  43. package/dist/src/member.js +34 -11
  44. package/dist/src/member.js.map +1 -1
  45. package/dist/src/members.d.ts +34 -0
  46. package/dist/src/members.d.ts.map +1 -0
  47. package/dist/src/members.js +146 -0
  48. package/dist/src/members.js.map +1 -0
  49. package/dist/src/notifications.d.ts +25 -0
  50. package/dist/src/notifications.d.ts.map +1 -0
  51. package/dist/src/notifications.js +121 -0
  52. package/dist/src/notifications.js.map +1 -0
  53. package/dist/src/testing.d.ts +101 -0
  54. package/dist/src/testing.d.ts.map +1 -0
  55. package/dist/src/testing.js +552 -0
  56. package/dist/src/testing.js.map +1 -0
  57. package/package.json +40 -4
package/client/index.ts CHANGED
@@ -1,9 +1,16 @@
1
- // The package root: every published module of the SDK (tool contract v2).
2
- // Each one is also its own subpath (@argentic/chest-sdk/member, /database,
3
- // /files, /errors), which pulls in nothing else. The files API is a namespace
4
- // here, as its names (get, put, list, delete, url) are too plain to stand
5
- // alone.
1
+ // The package root: every published module a tool's code uses (tool contract
2
+ // v2). Each one is also its own subpath (@argentic/chest-sdk/member, /chest,
3
+ // /database, /files, /members, /notifications, /events, /ai, /errors), which
4
+ // pulls in nothing else. The files, members, notifications, events and ai
5
+ // APIs are namespaces here, as their names (get, list, stat, move, notify,
6
+ // verify, chat…) are too plain to stand alone. @argentic/chest-sdk/testing is for a tool's tests only, and
7
+ // is not here.
6
8
  export * from "./src/errors.js";
7
9
  export * from "./src/member.js";
10
+ export * from "./src/chest.js";
8
11
  export * from "./src/database.js";
9
12
  export * as files from "./src/files.js";
13
+ export * as members from "./src/members.js";
14
+ export * as notifications from "./src/notifications.js";
15
+ export * as events from "./src/events.js";
16
+ export * as ai from "./src/ai.js";
@@ -0,0 +1,374 @@
1
+ import { ask, json, read } from "./api.js";
2
+ import { AiCapReached, AiModelNotAllowed, AiRefused, AiUnavailable, CapabilityNotGranted, ChestError, RateLimited, TooLarge, Unavailable, type AiUnavailableReason } from "./errors.js";
3
+ import { memberIdPattern } from "./member.js";
4
+
5
+ // AI models through the Chest, for a server tool whose chest.json declares
6
+ // "capabilities": ["ai"] and "ai": {"monthly", "models", "purpose"}: the
7
+ // Chest's owner connects the providers (their own keys) and maps the aliases
8
+ // the tool names (default, fast, smart, embedding) to models; the Chest meters
9
+ // every call against the tool's monthly cap. The tool never holds a key.
10
+ //
11
+ // import * as ai from "@argentic/chest-sdk/ai";
12
+ // const r = await ai.chat({ model: "default", messages: [{ role: "user", content: "Summarise: …" }], maxTokens: 800, member: who.id });
13
+ // r.text; r.toolCalls; r.usage.cost; // estimated euros
14
+ // for await (const chunk of ai.chat({ model: "fast", messages, stream: true })) write(chunk.text);
15
+ // const { embeddings } = await ai.embed({ model: "embedding", input: ["a", "b"] });
16
+ // const mapped = await ai.models(); // the declared aliases, their models and prices
17
+ // const { spent, cap, resetsAt } = await ai.usage(); // this tool's month
18
+ //
19
+ // AI can stop at any time — the month's budget spent, no connector, the
20
+ // provider down: keep the tool usable without it.
21
+ //
22
+ // import { AiCapReached, AiUnavailable } from "@argentic/chest-sdk/errors";
23
+ // let summary: string | null = null;
24
+ // try {
25
+ // summary = (await ai.chat({ model: "default", messages, maxTokens: 300 })).text;
26
+ // } catch (error) {
27
+ // if (!(error instanceof AiCapReached || error instanceof AiUnavailable)) throw error;
28
+ // // summary stays null: the page says "AI features are paused" and works without it
29
+ // }
30
+ //
31
+ // Errors: AiCapReached (402: the tool's or the Chest's month cap, scope and
32
+ // resetsAt), AiModelNotAllowed (403: a model the tool did not declare),
33
+ // CapabilityNotGranted (403), AiRefused (422: the provider's moderation),
34
+ // RateLimited (429: 60 requests a minute, 8 streams at once), TooLarge (413:
35
+ // a body beyond 10 MiB, or a context beyond the model's), AiUnavailable (503
36
+ // no_connector, 502 provider_key_invalid, 503 provider_unavailable),
37
+ // Unavailable (the Chest not reached, or an answer that is not its own),
38
+ // ChestError otherwise (invalid_body, invalid_request 400: the provider
39
+ // rejected the request's parameters, its message in the error's).
40
+
41
+ // The names a tool gives models: the owner maps each to a provider's model.
42
+ export type Alias = "default" | "fast" | "smart" | "embedding";
43
+ // The providers a Chest connects.
44
+ export type Provider = "openrouter";
45
+
46
+ // A message of a conversation, in the OpenAI Chat Completions shape: content
47
+ // is text, or parts (text, images) passed as they are; an assistant's message
48
+ // may carry tool_calls, and a tool's answer names the tool_call_id it answers.
49
+ export type ChatMessage = {
50
+ role: "system" | "developer" | "user" | "assistant" | "tool";
51
+ content?: string | unknown[] | null;
52
+ name?: string;
53
+ tool_calls?: { id: string; type: "function"; function: { name: string; arguments: string } }[];
54
+ tool_call_id?: string;
55
+ };
56
+ // A tool the model may call (the gateway never runs one): its name, what it
57
+ // does and the JSON Schema of its arguments.
58
+ export type ChatTool = { type: "function"; function: { name: string; description?: string; parameters?: Record<string, unknown>; strict?: boolean } };
59
+ // Whether and which tool the model calls.
60
+ export type ToolChoice = "auto" | "none" | "required" | { type: "function"; function: { name: string } };
61
+ // The shape of the answer: text, any JSON object, or JSON of that schema.
62
+ export type ResponseFormat = { type: "text" } | { type: "json_object" } | { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict?: boolean; description?: string } };
63
+
64
+ // What chat asks: an alias the tool declared, the conversation, and the
65
+ // options of the OpenAI shape (maxTokens 1 to 128,000, 4,096 when not said).
66
+ // member is the member the call is made for: attribution in the Chest's usage
67
+ // log only. signal aborts the call (its reason is thrown); the Chest ends a
68
+ // call after 10 minutes. With stream: true the answer comes in chunks.
69
+ export type ChatOptions = {
70
+ model: Alias;
71
+ messages: ChatMessage[];
72
+ maxTokens?: number;
73
+ temperature?: number;
74
+ topP?: number;
75
+ stop?: string | string[];
76
+ tools?: ChatTool[];
77
+ toolChoice?: ToolChoice;
78
+ responseFormat?: ResponseFormat;
79
+ parallelToolCalls?: boolean;
80
+ seed?: number;
81
+ reasoningEffort?: string;
82
+ member?: string;
83
+ signal?: AbortSignal;
84
+ };
85
+ // A call of a tool the model asks: its id (to name in the tool's answer), the
86
+ // tool's name, and its arguments as the model wrote them (JSON text).
87
+ export type ToolCall = { id: string; name: string; arguments: string };
88
+ // A piece of a tool call, in a stream: index says which call it continues;
89
+ // id and name come first, arguments in pieces to join.
90
+ export type ToolCallDelta = { index: number; id?: string; name?: string; arguments?: string };
91
+ // The tokens of a call (input, output, input read from the provider's cache)
92
+ // and its estimated cost in euros, counted against the caps.
93
+ export type Usage = { input: number; output: number; cached: number; cost: number };
94
+ // A whole answer: its text ("" when none), the assistant's message as it is
95
+ // added to the conversation, the tool calls asked, why it ended (stop,
96
+ // length, tool_calls, content_filter), the provider's model and the usage.
97
+ export type ChatResult = { text: string; message: ChatMessage; toolCalls: ToolCall[]; finishReason: string; model: string; usage: Usage };
98
+ // A piece of a streamed answer: its text ("" when none), tool call pieces,
99
+ // why it ended (once), and the usage (in the last chunk).
100
+ export type ChatChunk = { text: string; toolCalls?: ToolCallDelta[]; finishReason?: string; usage?: Usage };
101
+
102
+ // What embed asks: an alias the tool declared, 1 to 256 texts, the size of
103
+ // the vectors when the model can shorten them, and the member it is for.
104
+ export type EmbedOptions = { model: Alias; input: string | string[]; dimensions?: number; member?: string };
105
+ // One vector per text, in the order given, the provider's model, the input
106
+ // tokens and the estimated cost in euros.
107
+ export type Embeddings = { embeddings: number[][]; model: string; usage: { input: number; cost: number } };
108
+ // A declared alias the owner mapped: its provider's model and its prices, in
109
+ // US dollars per million tokens (as providers publish them).
110
+ export type AiModel = { alias: Alias; model: string; provider: Provider; input: number; output: number };
111
+ // This tool's month (YYYY-MM, UTC): estimated euros spent, the cap in force,
112
+ // and when the next month starts.
113
+ export type AiUsage = { month: string; spent: number; cap: number; resetsAt: Date };
114
+
115
+ const aliases: readonly string[] = ["default", "fast", "smart", "embedding"];
116
+ const providers: readonly string[] = ["openrouter"];
117
+ const reasons: readonly string[] = ["no_connector", "provider_key_invalid", "provider_unavailable"];
118
+ // The bounds of the Chest's gateway, and of what the SDK reads of it.
119
+ const maxBody = 10 << 20, maxAnswer = 16 << 20, maxLine = 1 << 20, maxOutput = 128000, maxInputs = 256;
120
+ const chatDeadline = 600_000;
121
+ // The HTTP status of each code the gateway sends, to map one that comes in a
122
+ // stream.
123
+ const statuses: Record<string, number> = { capability_not_granted: 403, model_not_allowed: 403, cap_reached: 402, rate_limited: 429, no_connector: 503, provider_key_invalid: 502, provider_unavailable: 503, content_refused: 422, too_large: 413, invalid_body: 400, invalid_request: 400 };
124
+ const rfc3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/u;
125
+
126
+ const invalid = (message: string): ChestError => new ChestError("invalid_body", 400, message);
127
+ const record = (value: unknown): Record<string, unknown> | null => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
128
+ const tokens = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
129
+ const amount = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
130
+ function time(value: unknown): Date | null {
131
+ if (typeof value !== "string" || !rfc3339.test(value)) return null;
132
+ const date = new Date(value);
133
+ return Number.isNaN(date.getTime()) ? null : date;
134
+ }
135
+
136
+ function checkModel(model: unknown): void {
137
+ if (typeof model !== "string" || !aliases.includes(model)) throw new AiModelNotAllowed();
138
+ }
139
+ function checkMember(member: unknown): string {
140
+ if (typeof member !== "string" || !memberIdPattern.test(member)) throw invalid("member is a member identifier (mbr_…)");
141
+ return member;
142
+ }
143
+
144
+ // request is the wire body of a chat, checked as the Chest checks it.
145
+ function request(options: ChatOptions, stream: boolean): Record<string, unknown> {
146
+ const o = options as Partial<ChatOptions> | null;
147
+ if (!o) throw invalid("chat takes options");
148
+ checkModel(o.model);
149
+ if (!Array.isArray(o.messages) || o.messages.length < 1 || !o.messages.every(m => typeof record(m)?.["role"] === "string")) throw invalid("messages are 1 or more {role, content}");
150
+ if (o.maxTokens !== undefined && (!Number.isInteger(o.maxTokens) || o.maxTokens < 1 || o.maxTokens > maxOutput)) throw invalid("maxTokens is 1 to 128000");
151
+ const pairs: [string, unknown][] = [["max_tokens", o.maxTokens], ["temperature", o.temperature], ["top_p", o.topP], ["stop", o.stop], ["tools", o.tools], ["tool_choice", o.toolChoice], ["response_format", o.responseFormat], ["parallel_tool_calls", o.parallelToolCalls], ["seed", o.seed], ["reasoning_effort", o.reasoningEffort], ["member", o.member === undefined ? undefined : checkMember(o.member)]];
152
+ return { model: o.model, messages: o.messages, ...Object.fromEntries(pairs.filter(([, v]) => v !== undefined)), ...(stream ? { stream: true } : {}) };
153
+ }
154
+
155
+ // send posts a body of JSON to the gateway, 10 MiB at most.
156
+ async function send(path: string, body: Record<string, unknown>, signal?: AbortSignal, deadline?: number): Promise<Response> {
157
+ const raw = JSON.stringify(body);
158
+ if (Buffer.byteLength(raw) > maxBody) throw new TooLarge();
159
+ return ask("ai", "POST", path, { body: raw, type: "application/json", ...(deadline !== undefined ? { deadline } : {}), ...(signal !== undefined ? { signal } : {}) });
160
+ }
161
+
162
+ // answer reads a success of 16 MiB at most as JSON; anything else is not the
163
+ // Chest's.
164
+ async function answer(response: Response, signal?: AbortSignal): Promise<unknown> {
165
+ try {
166
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await read(response, maxAnswer)));
167
+ } catch {
168
+ if (signal?.aborted) throw signal.reason;
169
+ throw new Unavailable();
170
+ }
171
+ }
172
+
173
+ // mapped turns a refusal of the gateway, with the status it came with, into
174
+ // what the tool tests.
175
+ function mapped(status: number, error: Record<string, unknown> | null): ChestError {
176
+ const code = error?.["error"];
177
+ const resets = time(error?.["resets"]), scope = error?.["scope"];
178
+ if (status === 402 && code === "cap_reached" && resets && (scope === "tool" || scope === "chest")) return new AiCapReached(scope, resets);
179
+ if (status === 403) return code === "model_not_allowed" ? new AiModelNotAllowed() : new CapabilityNotGranted("ai");
180
+ if (status === 429) return new RateLimited();
181
+ if (status === 413) return new TooLarge();
182
+ if (status === 422 && code === "content_refused") return new AiRefused();
183
+ if (typeof code === "string" && reasons.includes(code) && statuses[code] === status) return new AiUnavailable(code as AiUnavailableReason);
184
+ if (status === 400 && typeof code === "string" && /^[a-z_]{1,40}$/u.test(code)) {
185
+ const message = error?.["message"];
186
+ return new ChestError(code, 400, code === "invalid_request" && typeof message === "string" && message.length <= 300 ? `the AI provider rejected the request: ${message}` : `the Chest refused: ${code}`);
187
+ }
188
+ return new Unavailable();
189
+ }
190
+ async function refused(response: Response): Promise<ChestError> {
191
+ if (response.status < 400) {
192
+ await response.body?.cancel();
193
+ return new Unavailable();
194
+ }
195
+ let error: Record<string, unknown> | null = null;
196
+ try {
197
+ error = record(await json(response));
198
+ } catch {
199
+ // Not the gateway's refusal: mapped by its status alone.
200
+ }
201
+ return mapped(response.status, error);
202
+ }
203
+
204
+ function usageOf(value: unknown): Usage {
205
+ const u = record(value), cached = record(u?.["prompt_tokens_details"])?.["cached_tokens"];
206
+ if (!u || !tokens(u["prompt_tokens"]) || !tokens(u["completion_tokens"]) || !tokens(u["total_tokens"]) || !tokens(cached) || !amount(u["cost"])) throw new Unavailable();
207
+ return { input: u["prompt_tokens"], output: u["completion_tokens"], cached, cost: u["cost"] };
208
+ }
209
+
210
+ // result reads a chat completion; anything else is not the Chest's.
211
+ function result(value: unknown): ChatResult {
212
+ const c = record(value), choices = c?.["choices"];
213
+ if (!c || c["object"] !== "chat.completion" || typeof c["model"] !== "string" || !Array.isArray(choices) || choices.length !== 1) throw new Unavailable();
214
+ const choice = record(choices[0]), m = record(choice?.["message"]);
215
+ const content = m?.["content"], calls = m?.["tool_calls"];
216
+ if (!choice || choice["index"] !== 0 || typeof choice["finish_reason"] !== "string" || !m || m["role"] !== "assistant" || !(content === null || typeof content === "string") || !(calls === undefined || Array.isArray(calls))) throw new Unavailable();
217
+ const toolCalls = (calls ?? []).map((value: unknown): ToolCall => {
218
+ const call = record(value), f = record(call?.["function"]);
219
+ if (!call || typeof call["id"] !== "string" || call["type"] !== "function" || !f || typeof f["name"] !== "string" || typeof f["arguments"] !== "string") throw new Unavailable();
220
+ return { id: call["id"], name: f["name"], arguments: f["arguments"] };
221
+ });
222
+ const message: ChatMessage = { role: "assistant", content, ...(toolCalls.length ? { tool_calls: toolCalls.map(t => ({ id: t.id, type: "function" as const, function: { name: t.name, arguments: t.arguments } })) } : {}) };
223
+ return { text: content ?? "", message, toolCalls, finishReason: choice["finish_reason"], model: c["model"], usage: usageOf(c["usage"]) };
224
+ }
225
+
226
+ // chunk reads a chunk of a stream: an error of the gateway is thrown, a
227
+ // chunk is read as the tool gets it; anything else is not the Chest's.
228
+ function chunk(value: unknown): ChatChunk {
229
+ const c = record(value);
230
+ if (c && "error" in c) {
231
+ const code = c["error"];
232
+ throw mapped(typeof code === "string" ? statuses[code] ?? 503 : 503, c);
233
+ }
234
+ const choices = c?.["choices"], usage = c?.["usage"];
235
+ if (!c || c["object"] !== "chat.completion.chunk" || !Array.isArray(choices) || choices.length > 1) throw new Unavailable();
236
+ const piece: ChatChunk = { text: "" };
237
+ if (choices.length === 1) {
238
+ const choice = record(choices[0]), delta = record(choice?.["delta"]);
239
+ const content = delta?.["content"], calls = delta?.["tool_calls"], finish = choice?.["finish_reason"];
240
+ if (!choice || choice["index"] !== 0 || !delta || !(content === undefined || content === null || typeof content === "string") || !(calls === undefined || Array.isArray(calls)) || !(finish === undefined || finish === null || typeof finish === "string")) throw new Unavailable();
241
+ piece.text = content ?? "";
242
+ if (calls?.length) {
243
+ piece.toolCalls = calls.map((value: unknown): ToolCallDelta => {
244
+ const call = record(value), f = call?.["function"] === undefined ? {} : record(call["function"]);
245
+ const index = call?.["index"], id = call?.["id"], name = f?.["name"], args = f?.["arguments"];
246
+ if (!call || !f || !tokens(index) || !(id === undefined || typeof id === "string") || !(call["type"] === undefined || call["type"] === "function") || !(name === undefined || typeof name === "string") || !(args === undefined || typeof args === "string")) throw new Unavailable();
247
+ return { index, ...(id !== undefined ? { id } : {}), ...(name !== undefined ? { name } : {}), ...(args !== undefined ? { arguments: args } : {}) };
248
+ });
249
+ }
250
+ if (typeof finish === "string") piece.finishReason = finish;
251
+ }
252
+ if (usage !== undefined && usage !== null) piece.usage = usageOf(usage);
253
+ else if (choices.length === 0) throw new Unavailable();
254
+ return piece;
255
+ }
256
+
257
+ // lines reads the lines of an event stream, each 1 MiB at most; a line cut
258
+ // by the end of the stream is not read.
259
+ async function* lines(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
260
+ const decoder = new TextDecoder("utf-8", { fatal: true });
261
+ let pending = "";
262
+ for await (const bytes of body) {
263
+ pending += decoder.decode(bytes, { stream: true });
264
+ for (let at = pending.indexOf("\n"); at >= 0; at = pending.indexOf("\n")) {
265
+ const line = pending.slice(0, at);
266
+ pending = pending.slice(at + 1);
267
+ if (Buffer.byteLength(line) > maxLine + 1) throw new Unavailable();
268
+ yield line.endsWith("\r") ? line.slice(0, -1) : line;
269
+ }
270
+ if (Buffer.byteLength(pending) > maxLine) throw new Unavailable();
271
+ }
272
+ }
273
+
274
+ async function completed(options: ChatOptions): Promise<ChatResult> {
275
+ const response = await send("/ai/chat", request(options, false), options.signal, chatDeadline);
276
+ if (response.status !== 200) throw await refused(response);
277
+ return result(await answer(response, options.signal));
278
+ }
279
+
280
+ async function* streamed(options: ChatOptions): AsyncGenerator<ChatChunk> {
281
+ const response = await send("/ai/chat", request(options, true), options.signal, chatDeadline);
282
+ if (response.status !== 200) throw await refused(response);
283
+ if (!/^text\/event-stream(;|$)/iu.test(response.headers.get("content-type") ?? "") || !response.body) {
284
+ await response.body?.cancel();
285
+ throw new Unavailable();
286
+ }
287
+ // The stream ends with the usage, then [DONE]; a stream cut before is not
288
+ // the Chest's.
289
+ let usage = false, done = false;
290
+ try {
291
+ for await (const line of lines(response.body)) {
292
+ options.signal?.throwIfAborted();
293
+ if (line === "" || line.startsWith(":")) continue;
294
+ if (!line.startsWith("data:")) throw new Unavailable();
295
+ const data = line.slice(line.startsWith("data: ") ? 6 : 5);
296
+ if (data === "[DONE]") {
297
+ done = true;
298
+ break;
299
+ }
300
+ let value: unknown;
301
+ try {
302
+ value = JSON.parse(data);
303
+ } catch {
304
+ throw new Unavailable();
305
+ }
306
+ const piece = chunk(value);
307
+ if (piece.usage) usage = true;
308
+ yield piece;
309
+ }
310
+ } catch (error) {
311
+ if (options.signal?.aborted) throw options.signal.reason;
312
+ throw error instanceof ChestError ? error : new Unavailable();
313
+ }
314
+ if (!done || !usage) throw new Unavailable();
315
+ }
316
+
317
+ // chat asks a model for the next message of a conversation: the whole answer,
318
+ // or with stream: true its pieces as they come (text, tool call pieces, then
319
+ // the finish reason and the usage). Breaking out of the loop ends the call;
320
+ // what was produced is counted.
321
+ export function chat(options: ChatOptions & { stream: true }): AsyncIterable<ChatChunk>;
322
+ export function chat(options: ChatOptions & { stream?: false }): Promise<ChatResult>;
323
+ export function chat(options: ChatOptions & { stream?: boolean }): Promise<ChatResult> | AsyncIterable<ChatChunk>;
324
+ export function chat(options: ChatOptions & { stream?: boolean }): Promise<ChatResult> | AsyncIterable<ChatChunk> {
325
+ return options?.stream === true ? streamed(options) : completed(options);
326
+ }
327
+
328
+ // embed turns 1 to 256 texts into vectors, one per text in the order given.
329
+ export async function embed(options: EmbedOptions): Promise<Embeddings> {
330
+ const o = options as Partial<EmbedOptions> | null;
331
+ if (!o) throw invalid("embed takes options");
332
+ checkModel(o.model);
333
+ const inputs = typeof o.input === "string" ? [o.input] : o.input;
334
+ if (!Array.isArray(inputs) || inputs.length < 1 || inputs.length > maxInputs || !inputs.every(i => typeof i === "string")) throw invalid("input is 1 to 256 texts");
335
+ if (o.dimensions !== undefined && (!Number.isSafeInteger(o.dimensions) || o.dimensions < 1)) throw invalid("dimensions is a positive integer");
336
+ const body = { model: o.model, input: o.input, ...(o.dimensions !== undefined ? { dimensions: o.dimensions } : {}), ...(o.member !== undefined ? { member: checkMember(o.member) } : {}) };
337
+ const response = await send("/ai/embeddings", body);
338
+ if (response.status !== 200) throw await refused(response);
339
+ const a = record(await answer(response)), data = a?.["data"], u = record(a?.["usage"]);
340
+ if (!a || a["object"] !== "list" || typeof a["model"] !== "string" || !Array.isArray(data) || data.length !== inputs.length || !u || !tokens(u["prompt_tokens"]) || !tokens(u["total_tokens"]) || !amount(u["cost"])) throw new Unavailable();
341
+ const embeddings: number[][] = [];
342
+ for (const value of data) {
343
+ const e = record(value), index = e?.["index"], vector = e?.["embedding"];
344
+ if (!e || e["object"] !== "embedding" || !tokens(index) || index >= inputs.length || embeddings[index] !== undefined || !Array.isArray(vector) || vector.length < 1 || !vector.every(x => typeof x === "number" && Number.isFinite(x))) throw new Unavailable();
345
+ embeddings[index] = [...vector] as number[];
346
+ }
347
+ if (!embeddings.every(v => v.length === embeddings[0]!.length)) throw new Unavailable();
348
+ return { embeddings, model: a["model"], usage: { input: u["prompt_tokens"], cost: u["cost"] } };
349
+ }
350
+
351
+ // models are the aliases the tool declared that the owner mapped, in alias
352
+ // order: each one's model, provider and prices.
353
+ export async function models(): Promise<AiModel[]> {
354
+ const response = await ask("ai", "GET", "/ai/models");
355
+ if (response.status !== 200) throw await refused(response);
356
+ const list = record(await json(response))?.["models"];
357
+ if (!Array.isArray(list) || list.length > aliases.length) throw new Unavailable();
358
+ const seen: string[] = [];
359
+ return list.map((value: unknown): AiModel => {
360
+ const m = record(value), alias = m?.["alias"], model = m?.["model"], provider = m?.["provider"], input = m?.["input"], output = m?.["output"];
361
+ if (typeof alias !== "string" || !aliases.includes(alias) || aliases.indexOf(alias) <= aliases.indexOf(seen.at(-1) ?? "") || typeof model !== "string" || model.length < 1 || model.length > 200 || typeof provider !== "string" || !providers.includes(provider) || !amount(input) || !amount(output)) throw new Unavailable();
362
+ seen.push(alias);
363
+ return { alias: alias as Alias, model, provider: provider as Provider, input, output };
364
+ });
365
+ }
366
+
367
+ // usage is this tool's month: what it spent, its cap, when both reset.
368
+ export async function usage(): Promise<AiUsage> {
369
+ const response = await ask("ai", "GET", "/ai/usage");
370
+ if (response.status !== 200) throw await refused(response);
371
+ const u = record(await json(response)), month = u?.["month"], spent = u?.["spent"], cap = u?.["cap"], resetsAt = time(u?.["resets"]);
372
+ if (typeof month !== "string" || !/^\d{4}-(0[1-9]|1[0-2])$/u.test(month) || !amount(spent) || !tokens(cap) || !resetsAt) throw new Unavailable();
373
+ return { month, spent, cap, resetsAt };
374
+ }
@@ -0,0 +1,85 @@
1
+ import { CapabilityNotGranted, ChestError, QuotaExceeded, RateLimited, TooLarge, Unavailable } from "./errors.js";
2
+
3
+ // The Chest's API as a server tool reaches it, shared by the modules that
4
+ // call it (files, members): CHEST_API is http://127.0.0.1:<port>, the tool's
5
+ // launcher, which relays each request to the Chest — the container has no
6
+ // network. A call reaches what is the tool's only: its instance is its
7
+ // identity. Not a published module.
8
+
9
+ const maxAnswer = 4 << 20;
10
+ const deadline = 120000;
11
+
12
+ // base is the Chest's API as the launcher gives it; without, the version holds
13
+ // none of the capabilities that use it.
14
+ function base(capability: string): string {
15
+ const value = process.env["CHEST_API"];
16
+ if (typeof value !== "string" || !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}$/u.test(value) || Number(value.slice(17)) > 65535) throw new CapabilityNotGranted(capability);
17
+ return value;
18
+ }
19
+
20
+ // ask sends one request of a capability to the Chest; a failure to reach it
21
+ // is Unavailable. The request and the reading of its answer end after
22
+ // deadline milliseconds (120 seconds unless said), or when the caller's
23
+ // signal aborts: its reason is then thrown.
24
+ export async function ask(capability: string, method: string, path: string, init: { body?: Uint8Array<ArrayBuffer> | string; type?: string; deadline?: number; signal?: AbortSignal } = {}): Promise<Response> {
25
+ const headers: Record<string, string> = {};
26
+ if (init.type !== undefined) headers["Content-Type"] = init.type;
27
+ const url = base(capability) + path;
28
+ const timeout = AbortSignal.timeout(init.deadline ?? deadline);
29
+ try {
30
+ return await fetch(url, { method, headers, ...(init.body !== undefined ? { body: init.body } : {}), redirect: "error", signal: init.signal ? AbortSignal.any([timeout, init.signal]) : timeout });
31
+ } catch {
32
+ if (init.signal?.aborted) throw init.signal.reason;
33
+ throw new Unavailable();
34
+ }
35
+ }
36
+
37
+ // read takes a body of limit bytes at most; beyond, or cut, the answer is not
38
+ // the Chest's.
39
+ export async function read(response: Response, limit: number): Promise<Uint8Array> {
40
+ const declared = Number(response.headers.get("content-length") ?? "0");
41
+ if (declared > limit) throw new Unavailable();
42
+ const chunks: Uint8Array[] = [];
43
+ let size = 0;
44
+ try {
45
+ for await (const chunk of response.body ?? []) {
46
+ size += chunk.byteLength;
47
+ if (size > limit) throw new Unavailable();
48
+ chunks.push(chunk);
49
+ }
50
+ } catch {
51
+ throw new Unavailable();
52
+ }
53
+ const all = new Uint8Array(size);
54
+ let at = 0;
55
+ for (const chunk of chunks) {
56
+ all.set(chunk, at);
57
+ at += chunk.byteLength;
58
+ }
59
+ return all;
60
+ }
61
+
62
+ // json reads an answer of the Chest as JSON; anything else is Unavailable.
63
+ export async function json(response: Response): Promise<unknown> {
64
+ try {
65
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await read(response, maxAnswer)));
66
+ } catch {
67
+ throw new Unavailable();
68
+ }
69
+ }
70
+
71
+ // refusal turns an answer that is not a success into what the tool tests.
72
+ export async function refusal(response: Response, capability: string): Promise<ChestError> {
73
+ let code = "refused";
74
+ try {
75
+ const given = ((await json(response)) as { error?: unknown } | null)?.error;
76
+ if (typeof given === "string" && /^[a-z_]{1,40}$/u.test(given)) code = given;
77
+ } catch {
78
+ // The code stays "refused".
79
+ }
80
+ if (response.status === 403) return new CapabilityNotGranted(capability);
81
+ if (response.status === 413) return new TooLarge();
82
+ if (response.status === 429) return code === "rate_limited" ? new RateLimited() : new QuotaExceeded();
83
+ if (response.status >= 500) return new Unavailable();
84
+ return new ChestError(code, response.status, `the Chest refused: ${code}`);
85
+ }
@@ -0,0 +1,80 @@
1
+ import { ChestError } from "./errors.js";
2
+ import { languagePattern, timeZonePattern } from "./member.js";
3
+
4
+ // The Chest the tool runs in, the same for every member and every request:
5
+ // the organization it is of, its time zone and its language. The Chest sets
6
+ // them in the tool's environment at each start (CHEST_ORGANIZATION,
7
+ // CHEST_TIME_ZONE, CHEST_LANGUAGE) and starts the tool again when its owner
8
+ // changes one, so they are there outside any request too: in a scheduled
9
+ // job, at start-up, in a migration script. The Chest also sets its time zone
10
+ // as the zone of the tool's database sessions: there, current_date and
11
+ // now()::date are the Chest's day too.
12
+ //
13
+ // - organization.name is the organization's name as its owner wrote it
14
+ // ("Acme SAS"), plain text of 2 to 80 characters: for a header, a document,
15
+ // an email.
16
+ // - timeZone is an IANA zone ("Europe/Paris"; "UTC" until the owner sets
17
+ // one): the day of "due today", the hour of a reminder.
18
+ // - language is the Chest's own language, a primary tag ("en", "fr"): the
19
+ // language of what the tool writes for no one in particular (a public page
20
+ // before the visitor chooses, an export). A member's is member.language.
21
+ // - today() is the date ("YYYY-MM-DD") in the Chest's zone, now or at the
22
+ // instant given.
23
+ //
24
+ // Reading one outside a Chest (no fakeChest in a test, a development server
25
+ // without the variables) throws a ChestError "not_in_chest": a wrong zone
26
+ // read silently is the bug this module is for.
27
+ export type Chest = {
28
+ readonly organization: { readonly name: string };
29
+ readonly timeZone: string;
30
+ readonly language: string;
31
+ today(at?: Date | number): string;
32
+ };
33
+
34
+ // The shapes the Chest gives: the organization's (2 to 80 characters,
35
+ // counted as code points, without control characters), a zone's
36
+ // (timeZonePattern, and one this runtime knows), a language's.
37
+ const organizationPattern = /^[^\u0000-\u001f\u007f-\u009f]{2,80}$/u;
38
+
39
+ function read(name: string, valid: (value: string) => boolean): string {
40
+ const value = process.env[name];
41
+ if (typeof value !== "string" || !valid(value)) throw new ChestError("not_in_chest", 500, `not running in a Chest: ${name} is missing or invalid`);
42
+ return value;
43
+ }
44
+
45
+ function knownZone(zone: string): boolean {
46
+ if (!timeZonePattern.test(zone)) return false;
47
+ try {
48
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ const zone = (): string => read("CHEST_TIME_ZONE", knownZone);
56
+
57
+ // dateIn is the date at that instant in a zone, as YYYY-MM-DD.
58
+ function dateIn(at: Date, zone: string): string {
59
+ const parts = Object.fromEntries(new Intl.DateTimeFormat("en-US", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(at).map(p => [p.type, p.value]));
60
+ return `${parts["year"]}-${parts["month"]}-${parts["day"]}`;
61
+ }
62
+
63
+ // chest reads the environment at each access: what a test's fakeChest sets
64
+ // is what it answers.
65
+ export const chest: Chest = {
66
+ get organization() {
67
+ return { name: read("CHEST_ORGANIZATION", value => organizationPattern.test(value)) };
68
+ },
69
+ get timeZone() {
70
+ return zone();
71
+ },
72
+ get language() {
73
+ return read("CHEST_LANGUAGE", value => languagePattern.test(value));
74
+ },
75
+ today(at: Date | number = Date.now()): string {
76
+ const instant = typeof at === "number" ? new Date(at) : at;
77
+ if (Number.isNaN(instant.getTime())) throw new RangeError("today() needs a valid date");
78
+ return dateIn(instant, zone());
79
+ },
80
+ };
@@ -20,15 +20,26 @@ export class CapabilityNotGranted extends ChestError {
20
20
  }
21
21
  }
22
22
 
23
- // What the tool keeps would go beyond what its Chest gives it: its total
24
- // (1 GiB of files) or its count (10,000 objects).
23
+ // The call would take the tool beyond what its Chest gives it: for files,
24
+ // their total (1 GiB unless its manifest asks more) or their count (10,000
25
+ // objects); for notifications, 1,000 recipients an hour, 100 items per member
26
+ // a day or 600 badge writes a minute. A refused call changes nothing.
25
27
  export class QuotaExceeded extends ChestError {
26
28
  constructor() {
27
29
  super("quota_exceeded", 429, "the Chest refused: the tool's quota would be exceeded");
28
30
  }
29
31
  }
30
32
 
31
- // One object is beyond the bound of one (32 MiB for a file).
33
+ // The tool called the Chest's API more often than its bound (600 calls a
34
+ // minute for its members): it waits before calling again.
35
+ export class RateLimited extends ChestError {
36
+ constructor() {
37
+ super("rate_limited", 429, "the Chest refused: too many calls, wait a minute");
38
+ }
39
+ }
40
+
41
+ // One object is beyond the bound of one (for a file, the tool's largest
42
+ // object: 32 MiB unless its manifest asks more, 512 MiB at most).
32
43
  export class TooLarge extends ChestError {
33
44
  constructor() {
34
45
  super("too_large", 413, "the Chest refused: the object is too large");
@@ -42,3 +53,47 @@ export class Unavailable extends ChestError {
42
53
  super("unavailable", 503, "the Chest is unavailable");
43
54
  }
44
55
  }
56
+
57
+ // The month's AI budget is spent: the tool's cap (scope "tool") or the
58
+ // Chest's (scope "chest"), until resetsAt. Nothing was spent on the refused
59
+ // call. Keep the tool usable without AI and tell the member AI features are
60
+ // paused.
61
+ export class AiCapReached extends ChestError {
62
+ readonly scope: "tool" | "chest";
63
+ readonly resetsAt: Date;
64
+ constructor(scope: "tool" | "chest", resetsAt: Date) {
65
+ super("cap_reached", 402, `the Chest refused: the ${scope === "tool" ? "tool's" : "Chest's"} AI budget for the month is spent until ${resetsAt.toISOString()}`);
66
+ this.scope = scope;
67
+ this.resetsAt = resetsAt;
68
+ }
69
+ }
70
+
71
+ // Why AI is unavailable: no connector behind the model in this Chest, the
72
+ // provider refused the connector's key, or the provider failed or timed out.
73
+ export type AiUnavailableReason = "no_connector" | "provider_key_invalid" | "provider_unavailable";
74
+
75
+ // AI cannot answer now, for a reason the Chest's owner or the provider must
76
+ // fix (the code is the reason). Keep the tool usable without AI and tell the
77
+ // member AI features are paused.
78
+ export class AiUnavailable extends ChestError {
79
+ readonly reason: AiUnavailableReason;
80
+ constructor(reason: AiUnavailableReason) {
81
+ super(reason, reason === "provider_key_invalid" ? 502 : 503, reason === "no_connector" ? "AI is not set up in this Chest: no connector behind the model" : reason === "provider_key_invalid" ? "the AI provider refused the Chest's key" : "the AI provider failed or did not answer");
82
+ this.reason = reason;
83
+ }
84
+ }
85
+
86
+ // The model is not one of the aliases the tool declared in its chest.json
87
+ // ("ai": {"models"}): default, fast, smart, embedding.
88
+ export class AiModelNotAllowed extends ChestError {
89
+ constructor() {
90
+ super("model_not_allowed", 403, "the Chest refused: the model is not one the tool declared (default, fast, smart or embedding in chest.json)");
91
+ }
92
+ }
93
+
94
+ // The provider's moderation refused the content of the request.
95
+ export class AiRefused extends ChestError {
96
+ constructor() {
97
+ super("content_refused", 422, "the AI provider refused the content of the request");
98
+ }
99
+ }