@providerkit/core 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/README.md +33 -157
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +8 -0
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +36 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +72 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/key-pool.d.ts +60 -0
- package/dist/key-pool.d.ts.map +1 -0
- package/dist/key-pool.js +235 -0
- package/dist/key-pool.js.map +1 -0
- package/dist/providers/anthropic.d.ts +9 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +12 -13
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts +40 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +303 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +9 -0
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +38 -0
- package/dist/providers/responses.d.ts.map +1 -0
- package/dist/providers/responses.js +341 -0
- package/dist/providers/responses.js.map +1 -0
- package/dist/rate-limit.d.ts +29 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +194 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/transport.d.ts +18 -5
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +61 -35
- package/dist/transport.js.map +1 -1
- package/package.json +2 -2
- package/src/context.ts +7 -0
- package/src/errors.ts +79 -1
- package/src/index.ts +4 -0
- package/src/key-pool.ts +272 -0
- package/src/providers/anthropic.ts +21 -18
- package/src/providers/gemini.ts +386 -0
- package/src/providers/openai.ts +12 -0
- package/src/providers/responses.ts +455 -0
- package/src/rate-limit.ts +217 -0
- package/src/transport.ts +61 -35
package/dist/transport.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Adapters keep only their per-event mapping; everything about being an HTTP
|
|
6
6
|
// client lives here once.
|
|
7
|
-
import {
|
|
7
|
+
import { classifyHttp, isTransportFailure, ProviderError } from "./errors.js";
|
|
8
8
|
/** Join a base URL and a path without doubling or dropping the slash. */
|
|
9
9
|
export function apiUrl(baseUrl, path) {
|
|
10
10
|
return `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
@@ -53,7 +53,7 @@ export function retryAfterFromHeaders(headers, now = Date.now()) {
|
|
|
53
53
|
/** Turn a non-2xx response into the classified error every caller branches on. */
|
|
54
54
|
async function errorFor(provider, res) {
|
|
55
55
|
const text = await res.text().catch(() => "");
|
|
56
|
-
const kind =
|
|
56
|
+
const kind = classifyHttp(res.status, text);
|
|
57
57
|
const message = text
|
|
58
58
|
? `${provider} ${res.status}: ${text.slice(0, 500)}`
|
|
59
59
|
: `${provider} ${res.status} ${res.statusText}`;
|
|
@@ -91,49 +91,58 @@ export async function postJson(opts) {
|
|
|
91
91
|
throw await errorFor(opts.provider, res);
|
|
92
92
|
return (await res.json());
|
|
93
93
|
}
|
|
94
|
+
/** Pull the `data:` payload out of one SSE frame, joining continuation lines
|
|
95
|
+
* the way the spec says to. Returns null for a comment or a frame carrying
|
|
96
|
+
* only an `event:` name. */
|
|
97
|
+
function payloadOf(frame) {
|
|
98
|
+
const parts = [];
|
|
99
|
+
for (const line of frame.split("\n")) {
|
|
100
|
+
if (!line.startsWith("data:"))
|
|
101
|
+
continue;
|
|
102
|
+
parts.push(line.slice(5).replace(/^ /, ""));
|
|
103
|
+
}
|
|
104
|
+
// Gemini abandons the framing to report a mid-stream failure: the
|
|
105
|
+
// google.rpc.Status is appended as a bare JSON object with no `data:` on
|
|
106
|
+
// it. Dropped here it never reaches an adapter, and a 429 or a 503 that
|
|
107
|
+
// lands after the headers reads as an empty, successful turn. Requiring an
|
|
108
|
+
// object keeps comments and `event:`-only frames returning null.
|
|
109
|
+
if (parts.length === 0) {
|
|
110
|
+
const bare = frame.trim();
|
|
111
|
+
return bare.startsWith("{") ? bare : null;
|
|
112
|
+
}
|
|
113
|
+
const payload = parts.join("\n").trim();
|
|
114
|
+
return payload.length > 0 ? payload : null;
|
|
115
|
+
}
|
|
94
116
|
/**
|
|
95
|
-
*
|
|
117
|
+
* Yield each `data:` payload of an SSE body, trimmed — plus the bare JSON
|
|
118
|
+
* object a vendor appends outside the framing, which is only ever an error
|
|
119
|
+
* (see `payloadOf`).
|
|
96
120
|
*
|
|
97
|
-
* Frames are split on the blank line the spec requires, so a payload
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
121
|
+
* Frames are split on the blank line the spec requires, so a payload containing
|
|
122
|
+
* a bare newline survives; `[DONE]` is swallowed here rather than in every
|
|
123
|
+
* adapter. CRLF is normalized — some gateways send it, and a `\r` left on the
|
|
124
|
+
* end of a JSON payload is a parse error nobody enjoys debugging.
|
|
125
|
+
*
|
|
126
|
+
* Exported apart from `streamSse` because the envelope above it is the half an
|
|
127
|
+
* adopting app most often cannot take: an app with its own translated error
|
|
128
|
+
* copy, its own log levels, or its own auth refresh has to keep building the
|
|
129
|
+
* request and reading the failure itself. Framing is the half nobody should
|
|
130
|
+
* write twice — hand it a `res.body` and keep your own envelope.
|
|
101
131
|
*/
|
|
102
|
-
export async function*
|
|
103
|
-
const
|
|
104
|
-
if (!res.ok)
|
|
105
|
-
throw await errorFor(opts.provider, res);
|
|
106
|
-
// A 2xx with no body at all is an upstream anomaly, not a request we got
|
|
107
|
-
// wrong — worth the same retry a 5xx gets.
|
|
108
|
-
if (!res.body) {
|
|
109
|
-
throw new ProviderError(opts.provider, "overload", `${opts.provider}: empty response body`, {
|
|
110
|
-
status: res.status,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
const reader = res.body.getReader();
|
|
132
|
+
export async function* parseSseStream(body) {
|
|
133
|
+
const reader = body.getReader();
|
|
114
134
|
const decoder = new TextDecoder();
|
|
115
135
|
let buffer = "";
|
|
116
|
-
/** Pull the `data:` payload out of one SSE frame, joining continuation
|
|
117
|
-
* lines the way the spec says to. Returns null for a comment or a frame
|
|
118
|
-
* carrying only an `event:` name. */
|
|
119
|
-
function payloadOf(frame) {
|
|
120
|
-
const parts = [];
|
|
121
|
-
for (const line of frame.split("\n")) {
|
|
122
|
-
if (!line.startsWith("data:"))
|
|
123
|
-
continue;
|
|
124
|
-
parts.push(line.slice(5).replace(/^ /, ""));
|
|
125
|
-
}
|
|
126
|
-
if (parts.length === 0)
|
|
127
|
-
return null;
|
|
128
|
-
const payload = parts.join("\n").trim();
|
|
129
|
-
return payload.length > 0 ? payload : null;
|
|
130
|
-
}
|
|
131
136
|
try {
|
|
132
137
|
for (;;) {
|
|
133
138
|
const { done, value } = await reader.read();
|
|
134
139
|
if (done)
|
|
135
140
|
break;
|
|
136
|
-
|
|
141
|
+
// Normalized on the BUFFER, not on the decoded read: a network read can
|
|
142
|
+
// end between the CR and the LF, and normalizing each read separately
|
|
143
|
+
// leaves that CR stranded on the end of a payload — a JSON parse error
|
|
144
|
+
// that only ever reproduces under a particular packet split.
|
|
145
|
+
buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, "\n");
|
|
137
146
|
let boundary = buffer.indexOf("\n\n");
|
|
138
147
|
while (boundary !== -1) {
|
|
139
148
|
const frame = buffer.slice(0, boundary);
|
|
@@ -154,4 +163,21 @@ export async function* streamSse(opts) {
|
|
|
154
163
|
reader.releaseLock();
|
|
155
164
|
}
|
|
156
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* POST an SSE request and stream its payloads: the envelope (auth, the
|
|
168
|
+
* classified error, the retry hints) plus `parseSseStream`.
|
|
169
|
+
*/
|
|
170
|
+
export async function* streamSse(opts) {
|
|
171
|
+
const res = await send(opts);
|
|
172
|
+
if (!res.ok)
|
|
173
|
+
throw await errorFor(opts.provider, res);
|
|
174
|
+
// A 2xx with no body at all is an upstream anomaly, not a request we got
|
|
175
|
+
// wrong — worth the same retry a 5xx gets.
|
|
176
|
+
if (!res.body) {
|
|
177
|
+
throw new ProviderError(opts.provider, "overload", `${opts.provider}: empty response body`, {
|
|
178
|
+
status: res.status,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
yield* parseSseStream(res.body);
|
|
182
|
+
}
|
|
157
183
|
//# sourceMappingURL=transport.js.map
|
package/dist/transport.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,+EAA+E;AAC/E,sBAAsB;AACtB,EAAE;AACF,6EAA6E;AAC7E,0BAA0B;AAC1B,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@providerkit/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The layer under your agent loop: one seam for every LLM provider, plus the failure handling you only learn in production.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"author": "Gustavo
|
|
7
|
+
"author": "Gustavo Salomé",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
package/src/context.ts
CHANGED
|
@@ -56,6 +56,13 @@ export function conversationTokens(messages: readonly ChatMessage[]): number {
|
|
|
56
56
|
* that is not a guess. Estimate only when there is no reported count yet.
|
|
57
57
|
*/
|
|
58
58
|
export function needsCompaction(inputTokens: number, contextWindow: number): boolean {
|
|
59
|
+
// A window at or below the reserve puts the threshold at zero or less, and
|
|
60
|
+
// every turn — including one that has not reported usage yet — then reads as
|
|
61
|
+
// already full. That loops: folding a history this side of its first answer
|
|
62
|
+
// changes nothing, so the next check says the same thing. Small windows are
|
|
63
|
+
// not hypothetical; a caller that learns a real ceiling from a rejection
|
|
64
|
+
// (rather than guessing one) can legitimately arrive here with 8k.
|
|
65
|
+
if (inputTokens <= 0) return false;
|
|
59
66
|
return inputTokens >= contextWindow - CONTEXT_RESERVE_TOKENS;
|
|
60
67
|
}
|
|
61
68
|
|
package/src/errors.ts
CHANGED
|
@@ -221,6 +221,12 @@ export function isTransportFailure(err: unknown): boolean {
|
|
|
221
221
|
const message = readString(current, "message");
|
|
222
222
|
if (name === "APIConnectionError" || name === "APIConnectionTimeoutError") return true;
|
|
223
223
|
if (code !== undefined && TRANSPORT_CODES.has(code)) return true;
|
|
224
|
+
// undici — Node's fetch, and Bun's — parks the real reason in `code` while
|
|
225
|
+
// the thrown error says only "terminated", which is the most common way a
|
|
226
|
+
// stream dies mid-body. Its whole family shares one prefix; enumerating
|
|
227
|
+
// them ages badly, the prefix does not. UND_ERR_ABORTED is the caller's
|
|
228
|
+
// Stop wearing the same prefix, and is never ours to retry.
|
|
229
|
+
if (code?.startsWith("UND_ERR_") && code !== "UND_ERR_ABORTED") return true;
|
|
224
230
|
// A bare TypeError is also what a real bug throws ("x is not a function"),
|
|
225
231
|
// so the MESSAGE is checked, not just the type — misfiling one of those
|
|
226
232
|
// would retry a genuine bug three times and hide it.
|
|
@@ -274,7 +280,6 @@ const QUOTA_PATTERNS: readonly RegExp[] = [
|
|
|
274
280
|
/upgrade your plan/i,
|
|
275
281
|
/quota\b[^.]{0,40}\b(?:exhausted|exceeded|will be refreshed)/i,
|
|
276
282
|
/balance (?:is )?(?:too low|not enough|insufficient)/i,
|
|
277
|
-
/per\s*day|PerDay|insufficient_quota|billing/i,
|
|
278
283
|
/余额不足/,
|
|
279
284
|
/欠费/,
|
|
280
285
|
/额度(?:不足|已用完)/,
|
|
@@ -300,6 +305,21 @@ const CONTENT_PATTERNS: readonly RegExp[] = [
|
|
|
300
305
|
/safety|PROHIBITED_CONTENT|blocked|refusal/i,
|
|
301
306
|
];
|
|
302
307
|
|
|
308
|
+
/**
|
|
309
|
+
* A throttle said in words rather than in a 429.
|
|
310
|
+
*
|
|
311
|
+
* Every other kind has body evidence; rate had only the status, which is
|
|
312
|
+
* exactly the evidence an in-band failure lacks — an SSE response is already
|
|
313
|
+
* 200 when the throttle lands, so the reason arrives as a payload with no
|
|
314
|
+
* status line at all. Checked below the status branches, so it only ever
|
|
315
|
+
* catches what would otherwise fall through to "unknown".
|
|
316
|
+
*/
|
|
317
|
+
const RATE_PATTERNS: readonly RegExp[] = [
|
|
318
|
+
/rate[_\s-]?limit/i,
|
|
319
|
+
/too many requests/i,
|
|
320
|
+
/RESOURCE_EXHAUSTED/,
|
|
321
|
+
];
|
|
322
|
+
|
|
303
323
|
/** Theirs and temporary, said in words rather than a status. Gemini reports
|
|
304
324
|
* UNAVAILABLE/INTERNAL in the body; gateways say "capacity". */
|
|
305
325
|
const OVERLOAD_PATTERNS: readonly RegExp[] = [
|
|
@@ -320,6 +340,21 @@ const matches = (patterns: readonly RegExp[], text: string): boolean =>
|
|
|
320
340
|
* entitlement beats quota beats auth — each earlier category's fix is useless
|
|
321
341
|
* for the later ones.
|
|
322
342
|
*/
|
|
343
|
+
/**
|
|
344
|
+
* The kind of a failure that arrived as an HTTP response — a status and a body,
|
|
345
|
+
* with nothing thrown.
|
|
346
|
+
*
|
|
347
|
+
* `classify` below is for a caught error, and it reads `status` and the body
|
|
348
|
+
* text off that error when they are not passed separately. A response has no
|
|
349
|
+
* error object, so callers were inventing one to fill the slot: a bare body
|
|
350
|
+
* string, the same text twice, a `{ status, error }` literal. All three are
|
|
351
|
+
* inert — nothing on them can satisfy `isAbort` or `isTransportFailure` — so
|
|
352
|
+
* they were three spellings of `undefined`.
|
|
353
|
+
*/
|
|
354
|
+
export function classifyHttp(status: number | undefined, body: string): ErrorKind {
|
|
355
|
+
return classify(undefined, status, body);
|
|
356
|
+
}
|
|
357
|
+
|
|
323
358
|
export function classify(err: unknown, status?: number, body?: string): ErrorKind {
|
|
324
359
|
if (isAbort(err)) return "aborted";
|
|
325
360
|
if (isTransportFailure(err)) return "network";
|
|
@@ -348,6 +383,7 @@ export function classify(err: unknown, status?: number, body?: string): ErrorKin
|
|
|
348
383
|
// "the model is still loading" — both are worth another attempt.
|
|
349
384
|
if (code === 529 || code === 409) return "overload";
|
|
350
385
|
if (code !== undefined && code >= 500) return "overload";
|
|
386
|
+
if (matches(RATE_PATTERNS, text)) return "rate";
|
|
351
387
|
if (matches(OVERLOAD_PATTERNS, text)) return "overload";
|
|
352
388
|
if (/timed out|timeout/i.test(text)) return "timeout";
|
|
353
389
|
if (code !== undefined && code >= 400) return "invalid";
|
|
@@ -380,6 +416,48 @@ export function parseRetryAfterMs(err: unknown, body?: string): number | undefin
|
|
|
380
416
|
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
381
417
|
}
|
|
382
418
|
|
|
419
|
+
/**
|
|
420
|
+
* A failure the backend reports INSIDE an already-200 stream.
|
|
421
|
+
*
|
|
422
|
+
* The transport classified everything that failed before the body opened; this
|
|
423
|
+
* is the rest, and every streaming shape has the same hole. An SSE response
|
|
424
|
+
* commits to 200 the moment its headers go out, so a throttle, an overload, or
|
|
425
|
+
* a prompt found too long after the fact arrives as a payload in the body
|
|
426
|
+
* rather than as a status line. Left unread it matches no branch an adapter
|
|
427
|
+
* models, the loop skips it, and the turn ends as a successful zero-token
|
|
428
|
+
* completion: retry sees nothing to retry, and a key pool never rotates off an
|
|
429
|
+
* exhausted key.
|
|
430
|
+
*
|
|
431
|
+
* `error` is the vendor's own payload, whatever shape it came in — the numeric
|
|
432
|
+
* HTTP `code` Gemini and OpenRouter use, the slug OpenAI and Anthropic put in
|
|
433
|
+
* `code`/`type`, the canonical `status` name. The whole thing serializes into
|
|
434
|
+
* the searchable body, so RetryInfo's `retryDelay` is honoured wherever it sits.
|
|
435
|
+
*
|
|
436
|
+
* `unknown` is floored to "overload" rather than kept: a stream that dies after
|
|
437
|
+
* its headers is by construction a transient upstream fault, and "unknown" is
|
|
438
|
+
* never retried. Everything the body DOES name keeps its own kind — which is
|
|
439
|
+
* what tells the caller to wait, rotate a key, or compact instead of retrying
|
|
440
|
+
* a failure that will repeat.
|
|
441
|
+
*/
|
|
442
|
+
export function streamError(provider: string, error: unknown): ProviderError {
|
|
443
|
+
const body = JSON.stringify(error ?? {}) ?? "";
|
|
444
|
+
const status = readNumber(error, "code") ?? readNumber(error, "status");
|
|
445
|
+
const code =
|
|
446
|
+
readString(error, "code") ?? readString(error, "status") ?? readString(error, "type");
|
|
447
|
+
const kind = classifyHttp(status, body);
|
|
448
|
+
return new ProviderError(
|
|
449
|
+
provider,
|
|
450
|
+
kind === "unknown" ? "overload" : kind,
|
|
451
|
+
`${provider} stream error: ${readString(error, "message") ?? code ?? "no reason given"}`,
|
|
452
|
+
{
|
|
453
|
+
...(status !== undefined ? { status } : {}),
|
|
454
|
+
...(code ? { code } : {}),
|
|
455
|
+
retryAfterMs: parseRetryAfterMs(error, body),
|
|
456
|
+
body: body.slice(0, 2_000),
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
383
461
|
/** The loggable surface of a failure — so a dead run never reads
|
|
384
462
|
* "400 status code (no body)". */
|
|
385
463
|
export function describeProviderError(err: unknown): Record<string, unknown> {
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
export * from "./types.ts";
|
|
2
2
|
export * from "./errors.ts";
|
|
3
3
|
export * from "./retry.ts";
|
|
4
|
+
export * from "./key-pool.ts";
|
|
4
5
|
export * from "./watchdog.ts";
|
|
5
6
|
export * from "./usage.ts";
|
|
6
7
|
export * from "./transport.ts";
|
|
8
|
+
export * from "./rate-limit.ts";
|
|
7
9
|
export * from "./tool-args.ts";
|
|
8
10
|
export * from "./tools.ts";
|
|
9
11
|
export * from "./schema.ts";
|
|
10
12
|
export * from "./context.ts";
|
|
11
13
|
export * from "./providers/anthropic.ts";
|
|
12
14
|
export * from "./providers/openai.ts";
|
|
15
|
+
export * from "./providers/responses.ts";
|
|
16
|
+
export * from "./providers/gemini.ts";
|
package/src/key-pool.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// A rotating pool of API keys for one provider.
|
|
2
|
+
//
|
|
3
|
+
// Rate limits are scoped per project or per account, so several keys multiply
|
|
4
|
+
// throughput. Free-tier keys go first, round-robin, and the paid key last —
|
|
5
|
+
// free quota burns before money. A key that answers 429 is benched for the
|
|
6
|
+
// delay the provider named (Retry-After, or Gemini's RetryInfo; else a minute,
|
|
7
|
+
// and an hour when the body says the balance or the daily window is gone), a
|
|
8
|
+
// denied key for 12 h (durable, but key-specific — one dead key must not take
|
|
9
|
+
// the pool down with it), an overload only briefly, because a sibling project's
|
|
10
|
+
// key routes to a different backend.
|
|
11
|
+
//
|
|
12
|
+
// With ONE key there is nothing to rotate to, so NOTHING is evicted and the
|
|
13
|
+
// error propagates untouched: a transient 503 must not switch a single-key
|
|
14
|
+
// deployment off for a minute, where the caller's own retry is the whole
|
|
15
|
+
// recovery there is.
|
|
16
|
+
import { isTransient, ProviderError } from "./errors.ts";
|
|
17
|
+
import type { ErrorKind } from "./errors.ts";
|
|
18
|
+
import type {
|
|
19
|
+
ChatMessage,
|
|
20
|
+
Provider,
|
|
21
|
+
ProviderChunk,
|
|
22
|
+
StreamOptions,
|
|
23
|
+
ToolDefinition,
|
|
24
|
+
} from "./types.ts";
|
|
25
|
+
|
|
26
|
+
/** A per-minute throttle: the window it names is the next one. */
|
|
27
|
+
const RATE_COOLDOWN_MS = 60_000;
|
|
28
|
+
/** A balance or a daily window — minutes will not bring it back. */
|
|
29
|
+
const QUOTA_COOLDOWN_MS = 60 * 60_000;
|
|
30
|
+
const AUTH_COOLDOWN_MS = 12 * 60 * 60_000;
|
|
31
|
+
/** The vendor's bad time, not the key's — benched only long enough for the
|
|
32
|
+
* next call to land somewhere else. */
|
|
33
|
+
const TRANSIENT_COOLDOWN_MS = 60_000;
|
|
34
|
+
const MIN_EVICTION_MS = 1_000;
|
|
35
|
+
const MAX_EVICTION_MS = 12 * 60 * 60_000;
|
|
36
|
+
|
|
37
|
+
export type KeyTier = "free" | "paid";
|
|
38
|
+
|
|
39
|
+
interface PoolKey {
|
|
40
|
+
apiKey: string;
|
|
41
|
+
tier: KeyTier;
|
|
42
|
+
/** Epoch ms until which the key is out; 0 = available. */
|
|
43
|
+
evictedUntil: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface KeyPoolOptions {
|
|
47
|
+
/** Free-tier keys, walked round-robin before the paid one. */
|
|
48
|
+
keys: readonly string[];
|
|
49
|
+
paidKey?: string | null;
|
|
50
|
+
/** Fires whenever a key is benched — the pool's only report, in place of a
|
|
51
|
+
* logger a zero-dependency package has no business owning. */
|
|
52
|
+
onEvict?: (info: { tier: KeyTier; kind: ErrorKind; forMs: number }) => void;
|
|
53
|
+
/** Injected in tests, so an expiry can be exercised without waiting out a
|
|
54
|
+
* 12-hour cooldown. */
|
|
55
|
+
now?: () => number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** No key can serve right now. `retryAtMs` = when the soonest one is back. */
|
|
59
|
+
export class NoAvailableKeyError extends Error {
|
|
60
|
+
readonly retryAtMs: number;
|
|
61
|
+
|
|
62
|
+
constructor(label: string, retryAtMs: number) {
|
|
63
|
+
super(`No ${label} API key available (all rate-limited or denied)`);
|
|
64
|
+
this.name = "NoAvailableKeyError";
|
|
65
|
+
this.retryAtMs = retryAtMs;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class KeyPool {
|
|
70
|
+
private readonly label: string;
|
|
71
|
+
private readonly free: PoolKey[];
|
|
72
|
+
private readonly paid: PoolKey | null;
|
|
73
|
+
private readonly onEvict: KeyPoolOptions["onEvict"];
|
|
74
|
+
private readonly now: () => number;
|
|
75
|
+
private cursor = 0;
|
|
76
|
+
|
|
77
|
+
constructor(label: string, opts: KeyPoolOptions) {
|
|
78
|
+
this.label = label;
|
|
79
|
+
// A blank slot in the caller's config is not a key — an absent env var
|
|
80
|
+
// reads as "" and would otherwise be dialled once per rotation.
|
|
81
|
+
this.free = opts.keys
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((apiKey): PoolKey => ({ apiKey, tier: "free", evictedUntil: 0 }));
|
|
84
|
+
this.paid = opts.paidKey ? { apiKey: opts.paidKey, tier: "paid", evictedUntil: 0 } : null;
|
|
85
|
+
this.onEvict = opts.onEvict;
|
|
86
|
+
this.now = opts.now ?? Date.now;
|
|
87
|
+
if (this.free.length === 0 && !this.paid) {
|
|
88
|
+
throw new Error(`KeyPool(${this.label}) needs at least one key`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get size(): number {
|
|
93
|
+
return this.free.length + (this.paid ? 1 : 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run `fn` with the next available key, rotating on key-specific and
|
|
98
|
+
* transient failures. Everything else (a bad request, a content block) throws
|
|
99
|
+
* straight through — it would fail identically on every key, and spending the
|
|
100
|
+
* pool on it only turns one bad request into an outage.
|
|
101
|
+
*/
|
|
102
|
+
async with<T>(fn: (apiKey: string, tier: KeyTier) => Promise<T>): Promise<T> {
|
|
103
|
+
const candidates = this.candidates(this.now());
|
|
104
|
+
if (candidates.length === 0) throw new NoAvailableKeyError(this.label, this.nextAvailableAt());
|
|
105
|
+
|
|
106
|
+
let last: unknown;
|
|
107
|
+
for (const key of candidates) {
|
|
108
|
+
try {
|
|
109
|
+
return await fn(key.apiKey, key.tier);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
last = err;
|
|
112
|
+
// The single-key rule: with nothing to rotate to, benching the only key
|
|
113
|
+
// answers NoAvailableKeyError to every call for the next minute — for a
|
|
114
|
+
// failure the caller's own retry would have absorbed.
|
|
115
|
+
const cooldown = this.size > 1 ? cooldownFor(err) : null;
|
|
116
|
+
if (cooldown === null) throw err;
|
|
117
|
+
key.evictedUntil = this.now() + cooldown.forMs;
|
|
118
|
+
this.onEvict?.({ tier: key.tier, kind: cooldown.kind, forMs: cooldown.forMs });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Every candidate was evicted during this call.
|
|
123
|
+
if (this.candidates(this.now()).length === 0)
|
|
124
|
+
throw new NoAvailableKeyError(this.label, this.nextAvailableAt());
|
|
125
|
+
throw last;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Free keys round-robin from a moving cursor, then the paid key. */
|
|
129
|
+
private candidates(now: number): PoolKey[] {
|
|
130
|
+
const out: PoolKey[] = [];
|
|
131
|
+
const n = this.free.length;
|
|
132
|
+
for (let i = 0; i < n; i++) {
|
|
133
|
+
const key = this.free[(this.cursor + i) % n]!;
|
|
134
|
+
if (key.evictedUntil <= now) out.push(key);
|
|
135
|
+
}
|
|
136
|
+
if (n > 0) this.cursor = (this.cursor + 1) % n;
|
|
137
|
+
if (this.paid && this.paid.evictedUntil <= now) out.push(this.paid);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private nextAvailableAt(): number {
|
|
142
|
+
const all = this.paid ? [...this.free, this.paid] : this.free;
|
|
143
|
+
return Math.min(...all.map((key) => key.evictedUntil));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How long to bench a key for this failure, or null when the failure is not the
|
|
149
|
+
* key's fault — nothing is gained by rotating, and benching would spend the
|
|
150
|
+
* pool on a request that fails the same way everywhere.
|
|
151
|
+
*/
|
|
152
|
+
function cooldownFor(err: unknown): { kind: ErrorKind; forMs: number } | null {
|
|
153
|
+
if (!(err instanceof ProviderError)) return null;
|
|
154
|
+
const clamp = (ms: number) => Math.min(Math.max(ms, MIN_EVICTION_MS), MAX_EVICTION_MS);
|
|
155
|
+
switch (err.kind) {
|
|
156
|
+
// The three kinds that are about THIS key.
|
|
157
|
+
case "rate":
|
|
158
|
+
return { kind: err.kind, forMs: clamp(err.retryAfterMs ?? RATE_COOLDOWN_MS) };
|
|
159
|
+
case "quota":
|
|
160
|
+
return { kind: err.kind, forMs: clamp(err.retryAfterMs ?? QUOTA_COOLDOWN_MS) };
|
|
161
|
+
case "auth":
|
|
162
|
+
// 12 h, not forever: a key is also refused while a billing account is
|
|
163
|
+
// reinstated, and a pool that drops keys permanently ends up empty.
|
|
164
|
+
return { kind: err.kind, forMs: AUTH_COOLDOWN_MS };
|
|
165
|
+
default:
|
|
166
|
+
// Not the key's fault, but a sibling key is a different project on a
|
|
167
|
+
// different backend, so one more call is the cheapest way to find out —
|
|
168
|
+
// which is what an overload or a stalled request is worth. `network` is
|
|
169
|
+
// excluded deliberately: the socket died on our side and dies identically
|
|
170
|
+
// on every key, so benching for it would answer NoAvailableKeyError to
|
|
171
|
+
// what is really "no internet".
|
|
172
|
+
return isTransient(err.kind) && err.kind !== "network"
|
|
173
|
+
? { kind: err.kind, forMs: TRANSIENT_COOLDOWN_MS }
|
|
174
|
+
: null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Give any provider a rotating pool of keys.
|
|
180
|
+
*
|
|
181
|
+
* The subtlety is the seam's shape. `createStream` is an async generator, so
|
|
182
|
+
* CALLING it performs no I/O: the POST — and the 429 that should rotate the key
|
|
183
|
+
* — happens on the first `next()`, long after a `pool.with` wrapped around the
|
|
184
|
+
* call itself would have returned, with nothing left to rotate. So the first
|
|
185
|
+
* chunk is pulled INSIDE the pool and only the rest of the stream is consumed
|
|
186
|
+
* outside it.
|
|
187
|
+
*
|
|
188
|
+
* That line is also the honest one. Past the first chunk the answer is
|
|
189
|
+
* committed to one key, exactly as a retry is committed past its first chunk
|
|
190
|
+
* (retry.ts, rule 2): a mid-stream 429 evicts nothing and rotates nothing,
|
|
191
|
+
* because there is no way to resume a half-rendered answer on another key.
|
|
192
|
+
*/
|
|
193
|
+
export function withKeyPool(pool: KeyPool, factory: (apiKey: string) => Provider): Provider {
|
|
194
|
+
// The identity every key shares. Building an adapter performs no I/O — it
|
|
195
|
+
// closes over its config and nothing else — so a throwaway instance is the
|
|
196
|
+
// cheapest way to read `id` and `model` without holding a key outside the
|
|
197
|
+
// pool.
|
|
198
|
+
const identity = factory("");
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
id: identity.id,
|
|
202
|
+
model: identity.model,
|
|
203
|
+
|
|
204
|
+
async *createStream(
|
|
205
|
+
messages: ChatMessage[],
|
|
206
|
+
tools: ToolDefinition[],
|
|
207
|
+
opts: StreamOptions = {},
|
|
208
|
+
): AsyncIterable<ProviderChunk> {
|
|
209
|
+
const opened = await pool.with(async (apiKey) => {
|
|
210
|
+
// One AbortController per attempt, chained to the caller's own signal
|
|
211
|
+
// (retry.ts's rule). Finalizing a generator is not cancellation:
|
|
212
|
+
// `return()` unwinds the adapter down to `streamSse`'s finalizer, which
|
|
213
|
+
// only releases the reader's lock — the response body stays live and
|
|
214
|
+
// the request is never aborted. Without this controller an abandoned
|
|
215
|
+
// attempt keeps the provider generating the rest of the answer, holding
|
|
216
|
+
// a connection and a concurrency slot on the very key the pool is
|
|
217
|
+
// rotating away from.
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
const onAbort = () => controller.abort(opts.signal?.reason);
|
|
220
|
+
if (opts.signal?.aborted) onAbort();
|
|
221
|
+
else opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
222
|
+
|
|
223
|
+
const stream = factory(apiKey).createStream(messages, tools, {
|
|
224
|
+
...opts,
|
|
225
|
+
signal: controller.signal,
|
|
226
|
+
});
|
|
227
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
228
|
+
const release = async () => {
|
|
229
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
230
|
+
await close(iterator);
|
|
231
|
+
controller.abort();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
return { iterator, first: await iterator.next(), release };
|
|
236
|
+
} catch (err) {
|
|
237
|
+
// The request failed under the pool, which is about to try the next
|
|
238
|
+
// key: release this attempt before a second one opens against the
|
|
239
|
+
// same rate limit.
|
|
240
|
+
await release();
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const { iterator, first, release } = opened;
|
|
246
|
+
try {
|
|
247
|
+
if (first.done) return;
|
|
248
|
+
yield first.value;
|
|
249
|
+
for (;;) {
|
|
250
|
+
const next = await iterator.next();
|
|
251
|
+
if (next.done) return;
|
|
252
|
+
yield next.value;
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
// A consumer that breaks out of its loop never reaches the end of ours,
|
|
256
|
+
// and an attempt left unfinalized and un-aborted streams for the whole
|
|
257
|
+
// rest of the answer into a body nobody reads.
|
|
258
|
+
await release();
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Finalize an abandoned stream. `return()` can reject on its own (an aborted
|
|
265
|
+
* body), and that must never replace the failure being handled. */
|
|
266
|
+
async function close(iterator: AsyncIterator<ProviderChunk>): Promise<void> {
|
|
267
|
+
try {
|
|
268
|
+
await iterator.return?.();
|
|
269
|
+
} catch {
|
|
270
|
+
// The request is being abandoned either way.
|
|
271
|
+
}
|
|
272
|
+
}
|