@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.
- package/README.md +406 -51
- package/client/index.ts +8 -5
- package/client/src/ai.ts +374 -0
- package/client/src/api.ts +25 -3
- package/client/src/chest.ts +104 -0
- package/client/src/errors.ts +44 -0
- package/client/src/events.ts +12 -104
- package/client/src/files.ts +9 -9
- package/client/src/member.ts +26 -5
- package/client/src/members.ts +21 -16
- package/client/src/schedules.ts +87 -0
- package/client/src/signed.ts +166 -0
- package/client/src/testing.ts +290 -65
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/dist/src/ai.d.ts +131 -0
- package/dist/src/ai.d.ts.map +1 -0
- package/dist/src/ai.js +290 -0
- package/dist/src/ai.js.map +1 -0
- package/dist/src/api.d.ts +4 -0
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +25 -2
- package/dist/src/api.js.map +1 -1
- package/dist/src/chest.d.ts +15 -0
- package/dist/src/chest.d.ts.map +1 -0
- package/dist/src/chest.js +61 -0
- package/dist/src/chest.js.map +1 -0
- package/dist/src/errors.d.ts +16 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +36 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/events.d.ts +3 -6
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +8 -102
- package/dist/src/events.js.map +1 -1
- package/dist/src/files.d.ts +1 -0
- package/dist/src/files.d.ts.map +1 -1
- package/dist/src/files.js +6 -7
- package/dist/src/files.js.map +1 -1
- package/dist/src/member.d.ts +4 -0
- package/dist/src/member.d.ts.map +1 -1
- package/dist/src/member.js +17 -5
- package/dist/src/member.js.map +1 -1
- package/dist/src/members.d.ts +1 -1
- package/dist/src/members.d.ts.map +1 -1
- package/dist/src/members.js +9 -8
- package/dist/src/members.js.map +1 -1
- package/dist/src/schedules.d.ts +15 -0
- package/dist/src/schedules.d.ts.map +1 -0
- package/dist/src/schedules.js +45 -0
- package/dist/src/schedules.js.map +1 -0
- package/dist/src/signed.d.ts +29 -0
- package/dist/src/signed.d.ts.map +1 -0
- package/dist/src/signed.js +139 -0
- package/dist/src/signed.js.map +1 -0
- package/dist/src/testing.d.ts +53 -12
- package/dist/src/testing.d.ts.map +1 -1
- package/dist/src/testing.js +265 -60
- package/dist/src/testing.js.map +1 -1
- package/package.json +27 -4
package/dist/src/testing.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { createHash,
|
|
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
|
-
|
|
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
|
|
8
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
// Request, or the same Node request
|
|
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)
|
|
81
|
-
//
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
if (typeof
|
|
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
|
-
|
|
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("/
|
|
381
|
-
: url.pathname
|
|
382
|
-
: url.pathname
|
|
383
|
-
: url.pathname === "/
|
|
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
|
-
|
|
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_"
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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)) {
|