@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
@@ -1,11 +1,12 @@
1
- import { createHash, createHmac, randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import { groupIdPattern, memberIdPattern } from "./member.js";
4
4
  import { forget } from "./members.js";
5
- const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
5
+ import { fakeOrigins } from "./api.js";
6
+ import { eventChannel, scheduleChannel, sign, signClaims } from "./signed.js";
6
7
  // signAssertion is the Chest-Member value the Chest's front would send for
7
- // that member: HS256 under the key of the token (CHEST_TOKEN by default), for
8
- // the tool (CHEST_TOOL by default), valid 60 seconds from now.
8
+ // that member: HS256 under the key of the token, for the tool, valid 60
9
+ // seconds from when it is issued.
9
10
  export function signAssertion(member, options = {}) {
10
11
  const token = options.token ?? process.env["CHEST_TOKEN"];
11
12
  const tool = options.tool ?? process.env["CHEST_TOOL"];
@@ -14,27 +15,18 @@ export function signAssertion(member, options = {}) {
14
15
  if (!memberIdPattern.test(member.id) || !member.groups.every(g => groupIdPattern.test(g)))
15
16
  throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
16
17
  const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
17
- const body = encode({ alg: "HS256", typ: "JWT" }) + "." + encode({
18
+ // Under the label of the assertion's shape, as the Chest signs it and
19
+ // member() reads it.
20
+ return signClaims("Chest-Member v2", {
18
21
  iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
19
22
  given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
20
- admin: member.isAdmin, builder: member.isBuilder, groups: member.groups, ...(member.email === undefined ? {} : { email: member.email }),
21
- });
22
- // The key as the Chest derives it, and member() reads it: HMAC-SHA256 of
23
- // the label of the assertion's shape under the text of the token.
24
- const key = createHmac("sha256", Buffer.from(token, "utf8")).update("Chest-Member v2").digest();
25
- return body + "." + createHmac("sha256", key).update(body).digest("base64url");
26
- }
27
- // signEvent is the Chest-Event value the Chest would send with that body:
28
- // HS256 under the key the token derives for events, for the tool, naming the
29
- // event and the digest of the body, valid 60 seconds.
30
- function signEvent(id, body, options) {
31
- const iat = Math.floor(Date.now() / 1000);
32
- const signed = encode({ alg: "HS256", typ: "JWT" }) + "." + encode({ aud: options.tool, iat, exp: iat + 60, jti: id, digest: createHash("sha256").update(body).digest("base64url") });
33
- const key = createHmac("sha256", Buffer.from(options.token, "utf8")).update("Chest-Event v1").digest();
34
- return signed + "." + createHmac("sha256", key).update(signed).digest("base64url");
23
+ admin: member.isAdmin, builder: member.isBuilder, groups: member.groups, time_zone: member.timeZone, ...(member.email === undefined ? {} : { email: member.email }),
24
+ language: member.language,
25
+ }, token);
35
26
  }
36
- // withMember is the request carrying that member's assertion: a new Web
37
- // Request, or the same Node request with its header set.
27
+ // withMember is the request carrying that member's assertion, signed with
28
+ // the options of signAssertion: a new Web Request, or the same Node request
29
+ // with its header set.
38
30
  export function withMember(request, member, options = {}) {
39
31
  const assertion = signAssertion(member, options);
40
32
  if (request instanceof Request) {
@@ -49,6 +41,19 @@ export function withMember(request, member, options = {}) {
49
41
  const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
50
42
  const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
51
43
  const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}(\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}){0,7}$/u;
44
+ // Its links and uploads: their paths on the team host, their lives in
45
+ // seconds, the images it makes thumbnails of, the first bytes an upload of
46
+ // a type must start with, the endings of the names it chooses (chest/toolfiles).
47
+ const linkPath = "/_chest/files/", uploadPath = "/_chest/files/upload/", linkLife = 900, uploadLife = 900;
48
+ const mediaPattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
49
+ const thumbnailed = ["image/jpeg", "image/png", "image/gif", "image/webp"];
50
+ const starts = (...heads) => (data) => heads.some(head => data.subarray(0, head.length).equals(Buffer.from(head, "latin1")));
51
+ const signatures = {
52
+ "image/jpeg": starts("\xff\xd8\xff"), "image/png": starts("\x89PNG\r\n\x1a\n"), "image/gif": starts("GIF87a", "GIF89a"),
53
+ "image/webp": data => data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP", "application/pdf": starts("%PDF-"),
54
+ };
55
+ const extensions = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/avif": ".avif", "image/heic": ".heic", "application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", "application/json": ".json", "application/zip": ".zip", "video/mp4": ".mp4", "audio/mpeg": ".mp3" };
56
+ const newToken = () => randomBytes(18).toString("base64url") + "." + randomBytes(12).toString("base64url");
52
57
  // Its notifications: recipients and badges a call, text, quotas.
53
58
  const maxRecipients = 500, maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999;
54
59
  const recipientsPerHour = 1000, itemsPerDay = 100, badgesPerMinute = 600;
@@ -57,6 +62,22 @@ const reordering = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
57
62
  const cleanTitle = (s) => s.replace(/[\t\r\n]/gu, " ").replace(/\p{Cc}/gu, "").replace(reordering, "").trim();
58
63
  const cleanText = (s) => s.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[^\P{Cc}\n]/gu, "").replace(reordering, "").trim();
59
64
  const isPath = (p) => typeof p === "string" && p.length <= maxPath && /^\/chest([/?#][\x21-\x5b\x5d-\x7e]*)?$/u.test(p) && !p.includes("//") && !p.split(/[?#]/u)[0].split("/").some(x => /^(\.|%2e){1,2}$/iu.test(x));
65
+ // Its AI: aliases in order, bounds of a request, requests a minute.
66
+ const aliasOrder = ["default", "fast", "smart", "embedding"];
67
+ const chatKeys = ["model", "messages", "max_tokens", "stream", "temperature", "top_p", "stop", "tools", "tool_choice", "response_format", "parallel_tool_calls", "seed", "reasoning_effort", "member"];
68
+ const maxAiBody = 10 << 20, maxAiOutput = 128000, maxInputs = 256, aiPerMinute = 60, fakeDimensions = 8;
69
+ // A fake count of tokens: one per 4 characters.
70
+ const tokensOf = (text) => Math.ceil(text.length / 4);
71
+ // A vector of a text, the same for the same text: unit length.
72
+ function vectorOf(text, dimensions) {
73
+ const values = [];
74
+ for (let block = 0; values.length < dimensions; block++) {
75
+ for (const b of createHash("sha256").update(block + ":" + text).digest())
76
+ values.push(b / 127.5 - 1);
77
+ }
78
+ const kept = values.slice(0, dimensions), norm = Math.hypot(...kept) || 1;
79
+ return kept.map(x => x / norm);
80
+ }
60
81
  const fold = (s) => s.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
61
82
  function send(response, status, value, headers = {}) {
62
83
  if (value === undefined)
@@ -77,10 +98,11 @@ async function body(request, limit) {
77
98
  }
78
99
  // fakeChest starts a Chest's API on 127.0.0.1 and points the environment at
79
100
  // it: CHEST_API, CHEST_TOKEN (a new one) and CHEST_TOOL ("tool" unless the
80
- // environment names one). What member() and the modules of the SDK read is
81
- // then this Chest's.
101
+ // environment names one), and what the Chest is (CHEST_ORGANIZATION,
102
+ // CHEST_TIME_ZONE, CHEST_LANGUAGE, as options.chest says). What member(),
103
+ // chest and the modules of the SDK read is then this Chest's.
82
104
  export async function fakeChest(options = {}) {
83
- const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications"]);
105
+ const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications", "ai"]);
84
106
  const email = capabilities.has("members.email");
85
107
  const tool = process.env["CHEST_TOOL"] || "tool";
86
108
  const token = randomBytes(32).toString("base64url");
@@ -91,12 +113,12 @@ export async function fakeChest(options = {}) {
91
113
  const receives = options.receives ?? ["member.*"];
92
114
  // The erasures the tool was told of, by emit: those it may acknowledge.
93
115
  const erasures = new Set();
94
- const chest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], emit: async () => 0, close: async () => { } };
116
+ const chest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], ai: [], emit: async () => 0, run: async () => 0, close: async () => { } };
95
117
  const former = [...(options.former ?? [])];
96
118
  let window = 0, calls = 0;
97
- const shown = (m) => ({ id: m.id, first_name: m.firstName, last_name: m.lastName, name: m.name, photo: m.photo, role: m.role, admin: m.isAdmin, builder: m.isBuilder, groups: m.groups, ...(email && m.email !== undefined ? { email: m.email } : {}) });
119
+ const shown = (m) => ({ id: m.id, first_name: m.firstName, last_name: m.lastName, name: m.name, photo: m.photo, role: m.role, admin: m.isAdmin, builder: m.isBuilder, groups: m.groups, language: m.language, time_zone: m.timeZone, ...(email && m.email !== undefined ? { email: m.email } : {}) });
98
120
  const key = (m) => fold(m.name) + "\u0000" + m.id;
99
- const described = (name, f) => ({ name, type: f.type, size: f.data.byteLength, updated: f.updated });
121
+ const described = (name, f) => ({ name, type: f.type, size: f.data.byteLength, sha256: createHash("sha256").update(f.data).digest("hex"), updated: f.updated });
100
122
  async function members(request, response, url) {
101
123
  if (!capabilities.has("members"))
102
124
  return send(response, 403, { error: "capability_not_granted" });
@@ -139,8 +161,10 @@ export async function fakeChest(options = {}) {
139
161
  const m = chest.members.find(x => x.id === id), f = former.find(x => x.id === id);
140
162
  if (m)
141
163
  answer.members.push(shown(m));
142
- else if (f?.erased)
164
+ else if (f?.status === "erased")
143
165
  answer.former.push({ id, status: "erased" });
166
+ else if (f?.status === "no_access")
167
+ answer.former.push({ id, name: f.name ?? "Test member", status: "no_access" });
144
168
  else if (f)
145
169
  answer.former.push({ id, ...(f.name ? { name: f.name } : {}), status: "former" });
146
170
  else
@@ -167,22 +191,7 @@ export async function fakeChest(options = {}) {
167
191
  const names = [...files.keys()].filter(n => n.startsWith(prefix) && n > after).sort();
168
192
  return send(response, 200, { files: names.slice(0, 1000).map(n => described(n, files.get(n))), next: names.length > 1000 ? names[999] : null });
169
193
  }
170
- if (request.method === "POST" && url.pathname === "/files/url") {
171
- const raw = await body(request, 4096);
172
- let name;
173
- try {
174
- name = JSON.parse(raw?.toString() ?? "").name;
175
- }
176
- catch {
177
- name = undefined;
178
- }
179
- if (typeof name !== "string")
180
- return send(response, 400, { error: "invalid_body" });
181
- if (!files.has(name))
182
- return send(response, 404, { error: "not_found" });
183
- return send(response, 200, { url: `https://${tool}-chest.chest.test/_chest/files/${Buffer.from(name).toString("base64url")}.fake`, expires_in: 900 });
184
- }
185
- if (request.method === "POST" && (url.pathname === "/files/move" || url.pathname === "/files/upload-url")) {
194
+ if (request.method === "POST" && (url.pathname === "/files/url" || url.pathname === "/files/move" || url.pathname === "/files/upload-url")) {
186
195
  const raw = await body(request, 4096);
187
196
  let command = {};
188
197
  try {
@@ -191,12 +200,33 @@ export async function fakeChest(options = {}) {
191
200
  catch {
192
201
  command = {};
193
202
  }
203
+ if (url.pathname === "/files/url") {
204
+ const { name, thumbnail, download } = command;
205
+ if (typeof name !== "string" || !(thumbnail === undefined || thumbnail === 256 || thumbnail === 1024) || !(download === undefined || typeof download === "boolean"))
206
+ return send(response, 400, { error: "invalid_body" });
207
+ const object = files.get(name);
208
+ if (!object)
209
+ return send(response, 404, { error: "not_found" });
210
+ if (thumbnail !== undefined && !thumbnailed.includes(object.type))
211
+ return send(response, 400, { error: "no_thumbnail" });
212
+ const token = newToken();
213
+ links.set(token, { name, object, until: Date.now() + linkLife * 1000, download: download === true });
214
+ return send(response, 200, { url: `${chest.api}${linkPath}${token}`, expires_in: linkLife });
215
+ }
194
216
  if (url.pathname === "/files/upload-url") {
195
- // The upload itself goes from a member's browser to the team host,
196
- // which a fake Chest does not play: it only authorises it.
197
- if (typeof command["name"] !== "string")
217
+ const { name, max_size: maxSize, types, expires_in: expiresIn } = command;
218
+ const folder = typeof name === "string" && name.endsWith("/");
219
+ if (typeof name !== "string" || !namePattern.test(folder ? name.slice(0, -1) : name))
220
+ return send(response, 400, { error: "invalid_name" });
221
+ if (!(types === undefined || (Array.isArray(types) && types.length <= 8 && types.every(t => typeof t === "string" && mediaPattern.test(t)))))
222
+ return send(response, 400, { error: "invalid_type" });
223
+ if (!(maxSize === undefined || (typeof maxSize === "number" && Number.isSafeInteger(maxSize) && maxSize > 0)) || !(expiresIn === undefined || (typeof expiresIn === "number" && Number.isInteger(expiresIn) && expiresIn > 0 && expiresIn <= uploadLife)))
198
224
  return send(response, 400, { error: "invalid_body" });
199
- return send(response, 200, { url: `https://${tool}-chest.chest.test/_chest/files/upload/${Buffer.from(command["name"]).toString("base64url")}.fake`, method: "PUT", expires_in: typeof command["expires_in"] === "number" ? command["expires_in"] : 900 });
225
+ if ((maxSize ?? 0) > maxObject)
226
+ return send(response, 413, { error: "too_large" });
227
+ const token = newToken(), life = expiresIn ?? uploadLife;
228
+ uploads.set(token, { name, maxSize: maxSize ?? maxObject, types: types ?? [], until: Date.now() + life * 1000 });
229
+ return send(response, 200, { url: `${chest.api}${uploadPath}${token}`, method: "PUT", expires_in: life });
200
230
  }
201
231
  const from = command["from"], to = command["to"];
202
232
  if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to))
@@ -235,6 +265,46 @@ export async function fakeChest(options = {}) {
235
265
  }
236
266
  send(response, 404, { error: "not_found" });
237
267
  }
268
+ // The team host's part of the files, on the fake's own origin: a link it
269
+ // signed opens the content it was signed for, for its life, as long as
270
+ // the file is that content — a thumbnail is the image itself, a fake does
271
+ // not reduce it —; an upload token takes one file, once, within its life,
272
+ // of the types it names and up to its size, whose first bytes are those of
273
+ // its type, named by the Chest in a folder. Nobody's session is checked:
274
+ // the Chest's own front does that.
275
+ const links = new Map();
276
+ const uploads = new Map();
277
+ async function front(request, response, url) {
278
+ if (request.method === "GET" && url.pathname.startsWith(linkPath) && !url.pathname.startsWith(uploadPath)) {
279
+ const link = links.get(url.pathname.slice(linkPath.length));
280
+ if (!link || link.until < Date.now() || files.get(link.name) !== link.object)
281
+ return send(response, 404, { error: "not_found" });
282
+ const headers = { "Content-Type": link.object.type, "Content-Length": String(link.object.data.byteLength), "X-Content-Type-Options": "nosniff" };
283
+ if (link.download)
284
+ headers["Content-Disposition"] = `attachment; filename="${link.name.split("/").pop()}"`;
285
+ return void response.writeHead(200, headers).end(link.object.data);
286
+ }
287
+ if (request.method !== "PUT" || !url.pathname.startsWith(uploadPath))
288
+ return send(response, 404, { error: "not_found" });
289
+ const token = url.pathname.slice(uploadPath.length), grant = uploads.get(token);
290
+ uploads.delete(token);
291
+ if (!grant || grant.until < Date.now())
292
+ return send(response, 403, { error: "invalid_token" });
293
+ const type = (request.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
294
+ if (!mediaPattern.test(type) || type.endsWith("/*") || (grant.types.length > 0 && !grant.types.some(t => t === type || (t.endsWith("/*") && type.startsWith(t.slice(0, -1))))))
295
+ return send(response, 415, { error: "type_refused" });
296
+ const data = await body(request, grant.maxSize);
297
+ if (data === null)
298
+ return send(response, 413, { error: "too_large" });
299
+ if (signatures[type] && !signatures[type](data))
300
+ return send(response, 400, { error: "type_mismatch" });
301
+ const name = grant.name.endsWith("/") ? grant.name + randomBytes(10).toString("hex") + (extensions[type] ?? "") : grant.name;
302
+ const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
303
+ if (total + data.length > maxTotal || (!files.has(name) && files.size >= maxObjects))
304
+ return send(response, 429, { error: "quota_exceeded" });
305
+ files.set(name, { data: new Uint8Array(data), type, updated: new Date().toISOString() });
306
+ send(response, 201, { name, type, size: data.length });
307
+ }
238
308
  const hour = { start: 0, count: 0 }, minute = { start: 0, count: 0 }, days = new Map();
239
309
  const live = (w, span, now) => w !== undefined && w.count > 0 && now - w.start < span;
240
310
  const wait = (w, span, now) => ({ "Retry-After": String(Math.max(1, Math.ceil((w.start + span - now) / 1000))) });
@@ -359,6 +429,119 @@ export async function fakeChest(options = {}) {
359
429
  }
360
430
  send(response, 200, { delivered: kept, skipped: ids.filter(id => !access(id)) });
361
431
  }
432
+ // Its AI: the declared aliases, the spending this month, the requests this
433
+ // minute.
434
+ const mapped = (options.ai?.models ?? aliasOrder.map((alias) => ({ alias, model: "fake-" + alias }))).map(m => ({ alias: m.alias, model: m.model, provider: m.provider ?? "openrouter", input: m.input ?? 1, output: m.output ?? 2 }));
435
+ const cap = options.ai?.cap ?? 5;
436
+ const aiMinute = { start: 0, count: 0 };
437
+ let spent = 0;
438
+ const month = () => new Date().toISOString().slice(0, 7);
439
+ const resets = () => {
440
+ const now = new Date();
441
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString().replace(".000Z", "Z");
442
+ };
443
+ const spend = (m, input, output) => {
444
+ const cost = Math.round((input * m.input + output * m.output) / 1e6 * 1e6) / 1e6;
445
+ spent = Math.round((spent + cost) * 1e6) / 1e6;
446
+ return cost;
447
+ };
448
+ // echo is the text of the last user message: its text, or its text parts.
449
+ const echo = (request) => {
450
+ const last = [...request["messages"]].reverse().find(m => m?.role === "user")?.content;
451
+ return typeof last === "string" ? last : Array.isArray(last) ? last.map(p => p?.text).filter(t => typeof t === "string").join("") : "";
452
+ };
453
+ async function aiRoute(request, response, url) {
454
+ const raw = request.method === "POST" ? await body(request, maxAiBody) : null;
455
+ let command = null;
456
+ try {
457
+ const value = JSON.parse(raw?.toString() ?? "");
458
+ if (value !== null && typeof value === "object" && !Array.isArray(value))
459
+ command = value;
460
+ }
461
+ catch {
462
+ command = null;
463
+ }
464
+ chest.ai.push({ path: url.pathname, body: command });
465
+ if (!capabilities.has("ai"))
466
+ return send(response, 403, { error: "capability_not_granted" });
467
+ if (request.method === "GET" && url.pathname === "/ai/models")
468
+ return send(response, 200, { models: [...mapped].sort((a, b) => aliasOrder.indexOf(a.alias) - aliasOrder.indexOf(b.alias)) });
469
+ if (request.method === "GET" && url.pathname === "/ai/usage")
470
+ return send(response, 200, { month: month(), spent, cap, resets: resets() });
471
+ if (request.method !== "POST" || (url.pathname !== "/ai/chat" && url.pathname !== "/ai/embeddings"))
472
+ return send(response, 404, { error: "not_found" });
473
+ if (raw === null)
474
+ return send(response, 413, { error: "too_large" });
475
+ const chat = url.pathname === "/ai/chat";
476
+ if (!command || !Object.keys(command).every(k => chat ? chatKeys.includes(k) : ["model", "input", "dimensions", "member"].includes(k)))
477
+ return send(response, 400, { error: "invalid_body" });
478
+ const model = mapped.find(m => m.alias === command["model"]);
479
+ if (!model)
480
+ return send(response, 403, { error: "model_not_allowed" });
481
+ const member = command["member"];
482
+ if (member !== undefined && (typeof member !== "string" || !memberIdPattern.test(member)))
483
+ return send(response, 400, { error: "invalid_body" });
484
+ const now = Date.now();
485
+ // refused says the refusal of a valid request, if any: the rate, the
486
+ // provider, the cap.
487
+ const refused = () => {
488
+ const busy = live(aiMinute, 60_000, now) && aiMinute.count >= aiPerMinute;
489
+ if (busy)
490
+ send(response, 429, { error: "rate_limited" }, wait(aiMinute, 60_000, now));
491
+ else {
492
+ count(aiMinute, 60_000, now, 1);
493
+ if (options.ai?.unavailable)
494
+ send(response, options.ai.unavailable === "provider_key_invalid" ? 502 : 503, { error: options.ai.unavailable });
495
+ else if (spent >= cap)
496
+ send(response, 402, { error: "cap_reached", scope: "tool", resets: resets() });
497
+ }
498
+ return response.headersSent;
499
+ };
500
+ if (!chat) {
501
+ const input = command["input"], texts = typeof input === "string" ? [input] : input, dimensions = command["dimensions"] ?? fakeDimensions;
502
+ if (!Array.isArray(texts) || texts.length < 1 || texts.length > maxInputs || !texts.every(t => typeof t === "string") || typeof dimensions !== "number" || !Number.isInteger(dimensions) || dimensions < 1 || dimensions > 4096)
503
+ return send(response, 400, { error: "invalid_body" });
504
+ if (refused())
505
+ return;
506
+ const used = texts.reduce((sum, t) => sum + tokensOf(t), 0);
507
+ const cost = spend(model, used, 0);
508
+ return send(response, 200, { object: "list", data: texts.map((t, index) => ({ object: "embedding", index, embedding: vectorOf(t, dimensions) })), model: model.model, usage: { prompt_tokens: used, total_tokens: used, cost } });
509
+ }
510
+ const messages = command["messages"], limit = command["max_tokens"] ?? 4096;
511
+ if (!Array.isArray(messages) || messages.length < 1 || !messages.every(m => m !== null && typeof m === "object" && typeof m.role === "string") || typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > maxAiOutput || !(command["stream"] === undefined || typeof command["stream"] === "boolean"))
512
+ return send(response, 400, { error: "invalid_body" });
513
+ if (refused())
514
+ return;
515
+ const given = options.ai?.reply ? options.ai.reply(command) : echo(command);
516
+ const text = typeof given === "string" ? given : given.text ?? "";
517
+ const calls = (typeof given === "string" ? [] : given.toolCalls ?? []).map((c, i) => ({ id: c.id ?? `call_${i + 1}`, type: "function", function: { name: c.name, arguments: c.arguments } }));
518
+ const input = tokensOf(JSON.stringify(messages)), output = tokensOf(text + calls.map(c => c.function.name + c.function.arguments).join(""));
519
+ const usage = { prompt_tokens: input, completion_tokens: output, total_tokens: input + output, prompt_tokens_details: { cached_tokens: 0 }, cost: spend(model, input, output) };
520
+ const finish = calls.length ? "tool_calls" : "stop";
521
+ const head = { id: "chatcmpl-fake", created: Math.floor(now / 1000), model: model.model };
522
+ if (command["stream"] !== true) {
523
+ return send(response, 200, { ...head, object: "chat.completion", choices: [{ index: 0, message: { role: "assistant", content: calls.length && !text ? null : text, ...(calls.length ? { tool_calls: calls } : {}) }, finish_reason: finish }], usage });
524
+ }
525
+ // Streamed: the role, the text word by word, each call's name then its
526
+ // arguments in two halves, the finish reason, the usage, [DONE].
527
+ const chunk = (choices, extra = {}) => `data: ${JSON.stringify({ ...head, object: "chat.completion.chunk", choices, ...extra })}\n\n`;
528
+ const delta = (d, finishReason = null) => chunk([{ index: 0, delta: d, finish_reason: finishReason }]);
529
+ const parts = [delta({ role: "assistant", content: "" })];
530
+ for (const word of text.match(/\S+\s*|\s+/gu) ?? [])
531
+ parts.push(delta({ content: word }));
532
+ calls.forEach((c, index) => {
533
+ const half = Math.ceil(c.function.arguments.length / 2);
534
+ parts.push(delta({ tool_calls: [{ index, id: c.id, type: "function", function: { name: c.function.name, arguments: "" } }] }));
535
+ for (const piece of [c.function.arguments.slice(0, half), c.function.arguments.slice(half)])
536
+ if (piece)
537
+ parts.push(delta({ tool_calls: [{ index, function: { arguments: piece } }] }));
538
+ });
539
+ parts.push(delta({}, finish), chunk([], { usage }), "data: [DONE]\n\n");
540
+ response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
541
+ for (const part of parts)
542
+ response.write(part);
543
+ response.end();
544
+ }
362
545
  // The acknowledgment of an erasure the tool was told of (emit).
363
546
  async function erasuresRoute(request, response, url) {
364
547
  if (!receives.includes("member.*"))
@@ -377,10 +560,12 @@ export async function fakeChest(options = {}) {
377
560
  }
378
561
  const server = createServer((request, response) => {
379
562
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
380
- const route = url.pathname.startsWith("/erasures/") ? erasuresRoute
381
- : url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
382
- : url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
383
- : url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
563
+ const route = url.pathname.startsWith("/_chest/files/") ? front
564
+ : url.pathname.startsWith("/erasures/") ? erasuresRoute
565
+ : url.pathname.startsWith("/ai/") ? aiRoute
566
+ : url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
567
+ : url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
568
+ : url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
384
569
  if (!route)
385
570
  return send(response, 404, { error: "not_found" });
386
571
  route(request, response, url).catch(() => { if (!response.headersSent)
@@ -389,21 +574,41 @@ export async function fakeChest(options = {}) {
389
574
  response.destroy(); });
390
575
  });
391
576
  await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
392
- const saved = Object.fromEntries(["CHEST_API", "CHEST_TOKEN", "CHEST_TOOL"].map(name => [name, process.env[name]]));
577
+ const saved = Object.fromEntries(["CHEST_API", "CHEST_TOKEN", "CHEST_TOOL", "CHEST_ORGANIZATION", "CHEST_TIME_ZONE", "CHEST_LANGUAGE", "CHEST_CURRENCY", "CHEST_TEAM_URL", "CHEST_PUBLIC_URL"].map(name => [name, process.env[name]]));
393
578
  chest.api = "http://127.0.0.1:" + server.address().port;
394
- Object.assign(process.env, { CHEST_API: chest.api, CHEST_TOKEN: token, CHEST_TOOL: tool });
579
+ // Its links are taken by the files module of this process, for as long as
580
+ // it runs (api.ts, fakeOrigins).
581
+ fakeOrigins.add(chest.api);
582
+ const publicUrl = options.chest?.publicUrl === undefined ? `https://${tool}.chest.test` : options.chest.publicUrl;
583
+ Object.assign(process.env, { CHEST_API: chest.api, CHEST_TOKEN: token, CHEST_TOOL: tool, CHEST_ORGANIZATION: options.chest?.organization ?? "Test organization", CHEST_TIME_ZONE: options.chest?.timeZone ?? "UTC", CHEST_LANGUAGE: options.chest?.language ?? "en", CHEST_CURRENCY: options.chest?.currency ?? "EUR", CHEST_TEAM_URL: options.chest?.teamUrl ?? `https://${tool}-chest.chest.test` });
584
+ if (publicUrl === null)
585
+ delete process.env["CHEST_PUBLIC_URL"];
586
+ else
587
+ process.env["CHEST_PUBLIC_URL"] = publicUrl;
395
588
  forget();
589
+ // post delivers what the Chest posts to the tool on a channel — at its
590
+ // address, or to a handler — and says the status it answered.
591
+ const post = async (path, channel, id, body, to) => {
592
+ const request = new Request((typeof to === "string" ? to.replace(/\/$/u, "") : "http://tool.test") + path, { method: "POST", headers: { "Content-Type": "application/json", [channel.header]: sign(channel, id, body, { token, tool }) }, body });
593
+ const answer = typeof to === "string" ? await fetch(request, { redirect: "manual" }) : await to(request);
594
+ await answer.body?.cancel();
595
+ return answer.status;
596
+ };
597
+ const newId = (prefix) => prefix + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
396
598
  chest.emit = async (event, to) => {
397
- const id = event.id ?? "evt_" + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
599
+ const id = event.id ?? newId("evt_");
398
600
  const body = JSON.stringify({ id, type: event.type, occurredAt: event.occurredAt ?? new Date().toISOString(), data: event.data });
399
601
  if (event.type === "member.erased")
400
602
  erasures.add(event.data.erasure);
401
- const request = new Request(typeof to === "string" ? to.replace(/\/$/u, "") + "/chest-events" : "http://tool.test/chest-events", { method: "POST", headers: { "Content-Type": "application/json", "Chest-Event": signEvent(id, body, { token, tool }) }, body });
402
- const answer = typeof to === "string" ? await fetch(request, { redirect: "manual" }) : await to(request);
403
- await answer.body?.cancel();
404
- return answer.status;
603
+ return post("/chest-events", eventChannel, id, body, to);
604
+ };
605
+ chest.run = async (name, to, run = {}) => {
606
+ const id = run.id ?? newId("run_");
607
+ const body = JSON.stringify({ id, name, scheduledAt: run.scheduledAt ?? new Date().toISOString(), attempt: run.attempt ?? 1 });
608
+ return post("/chest-schedules", scheduleChannel, id, body, to);
405
609
  };
406
610
  chest.close = async () => {
611
+ fakeOrigins.delete(chest.api);
407
612
  server.closeAllConnections();
408
613
  await new Promise(resolve => server.close(() => resolve()));
409
614
  for (const [name, value] of Object.entries(saved)) {