localarena 0.1.0 → 0.2.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/CHANGELOG.md +22 -0
- package/README.md +252 -140
- package/docs/provider-and-task-support.md +165 -0
- package/examples/evaluation-config.json +157 -0
- package/javascript/src/evaluation.js +1712 -0
- package/javascript/src/index.d.ts +545 -0
- package/javascript/src/index.js +49 -0
- package/javascript/src/providers.js +1303 -0
- package/javascript/src/report.js +355 -0
- package/javascript/src/tasks.js +828 -0
- package/package.json +7 -3
- package/schema/localarena-evaluation-config-v1.schema.json +612 -0
- package/schema/localarena-evaluation-run-v1.schema.json +1422 -0
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
2
|
+
const DEFAULT_MAX_RETRIES = 0;
|
|
3
|
+
const DEFAULT_RETRY_BASE_MS = 500;
|
|
4
|
+
const DEFAULT_RETRY_MAX_MS = 8_000;
|
|
5
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
const RESERVED_REQUEST_FIELDS = new Set([
|
|
8
|
+
"model",
|
|
9
|
+
"messages",
|
|
10
|
+
"stream",
|
|
11
|
+
"max_tokens",
|
|
12
|
+
"max_completion_tokens",
|
|
13
|
+
"temperature",
|
|
14
|
+
"seed",
|
|
15
|
+
"stop",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const TARGET_REQUEST_OVERRIDE_FIELDS = new Set([
|
|
19
|
+
"maxTokens",
|
|
20
|
+
"maxOutputTokens",
|
|
21
|
+
"max_tokens",
|
|
22
|
+
"temperature",
|
|
23
|
+
"seed",
|
|
24
|
+
"stop",
|
|
25
|
+
"extraBody",
|
|
26
|
+
"extra_body",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const DEFAULT_RETRY_STATUSES = Object.freeze([
|
|
30
|
+
408,
|
|
31
|
+
429,
|
|
32
|
+
500,
|
|
33
|
+
502,
|
|
34
|
+
503,
|
|
35
|
+
504,
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const PROVIDER_PRESETS = Object.freeze({
|
|
39
|
+
llamacpp: Object.freeze({
|
|
40
|
+
id: "llamacpp",
|
|
41
|
+
baseURL: "http://127.0.0.1:8080/v1",
|
|
42
|
+
maxTokensField: "max_tokens",
|
|
43
|
+
}),
|
|
44
|
+
ollama: Object.freeze({
|
|
45
|
+
id: "ollama",
|
|
46
|
+
baseURL: "http://127.0.0.1:11434/v1",
|
|
47
|
+
maxTokensField: "max_tokens",
|
|
48
|
+
}),
|
|
49
|
+
lmstudio: Object.freeze({
|
|
50
|
+
id: "lmstudio",
|
|
51
|
+
baseURL: "http://127.0.0.1:1234/v1",
|
|
52
|
+
maxTokensField: "max_tokens",
|
|
53
|
+
}),
|
|
54
|
+
openrouter: Object.freeze({
|
|
55
|
+
id: "openrouter",
|
|
56
|
+
baseURL: "https://openrouter.ai/api/v1",
|
|
57
|
+
maxTokensField: "max_tokens",
|
|
58
|
+
requiresAPIKey: true,
|
|
59
|
+
}),
|
|
60
|
+
openai: Object.freeze({
|
|
61
|
+
id: "openai",
|
|
62
|
+
baseURL: "https://api.openai.com/v1",
|
|
63
|
+
maxTokensField: "max_completion_tokens",
|
|
64
|
+
requiresAPIKey: true,
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function assertRecord(value, field) {
|
|
69
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
70
|
+
throw new TypeError(`${field} must be an object`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function assertNonEmptyString(value, field) {
|
|
75
|
+
if (typeof value !== "string") {
|
|
76
|
+
throw new TypeError(`${field} must be a string`);
|
|
77
|
+
}
|
|
78
|
+
if (value.trim().length === 0) {
|
|
79
|
+
throw new RangeError(`${field} must not be empty`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertOptionalString(value, field) {
|
|
84
|
+
if (value === undefined || value === null) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
assertNonEmptyString(value, field);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function compareCodePoints(left, right) {
|
|
91
|
+
const leftPoints = Array.from(left, (value) => value.codePointAt(0));
|
|
92
|
+
const rightPoints = Array.from(right, (value) => value.codePointAt(0));
|
|
93
|
+
const length = Math.min(leftPoints.length, rightPoints.length);
|
|
94
|
+
|
|
95
|
+
for (let index = 0; index < length; index += 1) {
|
|
96
|
+
if (leftPoints[index] !== rightPoints[index]) {
|
|
97
|
+
return leftPoints[index] - rightPoints[index];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return leftPoints.length - rightPoints.length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function assertFiniteNumber(value, field, minimum = -Infinity) {
|
|
104
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
105
|
+
throw new TypeError(`${field} must be a finite number`);
|
|
106
|
+
}
|
|
107
|
+
if (value < minimum) {
|
|
108
|
+
throw new RangeError(`${field} must be at least ${minimum}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function assertSafeInteger(value, field, minimum = Number.MIN_SAFE_INTEGER) {
|
|
113
|
+
if (!Number.isSafeInteger(value)) {
|
|
114
|
+
throw new TypeError(`${field} must be a safe integer`);
|
|
115
|
+
}
|
|
116
|
+
if (value < minimum) {
|
|
117
|
+
throw new RangeError(`${field} must be at least ${minimum}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function freezeJson(value, field) {
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
|
|
124
|
+
function clone(current, path) {
|
|
125
|
+
if (
|
|
126
|
+
current === null ||
|
|
127
|
+
typeof current === "string" ||
|
|
128
|
+
typeof current === "boolean"
|
|
129
|
+
) {
|
|
130
|
+
return current;
|
|
131
|
+
}
|
|
132
|
+
if (typeof current === "number") {
|
|
133
|
+
if (!Number.isFinite(current)) {
|
|
134
|
+
throw new TypeError(`${path} must contain only finite numbers`);
|
|
135
|
+
}
|
|
136
|
+
if (Number.isInteger(current) && !Number.isSafeInteger(current)) {
|
|
137
|
+
throw new TypeError(`${path} integers must be within the safe range`);
|
|
138
|
+
}
|
|
139
|
+
return current;
|
|
140
|
+
}
|
|
141
|
+
if (typeof current !== "object") {
|
|
142
|
+
throw new TypeError(`${path} must contain only JSON values`);
|
|
143
|
+
}
|
|
144
|
+
if (seen.has(current)) {
|
|
145
|
+
throw new TypeError(`${field} must not contain cycles`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
seen.add(current);
|
|
149
|
+
let result;
|
|
150
|
+
if (Array.isArray(current)) {
|
|
151
|
+
result = current.map((item, index) => clone(item, `${path}[${index}]`));
|
|
152
|
+
} else {
|
|
153
|
+
const prototype = Object.getPrototypeOf(current);
|
|
154
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
155
|
+
throw new TypeError(`${path} must contain only plain objects`);
|
|
156
|
+
}
|
|
157
|
+
result = {};
|
|
158
|
+
for (const [key, item] of Object.entries(current)) {
|
|
159
|
+
Object.defineProperty(result, key, {
|
|
160
|
+
configurable: true,
|
|
161
|
+
enumerable: true,
|
|
162
|
+
value: clone(item, `${path}.${key}`),
|
|
163
|
+
writable: true,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
seen.delete(current);
|
|
168
|
+
return Object.freeze(result);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return clone(value, field);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeBaseURL(value) {
|
|
175
|
+
assertNonEmptyString(value, "baseURL");
|
|
176
|
+
|
|
177
|
+
let parsed;
|
|
178
|
+
try {
|
|
179
|
+
parsed = new URL(value);
|
|
180
|
+
} catch {
|
|
181
|
+
throw new ProviderConfigurationError("baseURL must be a valid URL");
|
|
182
|
+
}
|
|
183
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
184
|
+
throw new ProviderConfigurationError(
|
|
185
|
+
"baseURL must use the http or https protocol",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (parsed.username || parsed.password) {
|
|
189
|
+
throw new ProviderConfigurationError(
|
|
190
|
+
"baseURL must not contain credentials",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (parsed.search || parsed.hash) {
|
|
194
|
+
throw new ProviderConfigurationError(
|
|
195
|
+
"baseURL must not contain a query or fragment",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return parsed.href.replace(/\/+$/, "");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeHeaders(headers) {
|
|
203
|
+
assertRecord(headers, "headers");
|
|
204
|
+
const normalized = {};
|
|
205
|
+
|
|
206
|
+
for (const [rawName, rawValue] of Object.entries(headers)) {
|
|
207
|
+
assertNonEmptyString(rawName, "header name");
|
|
208
|
+
if (typeof rawValue !== "string") {
|
|
209
|
+
throw new TypeError("header values must be strings");
|
|
210
|
+
}
|
|
211
|
+
if (/[\r\n]/u.test(rawName) || /[\r\n]/u.test(rawValue)) {
|
|
212
|
+
throw new TypeError("headers must not contain line breaks");
|
|
213
|
+
}
|
|
214
|
+
const name = rawName.toLowerCase();
|
|
215
|
+
if (name === "host" || name === "content-length") {
|
|
216
|
+
throw new ProviderConfigurationError(
|
|
217
|
+
`${name} cannot be supplied as a default header`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
normalized[name] = rawValue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return Object.freeze(normalized);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function optionalNonNegativeInteger(value, field) {
|
|
227
|
+
if (value === undefined || value === null) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
assertSafeInteger(value, field, 0);
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeUsage(value) {
|
|
235
|
+
if (value === undefined || value === null) {
|
|
236
|
+
return new TokenUsage();
|
|
237
|
+
}
|
|
238
|
+
assertRecord(value, "usage");
|
|
239
|
+
return new TokenUsage({
|
|
240
|
+
inputTokens: value.prompt_tokens,
|
|
241
|
+
outputTokens: value.completion_tokens,
|
|
242
|
+
totalTokens: value.total_tokens,
|
|
243
|
+
cachedInputTokens: value.prompt_tokens_details?.cached_tokens,
|
|
244
|
+
reasoningTokens: value.completion_tokens_details?.reasoning_tokens,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function extractTextContent(content) {
|
|
249
|
+
if (typeof content === "string") {
|
|
250
|
+
return content;
|
|
251
|
+
}
|
|
252
|
+
if (!Array.isArray(content)) {
|
|
253
|
+
throw new ProviderResponseError(
|
|
254
|
+
"provider response did not contain text content",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const text = [];
|
|
259
|
+
let foundText = false;
|
|
260
|
+
for (const part of content) {
|
|
261
|
+
if (typeof part === "string") {
|
|
262
|
+
text.push(part);
|
|
263
|
+
foundText = true;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (part === null || typeof part !== "object" || part.type !== "text") {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (typeof part.text === "string") {
|
|
270
|
+
text.push(part.text);
|
|
271
|
+
foundText = true;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (
|
|
275
|
+
part.text !== null &&
|
|
276
|
+
typeof part.text === "object" &&
|
|
277
|
+
typeof part.text.value === "string"
|
|
278
|
+
) {
|
|
279
|
+
text.push(part.text.value);
|
|
280
|
+
foundText = true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!foundText) {
|
|
285
|
+
throw new ProviderResponseError(
|
|
286
|
+
"provider response did not contain text content",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return text.join("");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function parseRetryAfter(value, now) {
|
|
293
|
+
if (value === null) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
const seconds = Number(value);
|
|
297
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
298
|
+
return seconds * 1_000;
|
|
299
|
+
}
|
|
300
|
+
const timestamp = Date.parse(value);
|
|
301
|
+
if (!Number.isFinite(timestamp)) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
return Math.max(0, timestamp - now());
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function requestIdFrom(response) {
|
|
308
|
+
return (
|
|
309
|
+
response.headers.get("x-request-id") ??
|
|
310
|
+
response.headers.get("request-id") ??
|
|
311
|
+
undefined
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function abortError(provider, attempts) {
|
|
316
|
+
return new ProviderAbortError("provider request was aborted", {
|
|
317
|
+
provider,
|
|
318
|
+
attempts,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function defaultSleep(delayMs, signal) {
|
|
323
|
+
if (signal?.aborted) {
|
|
324
|
+
return Promise.reject(signal.reason);
|
|
325
|
+
}
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
let onAbort;
|
|
328
|
+
const timer = setTimeout(() => {
|
|
329
|
+
signal?.removeEventListener("abort", onAbort);
|
|
330
|
+
resolve();
|
|
331
|
+
}, delayMs);
|
|
332
|
+
if (signal === undefined) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
onAbort = () => {
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
signal.removeEventListener("abort", onAbort);
|
|
338
|
+
reject(signal.reason);
|
|
339
|
+
};
|
|
340
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function createAttemptSignal(parentSignal, timeoutMs) {
|
|
345
|
+
const controller = new AbortController();
|
|
346
|
+
let timedOut = false;
|
|
347
|
+
|
|
348
|
+
const onAbort = () => controller.abort(parentSignal.reason);
|
|
349
|
+
if (parentSignal !== undefined) {
|
|
350
|
+
if (parentSignal.aborted) {
|
|
351
|
+
controller.abort(parentSignal.reason);
|
|
352
|
+
} else {
|
|
353
|
+
parentSignal.addEventListener("abort", onAbort, { once: true });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const timer = setTimeout(() => {
|
|
358
|
+
timedOut = true;
|
|
359
|
+
controller.abort();
|
|
360
|
+
}, timeoutMs);
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
signal: controller.signal,
|
|
364
|
+
timedOut: () => timedOut,
|
|
365
|
+
cleanup() {
|
|
366
|
+
clearTimeout(timer);
|
|
367
|
+
parentSignal?.removeEventListener("abort", onAbort);
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function cancelBody(response) {
|
|
373
|
+
try {
|
|
374
|
+
await response.body?.cancel();
|
|
375
|
+
} catch {
|
|
376
|
+
// The response is already unusable, so cancellation errors are irrelevant.
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function readBoundedJson(response, maxBytes) {
|
|
381
|
+
const contentLength = response.headers.get("content-length");
|
|
382
|
+
if (contentLength !== null) {
|
|
383
|
+
const declared = Number(contentLength);
|
|
384
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
385
|
+
await cancelBody(response);
|
|
386
|
+
throw new ProviderResponseError("provider response exceeded the size limit");
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (response.body === null || response.body === undefined) {
|
|
391
|
+
throw new ProviderResponseError("provider response did not contain a body");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const reader = response.body.getReader();
|
|
395
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
396
|
+
let bytes = 0;
|
|
397
|
+
let text = "";
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
while (true) {
|
|
401
|
+
const { done, value } = await reader.read();
|
|
402
|
+
if (done) {
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
bytes += value.byteLength;
|
|
406
|
+
if (bytes > maxBytes) {
|
|
407
|
+
await reader.cancel();
|
|
408
|
+
throw new ProviderResponseError(
|
|
409
|
+
"provider response exceeded the size limit",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
text += decoder.decode(value, { stream: true });
|
|
413
|
+
}
|
|
414
|
+
text += decoder.decode();
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error instanceof ProviderError) {
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
throw new ProviderResponseError("provider response could not be read");
|
|
420
|
+
} finally {
|
|
421
|
+
reader.releaseLock();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
return JSON.parse(text);
|
|
426
|
+
} catch {
|
|
427
|
+
throw new ProviderResponseError("provider response was not valid JSON");
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function normalizeGenerationRequest(value) {
|
|
432
|
+
if (value instanceof GenerationRequest) {
|
|
433
|
+
return value;
|
|
434
|
+
}
|
|
435
|
+
assertRecord(value, "request");
|
|
436
|
+
return new GenerationRequest(value);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function generationRequestValues(request) {
|
|
440
|
+
return {
|
|
441
|
+
model: request.model,
|
|
442
|
+
messages: request.messages,
|
|
443
|
+
maxTokens: request.maxTokens,
|
|
444
|
+
temperature: request.temperature,
|
|
445
|
+
seed: request.seed,
|
|
446
|
+
stop: request.stop,
|
|
447
|
+
extraBody: request.extraBody,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function canonicalRequestValues(value) {
|
|
452
|
+
assertRecord(value, "request");
|
|
453
|
+
const output = { ...value };
|
|
454
|
+
|
|
455
|
+
let maxTokens;
|
|
456
|
+
for (const key of ["maxTokens", "maxOutputTokens", "max_tokens"]) {
|
|
457
|
+
if (Object.hasOwn(value, key) && value[key] !== undefined) {
|
|
458
|
+
maxTokens = value[key];
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
delete output.maxOutputTokens;
|
|
463
|
+
delete output.max_tokens;
|
|
464
|
+
if (maxTokens === undefined) {
|
|
465
|
+
delete output.maxTokens;
|
|
466
|
+
} else {
|
|
467
|
+
output.maxTokens = maxTokens;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const extraBody = value.extraBody ?? value.extra_body;
|
|
471
|
+
delete output.extra_body;
|
|
472
|
+
if (extraBody === undefined || extraBody === null) {
|
|
473
|
+
delete output.extraBody;
|
|
474
|
+
} else {
|
|
475
|
+
output.extraBody = extraBody;
|
|
476
|
+
}
|
|
477
|
+
return output;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function mergeRequestValues(defaults, request, model) {
|
|
481
|
+
const input =
|
|
482
|
+
request instanceof GenerationRequest
|
|
483
|
+
? generationRequestValues(request)
|
|
484
|
+
: request;
|
|
485
|
+
const normalizedDefaults = canonicalRequestValues(defaults);
|
|
486
|
+
const normalizedInput = canonicalRequestValues(input);
|
|
487
|
+
|
|
488
|
+
const merged = {
|
|
489
|
+
...normalizedDefaults,
|
|
490
|
+
...normalizedInput,
|
|
491
|
+
model,
|
|
492
|
+
extraBody: {
|
|
493
|
+
...(normalizedDefaults.extraBody ?? {}),
|
|
494
|
+
...(normalizedInput.extraBody ?? {}),
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
return merged;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export class ChatMessage {
|
|
501
|
+
constructor(roleOrOptions, content) {
|
|
502
|
+
const options =
|
|
503
|
+
typeof roleOrOptions === "string"
|
|
504
|
+
? { role: roleOrOptions, content }
|
|
505
|
+
: roleOrOptions;
|
|
506
|
+
assertRecord(options, "message");
|
|
507
|
+
assertNonEmptyString(options.role, "message.role");
|
|
508
|
+
if (!["system", "user", "assistant"].includes(options.role)) {
|
|
509
|
+
throw new RangeError(
|
|
510
|
+
"message.role must be system, user, or assistant",
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (typeof options.content !== "string") {
|
|
514
|
+
throw new TypeError("message.content must be a string");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
this.role = options.role;
|
|
518
|
+
this.content = options.content;
|
|
519
|
+
Object.freeze(this);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export class GenerationRequest {
|
|
524
|
+
constructor(options) {
|
|
525
|
+
assertRecord(options, "request");
|
|
526
|
+
assertNonEmptyString(options.model, "request.model");
|
|
527
|
+
|
|
528
|
+
let messages = options.messages;
|
|
529
|
+
if (messages === undefined) {
|
|
530
|
+
const input = options.input ?? options.prompt;
|
|
531
|
+
if (typeof input === "string") {
|
|
532
|
+
messages = [new ChatMessage("user", input)];
|
|
533
|
+
} else if (Array.isArray(input)) {
|
|
534
|
+
messages = input;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
538
|
+
throw new TypeError("request.messages must be a non-empty array");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const normalizedMessages = messages.map((message, index) => {
|
|
542
|
+
try {
|
|
543
|
+
return message instanceof ChatMessage
|
|
544
|
+
? message
|
|
545
|
+
: new ChatMessage(message);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
throw new TypeError(`request.messages[${index}] is invalid`, {
|
|
548
|
+
cause: error,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
if (options.system !== undefined) {
|
|
553
|
+
if (typeof options.system !== "string") {
|
|
554
|
+
throw new TypeError("request.system must be a string");
|
|
555
|
+
}
|
|
556
|
+
normalizedMessages.unshift(new ChatMessage("system", options.system));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
let maxTokens;
|
|
560
|
+
for (const key of ["maxTokens", "maxOutputTokens", "max_tokens"]) {
|
|
561
|
+
if (Object.hasOwn(options, key) && options[key] !== undefined) {
|
|
562
|
+
maxTokens = options[key];
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (maxTokens === undefined) {
|
|
567
|
+
maxTokens = 512;
|
|
568
|
+
}
|
|
569
|
+
if (maxTokens !== null) {
|
|
570
|
+
assertSafeInteger(maxTokens, "request.maxTokens", 1);
|
|
571
|
+
}
|
|
572
|
+
if (options.temperature !== undefined) {
|
|
573
|
+
assertFiniteNumber(options.temperature, "request.temperature", 0);
|
|
574
|
+
if (options.temperature > 2) {
|
|
575
|
+
throw new RangeError("request.temperature must be at most 2");
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (options.seed !== undefined) {
|
|
579
|
+
assertSafeInteger(options.seed, "request.seed");
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const stop = options.stop ?? [];
|
|
583
|
+
if (!Array.isArray(stop)) {
|
|
584
|
+
throw new TypeError("request.stop must be an array");
|
|
585
|
+
}
|
|
586
|
+
const normalizedStop = stop.map((value, index) => {
|
|
587
|
+
assertNonEmptyString(value, `request.stop[${index}]`);
|
|
588
|
+
return value;
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const extraBody = options.extraBody ?? options.extra_body ?? {};
|
|
592
|
+
assertRecord(extraBody, "request.extraBody");
|
|
593
|
+
const reserved = Object.keys(extraBody).filter((key) =>
|
|
594
|
+
RESERVED_REQUEST_FIELDS.has(key)
|
|
595
|
+
);
|
|
596
|
+
if (reserved.length > 0) {
|
|
597
|
+
throw new ProviderConfigurationError(
|
|
598
|
+
`request.extraBody must not override reserved fields: ${reserved.sort().join(", ")}`,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
this.model = options.model;
|
|
603
|
+
this.messages = Object.freeze(normalizedMessages);
|
|
604
|
+
this.maxTokens = maxTokens;
|
|
605
|
+
this.temperature = options.temperature;
|
|
606
|
+
this.seed = options.seed;
|
|
607
|
+
this.stop = Object.freeze(normalizedStop);
|
|
608
|
+
this.extraBody = freezeJson(extraBody, "request.extraBody");
|
|
609
|
+
Object.freeze(this);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export class TokenUsage {
|
|
614
|
+
constructor(options = {}) {
|
|
615
|
+
assertRecord(options, "usage");
|
|
616
|
+
this.inputTokens = optionalNonNegativeInteger(
|
|
617
|
+
options.inputTokens,
|
|
618
|
+
"usage.inputTokens",
|
|
619
|
+
);
|
|
620
|
+
this.outputTokens = optionalNonNegativeInteger(
|
|
621
|
+
options.outputTokens,
|
|
622
|
+
"usage.outputTokens",
|
|
623
|
+
);
|
|
624
|
+
this.totalTokens = optionalNonNegativeInteger(
|
|
625
|
+
options.totalTokens,
|
|
626
|
+
"usage.totalTokens",
|
|
627
|
+
);
|
|
628
|
+
this.cachedInputTokens = optionalNonNegativeInteger(
|
|
629
|
+
options.cachedInputTokens,
|
|
630
|
+
"usage.cachedInputTokens",
|
|
631
|
+
);
|
|
632
|
+
this.reasoningTokens = optionalNonNegativeInteger(
|
|
633
|
+
options.reasoningTokens,
|
|
634
|
+
"usage.reasoningTokens",
|
|
635
|
+
);
|
|
636
|
+
Object.freeze(this);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export class GenerationResult {
|
|
641
|
+
constructor(options) {
|
|
642
|
+
assertRecord(options, "generation");
|
|
643
|
+
if (typeof options.text !== "string") {
|
|
644
|
+
throw new TypeError("generation.text must be a string");
|
|
645
|
+
}
|
|
646
|
+
assertNonEmptyString(options.provider, "generation.provider");
|
|
647
|
+
assertNonEmptyString(options.model, "generation.model");
|
|
648
|
+
assertOptionalString(options.responseModel, "generation.responseModel");
|
|
649
|
+
assertOptionalString(options.finishReason, "generation.finishReason");
|
|
650
|
+
assertOptionalString(options.responseId, "generation.responseId");
|
|
651
|
+
assertOptionalString(options.requestId, "generation.requestId");
|
|
652
|
+
assertFiniteNumber(options.latencySeconds, "generation.latencySeconds", 0);
|
|
653
|
+
assertSafeInteger(options.attempts, "generation.attempts", 1);
|
|
654
|
+
|
|
655
|
+
this.text = options.text;
|
|
656
|
+
this.provider = options.provider;
|
|
657
|
+
this.model = options.model;
|
|
658
|
+
this.responseModel = options.responseModel;
|
|
659
|
+
this.finishReason = options.finishReason;
|
|
660
|
+
this.usage =
|
|
661
|
+
options.usage instanceof TokenUsage
|
|
662
|
+
? options.usage
|
|
663
|
+
: new TokenUsage(options.usage);
|
|
664
|
+
this.latencySeconds = options.latencySeconds;
|
|
665
|
+
this.attempts = options.attempts;
|
|
666
|
+
this.responseId = options.responseId;
|
|
667
|
+
this.requestId = options.requestId;
|
|
668
|
+
this.metadata = freezeJson(options.metadata ?? {}, "generation.metadata");
|
|
669
|
+
Object.freeze(this);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export class ModelInfo {
|
|
674
|
+
constructor(options) {
|
|
675
|
+
assertRecord(options, "model");
|
|
676
|
+
assertNonEmptyString(options.id, "model.id");
|
|
677
|
+
assertNonEmptyString(options.provider, "model.provider");
|
|
678
|
+
assertOptionalString(options.displayName, "model.displayName");
|
|
679
|
+
|
|
680
|
+
this.id = options.id;
|
|
681
|
+
this.provider = options.provider;
|
|
682
|
+
this.displayName = options.displayName;
|
|
683
|
+
this.metadata = freezeJson(options.metadata ?? {}, "model.metadata");
|
|
684
|
+
Object.freeze(this);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export class RequestPolicy {
|
|
689
|
+
constructor(options = {}) {
|
|
690
|
+
assertRecord(options, "requestPolicy");
|
|
691
|
+
|
|
692
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
693
|
+
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
694
|
+
const retryBaseMs = options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS;
|
|
695
|
+
const retryMaxMs = options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS;
|
|
696
|
+
const maxResponseBytes =
|
|
697
|
+
options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
698
|
+
const retryStatuses = options.retryStatuses ?? DEFAULT_RETRY_STATUSES;
|
|
699
|
+
|
|
700
|
+
assertSafeInteger(timeoutMs, "requestPolicy.timeoutMs", 1);
|
|
701
|
+
assertSafeInteger(maxRetries, "requestPolicy.maxRetries", 0);
|
|
702
|
+
if (maxRetries > 9) {
|
|
703
|
+
throw new RangeError("requestPolicy.maxRetries must be at most 9");
|
|
704
|
+
}
|
|
705
|
+
assertSafeInteger(retryBaseMs, "requestPolicy.retryBaseMs", 0);
|
|
706
|
+
assertSafeInteger(retryMaxMs, "requestPolicy.retryMaxMs", retryBaseMs);
|
|
707
|
+
assertSafeInteger(
|
|
708
|
+
maxResponseBytes,
|
|
709
|
+
"requestPolicy.maxResponseBytes",
|
|
710
|
+
1,
|
|
711
|
+
);
|
|
712
|
+
if (!Array.isArray(retryStatuses)) {
|
|
713
|
+
throw new TypeError("requestPolicy.retryStatuses must be an array");
|
|
714
|
+
}
|
|
715
|
+
const normalizedStatuses = retryStatuses.map((status, index) => {
|
|
716
|
+
assertSafeInteger(status, `requestPolicy.retryStatuses[${index}]`, 100);
|
|
717
|
+
if (status > 599) {
|
|
718
|
+
throw new RangeError(
|
|
719
|
+
`requestPolicy.retryStatuses[${index}] must be at most 599`,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
return status;
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
this.timeoutMs = timeoutMs;
|
|
726
|
+
this.maxRetries = maxRetries;
|
|
727
|
+
this.retryBaseMs = retryBaseMs;
|
|
728
|
+
this.retryMaxMs = retryMaxMs;
|
|
729
|
+
this.maxResponseBytes = maxResponseBytes;
|
|
730
|
+
this.retryStatuses = Object.freeze(Array.from(new Set(normalizedStatuses)));
|
|
731
|
+
Object.freeze(this);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export class ProviderError extends Error {
|
|
736
|
+
constructor(
|
|
737
|
+
message,
|
|
738
|
+
{
|
|
739
|
+
code = "provider_error",
|
|
740
|
+
provider,
|
|
741
|
+
status,
|
|
742
|
+
retryable = false,
|
|
743
|
+
requestId,
|
|
744
|
+
attempts = 0,
|
|
745
|
+
} = {},
|
|
746
|
+
) {
|
|
747
|
+
super(message);
|
|
748
|
+
this.name = new.target.name;
|
|
749
|
+
this.code = code;
|
|
750
|
+
this.provider = provider;
|
|
751
|
+
this.status = status;
|
|
752
|
+
this.retryable = retryable;
|
|
753
|
+
this.requestId = requestId;
|
|
754
|
+
this.attempts = attempts;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
toJSON() {
|
|
758
|
+
return {
|
|
759
|
+
name: this.name,
|
|
760
|
+
code: this.code,
|
|
761
|
+
message: this.message,
|
|
762
|
+
provider: this.provider,
|
|
763
|
+
status: this.status,
|
|
764
|
+
retryable: this.retryable,
|
|
765
|
+
requestId: this.requestId,
|
|
766
|
+
attempts: this.attempts,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export class ProviderConfigurationError extends ProviderError {
|
|
772
|
+
constructor(message, details = {}) {
|
|
773
|
+
super(message, { ...details, code: "configuration_error" });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export class ProviderRequestError extends ProviderError {
|
|
778
|
+
constructor(message, details = {}) {
|
|
779
|
+
super(message, { ...details, code: "request_error" });
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export class ProviderResponseError extends ProviderError {
|
|
784
|
+
constructor(message, details = {}) {
|
|
785
|
+
super(message, { ...details, code: "response_error" });
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export class ProviderTimeoutError extends ProviderError {
|
|
790
|
+
constructor(message, details = {}) {
|
|
791
|
+
super(message, {
|
|
792
|
+
...details,
|
|
793
|
+
code: "timeout",
|
|
794
|
+
retryable: details.retryable ?? true,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export class ProviderAbortError extends ProviderError {
|
|
800
|
+
constructor(message, details = {}) {
|
|
801
|
+
super(message, {
|
|
802
|
+
...details,
|
|
803
|
+
code: "aborted",
|
|
804
|
+
retryable: false,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export class OpenAICompatibleProvider {
|
|
810
|
+
#baseURL;
|
|
811
|
+
#apiKey;
|
|
812
|
+
#fetch;
|
|
813
|
+
#headers;
|
|
814
|
+
#policy;
|
|
815
|
+
#sleep;
|
|
816
|
+
#random;
|
|
817
|
+
#now;
|
|
818
|
+
#maxTokensField;
|
|
819
|
+
|
|
820
|
+
constructor(options) {
|
|
821
|
+
assertRecord(options, "provider");
|
|
822
|
+
const id = options.id ?? "generic";
|
|
823
|
+
assertNonEmptyString(id, "provider.id");
|
|
824
|
+
|
|
825
|
+
const fetchImplementation =
|
|
826
|
+
options.fetch ??
|
|
827
|
+
(typeof globalThis.fetch === "function"
|
|
828
|
+
? globalThis.fetch.bind(globalThis)
|
|
829
|
+
: undefined);
|
|
830
|
+
if (typeof fetchImplementation !== "function") {
|
|
831
|
+
throw new ProviderConfigurationError(
|
|
832
|
+
"a Fetch API implementation is required",
|
|
833
|
+
{ provider: id },
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
if (options.apiKey !== undefined && options.apiKey !== null) {
|
|
837
|
+
assertNonEmptyString(options.apiKey, "provider.apiKey");
|
|
838
|
+
if (/[\r\n]/u.test(options.apiKey)) {
|
|
839
|
+
throw new ProviderConfigurationError(
|
|
840
|
+
"provider.apiKey must not contain line breaks",
|
|
841
|
+
{ provider: id },
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (options.sleep !== undefined && typeof options.sleep !== "function") {
|
|
846
|
+
throw new TypeError("provider.sleep must be a function");
|
|
847
|
+
}
|
|
848
|
+
if (options.random !== undefined && typeof options.random !== "function") {
|
|
849
|
+
throw new TypeError("provider.random must be a function");
|
|
850
|
+
}
|
|
851
|
+
if (options.now !== undefined && typeof options.now !== "function") {
|
|
852
|
+
throw new TypeError("provider.now must be a function");
|
|
853
|
+
}
|
|
854
|
+
const maxTokensField = options.maxTokensField ?? "max_tokens";
|
|
855
|
+
if (
|
|
856
|
+
maxTokensField !== "max_tokens" &&
|
|
857
|
+
maxTokensField !== "max_completion_tokens"
|
|
858
|
+
) {
|
|
859
|
+
throw new ProviderConfigurationError(
|
|
860
|
+
"provider.maxTokensField must be max_tokens or max_completion_tokens",
|
|
861
|
+
{ provider: id },
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
this.id = id;
|
|
866
|
+
this.kind = "openai-compatible";
|
|
867
|
+
this.#baseURL = normalizeBaseURL(options.baseURL);
|
|
868
|
+
this.#apiKey = options.apiKey ?? undefined;
|
|
869
|
+
this.#fetch = fetchImplementation;
|
|
870
|
+
this.#headers = normalizeHeaders(
|
|
871
|
+
options.headers ?? options.defaultHeaders ?? {},
|
|
872
|
+
);
|
|
873
|
+
this.#policy =
|
|
874
|
+
options.requestPolicy instanceof RequestPolicy
|
|
875
|
+
? options.requestPolicy
|
|
876
|
+
: new RequestPolicy(options.requestPolicy);
|
|
877
|
+
this.#sleep = options.sleep ?? defaultSleep;
|
|
878
|
+
this.#random = options.random ?? Math.random;
|
|
879
|
+
this.#now = options.now ?? Date.now;
|
|
880
|
+
this.#maxTokensField = maxTokensField;
|
|
881
|
+
Object.freeze(this);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
get requestPolicy() {
|
|
885
|
+
return this.#policy;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
toJSON() {
|
|
889
|
+
return {
|
|
890
|
+
id: this.id,
|
|
891
|
+
kind: this.kind,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async listModels(options = {}) {
|
|
896
|
+
assertRecord(options, "options");
|
|
897
|
+
const { payload } = await this.#request("models", {
|
|
898
|
+
method: "GET",
|
|
899
|
+
signal: options.signal,
|
|
900
|
+
policy: options.requestPolicy,
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
if (!Array.isArray(payload?.data)) {
|
|
904
|
+
throw new ProviderResponseError(
|
|
905
|
+
"provider model response did not contain a data array",
|
|
906
|
+
{ provider: this.id },
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const models = payload.data.map((value, index) => {
|
|
911
|
+
if (value === null || typeof value !== "object") {
|
|
912
|
+
throw new ProviderResponseError(
|
|
913
|
+
`provider model entry ${index} was invalid`,
|
|
914
|
+
{ provider: this.id },
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
try {
|
|
918
|
+
const metadata = {};
|
|
919
|
+
if (typeof value.owned_by === "string") {
|
|
920
|
+
metadata.ownedBy = value.owned_by;
|
|
921
|
+
}
|
|
922
|
+
if (Number.isSafeInteger(value.created) && value.created >= 0) {
|
|
923
|
+
metadata.created = value.created;
|
|
924
|
+
}
|
|
925
|
+
if (
|
|
926
|
+
Number.isSafeInteger(value.context_length) &&
|
|
927
|
+
value.context_length >= 0
|
|
928
|
+
) {
|
|
929
|
+
metadata.contextLength = value.context_length;
|
|
930
|
+
}
|
|
931
|
+
return new ModelInfo({
|
|
932
|
+
id: value.id,
|
|
933
|
+
provider: this.id,
|
|
934
|
+
displayName:
|
|
935
|
+
typeof value.name === "string" && value.name.trim().length > 0
|
|
936
|
+
? value.name
|
|
937
|
+
: undefined,
|
|
938
|
+
metadata,
|
|
939
|
+
});
|
|
940
|
+
} catch {
|
|
941
|
+
throw new ProviderResponseError(
|
|
942
|
+
`provider model entry ${index} was invalid`,
|
|
943
|
+
{ provider: this.id },
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
models.sort((left, right) => compareCodePoints(left.id, right.id));
|
|
949
|
+
return Object.freeze(models);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async generate(requestValue, options = {}) {
|
|
953
|
+
assertRecord(options, "options");
|
|
954
|
+
const request = normalizeGenerationRequest(requestValue);
|
|
955
|
+
const body = {
|
|
956
|
+
...request.extraBody,
|
|
957
|
+
model: request.model,
|
|
958
|
+
messages: request.messages.map(({ role, content }) => ({ role, content })),
|
|
959
|
+
stream: false,
|
|
960
|
+
};
|
|
961
|
+
if (request.maxTokens !== null) {
|
|
962
|
+
body[this.#maxTokensField] = request.maxTokens;
|
|
963
|
+
}
|
|
964
|
+
if (request.temperature !== undefined) {
|
|
965
|
+
body.temperature = request.temperature;
|
|
966
|
+
}
|
|
967
|
+
if (request.seed !== undefined) {
|
|
968
|
+
body.seed = request.seed;
|
|
969
|
+
}
|
|
970
|
+
if (request.stop.length > 0) {
|
|
971
|
+
body.stop = request.stop;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const startedAt = this.#now();
|
|
975
|
+
const { payload, attempts, requestId } = await this.#request(
|
|
976
|
+
"chat/completions",
|
|
977
|
+
{
|
|
978
|
+
method: "POST",
|
|
979
|
+
body,
|
|
980
|
+
signal: options.signal,
|
|
981
|
+
policy: options.requestPolicy,
|
|
982
|
+
},
|
|
983
|
+
);
|
|
984
|
+
const latencySeconds = Math.max(0, (this.#now() - startedAt) / 1_000);
|
|
985
|
+
|
|
986
|
+
if (!Array.isArray(payload?.choices) || payload.choices.length === 0) {
|
|
987
|
+
throw new ProviderResponseError(
|
|
988
|
+
"provider response did not contain a choice",
|
|
989
|
+
{ provider: this.id, requestId, attempts },
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
const choice = payload.choices[0];
|
|
993
|
+
if (choice === null || typeof choice !== "object") {
|
|
994
|
+
throw new ProviderResponseError(
|
|
995
|
+
"provider response choice was invalid",
|
|
996
|
+
{ provider: this.id, requestId, attempts },
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
const content = choice.message?.content ?? choice.text;
|
|
1000
|
+
let text;
|
|
1001
|
+
try {
|
|
1002
|
+
text = extractTextContent(content);
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
if (error instanceof ProviderError) {
|
|
1005
|
+
error.provider = this.id;
|
|
1006
|
+
error.requestId = requestId;
|
|
1007
|
+
error.attempts = attempts;
|
|
1008
|
+
}
|
|
1009
|
+
throw error;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const metadata = {};
|
|
1013
|
+
if (typeof payload.system_fingerprint === "string") {
|
|
1014
|
+
metadata.systemFingerprint = payload.system_fingerprint;
|
|
1015
|
+
}
|
|
1016
|
+
if (typeof payload.service_tier === "string") {
|
|
1017
|
+
metadata.serviceTier = payload.service_tier;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return new GenerationResult({
|
|
1021
|
+
text,
|
|
1022
|
+
provider: this.id,
|
|
1023
|
+
model: request.model,
|
|
1024
|
+
responseModel:
|
|
1025
|
+
typeof payload.model === "string" ? payload.model : undefined,
|
|
1026
|
+
finishReason:
|
|
1027
|
+
typeof choice.finish_reason === "string"
|
|
1028
|
+
? choice.finish_reason
|
|
1029
|
+
: undefined,
|
|
1030
|
+
usage: normalizeUsage(payload.usage),
|
|
1031
|
+
latencySeconds,
|
|
1032
|
+
attempts,
|
|
1033
|
+
responseId: typeof payload.id === "string" ? payload.id : undefined,
|
|
1034
|
+
requestId,
|
|
1035
|
+
metadata,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async #request(path, { method, body, signal, policy }) {
|
|
1040
|
+
if (signal !== undefined && !(signal instanceof AbortSignal)) {
|
|
1041
|
+
throw new TypeError("options.signal must be an AbortSignal");
|
|
1042
|
+
}
|
|
1043
|
+
const requestPolicy =
|
|
1044
|
+
policy === undefined
|
|
1045
|
+
? this.#policy
|
|
1046
|
+
: policy instanceof RequestPolicy
|
|
1047
|
+
? policy
|
|
1048
|
+
: new RequestPolicy({
|
|
1049
|
+
...this.#policy,
|
|
1050
|
+
...policy,
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
if (signal?.aborted) {
|
|
1054
|
+
throw abortError(this.id, 0);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const url = `${this.#baseURL}/${path}`;
|
|
1058
|
+
const headers = {
|
|
1059
|
+
accept: "application/json",
|
|
1060
|
+
...this.#headers,
|
|
1061
|
+
};
|
|
1062
|
+
if (body !== undefined) {
|
|
1063
|
+
headers["content-type"] = "application/json";
|
|
1064
|
+
}
|
|
1065
|
+
if (this.#apiKey !== undefined) {
|
|
1066
|
+
headers.authorization = `Bearer ${this.#apiKey}`;
|
|
1067
|
+
}
|
|
1068
|
+
const serializedBody = body === undefined ? undefined : JSON.stringify(body);
|
|
1069
|
+
const maximumAttempts = requestPolicy.maxRetries + 1;
|
|
1070
|
+
|
|
1071
|
+
for (let attempts = 1; attempts <= maximumAttempts; attempts += 1) {
|
|
1072
|
+
if (signal?.aborted) {
|
|
1073
|
+
throw abortError(this.id, attempts - 1);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const attemptSignal = createAttemptSignal(
|
|
1077
|
+
signal,
|
|
1078
|
+
requestPolicy.timeoutMs,
|
|
1079
|
+
);
|
|
1080
|
+
let failure;
|
|
1081
|
+
let retryAfterMs;
|
|
1082
|
+
|
|
1083
|
+
try {
|
|
1084
|
+
const response = await this.#fetch(url, {
|
|
1085
|
+
method,
|
|
1086
|
+
headers,
|
|
1087
|
+
body: serializedBody,
|
|
1088
|
+
signal: attemptSignal.signal,
|
|
1089
|
+
redirect: "error",
|
|
1090
|
+
});
|
|
1091
|
+
const requestId = requestIdFrom(response);
|
|
1092
|
+
|
|
1093
|
+
if (!response.ok) {
|
|
1094
|
+
retryAfterMs = parseRetryAfter(
|
|
1095
|
+
response.headers.get("retry-after"),
|
|
1096
|
+
this.#now,
|
|
1097
|
+
);
|
|
1098
|
+
await cancelBody(response);
|
|
1099
|
+
failure = new ProviderRequestError(
|
|
1100
|
+
`provider request failed with HTTP ${response.status}`,
|
|
1101
|
+
{
|
|
1102
|
+
provider: this.id,
|
|
1103
|
+
status: response.status,
|
|
1104
|
+
retryable: requestPolicy.retryStatuses.includes(response.status),
|
|
1105
|
+
requestId,
|
|
1106
|
+
attempts,
|
|
1107
|
+
},
|
|
1108
|
+
);
|
|
1109
|
+
} else {
|
|
1110
|
+
try {
|
|
1111
|
+
const payload = await readBoundedJson(
|
|
1112
|
+
response,
|
|
1113
|
+
requestPolicy.maxResponseBytes,
|
|
1114
|
+
);
|
|
1115
|
+
return { payload, attempts, requestId };
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
if (signal?.aborted) {
|
|
1118
|
+
failure = abortError(this.id, attempts);
|
|
1119
|
+
} else if (attemptSignal.timedOut()) {
|
|
1120
|
+
failure = new ProviderTimeoutError(
|
|
1121
|
+
"provider request timed out",
|
|
1122
|
+
{ provider: this.id, attempts },
|
|
1123
|
+
);
|
|
1124
|
+
} else if (error instanceof ProviderError) {
|
|
1125
|
+
error.provider = this.id;
|
|
1126
|
+
error.requestId = requestId;
|
|
1127
|
+
error.attempts = attempts;
|
|
1128
|
+
failure = error;
|
|
1129
|
+
} else {
|
|
1130
|
+
failure = new ProviderResponseError(
|
|
1131
|
+
"provider response could not be processed",
|
|
1132
|
+
{ provider: this.id, requestId, attempts },
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
} catch {
|
|
1138
|
+
if (signal?.aborted) {
|
|
1139
|
+
failure = abortError(this.id, attempts);
|
|
1140
|
+
} else if (attemptSignal.timedOut()) {
|
|
1141
|
+
failure = new ProviderTimeoutError("provider request timed out", {
|
|
1142
|
+
provider: this.id,
|
|
1143
|
+
attempts,
|
|
1144
|
+
});
|
|
1145
|
+
} else {
|
|
1146
|
+
failure = new ProviderRequestError("provider request failed", {
|
|
1147
|
+
provider: this.id,
|
|
1148
|
+
retryable: true,
|
|
1149
|
+
attempts,
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
} finally {
|
|
1153
|
+
attemptSignal.cleanup();
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if (signal?.aborted) {
|
|
1157
|
+
throw abortError(this.id, attempts);
|
|
1158
|
+
}
|
|
1159
|
+
if (!failure.retryable || attempts >= maximumAttempts) {
|
|
1160
|
+
throw failure;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const randomValue = Number(this.#random());
|
|
1164
|
+
const boundedRandom = Number.isFinite(randomValue)
|
|
1165
|
+
? Math.min(1, Math.max(0, randomValue))
|
|
1166
|
+
: 0;
|
|
1167
|
+
const exponential = Math.min(
|
|
1168
|
+
requestPolicy.retryMaxMs,
|
|
1169
|
+
requestPolicy.retryBaseMs * 2 ** (attempts - 1),
|
|
1170
|
+
);
|
|
1171
|
+
const delayMs = Math.min(
|
|
1172
|
+
requestPolicy.retryMaxMs,
|
|
1173
|
+
Math.max(exponential * boundedRandom, retryAfterMs ?? 0),
|
|
1174
|
+
);
|
|
1175
|
+
try {
|
|
1176
|
+
await this.#sleep(delayMs, signal);
|
|
1177
|
+
} catch {
|
|
1178
|
+
throw abortError(this.id, attempts);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
throw new ProviderRequestError("provider request failed", {
|
|
1183
|
+
provider: this.id,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export class ModelTarget {
|
|
1189
|
+
constructor(options) {
|
|
1190
|
+
assertRecord(options, "target");
|
|
1191
|
+
assertNonEmptyString(options.name, "target.name");
|
|
1192
|
+
assertNonEmptyString(options.model, "target.model");
|
|
1193
|
+
if (
|
|
1194
|
+
options.provider === null ||
|
|
1195
|
+
typeof options.provider !== "object" ||
|
|
1196
|
+
typeof options.provider.generate !== "function"
|
|
1197
|
+
) {
|
|
1198
|
+
throw new TypeError("target.provider must implement generate()");
|
|
1199
|
+
}
|
|
1200
|
+
const providerLabel = options.provider.id ?? options.provider.name;
|
|
1201
|
+
assertNonEmptyString(providerLabel, "target.provider label");
|
|
1202
|
+
const requestOverrides = options.requestOverrides ?? {};
|
|
1203
|
+
assertRecord(requestOverrides, "target.requestOverrides");
|
|
1204
|
+
const unsupportedOverrides = Object.keys(requestOverrides).filter(
|
|
1205
|
+
(key) => !TARGET_REQUEST_OVERRIDE_FIELDS.has(key),
|
|
1206
|
+
);
|
|
1207
|
+
if (unsupportedOverrides.length > 0) {
|
|
1208
|
+
throw new ProviderConfigurationError(
|
|
1209
|
+
`target.requestOverrides contains unsupported fields: ${unsupportedOverrides.sort().join(", ")}`,
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
const normalizedOverrides = canonicalRequestValues(requestOverrides);
|
|
1213
|
+
new GenerationRequest({
|
|
1214
|
+
model: options.model,
|
|
1215
|
+
input: "",
|
|
1216
|
+
...normalizedOverrides,
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
this.name = options.name;
|
|
1220
|
+
this.provider = options.provider;
|
|
1221
|
+
this.providerName = providerLabel;
|
|
1222
|
+
this.model = options.model;
|
|
1223
|
+
this.requestOverrides = freezeJson(
|
|
1224
|
+
normalizedOverrides,
|
|
1225
|
+
"target.requestOverrides",
|
|
1226
|
+
);
|
|
1227
|
+
Object.freeze(this);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
async generate(request, options = {}) {
|
|
1231
|
+
const merged = mergeRequestValues(
|
|
1232
|
+
this.requestOverrides,
|
|
1233
|
+
request,
|
|
1234
|
+
this.model,
|
|
1235
|
+
);
|
|
1236
|
+
return this.provider.generate(merged, options);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
toJSON() {
|
|
1240
|
+
const maxTokens = Object.hasOwn(this.requestOverrides, "maxTokens")
|
|
1241
|
+
? this.requestOverrides.maxTokens
|
|
1242
|
+
: 512;
|
|
1243
|
+
return {
|
|
1244
|
+
name: this.name,
|
|
1245
|
+
provider: this.providerName,
|
|
1246
|
+
model: this.model,
|
|
1247
|
+
parameters: {
|
|
1248
|
+
max_tokens: maxTokens,
|
|
1249
|
+
temperature: this.requestOverrides.temperature ?? null,
|
|
1250
|
+
seed: this.requestOverrides.seed ?? null,
|
|
1251
|
+
stop: Array.isArray(this.requestOverrides.stop)
|
|
1252
|
+
? [...this.requestOverrides.stop]
|
|
1253
|
+
: [],
|
|
1254
|
+
},
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
export function createProvider(name, options = {}) {
|
|
1260
|
+
assertNonEmptyString(name, "name");
|
|
1261
|
+
assertRecord(options, "options");
|
|
1262
|
+
|
|
1263
|
+
const normalized = name.toLowerCase().replace(/[\s_.-]+/gu, "");
|
|
1264
|
+
if (normalized === "generic" || normalized === "custom") {
|
|
1265
|
+
if (options.baseURL === undefined) {
|
|
1266
|
+
throw new ProviderConfigurationError(
|
|
1267
|
+
"custom provider requires baseURL",
|
|
1268
|
+
{ provider: options.id ?? "custom" },
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
return new OpenAICompatibleProvider({
|
|
1272
|
+
...options,
|
|
1273
|
+
id: options.id ?? "custom",
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const presetName =
|
|
1278
|
+
normalized === "llamacpp"
|
|
1279
|
+
? "llamacpp"
|
|
1280
|
+
: normalized === "lmstudio"
|
|
1281
|
+
? "lmstudio"
|
|
1282
|
+
: normalized;
|
|
1283
|
+
const preset = PROVIDER_PRESETS[presetName];
|
|
1284
|
+
if (preset === undefined) {
|
|
1285
|
+
throw new ProviderConfigurationError(`unknown provider preset: ${name}`);
|
|
1286
|
+
}
|
|
1287
|
+
if (
|
|
1288
|
+
preset.requiresAPIKey &&
|
|
1289
|
+
(options.apiKey === undefined || options.apiKey === null)
|
|
1290
|
+
) {
|
|
1291
|
+
throw new ProviderConfigurationError(
|
|
1292
|
+
`${preset.id} requires an API key`,
|
|
1293
|
+
{ provider: preset.id },
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
return new OpenAICompatibleProvider({
|
|
1298
|
+
...options,
|
|
1299
|
+
id: options.id ?? preset.id,
|
|
1300
|
+
baseURL: options.baseURL ?? preset.baseURL,
|
|
1301
|
+
maxTokensField: options.maxTokensField ?? preset.maxTokensField,
|
|
1302
|
+
});
|
|
1303
|
+
}
|