@providerkit/core 0.22.0 → 0.23.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 (97) hide show
  1. package/dist/auth/flows.d.ts.map +1 -1
  2. package/dist/auth/flows.js +5 -5
  3. package/dist/auth/flows.js.map +1 -1
  4. package/dist/auth/index.d.ts +1 -0
  5. package/dist/auth/index.d.ts.map +1 -1
  6. package/dist/auth/index.js +1 -0
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/auth/oauth.d.ts +12 -0
  9. package/dist/auth/oauth.d.ts.map +1 -1
  10. package/dist/auth/oauth.js +19 -1
  11. package/dist/auth/oauth.js.map +1 -1
  12. package/dist/auth/token-source.d.ts +9 -3
  13. package/dist/auth/token-source.d.ts.map +1 -1
  14. package/dist/auth/token-source.js +24 -9
  15. package/dist/auth/token-source.js.map +1 -1
  16. package/dist/auth/with-token.d.ts +29 -0
  17. package/dist/auth/with-token.d.ts.map +1 -0
  18. package/dist/auth/with-token.js +85 -0
  19. package/dist/auth/with-token.js.map +1 -0
  20. package/dist/context.d.ts +39 -1
  21. package/dist/context.d.ts.map +1 -1
  22. package/dist/context.js +119 -0
  23. package/dist/context.js.map +1 -1
  24. package/dist/errors.d.ts +12 -3
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +44 -5
  27. package/dist/errors.js.map +1 -1
  28. package/dist/fallback.d.ts +45 -1
  29. package/dist/fallback.d.ts.map +1 -1
  30. package/dist/fallback.js +138 -75
  31. package/dist/fallback.js.map +1 -1
  32. package/dist/key-pool.d.ts +10 -4
  33. package/dist/key-pool.d.ts.map +1 -1
  34. package/dist/key-pool.js +29 -12
  35. package/dist/key-pool.js.map +1 -1
  36. package/dist/presets.d.ts +7 -2
  37. package/dist/presets.d.ts.map +1 -1
  38. package/dist/presets.js +12 -2
  39. package/dist/presets.js.map +1 -1
  40. package/dist/providers/anthropic.d.ts.map +1 -1
  41. package/dist/providers/anthropic.js +80 -25
  42. package/dist/providers/anthropic.js.map +1 -1
  43. package/dist/providers/gemini.d.ts +6 -0
  44. package/dist/providers/gemini.d.ts.map +1 -1
  45. package/dist/providers/gemini.js +102 -27
  46. package/dist/providers/gemini.js.map +1 -1
  47. package/dist/providers/openai.d.ts.map +1 -1
  48. package/dist/providers/openai.js +63 -14
  49. package/dist/providers/openai.js.map +1 -1
  50. package/dist/providers/responses.d.ts.map +1 -1
  51. package/dist/providers/responses.js +98 -52
  52. package/dist/providers/responses.js.map +1 -1
  53. package/dist/rate-limit.d.ts +10 -0
  54. package/dist/rate-limit.d.ts.map +1 -1
  55. package/dist/rate-limit.js +7 -1
  56. package/dist/rate-limit.js.map +1 -1
  57. package/dist/retry.d.ts +11 -0
  58. package/dist/retry.d.ts.map +1 -1
  59. package/dist/retry.js +25 -12
  60. package/dist/retry.js.map +1 -1
  61. package/dist/tool-args.d.ts +22 -0
  62. package/dist/tool-args.d.ts.map +1 -1
  63. package/dist/tool-args.js +28 -0
  64. package/dist/tool-args.js.map +1 -1
  65. package/dist/transport.d.ts +34 -4
  66. package/dist/transport.d.ts.map +1 -1
  67. package/dist/transport.js +77 -16
  68. package/dist/transport.js.map +1 -1
  69. package/dist/types.d.ts +13 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/types.js +0 -8
  72. package/dist/types.js.map +1 -1
  73. package/dist/watchdog.d.ts +2 -0
  74. package/dist/watchdog.d.ts.map +1 -1
  75. package/dist/watchdog.js +25 -6
  76. package/dist/watchdog.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/auth/flows.ts +5 -4
  79. package/src/auth/index.ts +1 -0
  80. package/src/auth/oauth.ts +23 -1
  81. package/src/auth/token-source.ts +30 -11
  82. package/src/auth/with-token.ts +97 -0
  83. package/src/context.ts +137 -2
  84. package/src/errors.ts +46 -6
  85. package/src/fallback.ts +173 -75
  86. package/src/key-pool.ts +30 -13
  87. package/src/presets.ts +12 -2
  88. package/src/providers/anthropic.ts +83 -24
  89. package/src/providers/gemini.ts +102 -28
  90. package/src/providers/openai.ts +61 -13
  91. package/src/providers/responses.ts +102 -50
  92. package/src/rate-limit.ts +31 -3
  93. package/src/retry.ts +41 -10
  94. package/src/tool-args.ts +44 -0
  95. package/src/transport.ts +97 -17
  96. package/src/types.ts +13 -0
  97. package/src/watchdog.ts +25 -6
