ai-resilient 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.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/index.cjs +645 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +187 -0
- package/dist/index.d.ts +187 -0
- package/dist/index.js +612 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
AllModelsExhaustedError: () => AllModelsExhaustedError,
|
|
24
|
+
LimitTracker: () => LimitTracker,
|
|
25
|
+
classifyError: () => classifyError,
|
|
26
|
+
createResilient: () => createResilient,
|
|
27
|
+
getRetryAfterMs: () => getRetryAfterMs,
|
|
28
|
+
memoryStore: () => memoryStore,
|
|
29
|
+
parseRateLimitHeaders: () => parseRateLimitHeaders
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(index_exports);
|
|
32
|
+
|
|
33
|
+
// src/header-parsers.ts
|
|
34
|
+
function lower(headers) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function num(value) {
|
|
40
|
+
if (value === void 0) return void 0;
|
|
41
|
+
const n = Number(value);
|
|
42
|
+
return Number.isFinite(n) ? n : void 0;
|
|
43
|
+
}
|
|
44
|
+
function parseDuration(value) {
|
|
45
|
+
if (value === void 0) return void 0;
|
|
46
|
+
const plain = Number(value);
|
|
47
|
+
if (Number.isFinite(plain)) return plain * 1e3;
|
|
48
|
+
const pattern = /(\d+(?:\.\d+)?)(ms|h|m|s)/g;
|
|
49
|
+
let ms = 0;
|
|
50
|
+
let matched = false;
|
|
51
|
+
for (const [, amount, unit] of value.matchAll(pattern)) {
|
|
52
|
+
matched = true;
|
|
53
|
+
const n = Number(amount);
|
|
54
|
+
if (unit === "h") ms += n * 36e5;
|
|
55
|
+
else if (unit === "m") ms += n * 6e4;
|
|
56
|
+
else if (unit === "s") ms += n * 1e3;
|
|
57
|
+
else ms += n;
|
|
58
|
+
}
|
|
59
|
+
return matched ? Math.round(ms) : void 0;
|
|
60
|
+
}
|
|
61
|
+
function parseResetTimestamp(value) {
|
|
62
|
+
if (value === void 0) return void 0;
|
|
63
|
+
const t = Date.parse(value);
|
|
64
|
+
if (Number.isNaN(t)) return void 0;
|
|
65
|
+
return Math.max(0, t - Date.now());
|
|
66
|
+
}
|
|
67
|
+
function parseOpenAIStyle(h) {
|
|
68
|
+
return {
|
|
69
|
+
requestsRemaining: num(h["x-ratelimit-remaining-requests"]),
|
|
70
|
+
requestsLimit: num(h["x-ratelimit-limit-requests"]),
|
|
71
|
+
requestsResetMs: parseDuration(h["x-ratelimit-reset-requests"]),
|
|
72
|
+
tokensRemaining: num(h["x-ratelimit-remaining-tokens"]),
|
|
73
|
+
tokensLimit: num(h["x-ratelimit-limit-tokens"]),
|
|
74
|
+
tokensResetMs: parseDuration(h["x-ratelimit-reset-tokens"])
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function parseAnthropic(h) {
|
|
78
|
+
return {
|
|
79
|
+
requestsRemaining: num(h["anthropic-ratelimit-requests-remaining"]),
|
|
80
|
+
requestsLimit: num(h["anthropic-ratelimit-requests-limit"]),
|
|
81
|
+
requestsResetMs: parseResetTimestamp(
|
|
82
|
+
h["anthropic-ratelimit-requests-reset"]
|
|
83
|
+
),
|
|
84
|
+
tokensRemaining: num(h["anthropic-ratelimit-tokens-remaining"]),
|
|
85
|
+
tokensLimit: num(h["anthropic-ratelimit-tokens-limit"]),
|
|
86
|
+
tokensResetMs: parseResetTimestamp(h["anthropic-ratelimit-tokens-reset"])
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function parseIetfReset(value) {
|
|
90
|
+
const reset = num(value);
|
|
91
|
+
if (reset === void 0) return void 0;
|
|
92
|
+
const YEAR_S = 31536e3;
|
|
93
|
+
if (reset > YEAR_S) {
|
|
94
|
+
return Math.max(0, reset * 1e3 - Date.now());
|
|
95
|
+
}
|
|
96
|
+
return reset * 1e3;
|
|
97
|
+
}
|
|
98
|
+
function parseGeneric(h) {
|
|
99
|
+
const openai = parseOpenAIStyle(h);
|
|
100
|
+
if (Object.values(openai).some((v) => v !== void 0)) return openai;
|
|
101
|
+
const reset = parseIetfReset(h["ratelimit-reset"]);
|
|
102
|
+
return {
|
|
103
|
+
requestsRemaining: num(h["ratelimit-remaining"]),
|
|
104
|
+
requestsLimit: num(h["ratelimit-limit"]),
|
|
105
|
+
...reset !== void 0 ? { requestsResetMs: reset } : {}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function parseRateLimitHeaders(provider, headers) {
|
|
109
|
+
if (headers === void 0) return {};
|
|
110
|
+
const h = lower(headers);
|
|
111
|
+
const p = provider.toLowerCase();
|
|
112
|
+
if (p.startsWith("openai") || p.startsWith("groq")) {
|
|
113
|
+
return parseOpenAIStyle(h);
|
|
114
|
+
}
|
|
115
|
+
if (p.startsWith("anthropic")) {
|
|
116
|
+
return parseAnthropic(h);
|
|
117
|
+
}
|
|
118
|
+
return parseGeneric(h);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/limit-tracker.ts
|
|
122
|
+
var MINUTE_MS = 6e4;
|
|
123
|
+
var DAY_MS = 864e5;
|
|
124
|
+
var DEFAULT_SNAPSHOT_TTL_MS = 6e4;
|
|
125
|
+
var MAX_SNAPSHOT_TTL_MS = DAY_MS;
|
|
126
|
+
var LimitTracker = class {
|
|
127
|
+
store;
|
|
128
|
+
threshold;
|
|
129
|
+
cooldown;
|
|
130
|
+
/** Per-key promise chains serializing usage counter updates. */
|
|
131
|
+
usageLocks = /* @__PURE__ */ new Map();
|
|
132
|
+
constructor(options) {
|
|
133
|
+
this.store = options.store;
|
|
134
|
+
this.threshold = options.threshold;
|
|
135
|
+
this.cooldown = options.cooldown;
|
|
136
|
+
}
|
|
137
|
+
benchKey(modelKey) {
|
|
138
|
+
return `ai-resilient:bench:${modelKey}`;
|
|
139
|
+
}
|
|
140
|
+
usageKey(modelKey) {
|
|
141
|
+
return `ai-resilient:usage:${modelKey}`;
|
|
142
|
+
}
|
|
143
|
+
headersKey(modelKey) {
|
|
144
|
+
return `ai-resilient:headers:${modelKey}`;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* False if the model is benched (cooldown active) or usage is at or
|
|
148
|
+
* beyond `(1 - threshold)` of any known limit.
|
|
149
|
+
*/
|
|
150
|
+
async isAvailable(modelKey, limits) {
|
|
151
|
+
try {
|
|
152
|
+
if (await this.isBenched(modelKey)) return false;
|
|
153
|
+
if (await this.isNearHeaderLimit(modelKey)) return false;
|
|
154
|
+
if (limits && await this.isNearDeclaredLimit(modelKey, limits)) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
} catch {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async isBenched(modelKey) {
|
|
163
|
+
const raw = await this.store.get(this.benchKey(modelKey));
|
|
164
|
+
if (raw === null) return false;
|
|
165
|
+
const expiresAt = Number(raw);
|
|
166
|
+
return Number.isFinite(expiresAt) && Date.now() < expiresAt;
|
|
167
|
+
}
|
|
168
|
+
async isNearHeaderLimit(modelKey) {
|
|
169
|
+
const raw = await this.store.get(this.headersKey(modelKey));
|
|
170
|
+
if (raw === null) return false;
|
|
171
|
+
const snapshot = JSON.parse(raw);
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
for (const dim of [snapshot.requests, snapshot.tokens]) {
|
|
174
|
+
if (dim === void 0 || now >= dim.expiresAt) continue;
|
|
175
|
+
if (this.remainingBelowThreshold(dim.remaining, dim.limit)) return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
remainingBelowThreshold(remaining, limit) {
|
|
180
|
+
if (remaining === void 0) return false;
|
|
181
|
+
if (remaining <= 0) return true;
|
|
182
|
+
if (limit === void 0 || limit <= 0) return false;
|
|
183
|
+
return remaining / limit <= this.threshold;
|
|
184
|
+
}
|
|
185
|
+
async isNearDeclaredLimit(modelKey, limits) {
|
|
186
|
+
const usage = await this.readUsage(modelKey);
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
if (limits.requestsPerMinute !== void 0) {
|
|
189
|
+
const used = usage.requests.filter((t) => now - t < MINUTE_MS).length;
|
|
190
|
+
if (used >= (1 - this.threshold) * limits.requestsPerMinute) return true;
|
|
191
|
+
}
|
|
192
|
+
if (limits.requestsPerDay !== void 0) {
|
|
193
|
+
const used = usage.requests.filter((t) => now - t < DAY_MS).length;
|
|
194
|
+
if (used >= (1 - this.threshold) * limits.requestsPerDay) return true;
|
|
195
|
+
}
|
|
196
|
+
if (limits.tokensPerMinute !== void 0) {
|
|
197
|
+
const used = usage.tokens.filter(([t]) => now - t < MINUTE_MS).reduce((sum, [, n]) => sum + n, 0);
|
|
198
|
+
if (used >= (1 - this.threshold) * limits.tokensPerMinute) return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Record a successful call: parse provider rate-limit headers and
|
|
204
|
+
* increment sliding-window counters for declared limits.
|
|
205
|
+
*/
|
|
206
|
+
async recordSuccess(modelKey, input) {
|
|
207
|
+
try {
|
|
208
|
+
if (input.headers !== void 0) {
|
|
209
|
+
await this.recordHeaders(modelKey, input.provider, input.headers);
|
|
210
|
+
}
|
|
211
|
+
if (input.limits !== void 0) {
|
|
212
|
+
await this.recordUsage(modelKey, input.limits, input.totalTokens);
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async recordHeaders(modelKey, provider, headers) {
|
|
218
|
+
const parsed = parseRateLimitHeaders(provider, headers);
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
const snapshot = {};
|
|
221
|
+
if (parsed.requestsRemaining !== void 0) {
|
|
222
|
+
snapshot.requests = {
|
|
223
|
+
remaining: parsed.requestsRemaining,
|
|
224
|
+
limit: parsed.requestsLimit,
|
|
225
|
+
expiresAt: now + Math.min(
|
|
226
|
+
parsed.requestsResetMs ?? DEFAULT_SNAPSHOT_TTL_MS,
|
|
227
|
+
MAX_SNAPSHOT_TTL_MS
|
|
228
|
+
)
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (parsed.tokensRemaining !== void 0) {
|
|
232
|
+
snapshot.tokens = {
|
|
233
|
+
remaining: parsed.tokensRemaining,
|
|
234
|
+
limit: parsed.tokensLimit,
|
|
235
|
+
expiresAt: now + Math.min(
|
|
236
|
+
parsed.tokensResetMs ?? DEFAULT_SNAPSHOT_TTL_MS,
|
|
237
|
+
MAX_SNAPSHOT_TTL_MS
|
|
238
|
+
)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const ttl = Math.max(
|
|
242
|
+
(snapshot.requests?.expiresAt ?? 0) - now,
|
|
243
|
+
(snapshot.tokens?.expiresAt ?? 0) - now
|
|
244
|
+
);
|
|
245
|
+
if (ttl <= 0) return;
|
|
246
|
+
await this.store.set(
|
|
247
|
+
this.headersKey(modelKey),
|
|
248
|
+
JSON.stringify(snapshot),
|
|
249
|
+
ttl
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
recordUsage(modelKey, limits, totalTokens) {
|
|
253
|
+
return this.serializeUsageUpdate(modelKey, async () => {
|
|
254
|
+
const usage = await this.readUsage(modelKey);
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
const trackDay = limits.requestsPerDay !== void 0 ? DAY_MS : MINUTE_MS;
|
|
257
|
+
usage.requests = usage.requests.filter((t) => now - t < trackDay);
|
|
258
|
+
usage.requests.push(now);
|
|
259
|
+
usage.tokens = usage.tokens.filter(([t]) => now - t < MINUTE_MS);
|
|
260
|
+
if (limits.tokensPerMinute !== void 0 && totalTokens !== void 0) {
|
|
261
|
+
usage.tokens.push([now, totalTokens]);
|
|
262
|
+
}
|
|
263
|
+
await this.store.set(
|
|
264
|
+
this.usageKey(modelKey),
|
|
265
|
+
JSON.stringify(usage),
|
|
266
|
+
trackDay
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
serializeUsageUpdate(modelKey, fn) {
|
|
271
|
+
const prev = this.usageLocks.get(modelKey) ?? Promise.resolve();
|
|
272
|
+
const next = prev.then(fn, fn);
|
|
273
|
+
const tail = next.then(
|
|
274
|
+
() => void 0,
|
|
275
|
+
() => void 0
|
|
276
|
+
);
|
|
277
|
+
this.usageLocks.set(modelKey, tail);
|
|
278
|
+
void tail.then(() => {
|
|
279
|
+
if (this.usageLocks.get(modelKey) === tail) {
|
|
280
|
+
this.usageLocks.delete(modelKey);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
return next;
|
|
284
|
+
}
|
|
285
|
+
async readUsage(modelKey) {
|
|
286
|
+
const raw = await this.store.get(this.usageKey(modelKey));
|
|
287
|
+
if (raw === null) return { requests: [], tokens: [] };
|
|
288
|
+
const parsed = JSON.parse(raw);
|
|
289
|
+
return {
|
|
290
|
+
requests: Array.isArray(parsed.requests) ? parsed.requests : [],
|
|
291
|
+
tokens: Array.isArray(parsed.tokens) ? parsed.tokens : []
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Bench a model after a rate-limit error, for `retryAfterMs` when the
|
|
296
|
+
* provider sent one, otherwise for the configured cooldown.
|
|
297
|
+
*/
|
|
298
|
+
async recordRateLimit(modelKey, retryAfterMs) {
|
|
299
|
+
const benchMs = retryAfterMs ?? this.cooldown;
|
|
300
|
+
if (benchMs <= 0) return;
|
|
301
|
+
try {
|
|
302
|
+
await this.store.set(
|
|
303
|
+
this.benchKey(modelKey),
|
|
304
|
+
String(Date.now() + benchMs),
|
|
305
|
+
benchMs
|
|
306
|
+
);
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// src/classify-error.ts
|
|
313
|
+
function getErrorShape(error) {
|
|
314
|
+
if (typeof error !== "object" || error === null) {
|
|
315
|
+
return { message: String(error) };
|
|
316
|
+
}
|
|
317
|
+
const e = error;
|
|
318
|
+
return {
|
|
319
|
+
statusCode: typeof e["statusCode"] === "number" ? e["statusCode"] : void 0,
|
|
320
|
+
responseHeaders: typeof e["responseHeaders"] === "object" && e["responseHeaders"] !== null ? e["responseHeaders"] : void 0,
|
|
321
|
+
isRetryable: typeof e["isRetryable"] === "boolean" ? e["isRetryable"] : void 0,
|
|
322
|
+
message: typeof e["message"] === "string" ? e["message"] : void 0
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
var RATE_LIMIT_PATTERN = /rate.?limit|too many requests|quota|exhausted|billing|insufficient.?quota/i;
|
|
326
|
+
var TRANSIENT_PATTERN = /overloaded|timeout|timed.?out|econnreset|econnrefused|etimedout|socket hang up|network|fetch failed|service unavailable|internal server error|bad gateway/i;
|
|
327
|
+
function getHeader(headers, name) {
|
|
328
|
+
if (!headers) return void 0;
|
|
329
|
+
for (const key of Object.keys(headers)) {
|
|
330
|
+
if (key.toLowerCase() === name) return headers[key];
|
|
331
|
+
}
|
|
332
|
+
return void 0;
|
|
333
|
+
}
|
|
334
|
+
function classifyError(error) {
|
|
335
|
+
const { statusCode, responseHeaders, isRetryable, message } = getErrorShape(error);
|
|
336
|
+
const msg = message ?? "";
|
|
337
|
+
if (statusCode === 429) return "rate-limit";
|
|
338
|
+
if (statusCode === 400 || statusCode === 401 || statusCode === 403) {
|
|
339
|
+
return "fatal";
|
|
340
|
+
}
|
|
341
|
+
if (statusCode !== void 0 && statusCode >= 500) return "transient";
|
|
342
|
+
if (getHeader(responseHeaders, "retry-after") !== void 0) {
|
|
343
|
+
return "rate-limit";
|
|
344
|
+
}
|
|
345
|
+
if (RATE_LIMIT_PATTERN.test(msg)) return "rate-limit";
|
|
346
|
+
if (isRetryable === true) return "transient";
|
|
347
|
+
if (TRANSIENT_PATTERN.test(msg)) return "transient";
|
|
348
|
+
return "fatal";
|
|
349
|
+
}
|
|
350
|
+
function getRetryAfterMs(error) {
|
|
351
|
+
const { responseHeaders } = getErrorShape(error);
|
|
352
|
+
const raw = getHeader(responseHeaders, "retry-after");
|
|
353
|
+
if (raw === void 0) return void 0;
|
|
354
|
+
const seconds = Number(raw);
|
|
355
|
+
if (Number.isFinite(seconds)) {
|
|
356
|
+
return Math.max(0, seconds * 1e3);
|
|
357
|
+
}
|
|
358
|
+
const date = Date.parse(raw);
|
|
359
|
+
if (!Number.isNaN(date)) {
|
|
360
|
+
return Math.max(0, date - Date.now());
|
|
361
|
+
}
|
|
362
|
+
return void 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/errors.ts
|
|
366
|
+
var AllModelsExhaustedError = class extends Error {
|
|
367
|
+
name = "AllModelsExhaustedError";
|
|
368
|
+
attempts;
|
|
369
|
+
constructor(attempts) {
|
|
370
|
+
const summary = attempts.map((a) => `${a.modelId} (${a.classification})`).join(", ");
|
|
371
|
+
super(
|
|
372
|
+
`All models exhausted after ${attempts.length} attempt(s): ${summary}`
|
|
373
|
+
);
|
|
374
|
+
this.attempts = attempts;
|
|
375
|
+
}
|
|
376
|
+
static isInstance(error) {
|
|
377
|
+
return error instanceof Error && error.name === "AllModelsExhaustedError";
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// src/resilient-model.ts
|
|
382
|
+
var PRELUDE_PART_TYPES = /* @__PURE__ */ new Set(["stream-start", "response-metadata"]);
|
|
383
|
+
var ResilientLanguageModel = class {
|
|
384
|
+
specificationVersion = "v2";
|
|
385
|
+
provider = "ai-resilient";
|
|
386
|
+
candidates;
|
|
387
|
+
tracker;
|
|
388
|
+
onFallback;
|
|
389
|
+
onError;
|
|
390
|
+
/**
|
|
391
|
+
* Bookkeeping writes chained off the response path. buildPlan awaits
|
|
392
|
+
* this so the next call still sees all prior usage before deciding
|
|
393
|
+
* availability (read-your-writes), without success responses paying
|
|
394
|
+
* store-write latency.
|
|
395
|
+
*/
|
|
396
|
+
pendingRecords = Promise.resolve();
|
|
397
|
+
constructor(options) {
|
|
398
|
+
if (options.models.length === 0) {
|
|
399
|
+
throw new Error("ai-resilient: at least one model is required");
|
|
400
|
+
}
|
|
401
|
+
const seen = /* @__PURE__ */ new Map();
|
|
402
|
+
this.candidates = options.models.map((config) => {
|
|
403
|
+
const base = `${config.model.provider}:${config.model.modelId}`;
|
|
404
|
+
const count = seen.get(base) ?? 0;
|
|
405
|
+
seen.set(base, count + 1);
|
|
406
|
+
return { config, key: count === 0 ? base : `${base}#${count}` };
|
|
407
|
+
});
|
|
408
|
+
this.tracker = options.tracker;
|
|
409
|
+
this.onFallback = options.onFallback;
|
|
410
|
+
this.onError = options.onError;
|
|
411
|
+
}
|
|
412
|
+
get modelId() {
|
|
413
|
+
return this.primary.modelId;
|
|
414
|
+
}
|
|
415
|
+
get supportedUrls() {
|
|
416
|
+
return this.primary.supportedUrls;
|
|
417
|
+
}
|
|
418
|
+
get primary() {
|
|
419
|
+
return this.candidates[0].config.model;
|
|
420
|
+
}
|
|
421
|
+
queueRecordSuccess(candidate, headers, usage) {
|
|
422
|
+
this.pendingRecords = this.pendingRecords.then(() => this.recordSuccess(candidate, headers, usage)).catch(() => {
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Order candidates and mark near-limit/benched ones as skipped. If
|
|
427
|
+
* every model would be skipped, try all of them in configured order
|
|
428
|
+
* anyway — attempting beats failing without trying.
|
|
429
|
+
*/
|
|
430
|
+
async buildPlan() {
|
|
431
|
+
await this.pendingRecords;
|
|
432
|
+
const plan = [];
|
|
433
|
+
for (const candidate of this.candidates) {
|
|
434
|
+
const available = await this.tracker.isAvailable(
|
|
435
|
+
candidate.key,
|
|
436
|
+
candidate.config.limits
|
|
437
|
+
);
|
|
438
|
+
plan.push({ candidate, skip: !available });
|
|
439
|
+
}
|
|
440
|
+
if (plan.every((entry) => entry.skip)) {
|
|
441
|
+
for (const entry of plan) entry.skip = false;
|
|
442
|
+
}
|
|
443
|
+
return plan;
|
|
444
|
+
}
|
|
445
|
+
fireFallbacks(pending, to) {
|
|
446
|
+
for (const { from, reason } of pending) {
|
|
447
|
+
this.onFallback?.({ from, to, reason });
|
|
448
|
+
}
|
|
449
|
+
pending.length = 0;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Handle a failed attempt: notify, classify, bench on rate-limit, and
|
|
453
|
+
* queue the fallback notification. Rethrows fatal errors.
|
|
454
|
+
*/
|
|
455
|
+
async handleFailure(error, candidate, attempts, pending) {
|
|
456
|
+
const modelId = candidate.config.model.modelId;
|
|
457
|
+
this.onError?.(error, modelId);
|
|
458
|
+
const classification = classifyError(error);
|
|
459
|
+
if (classification === "fatal") throw error;
|
|
460
|
+
attempts.push({ modelId, error, classification });
|
|
461
|
+
if (classification === "rate-limit") {
|
|
462
|
+
await this.tracker.recordRateLimit(candidate.key, getRetryAfterMs(error));
|
|
463
|
+
}
|
|
464
|
+
pending.push({ from: modelId, reason: classification });
|
|
465
|
+
}
|
|
466
|
+
async recordSuccess(candidate, headers, usage) {
|
|
467
|
+
const totalTokens = usage?.totalTokens ?? (usage?.inputTokens !== void 0 || usage?.outputTokens !== void 0 ? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) : void 0);
|
|
468
|
+
await this.tracker.recordSuccess(candidate.key, {
|
|
469
|
+
provider: candidate.config.model.provider,
|
|
470
|
+
headers,
|
|
471
|
+
totalTokens,
|
|
472
|
+
limits: candidate.config.limits
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
async doGenerate(options) {
|
|
476
|
+
const plan = await this.buildPlan();
|
|
477
|
+
const attempts = [];
|
|
478
|
+
const pending = [];
|
|
479
|
+
for (const entry of plan) {
|
|
480
|
+
const { candidate } = entry;
|
|
481
|
+
const modelId = candidate.config.model.modelId;
|
|
482
|
+
if (entry.skip) {
|
|
483
|
+
pending.push({ from: modelId, reason: "proactive" });
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
this.fireFallbacks(pending, modelId);
|
|
487
|
+
try {
|
|
488
|
+
const result = await candidate.config.model.doGenerate(options);
|
|
489
|
+
this.queueRecordSuccess(
|
|
490
|
+
candidate,
|
|
491
|
+
result.response?.headers,
|
|
492
|
+
result.usage
|
|
493
|
+
);
|
|
494
|
+
return result;
|
|
495
|
+
} catch (error) {
|
|
496
|
+
await this.handleFailure(error, candidate, attempts, pending);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
throw new AllModelsExhaustedError(attempts);
|
|
500
|
+
}
|
|
501
|
+
async doStream(options) {
|
|
502
|
+
const plan = await this.buildPlan();
|
|
503
|
+
const attempts = [];
|
|
504
|
+
const pending = [];
|
|
505
|
+
let index = 0;
|
|
506
|
+
const acquire = async () => {
|
|
507
|
+
while (index < plan.length) {
|
|
508
|
+
const entry = plan[index++];
|
|
509
|
+
const { candidate } = entry;
|
|
510
|
+
const modelId = candidate.config.model.modelId;
|
|
511
|
+
if (entry.skip) {
|
|
512
|
+
pending.push({ from: modelId, reason: "proactive" });
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
this.fireFallbacks(pending, modelId);
|
|
516
|
+
try {
|
|
517
|
+
const result = await candidate.config.model.doStream(options);
|
|
518
|
+
return { candidate, result };
|
|
519
|
+
} catch (error) {
|
|
520
|
+
await this.handleFailure(error, candidate, attempts, pending);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
throw new AllModelsExhaustedError(attempts);
|
|
524
|
+
};
|
|
525
|
+
for (; ; ) {
|
|
526
|
+
const { candidate, result } = await acquire();
|
|
527
|
+
const reader = result.stream.getReader();
|
|
528
|
+
const prelude = [];
|
|
529
|
+
let firstContent;
|
|
530
|
+
let usage;
|
|
531
|
+
let done = false;
|
|
532
|
+
try {
|
|
533
|
+
for (; ; ) {
|
|
534
|
+
const { done: readDone, value } = await reader.read();
|
|
535
|
+
if (readDone) {
|
|
536
|
+
done = true;
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
if (value.type === "error") {
|
|
540
|
+
throw value.error;
|
|
541
|
+
}
|
|
542
|
+
if (value.type === "finish") usage = value.usage;
|
|
543
|
+
if (PRELUDE_PART_TYPES.has(value.type)) {
|
|
544
|
+
prelude.push(value);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
firstContent = value;
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
} catch (error) {
|
|
551
|
+
reader.cancel().catch(() => {
|
|
552
|
+
});
|
|
553
|
+
await this.handleFailure(error, candidate, attempts, pending);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
let preludeIndex = 0;
|
|
557
|
+
const stream = new ReadableStream({
|
|
558
|
+
pull: async (controller) => {
|
|
559
|
+
if (preludeIndex < prelude.length) {
|
|
560
|
+
controller.enqueue(prelude[preludeIndex++]);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (firstContent !== void 0) {
|
|
564
|
+
const part = firstContent;
|
|
565
|
+
firstContent = void 0;
|
|
566
|
+
controller.enqueue(part);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (!done) {
|
|
570
|
+
const { done: readDone, value } = await reader.read();
|
|
571
|
+
if (!readDone) {
|
|
572
|
+
if (value.type === "finish") usage = value.usage;
|
|
573
|
+
controller.enqueue(value);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
done = true;
|
|
577
|
+
}
|
|
578
|
+
this.queueRecordSuccess(candidate, result.response?.headers, usage);
|
|
579
|
+
controller.close();
|
|
580
|
+
},
|
|
581
|
+
cancel: (reason) => reader.cancel(reason)
|
|
582
|
+
});
|
|
583
|
+
return {
|
|
584
|
+
stream,
|
|
585
|
+
...result.request !== void 0 ? { request: result.request } : {},
|
|
586
|
+
...result.response !== void 0 ? { response: result.response } : {}
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
// src/stores/memory.ts
|
|
593
|
+
function memoryStore() {
|
|
594
|
+
const map = /* @__PURE__ */ new Map();
|
|
595
|
+
function evictIfExpired(key) {
|
|
596
|
+
const entry = map.get(key);
|
|
597
|
+
if (entry === void 0) return void 0;
|
|
598
|
+
if (entry.expiresAt !== void 0 && Date.now() >= entry.expiresAt) {
|
|
599
|
+
map.delete(key);
|
|
600
|
+
return void 0;
|
|
601
|
+
}
|
|
602
|
+
return entry;
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
get(key) {
|
|
606
|
+
const entry = evictIfExpired(key);
|
|
607
|
+
return Promise.resolve(entry?.value ?? null);
|
|
608
|
+
},
|
|
609
|
+
set(key, value, ttlMs) {
|
|
610
|
+
map.set(key, {
|
|
611
|
+
value,
|
|
612
|
+
...ttlMs !== void 0 ? { expiresAt: Date.now() + ttlMs } : {}
|
|
613
|
+
});
|
|
614
|
+
return Promise.resolve();
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/index.ts
|
|
620
|
+
var DEFAULT_THRESHOLD = 0.1;
|
|
621
|
+
var DEFAULT_COOLDOWN_MS = 6e4;
|
|
622
|
+
function createResilient(options) {
|
|
623
|
+
const tracker = new LimitTracker({
|
|
624
|
+
store: options.store ?? memoryStore(),
|
|
625
|
+
threshold: options.threshold ?? DEFAULT_THRESHOLD,
|
|
626
|
+
cooldown: options.cooldown ?? DEFAULT_COOLDOWN_MS
|
|
627
|
+
});
|
|
628
|
+
return new ResilientLanguageModel({
|
|
629
|
+
models: options.models,
|
|
630
|
+
tracker,
|
|
631
|
+
...options.onFallback !== void 0 ? { onFallback: options.onFallback } : {},
|
|
632
|
+
...options.onError !== void 0 ? { onError: options.onError } : {}
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
636
|
+
0 && (module.exports = {
|
|
637
|
+
AllModelsExhaustedError,
|
|
638
|
+
LimitTracker,
|
|
639
|
+
classifyError,
|
|
640
|
+
createResilient,
|
|
641
|
+
getRetryAfterMs,
|
|
642
|
+
memoryStore,
|
|
643
|
+
parseRateLimitHeaders
|
|
644
|
+
});
|
|
645
|
+
//# sourceMappingURL=index.cjs.map
|