@kimpton-ai/evalrouter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,365 @@
1
+ export class ClientError extends Error {
2
+ code;
3
+ idempotency_key;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = this.constructor.name;
8
+ }
9
+ toJSON() {
10
+ return { code: this.code, message: this.message,
11
+ ...(this.idempotency_key !== undefined ? { idempotency_key: this.idempotency_key } : {}) };
12
+ }
13
+ }
14
+ export class APIError extends ClientError {
15
+ status;
16
+ requestId;
17
+ details;
18
+ constructor(status, code, requestId = null, message, details = null) {
19
+ super(code, message || `API request failed (HTTP ${status}, ${code}).`);
20
+ this.status = status;
21
+ this.requestId = requestId;
22
+ this.details = details;
23
+ }
24
+ toJSON() {
25
+ return {
26
+ ...super.toJSON(),
27
+ status: this.status,
28
+ request_id: this.requestId,
29
+ details: this.details,
30
+ };
31
+ }
32
+ }
33
+ export class RequestTimeout extends ClientError {
34
+ constructor() {
35
+ super("request_timeout", "The request deadline expired; a submitted operation may have committed.");
36
+ }
37
+ }
38
+ export class WaitCancelled extends ClientError {
39
+ constructor() {
40
+ super("wait_cancelled", "Local waiting was cancelled. The server evaluation was not cancelled.");
41
+ }
42
+ }
43
+ export function positive(value, label) {
44
+ if (!Number.isFinite(value) || value <= 0 || value > 2_147_483_647)
45
+ throw new ClientError("invalid_configuration", `${label} must be a positive finite number no greater than 2147483647.`);
46
+ return value;
47
+ }
48
+ export function segment(value) {
49
+ if (typeof value !== "string" ||
50
+ !/^[A-Za-z0-9_.:@+-]{1,200}$/.test(value) ||
51
+ [".", ".."].includes(value))
52
+ throw new ClientError("invalid_input", "Resource identifiers must be nonempty path-safe identifiers.");
53
+ return encodeURIComponent(value);
54
+ }
55
+ function normalizeBase(value, allowHttp) {
56
+ try {
57
+ if (/[\u0000-\u0020\\]/.test(value))
58
+ throw new Error();
59
+ const url = new URL(value);
60
+ const local = url.hostname === "localhost" ||
61
+ url.hostname === "[::1]" ||
62
+ /^127\.\d+\.\d+\.\d+$/.test(url.hostname);
63
+ if (url.username ||
64
+ url.password ||
65
+ url.search ||
66
+ url.hash ||
67
+ !["", "/v1"].includes(url.pathname.replace(/\/+$/, "")))
68
+ throw new Error();
69
+ if (url.protocol !== "https:" &&
70
+ !(url.protocol === "http:" && allowHttp && local))
71
+ throw new Error();
72
+ return `${url.origin}/v1`;
73
+ }
74
+ catch {
75
+ throw new ClientError("invalid_configuration", "Set an HTTPS API origin or /v1 URL. Explicit HTTP is allowed only for loopback development.");
76
+ }
77
+ }
78
+ export function checkpoint(deadline, signal) {
79
+ if (signal?.aborted)
80
+ throw new WaitCancelled();
81
+ const remaining = deadline - performance.now();
82
+ if (remaining <= 0)
83
+ throw new RequestTimeout();
84
+ return remaining;
85
+ }
86
+ export async function pause(delay, deadline, signal) {
87
+ checkpoint(deadline, signal);
88
+ const resumeAt = performance.now() + delay;
89
+ // Timers can wake early. Recheck the monotonic clock so an early callback
90
+ // cannot shorten Retry-After or turn a deadline-clipped wait into a retry.
91
+ while (true) {
92
+ const remaining = checkpoint(deadline, signal);
93
+ const waiting = resumeAt - performance.now();
94
+ if (waiting <= 0)
95
+ return;
96
+ await new Promise((resolve, reject) => {
97
+ const cleanup = () => {
98
+ clearTimeout(timer);
99
+ signal?.removeEventListener("abort", abort);
100
+ };
101
+ const abort = () => {
102
+ cleanup();
103
+ reject(new WaitCancelled());
104
+ };
105
+ const timer = setTimeout(() => {
106
+ cleanup();
107
+ resolve();
108
+ }, Math.ceil(Math.min(waiting, remaining)));
109
+ signal?.addEventListener("abort", abort, { once: true });
110
+ if (signal?.aborted)
111
+ abort();
112
+ });
113
+ }
114
+ }
115
+ function retryDelay(attempt, header) {
116
+ let delay = Math.min(5000, 250 * 2 ** attempt) + Math.random() * 250;
117
+ if (header && header.length < 100) {
118
+ const numeric = Number(header);
119
+ const suggested = Number.isFinite(numeric)
120
+ ? numeric * 1000
121
+ : Date.parse(header) - Date.now();
122
+ if (Number.isFinite(suggested))
123
+ delay = Math.max(delay, suggested);
124
+ }
125
+ return delay;
126
+ }
127
+ function secretValues(body) {
128
+ if (!body || typeof body !== "object")
129
+ return [];
130
+ return Object.entries(body).flatMap(([key, value]) => [
131
+ ...(/key|secret|token|password/i.test(key) &&
132
+ typeof value === "string" &&
133
+ value
134
+ ? [value]
135
+ : []),
136
+ ...secretValues(value),
137
+ ]);
138
+ }
139
+ function safeErrorValue(value, secrets, depth = 0) {
140
+ if (depth > 4)
141
+ return "[truncated]";
142
+ if (typeof value === "string") {
143
+ let text = value;
144
+ for (const secret of secrets)
145
+ text = text.replaceAll(secret, "[redacted]");
146
+ return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 2000);
147
+ }
148
+ if (Array.isArray(value))
149
+ return value
150
+ .slice(0, 100)
151
+ .map((item) => safeErrorValue(item, secrets, depth + 1));
152
+ if (value && typeof value === "object")
153
+ return Object.fromEntries(Object.entries(value)
154
+ .slice(0, 100)
155
+ .map(([key, item]) => [
156
+ safeErrorValue(key, secrets).slice(0, 100),
157
+ /key|secret|token|password|authorization|cookie/i.test(key)
158
+ ? "[redacted]"
159
+ : safeErrorValue(item, secrets, depth + 1),
160
+ ]));
161
+ return value === null ||
162
+ typeof value === "number" ||
163
+ typeof value === "boolean"
164
+ ? value
165
+ : null;
166
+ }
167
+ async function readBody(response, maximum, deadline, signal) {
168
+ const reader = response.body?.getReader();
169
+ if (!reader)
170
+ return new Uint8Array();
171
+ const chunks = [];
172
+ let size = 0;
173
+ try {
174
+ while (true) {
175
+ checkpoint(deadline, signal);
176
+ const { done, value } = await reader.read();
177
+ if (done)
178
+ break;
179
+ size += value.byteLength;
180
+ if (size > maximum)
181
+ throw new ClientError("response_too_large", "API response exceeds the configured size limit.");
182
+ chunks.push(value);
183
+ }
184
+ checkpoint(deadline, signal);
185
+ return Buffer.concat(chunks);
186
+ }
187
+ finally {
188
+ await reader.cancel().catch(() => undefined);
189
+ reader.releaseLock();
190
+ }
191
+ }
192
+ export class Transport {
193
+ timeoutMs;
194
+ requestTimeoutMs;
195
+ maxRetries;
196
+ maxResponseBytes;
197
+ maxExportBytes;
198
+ #base;
199
+ #key;
200
+ #workspace;
201
+ #fetch;
202
+ constructor(options = {}) {
203
+ if (typeof window !== "undefined")
204
+ throw new ClientError("invalid_configuration", "Use workspace API keys only in trusted server applications.");
205
+ this.#base = normalizeBase(options.baseUrl ?? process.env.EVALROUTER_BASE_URL ?? "", options.allowHttp === true);
206
+ this.#key = options.apiKey ?? process.env.EVALROUTER_API_KEY;
207
+ this.#workspace =
208
+ options.workspaceId ?? process.env.EVALROUTER_WORKSPACE_ID;
209
+ for (const [label, value] of [
210
+ ["API key", this.#key],
211
+ ["Workspace ID", this.#workspace],
212
+ ]) {
213
+ if (value !== undefined &&
214
+ (typeof value !== "string" || !/^[\x21-\x7e]{1,4096}$/.test(value)))
215
+ throw new ClientError("invalid_configuration", `${label} must be printable non-space ASCII.`);
216
+ }
217
+ this.timeoutMs = positive(options.timeoutMs ?? 120_000, "Timeout");
218
+ this.requestTimeoutMs = positive(options.requestTimeoutMs ?? 30_000, "Request timeout");
219
+ this.maxRetries = options.maxRetries ?? 2;
220
+ if (!Number.isInteger(this.maxRetries) ||
221
+ this.maxRetries < 0 ||
222
+ this.maxRetries > 5)
223
+ throw new ClientError("invalid_configuration", "maxRetries must be between 0 and 5.");
224
+ this.maxResponseBytes = options.maxResponseBytes ?? 64 * 1024 * 1024;
225
+ this.maxExportBytes = options.maxExportBytes ?? 256 * 1024 * 1024;
226
+ for (const value of [this.maxResponseBytes, this.maxExportBytes]) {
227
+ if (!Number.isSafeInteger(value) || value < 1)
228
+ throw new ClientError("invalid_configuration", "Response size limits must be positive safe integers.");
229
+ }
230
+ this.#fetch = options.fetch ?? globalThis.fetch;
231
+ }
232
+ async request(method, path, request) {
233
+ const options = request.options ?? {};
234
+ const deadline = performance.now() +
235
+ positive(options.timeoutMs ?? this.timeoutMs, "Timeout");
236
+ if (!path.startsWith("/") || path.startsWith("//"))
237
+ throw new ClientError("invalid_input", "Invalid API resource path.");
238
+ const url = new URL(this.#base + path);
239
+ const headers = {
240
+ Accept: request.binary ? "*/*" : "application/json",
241
+ "User-Agent": "kimpton-evalrouter-typescript/0.1.0",
242
+ };
243
+ if (this.#key)
244
+ headers.Authorization = `Bearer ${this.#key}`;
245
+ if (this.#workspace)
246
+ headers["X-Workspace-Id"] = this.#workspace;
247
+ if (request.idempotency_key !== undefined) {
248
+ if (typeof request.idempotency_key !== "string" ||
249
+ !/^[\x20-\x7e]{1,128}$/.test(request.idempotency_key))
250
+ throw new ClientError("invalid_input", "Idempotency keys must contain 1–128 printable ASCII characters.");
251
+ headers["Idempotency-Key"] = request.idempotency_key;
252
+ }
253
+ let body;
254
+ try {
255
+ body =
256
+ request.body === undefined
257
+ ? undefined
258
+ : JSON.stringify(request.body, (_key, value) => {
259
+ if (typeof value === "number" && !Number.isFinite(value))
260
+ throw new Error();
261
+ return value;
262
+ });
263
+ for (const [key, value] of Object.entries(request.query ?? {})) {
264
+ if (value !== undefined && value !== null)
265
+ url.searchParams.set(key, String(value));
266
+ }
267
+ }
268
+ catch {
269
+ throw new ClientError("invalid_input", "Request data must be JSON-serializable.");
270
+ }
271
+ if (body !== undefined)
272
+ headers["Content-Type"] = "application/json";
273
+ const secrets = [this.#key, ...secretValues(request.body)].filter((v) => !!v);
274
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
275
+ const remaining = checkpoint(deadline, options.signal);
276
+ const controller = new AbortController();
277
+ const abort = () => controller.abort();
278
+ options.signal?.addEventListener("abort", abort, { once: true });
279
+ const timer = setTimeout(abort, Math.ceil(Math.min(remaining, this.requestTimeoutMs)));
280
+ let error;
281
+ let delay = retryDelay(attempt, null);
282
+ try {
283
+ checkpoint(deadline, options.signal);
284
+ const response = await this.#fetch(url, {
285
+ method,
286
+ headers,
287
+ body,
288
+ signal: controller.signal,
289
+ redirect: "manual",
290
+ credentials: "omit",
291
+ });
292
+ if (response.status >= 300 && response.status < 400) {
293
+ await response.body?.cancel().catch(() => undefined);
294
+ throw new APIError(response.status, "redirect_rejected");
295
+ }
296
+ const raw = await readBody(response, response.ok
297
+ ? request.binary
298
+ ? this.maxExportBytes
299
+ : this.maxResponseBytes
300
+ : 65536, deadline, options.signal);
301
+ if (response.ok) {
302
+ if (request.binary)
303
+ return raw;
304
+ try {
305
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw));
306
+ if (!value || typeof value !== "object" || Array.isArray(value))
307
+ throw new Error();
308
+ checkpoint(deadline, options.signal);
309
+ return value;
310
+ }
311
+ catch (e) {
312
+ if (e instanceof ClientError)
313
+ throw e;
314
+ throw new ClientError("invalid_response", "API returned an invalid JSON object.");
315
+ }
316
+ }
317
+ let code = "http_error", requestId = response.headers.get("X-Request-Id");
318
+ let message, details = null;
319
+ try {
320
+ const serverError = JSON.parse(new TextDecoder().decode(raw))?.error;
321
+ const candidate = serverError?.code;
322
+ if (typeof candidate === "string" &&
323
+ /^[a-z][a-z0-9_]{0,99}$/.test(candidate))
324
+ code = candidate;
325
+ if (typeof serverError?.message === "string")
326
+ message = safeErrorValue(serverError.message, secrets);
327
+ details = safeErrorValue(serverError?.details, secrets);
328
+ }
329
+ catch {
330
+ /* Untrusted response text does not enter exceptions. */
331
+ }
332
+ if (secrets.some((secret) => code.includes(secret)))
333
+ code = "http_error";
334
+ if (!requestId ||
335
+ !/^[A-Za-z0-9_-]{1,100}$/.test(requestId) ||
336
+ secrets.some((secret) => requestId.includes(secret)))
337
+ requestId = null;
338
+ error = new APIError(response.status, code, requestId, message, details);
339
+ if (![429, 502, 503, 504].includes(response.status))
340
+ throw error;
341
+ delay = retryDelay(attempt, response.headers.get("Retry-After"));
342
+ }
343
+ catch (e) {
344
+ checkpoint(deadline, options.signal);
345
+ if (e instanceof ClientError)
346
+ throw e;
347
+ error = new ClientError("network_error", "The API request failed; a submitted operation may have committed.");
348
+ }
349
+ finally {
350
+ clearTimeout(timer);
351
+ options.signal?.removeEventListener("abort", abort);
352
+ }
353
+ if (!request.retry || attempt === this.maxRetries)
354
+ throw error;
355
+ await pause(delay, deadline, options.signal);
356
+ }
357
+ throw new Error("Unreachable retry state");
358
+ }
359
+ }
360
+ export class Resource {
361
+ client;
362
+ constructor(client) {
363
+ this.client = client;
364
+ }
365
+ }