package/src/errors.ts CHANGED
@@ -343,6 +343,12 @@ const CONTEXT_PATTERNS: readonly RegExp[] = [
343
343
  // "input length and `max_tokens` exceed context limit: 188059 + 20000 > 200000"
344
344
  /input length and `?max_tokens`? exceed context limit/i,
345
345
  /exceed(?:s|ed)? (?:the )?context limit/i,
346
+ // GitHub Copilot: "prompt token count of 270000 exceeds the limit of 128000",
347
+ // code `model_max_prompt_tokens_exceeded`. No other wording here reads it,
348
+ // so it landed on `invalid` and the conversation never compacted. cc-proxy
349
+ // shipped the same two patterns for the same reason (0.1.51).
350
+ /model_max_prompt_tokens_exceeded/i,
351
+ /prompt token count of \d+ exceeds the limit/i,
346
352
  // Scoped to the thing that overflowed: a bare "exceeds the maximum" also
347
353
  // covers image counts and per-minute token rates, which compaction can't fix.
348
354
  /exceeds? the (?:model'?s? )?maximum (?:input |prompt |context )?(?:tokens?|length|context)/i,
@@ -652,6 +658,25 @@ export function parseContextOverflow(err: unknown, body?: string): ContextOverfl
652
658
  return undefined;
653
659
  }
654
660
 
661
+ /**
662
+ * The HTTP status an in-band error's type stands for when it states none:
663
+ * Anthropic's types, then OpenAI's, read from `type` and then from a slug
664
+ * `code`. cc-proxy's table. A Map, so a type named after an Object method
665
+ * finds nothing rather than a function.
666
+ */
667
+ const STATUS_BY_TYPE: ReadonlyMap<string, number> = new Map([
668
+ ["invalid_request_error", 400],
669
+ ["authentication_error", 401],
670
+ ["permission_error", 403],
671
+ ["not_found_error", 404],
672
+ ["request_too_large", 413],
673
+ ["rate_limit_error", 429],
674
+ ["api_error", 500],
675
+ ["server_error", 500],
676
+ ["internal_server_error", 500],
677
+ ["overloaded_error", 529],
678
+ ]);
679
+
655
680
  /**
656
681
  * A failure the backend reports INSIDE an already-200 stream.
657
682
  *
@@ -669,19 +694,34 @@ export function parseContextOverflow(err: unknown, body?: string): ContextOverfl
669
694
  * `code`/`type`, the canonical `status` name. The whole thing serializes into
670
695
  * the searchable body, so RetryInfo's `retryDelay` is honoured wherever it sits.
671
696
  *
697
+ * A number is a status only from 400 to 599. Vendors put their own codes in
698
+ * the same field: Z.ai's 1113 is an empty balance, and read as a status it was
699
+ * past 500, which skips the 4xx body patterns that name it quota and lands on
700
+ * overload. With no status stated, the error's type says which one it stands
701
+ * for (`STATUS_BY_TYPE`), and the body still outranks it (invariant 3).
702
+ *
672
703
  * `unknown` is floored to "overload" rather than kept: a stream that dies after
673
704
  * its headers is by construction a transient upstream fault, and "unknown" is
674
- * never retried. Everything the body does name keeps its own kind, which is
675
- * what tells the caller to wait, rotate a key, or compact instead of retrying
676
- * a failure that will repeat.
705
+ * never retried. Only an error with no status and no type it knows gets there.
706
+ * Before the type was read, one `invalid_request_error` in words no pattern
707
+ * knew was floored too: retried, then walked across every backup, cooling each
708
+ * for 30s, on a request that fails the same way everywhere. Everything the
709
+ * body does name keeps its own kind, which is what tells the caller to wait,
710
+ * rotate a key, or compact instead of retrying a failure that will repeat.
677
711
  */
678
712
  export function streamError(provider: string, error: unknown): ProviderError {
679
713
  const body = JSON.stringify(error ?? {}) ?? "";
680
- const status =
681
- readNumber(error, "code") ?? readNumber(error, "status") ?? readNumber(error, "status_code");
714
+ const status = [
715
+ readNumber(error, "code"),
716
+ readNumber(error, "status"),
717
+ readNumber(error, "status_code"),
718
+ ].find((value) => value !== undefined && value >= 400 && value < 600);
682
719
  const code =
683
720
  readString(error, "code") ?? readString(error, "status") ?? readString(error, "type");
684
- const kind = classifyHttp(status, body);
721
+ const typeStatus =
722
+ STATUS_BY_TYPE.get(readString(error, "type") ?? "") ??
723
+ STATUS_BY_TYPE.get(readString(error, "code") ?? "");
724
+ const kind = classifyHttp(status ?? typeStatus, body);
685
725
  return new ProviderError(
686
726
  provider,
687
727
  kind === "unknown" ? "overload" : kind,
package/src/fallback.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { classify, parseRetryAfterMs, ProviderError, type ErrorKind } from "./errors.ts";
2
2
  import type { RateLimitWindow } from "./rate-limit.ts";
3
3
  import type { ProviderPresetId } from "./presets.ts";
4
+ import { sleep } from "./retry.ts";
4
5
  import type { Provider, ProviderChunk } from "./types.ts";
5
- import { withWatchdog, type WatchdogOptions } from "./watchdog.ts";
6
+ import { STREAM_PROGRESS_MS, withWatchdog, type WatchdogOptions } from "./watchdog.ts";
6
7
  import { createPresetProvider, type PresetProviderConfig } from "./providers/factory.ts";
7
8
 
8
9
  /** Retry intervals when the server supplies no deadline, not estimates of when
@@ -30,6 +31,20 @@ export interface FallbackOptions<T> {
30
31
  retryAtMs: number;
31
32
  window?: RateLimitWindow;
32
33
  }) => void;
34
+ /** Where cooldowns live; a private Map when omitted. Hand one store to every
35
+ * pool that holds the same candidate and a lockout learned in one chain
36
+ * benches it in all of them, instead of each chain spending a call to find
37
+ * the same weekly wall. Pools sharing a store must share `cooldownMs` too: a
38
+ * bench set by one pool is honoured by the others, so a kind one of them
39
+ * turned off (`null`) would still divert its calls whenever another pool
40
+ * benched the candidate for it. */
41
+ cooldowns?: CooldownStore<T>;
42
+ /** How long a recovery probe may go without a first chunk before another
43
+ * call may replace it. A probe that hung used to hold its candidate out of
44
+ * service for good. Defaults to the watchdog's progress clock
45
+ * (`STREAM_PROGRESS_MS`): a call silent that long is one the watchdog
46
+ * would already have killed. */
47
+ probeStaleMs?: number;
33
48
  /** Inject a clock for deterministic tests. */
34
49
  now?: () => number;
35
50
  }
@@ -93,11 +108,53 @@ export function withConfiguredFallbacks(
93
108
  );
94
109
  }
95
110
 
96
- interface Cooldown {
111
+ /** One candidate's bench, as a pool records it. */
112
+ export interface Cooldown {
97
113
  retryAtMs: number;
98
114
  kind: ErrorKind;
99
115
  window?: RateLimitWindow;
100
- probing: boolean;
116
+ /** The one call let through once the bench expired. Only that call clears
117
+ * it, and `settled` resolves at its first chunk or its end. */
118
+ probe?: { since: number; settled: Promise<void>; settle(): void };
119
+ }
120
+
121
+ /** Where a pool keeps its benches. A `Map` fits, and so does a `WeakMap` keyed
122
+ * by providers, which lets a bench go when its provider does. `get` must
123
+ * return the very object `set` was given: the probe is tracked on it. */
124
+ export interface CooldownStore<T> {
125
+ get(candidate: T): Cooldown | undefined;
126
+ set(candidate: T, cooldown: Cooldown): void;
127
+ delete(candidate: T): unknown;
128
+ }
129
+
130
+ function openProbe(since: number): NonNullable<Cooldown["probe"]> {
131
+ let settle = (): void => {};
132
+ const settled = new Promise<void>((resolve) => {
133
+ settle = resolve;
134
+ });
135
+ return { since, settled, settle };
136
+ }
137
+
138
+ /** How many times a call that found nothing free waits on a probe before it
139
+ * fails. The first wait covers a probe that answers or fails. The second
140
+ * covers one that ended with neither (its own caller stopped it) and was
141
+ * taken over by another waiter. Past that the caller's own retry decides. */
142
+ const PROBE_WAITS = 2;
143
+
144
+ /** Resolve when any probe settles or `ms` passes. Rejects with the caller's
145
+ * reason on abort, and leaves no timer running once a probe wins. */
146
+ async function waitForProbe(
147
+ settled: readonly Promise<void>[],
148
+ ms: number,
149
+ signal?: AbortSignal,
150
+ ): Promise<void> {
151
+ const done = new AbortController();
152
+ const wake = signal ? AbortSignal.any([signal, done.signal]) : done.signal;
153
+ try {
154
+ await Promise.race([...settled, sleep(Math.max(0, ms), wake)]);
155
+ } finally {
156
+ done.abort();
157
+ }
101
158
  }
102
159
 
103
160
  /** The whole chain is unavailable. Fail promptly instead of holding a request
@@ -126,7 +183,8 @@ export class NoAvailableProviderError extends ProviderError {
126
183
  * a background timer, vendor SDK, database or global singleton. */
127
184
  export class FallbackPool<T> {
128
185
  private readonly candidates: readonly T[];
129
- private readonly cooldowns = new Map<T, Cooldown>();
186
+ private readonly cooldowns: CooldownStore<T>;
187
+ private readonly probeStaleMs: number;
130
188
  private readonly now: () => number;
131
189
 
132
190
  constructor(
@@ -146,14 +204,21 @@ export class FallbackPool<T> {
146
204
  }
147
205
  }
148
206
  this.candidates = [...candidates];
207
+ this.cooldowns = options.cooldowns ?? new Map();
208
+ this.probeStaleMs = options.probeStaleMs ?? STREAM_PROGRESS_MS;
209
+ if (!Number.isFinite(this.probeStaleMs) || this.probeStaleMs < 0) {
210
+ throw new Error("providerkit: probeStaleMs must be finite, non-negative milliseconds");
211
+ }
149
212
  this.now = options.now ?? Date.now;
150
213
  }
151
214
 
152
215
  /** Clear a cooldown after replacing a key or fixing configuration. Omit the
153
- * candidate to clear all. Does not interrupt calls already in flight. */
216
+ * candidate to clear all of this pool's own; a shared store keeps the benches
217
+ * of candidates only other pools hold. Does not interrupt calls in flight. */
154
218
  reset(...selection: [] | [T]): void {
155
- if (selection.length === 0) this.cooldowns.clear();
156
- else this.cooldowns.delete(selection[0]);
219
+ for (const candidate of selection.length === 0 ? this.candidates : selection) {
220
+ this.cooldowns.delete(candidate);
221
+ }
157
222
  }
158
223
 
159
224
  status(): Array<{
@@ -163,12 +228,12 @@ export class FallbackPool<T> {
163
228
  kind?: ErrorKind;
164
229
  window?: RateLimitWindow;
165
230
  }> {
166
- return this.candidates.map((candidate) => ({
167
- candidate,
168
- retryAtMs: 0,
169
- probing: false,
170
- ...this.cooldowns.get(candidate),
171
- }));
231
+ return this.candidates.map((candidate) => {
232
+ const cooldown = this.cooldowns.get(candidate);
233
+ if (!cooldown) return { candidate, retryAtMs: 0, probing: false };
234
+ const { probe, ...bench } = cooldown;
235
+ return { candidate, ...bench, probing: probe !== undefined };
236
+ });
172
237
  }
173
238
 
174
239
  /** Buffered calls only: do not wrap an agent turn that has run side effects. */
@@ -192,74 +257,107 @@ export class FallbackPool<T> {
192
257
  signal?.throwIfAborted();
193
258
  let hasFailed = false;
194
259
  let lastError: unknown;
195
- for (const candidate of this.candidates) {
196
- signal?.throwIfAborted();
197
- const previous = this.cooldowns.get(candidate);
198
- if (previous && (previous.retryAtMs > this.now() || previous.probing)) continue;
199
- // One recovery probe after expiry. Healthy endpoints still allow normal
200
- // concurrency; only a known failed endpoint gets a single probe.
201
- if (previous) previous.probing = true;
202
- const controller = new AbortController();
203
- const inner = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
204
- let emitted = false;
205
- let attemptFailed = false;
206
- const startedWithCooldown = previous !== undefined;
207
- try {
208
- for await (const chunk of attempt(candidate, inner)) {
260
+ for (let round = 0; ; round++) {
261
+ const probing: Array<NonNullable<Cooldown["probe"]>> = [];
262
+ for (const candidate of this.candidates) {
263
+ signal?.throwIfAborted();
264
+ const previous = this.cooldowns.get(candidate);
265
+ if (previous && previous.retryAtMs > this.now()) continue;
266
+ if (previous?.probe && this.now() - previous.probe.since < this.probeStaleMs) {
267
+ probing.push(previous.probe);
268
+ continue;
269
+ }
270
+ // One recovery probe after expiry. Healthy endpoints still allow normal
271
+ // concurrency; only a known failed endpoint gets a single probe. A stale
272
+ // one is replaced: it hung, and holding the candidate for it is an outage.
273
+ const probe = previous ? openProbe(this.now()) : undefined;
274
+ if (previous) previous.probe = probe;
275
+ const controller = new AbortController();
276
+ const inner = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
277
+ let emitted = false;
278
+ try {
279
+ for await (const chunk of attempt(candidate, inner)) {
280
+ inner.throwIfAborted();
281
+ if (!emitted) {
282
+ this.recovered(candidate, previous);
283
+ probe?.settle();
284
+ }
285
+ emitted = true;
286
+ yield chunk;
287
+ }
209
288
  inner.throwIfAborted();
210
- emitted = true;
211
- // Only a call that was admitted as a recovery probe (started while on cooldown)
212
- // is authorized to clear that cooldown upon succeeding.
213
- if (startedWithCooldown && this.cooldowns.get(candidate) === previous) {
214
- this.cooldowns.delete(candidate);
289
+ if (!emitted) this.recovered(candidate, previous);
290
+ return;
291
+ } catch (error) {
292
+ signal?.throwIfAborted();
293
+ const kind = classify(error);
294
+ if (kind === "aborted") throw error;
295
+ const configured = this.options.cooldownMs?.[kind];
296
+ const interval = configured === undefined ? DEFAULT_FALLBACK_COOLDOWNS[kind] : configured;
297
+ if (interval === undefined || interval === null) throw error;
298
+ const now = this.now();
299
+ const after = parseRetryAfterMs(error);
300
+ const reset = error instanceof ProviderError ? error.resetAtMs : undefined;
301
+ const hints = [reset, after === undefined ? undefined : now + after].filter(
302
+ (value): value is number => value !== undefined && Number.isFinite(value),
303
+ );
304
+ const retryAtMs = Math.max(
305
+ now,
306
+ ...(hints.length ? hints : [now + interval]),
307
+ this.cooldowns.get(candidate)?.retryAtMs ?? 0,
308
+ );
309
+ const window = error instanceof ProviderError ? error.window : undefined;
310
+ this.cooldowns.set(candidate, { retryAtMs, kind, window });
311
+ this.options.onCooldown?.({ candidate, error, kind, retryAtMs, window });
312
+ hasFailed = true;
313
+ lastError = error;
314
+ if (emitted) throw error;
315
+ } finally {
316
+ controller.abort();
317
+ // Only the owner releases its probe. Clearing whatever probe was set
318
+ // let a stale probe that ended late free the one that replaced it, and
319
+ // any call that did not fail let a second probe in.
320
+ if (probe) {
321
+ if (previous?.probe === probe) previous.probe = undefined;
322
+ probe.settle();
215
323
  }
216
- yield chunk;
217
- }
218
- inner.throwIfAborted();
219
- if (startedWithCooldown && this.cooldowns.get(candidate) === previous) {
220
- this.cooldowns.delete(candidate);
221
324
  }
222
- return;
223
- } catch (error) {
224
- signal?.throwIfAborted();
225
- const kind = classify(error);
226
- if (kind === "aborted") throw error;
227
- const configured = this.options.cooldownMs?.[kind];
228
- const interval = configured === undefined ? DEFAULT_FALLBACK_COOLDOWNS[kind] : configured;
229
- if (interval === undefined || interval === null) throw error;
230
- const now = this.now();
231
- const after = parseRetryAfterMs(error);
232
- const reset = error instanceof ProviderError ? error.resetAtMs : undefined;
233
- const hints = [reset, after === undefined ? undefined : now + after].filter(
234
- (value): value is number => value !== undefined && Number.isFinite(value),
235
- );
236
- const retryAtMs = Math.max(
237
- now,
238
- ...(hints.length ? hints : [now + interval]),
239
- this.cooldowns.get(candidate)?.retryAtMs ?? 0,
325
+ }
326
+ if (hasFailed) throw lastError;
327
+ // Nothing was free, and a probe is deciding whether something will be.
328
+ // Failing here answered a 0 ms retry, which `withRetry` spent at once: at
329
+ // 70 concurrent calls every one but the probe failed. Wait for a probe to
330
+ // answer or end, or until the oldest goes stale, then look again.
331
+ if (probing.length > 0 && round < PROBE_WAITS) {
332
+ const staleAt = Math.min(...probing.map((probe) => probe.since + this.probeStaleMs));
333
+ await waitForProbe(
334
+ probing.map((probe) => probe.settled),
335
+ staleAt - this.now(),
336
+ signal,
240
337
  );
241
- const window = error instanceof ProviderError ? error.window : undefined;
242
- this.cooldowns.set(candidate, { retryAtMs, kind, window, probing: false });
243
- this.options.onCooldown?.({ candidate, error, kind, retryAtMs, window });
244
- attemptFailed = true;
245
- hasFailed = true;
246
- lastError = error;
247
- if (emitted) throw error;
248
- } finally {
249
- controller.abort();
250
- const current = this.cooldowns.get(candidate);
251
- if (current && current.probing && !attemptFailed) {
252
- current.probing = false;
253
- }
338
+ continue;
254
339
  }
340
+ // Every candidate is cooling, or its probe outlasted the waits; a probe
341
+ // still in flight makes the next opportunity now.
342
+ const retryAtMs = Math.min(
343
+ ...this.status().map((entry) => Math.max(this.now(), entry.retryAtMs)),
344
+ );
345
+ throw new NoAvailableProviderError(retryAtMs, this.now());
346
+ }
347
+ }
348
+
349
+ /** An answer proves the candidate serves again. It lifts the bench this call
350
+ * was admitted under as the probe, and any bench a sibling set while it ran:
351
+ * a call that started before one stray 401 and then answered used to leave
352
+ * a busy primary benched for an hour. Rate and quota stay. A call that
353
+ * started before the throttle proves nothing about the window, and lifting
354
+ * it walks every caller back into the 429. */
355
+ private recovered(candidate: T, admittedUnder: Cooldown | undefined): void {
356
+ const current = this.cooldowns.get(candidate);
357
+ if (!current) return;
358
+ if (current === admittedUnder || (current.kind !== "rate" && current.kind !== "quota")) {
359
+ this.cooldowns.delete(candidate);
255
360
  }
256
- if (hasFailed) throw lastError;
257
- // If all expired candidates are already being probed, the next opportunity
258
- // is now. Other callers need not wait for those probes or start duplicates.
259
- const retryAtMs = Math.min(
260
- ...this.status().map((entry) => Math.max(this.now(), entry.retryAtMs)),
261
- );
262
- throw new NoAvailableProviderError(retryAtMs, this.now());
263
361
  }
264
362
  }
265
363
 
package/src/key-pool.ts CHANGED
@@ -55,12 +55,20 @@ export interface KeyPoolOptions {
55
55
  now?: () => number;
56
56
  }
57
57
 
58
- /** No key can serve right now. `retryAtMs` = when the soonest one is back. */
59
- export class NoAvailableKeyError extends Error {
58
+ /** No key can serve right now. `retryAtMs` = when the soonest one is back.
59
+ *
60
+ * A `ProviderError` of kind `rate` that carries that moment as `resetAtMs` and
61
+ * `retryAfterMs`. As a plain `Error` it classified as `rate` only by its
62
+ * wording and carried no deadline anyone read, so a FallbackPool around the
63
+ * key pool benched it for its default minute, whatever the keys had said. */
64
+ export class NoAvailableKeyError extends ProviderError {
60
65
  readonly retryAtMs: number;
61
66
 
62
- constructor(label: string, retryAtMs: number) {
63
- super(`No ${label} API key available (all rate-limited or denied)`);
67
+ constructor(label: string, retryAtMs: number, now: number) {
68
+ super(label, "rate", `No ${label} API key available (all rate-limited or denied)`, {
69
+ resetAtMs: retryAtMs,
70
+ retryAfterMs: Math.max(0, retryAtMs - now),
71
+ });
64
72
  this.name = "NoAvailableKeyError";
65
73
  this.retryAtMs = retryAtMs;
66
74
  }
@@ -101,7 +109,7 @@ export class KeyPool {
101
109
  */
102
110
  async with<T>(fn: (apiKey: string, tier: KeyTier) => Promise<T>): Promise<T> {
103
111
  const candidates = this.candidates(this.now());
104
- if (candidates.length === 0) throw new NoAvailableKeyError(this.label, this.nextAvailableAt());
112
+ if (candidates.length === 0) throw this.noneAvailable();
105
113
 
106
114
  let last: unknown;
107
115
  for (const key of candidates) {
@@ -112,7 +120,7 @@ export class KeyPool {
112
120
  // The single-key rule: with nothing to rotate to, benching the only key
113
121
  // answers NoAvailableKeyError to every call for the next minute — for a
114
122
  // failure the caller's own retry would have absorbed.
115
- const cooldown = this.size > 1 ? cooldownFor(err) : null;
123
+ const cooldown = this.size > 1 ? cooldownFor(err, this.now()) : null;
116
124
  if (cooldown === null) throw err;
117
125
  key.evictedUntil = this.now() + cooldown.forMs;
118
126
  this.onEvict?.({ tier: key.tier, kind: cooldown.kind, forMs: cooldown.forMs });
@@ -120,8 +128,7 @@ export class KeyPool {
120
128
  }
121
129
 
122
130
  // Every candidate was evicted during this call.
123
- if (this.candidates(this.now()).length === 0)
124
- throw new NoAvailableKeyError(this.label, this.nextAvailableAt());
131
+ if (this.candidates(this.now()).length === 0) throw this.noneAvailable();
125
132
  throw last;
126
133
  }
127
134
 
@@ -138,9 +145,10 @@ export class KeyPool {
138
145
  return out;
139
146
  }
140
147
 
141
- private nextAvailableAt(): number {
148
+ private noneAvailable(): NoAvailableKeyError {
142
149
  const all = this.paid ? [...this.free, this.paid] : this.free;
143
- return Math.min(...all.map((key) => key.evictedUntil));
150
+ const retryAtMs = Math.min(...all.map((key) => key.evictedUntil));
151
+ return new NoAvailableKeyError(this.label, retryAtMs, this.now());
144
152
  }
145
153
  }
146
154
 
@@ -149,15 +157,24 @@ export class KeyPool {
149
157
  * key's fault. Nothing is gained by rotating, and benching would spend the
150
158
  * pool on a request that fails the same way everywhere.
151
159
  */
152
- function cooldownFor(err: unknown): { kind: ErrorKind; forMs: number } | null {
160
+ function cooldownFor(err: unknown, now: number): { kind: ErrorKind; forMs: number } | null {
153
161
  if (!(err instanceof ProviderError)) return null;
154
162
  const clamp = (ms: number) => Math.min(Math.max(ms, MIN_EVICTION_MS), MAX_EVICTION_MS);
155
163
  switch (err.kind) {
156
164
  // The three kinds that are about THIS key.
157
165
  case "rate":
158
166
  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) };
167
+ case "quota": {
168
+ // A spent window names its reset, and that outranks Retry-After: Anthropic
169
+ // sends `retry-after: 60` beside a weekly window, and a key benched for
170
+ // the minute is dialled again into the same wall.
171
+ const reset = err.resetAtMs;
172
+ const forMs =
173
+ reset !== undefined && Number.isFinite(reset)
174
+ ? reset - now
175
+ : (err.retryAfterMs ?? QUOTA_COOLDOWN_MS);
176
+ return { kind: err.kind, forMs: clamp(forMs) };
177
+ }
161
178
  case "auth":
162
179
  // 12 h, not forever: a key is also refused while a billing account is
163
180
  // reinstated, and a pool that drops keys permanently ends up empty.
package/src/presets.ts CHANGED
@@ -373,8 +373,9 @@ export const PROVIDER_PRESETS = {
373
373
  * Go serves three wires, and `routes` picks one from the model id (cc-proxy's
374
374
  * table, from Go's own docs): `minimax-` and `qwen` on Anthropic /messages
375
375
  * (key in `x-api-key`), `gpt-`, `grok-` and `muse-spark-` on /responses, and
376
- * everything else on chat completions. Each model in a chain resolves its
377
- * own route, so one preset serves a chain that crosses wires.
376
+ * everything else on chat completions, `grok-4.5` included. Each model in a
377
+ * chain resolves its own route, so one preset serves a chain that crosses
378
+ * wires.
378
379
  *
379
380
  * Muse Contributor, measured 2026-09-28 on /responses: text, images, strict
380
381
  * JSON Schema, parallel tools and tool-result round trips all passed. It
@@ -408,6 +409,15 @@ export const PROVIDER_PRESETS = {
408
409
  baseUrl: "https://opencode.ai/zen/go",
409
410
  auth: "key",
410
411
  },
412
+ {
413
+ // The one Grok Go serves on chat completions, while 4.6 and 4.7 are on
414
+ // /responses: cc-proxy's catalog pins it there as an override of the
415
+ // family rule. It sits above the `grok-` route because the first match
416
+ // wins.
417
+ prefixes: ["grok-4.5"],
418
+ shape: "openai",
419
+ baseUrl: "https://opencode.ai/zen/go/v1",
420
+ },
411
421
  {
412
422
  prefixes: ["gpt-", "grok-", "muse-spark-"],
413
423
  shape: "responses",