@argentic/chest-sdk 0.2.0 → 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.
@@ -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
+ }
package/client/src/api.ts CHANGED
@@ -18,14 +18,18 @@ function base(capability: string): string {
18
18
  }
19
19
 
20
20
  // ask sends one request of a capability to the Chest; a failure to reach it
21
- // is Unavailable.
22
- export async function ask(capability: string, method: string, path: string, init: { body?: Uint8Array<ArrayBuffer> | string; type?: string } = {}): Promise<Response> {
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> {
23
25
  const headers: Record<string, string> = {};
24
26
  if (init.type !== undefined) headers["Content-Type"] = init.type;
25
27
  const url = base(capability) + path;
28
+ const timeout = AbortSignal.timeout(init.deadline ?? deadline);
26
29
  try {
27
- return await fetch(url, { method, headers, ...(init.body !== undefined ? { body: init.body } : {}), redirect: "error", signal: AbortSignal.timeout(deadline) });
30
+ return await fetch(url, { method, headers, ...(init.body !== undefined ? { body: init.body } : {}), redirect: "error", signal: init.signal ? AbortSignal.any([timeout, init.signal]) : timeout });
28
31
  } catch {
32
+ if (init.signal?.aborted) throw init.signal.reason;
29
33
  throw new Unavailable();
30
34
  }
31
35
  }
@@ -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
+ };
@@ -53,3 +53,47 @@ export class Unavailable extends ChestError {
53
53
  super("unavailable", 503, "the Chest is unavailable");
54
54
  }
55
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
+ }
@@ -14,6 +14,14 @@ import type { IncomingMessage } from "node:http";
14
14
  // declares: null when there is none.
15
15
  // - isBuilder says they build this tool; groups are the groups that give them
16
16
  // this tool ("grp_…").
17
+ // - language is the language the Chest speaks to this member (their own,
18
+ // else the Chest's default): a BCP 47 primary tag the product speaks
19
+ // ("en", "fr"…). The tool's private part (/chest) speaks it to them; a
20
+ // notification or an email to them is written in it.
21
+ // - timeZone is the IANA zone the member works in ("America/New_York"):
22
+ // the one they chose in their profile, else their browser's, else the
23
+ // Chest's. Show them times in it; remind them at their hour in it. The
24
+ // company's day and business rules are the Chest's (chest.timeZone).
17
25
  // - email is there only when the tool holds "members.email".
18
26
  export type Member = {
19
27
  id: string;
@@ -25,22 +33,34 @@ export type Member = {
25
33
  isAdmin: boolean;
26
34
  isBuilder: boolean;
27
35
  groups: string[];
36
+ language: string;
37
+ timeZone: string;
28
38
  email?: string;
29
39
  };
40
+ // What is the same for every member — the organization, the company's time
41
+ // zone — is the Chest's: the chest module.
30
42
 
31
43
  // The grammars of the identifiers the Chest mints: a tool may check with them
32
44
  // the identifiers it stores.
33
45
  export const memberIdPattern = /^mbr_[a-z2-7]{26}$/u;
34
46
  export const groupIdPattern = /^grp_[a-z2-7]{26}$/u;
47
+ // The grammar of a language the Chest gives: a primary tag, whichever the
48
+ // product speaks (a language added to the Chest needs no change here); of a
49
+ // zone: UTC, or an area and a location ("Europe/Paris",
50
+ // "America/Argentina/Buenos_Aires").
51
+ export const languagePattern = /^[a-z]{2,3}$/u;
52
+ export const timeZonePattern = /^(?:UTC|[A-Z][A-Za-z_]{1,31}(?:\/[A-Za-z0-9_+-]{1,31}){1,2})$/u;
35
53
 
36
54
  // The key of the assertions is HMAC-SHA256 of this label under the text of
37
55
  // CHEST_TOKEN, exactly as the Chest derives it (chest/toolfront). Its version
38
- // is the shape of the claims: an assertion of another shape is refused. This
39
- // module stands alone (node:* only), so that it can be copied by itself.
56
+ // changes when a claim changes meaning or goes, so that an assertion of
57
+ // another shape is refused rather than misread; a claim added keeps it, as a
58
+ // reader of the former claims still reads them. This module stands alone
59
+ // (node:* only), so that it can be copied by itself.
40
60
  const label = "Chest-Member v2";
41
61
  // The claims every assertion carries; email only for a tool that holds
42
62
  // members.email.
43
- const claims = ["iss", "aud", "iat", "exp", "sub", "given_name", "family_name", "name", "picture", "role", "admin", "builder", "groups"] as const;
63
+ const claims = ["iss", "aud", "iat", "exp", "sub", "given_name", "family_name", "name", "picture", "role", "admin", "builder", "groups", "language", "time_zone"] as const;
44
64
  // Clocks of the Chest and of the container may differ by this much, in seconds.
45
65
  const skew = 5;
46
66
  // An assertion is a few hundred bytes; anything longer is not one.
@@ -87,12 +107,13 @@ export function member(request: IncomingMessage | Request): Member | null {
87
107
  if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) return null;
88
108
  const payload = json(encodedPayload);
89
109
  if (!payload || !claims.every(name => Object.hasOwn(payload, name))) return null;
90
- const { iss, aud, iat, exp, sub, given_name, family_name, name, email, picture, role, admin, builder, groups } = payload;
110
+ const { iss, aud, iat, exp, sub, given_name, family_name, name, email, picture, role, admin, builder, groups, language, time_zone } = payload;
91
111
  if (typeof iss !== "string" || iss === "" || aud !== tool || typeof sub !== "string" || !memberIdPattern.test(sub)) return null;
92
112
  if (typeof iat !== "number" || !Number.isSafeInteger(iat) || typeof exp !== "number" || !Number.isSafeInteger(exp) || exp <= iat) return null;
93
113
  const now = Math.floor(Date.now() / 1000);
94
114
  if (iat > now + skew || exp <= now - skew) return null;
95
115
  if (typeof given_name !== "string" || typeof family_name !== "string" || typeof name !== "string" || typeof picture !== "string" || typeof role !== "string" || typeof admin !== "boolean" || typeof builder !== "boolean") return null;
96
116
  if (!Array.isArray(groups) || groups.length > 16 || !groups.every(g => typeof g === "string" && groupIdPattern.test(g)) || (email !== undefined && typeof email !== "string")) return null;
97
- return { id: sub, firstName: given_name, lastName: family_name, name, photo: picture === "" ? null : picture, role: role === "" ? null : role, isAdmin: admin, isBuilder: builder, groups: [...groups] as string[], ...(email === undefined ? {} : { email }) };
117
+ if (typeof language !== "string" || !languagePattern.test(language) || typeof time_zone !== "string" || !timeZonePattern.test(time_zone)) return null;
118
+ return { id: sub, firstName: given_name, lastName: family_name, name, photo: picture === "" ? null : picture, role: role === "" ? null : role, isAdmin: admin, isBuilder: builder, groups: [...groups] as string[], language, timeZone: time_zone, ...(email === undefined ? {} : { email }) };
98
119
  }
