@argentic/chest-sdk 0.2.0 → 0.4.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 (62) hide show
  1. package/README.md +406 -51
  2. package/client/index.ts +8 -5
  3. package/client/src/ai.ts +374 -0
  4. package/client/src/api.ts +25 -3
  5. package/client/src/chest.ts +104 -0
  6. package/client/src/errors.ts +44 -0
  7. package/client/src/events.ts +12 -104
  8. package/client/src/files.ts +9 -9
  9. package/client/src/member.ts +26 -5
  10. package/client/src/members.ts +21 -16
  11. package/client/src/schedules.ts +87 -0
  12. package/client/src/signed.ts +166 -0
  13. package/client/src/testing.ts +290 -65
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -5
  17. package/dist/index.js.map +1 -1
  18. package/dist/src/ai.d.ts +131 -0
  19. package/dist/src/ai.d.ts.map +1 -0
  20. package/dist/src/ai.js +290 -0
  21. package/dist/src/ai.js.map +1 -0
  22. package/dist/src/api.d.ts +4 -0
  23. package/dist/src/api.d.ts.map +1 -1
  24. package/dist/src/api.js +25 -2
  25. package/dist/src/api.js.map +1 -1
  26. package/dist/src/chest.d.ts +15 -0
  27. package/dist/src/chest.d.ts.map +1 -0
  28. package/dist/src/chest.js +61 -0
  29. package/dist/src/chest.js.map +1 -0
  30. package/dist/src/errors.d.ts +16 -0
  31. package/dist/src/errors.d.ts.map +1 -1
  32. package/dist/src/errors.js +36 -0
  33. package/dist/src/errors.js.map +1 -1
  34. package/dist/src/events.d.ts +3 -6
  35. package/dist/src/events.d.ts.map +1 -1
  36. package/dist/src/events.js +8 -102
  37. package/dist/src/events.js.map +1 -1
  38. package/dist/src/files.d.ts +1 -0
  39. package/dist/src/files.d.ts.map +1 -1
  40. package/dist/src/files.js +6 -7
  41. package/dist/src/files.js.map +1 -1
  42. package/dist/src/member.d.ts +4 -0
  43. package/dist/src/member.d.ts.map +1 -1
  44. package/dist/src/member.js +17 -5
  45. package/dist/src/member.js.map +1 -1
  46. package/dist/src/members.d.ts +1 -1
  47. package/dist/src/members.d.ts.map +1 -1
  48. package/dist/src/members.js +9 -8
  49. package/dist/src/members.js.map +1 -1
  50. package/dist/src/schedules.d.ts +15 -0
  51. package/dist/src/schedules.d.ts.map +1 -0
  52. package/dist/src/schedules.js +45 -0
  53. package/dist/src/schedules.js.map +1 -0
  54. package/dist/src/signed.d.ts +29 -0
  55. package/dist/src/signed.d.ts.map +1 -0
  56. package/dist/src/signed.js +139 -0
  57. package/dist/src/signed.js.map +1 -0
  58. package/dist/src/testing.d.ts +53 -12
  59. package/dist/src/testing.d.ts.map +1 -1
  60. package/dist/src/testing.js +265 -60
  61. package/dist/src/testing.js.map +1 -1
  62. package/package.json +27 -4
package/client/index.ts CHANGED
@@ -1,14 +1,17 @@
1
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,
3
- // /database, /files, /members, /notifications, /events, /errors), which
4
- // pulls in nothing else. The files, members, notifications and events APIs
5
- // are namespaces here, as their names (get, list, stat, move, notify,
6
- // verify…) are too plain to stand alone. @argentic/chest-sdk/testing is for a tool's tests only, and
2
+ // 0.4). Each one is also its own subpath (@argentic/chest-sdk/member, /chest,
3
+ // /database, /files, /members, /notifications, /events, /schedules, /ai, /errors), which
4
+ // pulls in nothing else. The files, members, notifications, events, schedules 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
7
  // is not here.
8
8
  export * from "./src/errors.js";
9
9
  export * from "./src/member.js";
10
+ export * from "./src/chest.js";
10
11
  export * from "./src/database.js";
11
12
  export * as files from "./src/files.js";
12
13
  export * as members from "./src/members.js";
