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