@@ -1,6 +1,6 @@
1
1
  import { ask, json, refusal } from "./api.js";
2
2
  import { ChestError, Unavailable } from "./errors.js";
3
- import { groupIdPattern, memberIdPattern, type Member } from "./member.js";
3
+ import { groupIdPattern, languagePattern, memberIdPattern, timeZonePattern, type Member } from "./member.js";
4
4
 
5
5
  // Who has the tool, for a server tool whose chest.json declares
6
6
  // "capabilities": ["members"] (and "members.email" for their addresses):
@@ -47,8 +47,8 @@ const text = (value: unknown, max: number): value is string => typeof value ===
47
47
  // Chest's answer.
48
48
  function shown(value: unknown): Member {
49
49
  const m = value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
50
- if (!m || typeof m["id"] !== "string" || !memberIdPattern.test(m["id"]) || !text(m["first_name"], 256) || !text(m["last_name"], 256) || !text(m["name"], 520) || !(m["photo"] === null || text(m["photo"], 200)) || !(m["role"] === null || text(m["role"], 48)) || typeof m["admin"] !== "boolean" || typeof m["builder"] !== "boolean" || !Array.isArray(m["groups"]) || m["groups"].length > 16 || !m["groups"].every(g => typeof g === "string" && groupIdPattern.test(g)) || !(m["email"] === undefined || text(m["email"], 254))) throw new Unavailable();
51
- return { id: m["id"], firstName: m["first_name"], lastName: m["last_name"], name: m["name"], photo: m["photo"], role: m["role"], isAdmin: m["admin"], isBuilder: m["builder"], groups: [...m["groups"]] as string[], ...(m["email"] === undefined ? {} : { email: m["email"] }) };
50
+ if (!m || typeof m["id"] !== "string" || !memberIdPattern.test(m["id"]) || !text(m["first_name"], 256) || !text(m["last_name"], 256) || !text(m["name"], 520) || !(m["photo"] === null || text(m["photo"], 200)) || !(m["role"] === null || text(m["role"], 48)) || typeof m["admin"] !== "boolean" || typeof m["builder"] !== "boolean" || !Array.isArray(m["groups"]) || m["groups"].length > 16 || !m["groups"].every(g => typeof g === "string" && groupIdPattern.test(g)) || typeof m["language"] !== "string" || !languagePattern.test(m["language"]) || typeof m["time_zone"] !== "string" || !timeZonePattern.test(m["time_zone"]) || !(m["email"] === undefined || text(m["email"], 254))) throw new Unavailable();
51
+ return { id: m["id"], firstName: m["first_name"], lastName: m["last_name"], name: m["name"], photo: m["photo"], role: m["role"], isAdmin: m["admin"], isBuilder: m["builder"], groups: [...m["groups"]] as string[], language: m["language"], timeZone: m["time_zone"], ...(m["email"] === undefined ? {} : { email: m["email"] }) };
52
52
  }
53
53
 
54
54
  // list says the members who have the tool, by name then identifier, limit