13
14
  export * as notifications from "./src/notifications.js";
14
15
  export * as events from "./src/events.js";
16
+ export * as schedules from "./src/schedules.js";
17
+ 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
+ }
package/client/src/api.ts CHANGED
@@ -9,6 +9,24 @@ import { CapabilityNotGranted, ChestError, QuotaExceeded, RateLimited, TooLarge,
9
9
  const maxAnswer = 4 << 20;
10
10
  const deadline = 120000;
11
11
 
12
+ // fakeOrigins are the origins of the fake Chests a test started in this
13
+ // process (testing.ts, fakeChest), each http://127.0.0.1:<port>: links and
14
+ // uploads a fake gives on them are taken as the Chest's https ones are. Only
15
+ // the testing module adds to it — the one module no production code
16
+ // imports —, and only for as long as its fake runs; an origin is never read
17
+ // from the environment, so nothing set around a tool widens what it accepts.
18
+ export const fakeOrigins = new Set<string>();
19
+
20
+ // chestLink reads a link to the team host the Chest answered, at path (its
21
+ // links, its uploads): the token it carries, or undefined for an address
22
+ // that is not one — https, or the origin of a fake Chest of this process.
23
+ export function chestLink(url: unknown, path: string): string | undefined {
24
+ if (typeof url !== "string") return undefined;
25
+ const found = /^(https:\/\/[A-Za-z0-9.-]{1,253}(?::[0-9]{1,5})?|http:\/\/127\.0\.0\.1:[0-9]{1,5})(\/_chest\/[a-z/]+\/)([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/u.exec(url);
26
+ if (!found || found[2] !== path || (found[1]!.startsWith("http:") && !fakeOrigins.has(found[1]!))) return undefined;
27
+ return found[3];
28
+ }
29
+
12
30
  // base is the Chest's API as the launcher gives it; without, the version holds
13
31
  // none of the capabilities that use it.
14
32
  function base(capability: string): string {
@@ -18,14 +36,18 @@ function base(capability: string): string {
18
36
  }
19
37
 
20
38
  // 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> {
39
+ // is Unavailable. The request and the reading of its answer end after
40
+ // deadline milliseconds (120 seconds unless said), or when the caller's
41
+ // signal aborts: its reason is then thrown.
42
+ export async function ask(capability: string, method: string, path: string, init: { body?: Uint8Array<ArrayBuffer> | string; type?: string; deadline?: number; signal?: AbortSignal } = {}): Promise<Response> {
23
43
  const headers: Record<string, string> = {};
24
44
  if (init.type !== undefined) headers["Content-Type"] = init.type;
25
45
  const url = base(capability) + path;
46
+ const timeout = AbortSignal.timeout(init.deadline ?? deadline);
26
47
  try {
27
- return await fetch(url, { method, headers, ...(init.body !== undefined ? { body: init.body } : {}), redirect: "error", signal: AbortSignal.timeout(deadline) });
48
+ return await fetch(url, { method, headers, ...(init.body !== undefined ? { body: init.body } : {}), redirect: "error", signal: init.signal ? AbortSignal.any([timeout, init.signal]) : timeout });
28
49
  } catch {
50
+ if (init.signal?.aborted) throw init.signal.reason;
29
51
  throw new Unavailable();
30
52
  }
31
53
  }
@@ -0,0 +1,104 @@
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, its language and its currency —
6
+ // and where this tool is reached. The Chest sets them in the tool's
7
+ // environment at each start (CHEST_ORGANIZATION, CHEST_TIME_ZONE,
8
+ // CHEST_LANGUAGE, CHEST_CURRENCY, CHEST_TEAM_URL, CHEST_PUBLIC_URL) and
9
+ // starts the tool again, when it is awake, as soon as one changes, so they
10
+ // are there outside any request too: in a scheduled job, at start-up, in a
11
+ // migration script. The Chest also sets its time zone
12
+ // as the zone of the tool's database sessions: there, current_date and
13
+ // now()::date are the Chest's day too.
14
+ //
15
+ // - organization.name is the organization's name as its owner wrote it
16
+ // ("Acme SAS"), plain text of 2 to 80 characters: for a header, a document,
17
+ // an email.
18
+ // - timeZone is an IANA zone ("Europe/Paris"; "UTC" until the owner sets
19
+ // one): the day of "due today", the hour of a reminder.
20
+ // - language is the Chest's own language, a primary tag ("en", "fr"): the
21
+ // language of what the tool writes for no one in particular (a public page
22
+ // before the visitor chooses, an export). A member's is member.language.
23
+ // - currency is the ISO 4217 code of the Chest's currency ("EUR" until the
24
+ // owner sets one): the amounts the tool writes — a quote, a price.
25
+ // - tool.teamUrl is the origin of the tool's team host, where its members
26
+ // open /chest; tool.publicUrl the origin of its public part — the
27
+ // company's own domain when the owner connected one —, null for a tool
28
+ // without a public part. Origins, without a path: a link in an email is
29
+ // new URL("/chest/tasks/42", chest.tool.teamUrl). Store paths, never
30
+ // these origins: they change with a custom domain.
31
+ // - today() is the date ("YYYY-MM-DD") in the Chest's zone, now or at the
32
+ // instant given.
33
+ //
34
+ // Reading one outside a Chest (no fakeChest in a test, a development server
35
+ // without the variables) throws a ChestError "not_in_chest": a wrong zone
36
+ // read silently is the bug this module is for.
37
+ export type Chest = {
38
+ readonly organization: { readonly name: string };
39
+ readonly timeZone: string;
40
+ readonly language: string;
41
+ readonly currency: string;
42
+ readonly tool: { readonly teamUrl: string; readonly publicUrl: string | null };
43
+ today(at?: Date | number): string;
44
+ };
45
+
46
+ // The shapes the Chest gives: the organization's (2 to 80 characters,
47
+ // counted as code points, without control characters), a zone's
48
+ // (timeZonePattern, and one this runtime knows), a language's, a currency's,
49
+ // an origin's.
50
+ const organizationPattern = /^[^\u0000-\u001f\u007f-\u009f]{2,80}$/u;
51
+ // An ISO 4217 code; an https origin without a path, as the Chest gives them.
52
+ const currencyPattern = /^[A-Z]{3}$/u;
53
+ const originPattern = /^https:\/\/[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?(:[0-9]{1,5})?$/u;
54
+
55
+ function read(name: string, valid: (value: string) => boolean): string {
56
+ const value = process.env[name];
57
+ if (typeof value !== "string" || !valid(value)) throw new ChestError("not_in_chest", 500, `not running in a Chest: ${name} is missing or invalid`);
58
+ return value;
59
+ }
60
+
61
+ function knownZone(zone: string): boolean {
62
+ if (!timeZonePattern.test(zone)) return false;
63
+ try {
64
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ const zone = (): string => read("CHEST_TIME_ZONE", knownZone);
72
+
73
+ // dateIn is the date at that instant in a zone, as YYYY-MM-DD.
74
+ function dateIn(at: Date, zone: string): string {
75
+ 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]));
76
+ return `${parts["year"]}-${parts["month"]}-${parts["day"]}`;
77
+ }
78
+
79
+ // chest reads the environment at each access: what a test's fakeChest sets
80
+ // is what it answers.
81
+ export const chest: Chest = {
82
+ get organization() {
83
+ return { name: read("CHEST_ORGANIZATION", value => organizationPattern.test(value)) };
84
+ },
85
+ get timeZone() {
86
+ return zone();
87
+ },
88
+ get language() {
89
+ return read("CHEST_LANGUAGE", value => languagePattern.test(value));
90
+ },
91
+ get currency() {
92
+ return read("CHEST_CURRENCY", value => currencyPattern.test(value));
93
+ },
94
+ get tool() {
95
+ const teamUrl = read("CHEST_TEAM_URL", value => originPattern.test(value));
96
+ const publicUrl = process.env["CHEST_PUBLIC_URL"] === undefined ? null : read("CHEST_PUBLIC_URL", value => originPattern.test(value));
97
+ return { teamUrl, publicUrl };
98
+ },
99
+ today(at: Date | number = Date.now()): string {
100
+ const instant = typeof at === "number" ? new Date(at) : at;
101
+ if (Number.isNaN(instant.getTime())) throw new RangeError("today() needs a valid date");
102
+ return dateIn(instant, zone());
103
+ },
104
+ };
@@ -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
+ }