@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.
Files changed (50) hide show
  1. package/README.md +33 -157
  2. package/dist/context.d.ts.map +1 -1
  3. package/dist/context.js +8 -0
  4. package/dist/context.js.map +1 -1
  5. package/dist/errors.d.ts +36 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +72 -1
  8. package/dist/errors.js.map +1 -1
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/key-pool.d.ts +60 -0
  14. package/dist/key-pool.d.ts.map +1 -0
  15. package/dist/key-pool.js +235 -0
  16. package/dist/key-pool.js.map +1 -0
  17. package/dist/providers/anthropic.d.ts +9 -0
  18. package/dist/providers/anthropic.d.ts.map +1 -1
  19. package/dist/providers/anthropic.js +12 -13
  20. package/dist/providers/anthropic.js.map +1 -1
  21. package/dist/providers/gemini.d.ts +40 -0
  22. package/dist/providers/gemini.d.ts.map +1 -0
  23. package/dist/providers/gemini.js +303 -0
  24. package/dist/providers/gemini.js.map +1 -0
  25. package/dist/providers/openai.d.ts.map +1 -1
  26. package/dist/providers/openai.js +9 -0
  27. package/dist/providers/openai.js.map +1 -1
  28. package/dist/providers/responses.d.ts +38 -0
  29. package/dist/providers/responses.d.ts.map +1 -0
  30. package/dist/providers/responses.js +341 -0
  31. package/dist/providers/responses.js.map +1 -0
  32. package/dist/rate-limit.d.ts +29 -0
  33. package/dist/rate-limit.d.ts.map +1 -0
  34. package/dist/rate-limit.js +194 -0
  35. package/dist/rate-limit.js.map +1 -0
  36. package/dist/transport.d.ts +18 -5
  37. package/dist/transport.d.ts.map +1 -1
  38. package/dist/transport.js +61 -35
  39. package/dist/transport.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/context.ts +7 -0
  42. package/src/errors.ts +79 -1
  43. package/src/index.ts +4 -0
  44. package/src/key-pool.ts +272 -0
  45. package/src/providers/anthropic.ts +21 -18
  46. package/src/providers/gemini.ts +386 -0
  47. package/src/providers/openai.ts +12 -0
  48. package/src/providers/responses.ts +455 -0
  49. package/src/rate-limit.ts +217 -0
  50. 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 { classify, isTransportFailure, ProviderError } from "./errors.js";
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 = classify({ status: res.status, error: text }, res.status, text);
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
- * POST an SSE request and yield each `data:` payload, trimmed.
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
- * containing a bare newline survives; `[DONE]` is swallowed here rather than in
99
- * every adapter. CRLF is normalized — some gateways send it, and a `\r` left on
100
- * the end of a JSON payload is a parse error nobody enjoys debugging.
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* streamSse(opts) {
103
- const res = await send(opts);
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
- buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
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
@@ -1 +1 @@
1
- {"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"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"}
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.1.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 Salom\u00e9",
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";
@@ -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
+ }