@providerkit/core 0.7.1 → 0.8.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 (60) hide show
  1. package/dist/errors.d.ts +6 -0
  2. package/dist/errors.d.ts.map +1 -1
  3. package/dist/errors.js +18 -0
  4. package/dist/errors.js.map +1 -1
  5. package/dist/fallback.d.ts +73 -0
  6. package/dist/fallback.d.ts.map +1 -0
  7. package/dist/fallback.js +182 -0
  8. package/dist/fallback.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/providers/anthropic.d.ts +2 -1
  14. package/dist/providers/anthropic.d.ts.map +1 -1
  15. package/dist/providers/anthropic.js +3 -1
  16. package/dist/providers/anthropic.js.map +1 -1
  17. package/dist/providers/factory.d.ts +2 -1
  18. package/dist/providers/factory.d.ts.map +1 -1
  19. package/dist/providers/factory.js +16 -4
  20. package/dist/providers/factory.js.map +1 -1
  21. package/dist/providers/gemini.d.ts +2 -1
  22. package/dist/providers/gemini.d.ts.map +1 -1
  23. package/dist/providers/gemini.js +3 -1
  24. package/dist/providers/gemini.js.map +1 -1
  25. package/dist/providers/openai.d.ts +2 -1
  26. package/dist/providers/openai.d.ts.map +1 -1
  27. package/dist/providers/openai.js +3 -1
  28. package/dist/providers/openai.js.map +1 -1
  29. package/dist/providers/responses.d.ts +2 -1
  30. package/dist/providers/responses.d.ts.map +1 -1
  31. package/dist/providers/responses.js +3 -1
  32. package/dist/providers/responses.js.map +1 -1
  33. package/dist/rate-limit.d.ts +3 -0
  34. package/dist/rate-limit.d.ts.map +1 -1
  35. package/dist/rate-limit.js +16 -0
  36. package/dist/rate-limit.js.map +1 -1
  37. package/dist/retry.d.ts +2 -0
  38. package/dist/retry.d.ts.map +1 -1
  39. package/dist/retry.js +13 -5
  40. package/dist/retry.js.map +1 -1
  41. package/dist/transport.d.ts.map +1 -1
  42. package/dist/transport.js +5 -2
  43. package/dist/transport.js.map +1 -1
  44. package/dist/types.d.ts +8 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/types.js +5 -1
  47. package/dist/types.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/errors.ts +19 -0
  50. package/src/fallback.ts +228 -0
  51. package/src/index.ts +1 -0
  52. package/src/providers/anthropic.ts +4 -2
  53. package/src/providers/factory.ts +19 -5
  54. package/src/providers/gemini.ts +5 -2
  55. package/src/providers/openai.ts +5 -2
  56. package/src/providers/responses.ts +5 -2
  57. package/src/rate-limit.ts +22 -0
  58. package/src/retry.ts +14 -5
  59. package/src/transport.ts +5 -2
  60. package/src/types.ts +9 -1
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,2EAA2E;AAC3E,mEAAmE;AACnE,EAAE;AACF,8EAA8E;AAC9E,8EAA8E;AAC9E,6EAA6E;AAC7E,0BAA0B;AAE1B;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAU,CAAC;AAGzE,2EAA2E;AAC3E,MAAM,UAAU,QAAQ,CAAC,KAAa;IACpC,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,KAAK,KAAK,CAAC,CAAC;AACpD,CAAC;AA4GD,MAAM,CAAC,MAAM,WAAW,GAAe;IACrC,WAAW,EAAE,CAAC;IACd,iBAAiB,EAAE,CAAC;IACpB,YAAY,EAAE,CAAC;CAChB,CAAC;AAwGF;kEACkE;AAClE,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,MAAoC,EACpC,KAAa;IAEb,IAAI,IAAI,GAAG,EAAE,CAAC;IACd,IAAI,SAAS,GAAG,EAAE,CAAC;IACnB,4EAA4E;IAC5E,4EAA4E;IAC5E,yEAAyE;IACzE,6DAA6D;IAC7D,IAAI,gBAAuC,CAAC;IAC5C,IAAI,KAAK,GAAe,WAAW,CAAC;IACpC,IAAI,YAAY,GAAwB,IAAI,CAAC;IAC7C,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QACjC,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YAC3B,IAAI,KAAK,CAAC,OAAO;gBAAE,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC;YACzC,IAAI,KAAK,CAAC,SAAS;gBAAE,SAAS,IAAI,KAAK,CAAC,SAAS,CAAC;YAClD,IAAI,KAAK,CAAC,gBAAgB,EAAE,MAAM;gBAAE,gBAAgB,GAAG,KAAK,CAAC,gBAAgB,CAAC;QAChF,CAAC;aAAM,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;YACjD,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC;QACtB,CAAC;aAAM,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC;YACzD,YAAY,GAAG,KAAK,CAAC,YAAY,CAAC;QACpC,CAAC;IACH,CAAC;IACD,OAAO;QACL,IAAI;QACJ,SAAS;QACT,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjD,KAAK;QACL,YAAY;QACZ,KAAK;KACN,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,cAAc,CAAC,QAAgC;IAC7D,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;QAC9B,IAAI,OAAO,CAAC,IAAI,KAAK,WAAW;YAAE,OAAO,OAAO,CAAC;QACjD,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS,IAAI,OAAO,CAAC,gBAAgB,KAAK,SAAS;YAAE,OAAO,OAAO,CAAC;QAC9F,yEAAyE;QACzE,yEAAyE;QACzE,iEAAiE;QACjE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,gBAAgB,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,CAAC;QAC1E,OAAO,IAAI,CAAC;IACd,CAAC,CAAC,CAAC;AACL,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,SAAS,CAAC,IAAe;IACvC,OAAO,QAAQ,IAAI,CAAC,QAAQ,WAAW,IAAI,CAAC,IAAI,EAAE,CAAC;AACrD,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@providerkit/core",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",
package/src/errors.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  // read before the status — status alone gives the wrong advice ("retry" for an
11
11
  // empty balance, "check your key" for a plan that never included the API).
12
12
 
13
+ import { parseUsageLimitBody, type RateLimitWindow } from "./rate-limit.ts";
14
+
13
15
  /** What kind of failure this is, named by what actually fixes it. */
14
16
  export type ErrorKind =
15
17
  /** Our own cancel — the caller pressed Stop. Never retried. */
@@ -67,6 +69,9 @@ export class ProviderError extends Error {
67
69
  /** Honoured when the provider said how long to wait (Retry-After, or
68
70
  * Gemini's RetryInfo.retryDelay). */
69
71
  readonly retryAfterMs?: number;
72
+ /** Absolute reset of the binding limit. Can be days beyond Retry-After. */
73
+ readonly resetAtMs?: number;
74
+ readonly window?: RateLimitWindow;
70
75
  /** The provider's own response body, truncated — the actual reason, which is
71
76
  * otherwise lost behind "400 status code (no body)". */
72
77
  readonly body?: string;
@@ -79,6 +84,8 @@ export class ProviderError extends Error {
79
84
  status?: number;
80
85
  code?: string;
81
86
  retryAfterMs?: number;
87
+ resetAtMs?: number;
88
+ window?: RateLimitWindow;
82
89
  body?: string;
83
90
  cause?: unknown;
84
91
  } = {},
@@ -90,6 +97,8 @@ export class ProviderError extends Error {
90
97
  this.status = opts.status;
91
98
  this.code = opts.code;
92
99
  this.retryAfterMs = opts.retryAfterMs;
100
+ this.resetAtMs = opts.resetAtMs;
101
+ this.window = opts.window;
93
102
  this.body = opts.body;
94
103
  }
95
104
 
@@ -113,6 +122,7 @@ export class ProviderError extends Error {
113
122
  status,
114
123
  code,
115
124
  retryAfterMs: parseRetryAfterMs(err, body),
125
+ ...parseUsageLimitBody(body),
116
126
  body: body.slice(0, 2_000) || undefined,
117
127
  cause: err,
118
128
  });
@@ -411,7 +421,13 @@ export function classify(err: unknown, status?: number, body?: string): ErrorKin
411
421
  * backoff shorter than the window just burns an attempt.
412
422
  */
413
423
  export function parseRetryAfterMs(err: unknown, body?: string): number | undefined {
424
+ // Adapters already normalized the response headers. Re-parsing only the body
425
+ // discarded this property and made retries ignore their own Retry-After.
426
+ const normalized = readNumber(err, "retryAfterMs");
427
+ if (normalized !== undefined && Number.isFinite(normalized) && normalized >= 0) return normalized;
414
428
  const text = body ?? bodyTextOf(err);
429
+ const usageLimit = parseUsageLimitBody(text);
430
+ if (usageLimit.retryAfterMs !== undefined) return usageLimit.retryAfterMs;
415
431
  const delay = text.match(/"?retryDelay"?\s*[:=]\s*"?(\d+(?:\.\d+)?)s"?/i);
416
432
  if (delay?.[1]) return Math.round(parseFloat(delay[1]) * 1000);
417
433
 
@@ -467,6 +483,7 @@ export function streamError(provider: string, error: unknown): ProviderError {
467
483
  ...(status !== undefined ? { status } : {}),
468
484
  ...(code ? { code } : {}),
469
485
  retryAfterMs: parseRetryAfterMs(error, body),
486
+ ...parseUsageLimitBody(body),
470
487
  body: body.slice(0, 2_000),
471
488
  },
472
489
  );
@@ -482,6 +499,8 @@ export function describeProviderError(err: unknown): Record<string, unknown> {
482
499
  status: err.status,
483
500
  code: err.code,
484
501
  retryAfterMs: err.retryAfterMs,
502
+ resetAtMs: err.resetAtMs,
503
+ window: err.window,
485
504
  error: err.message,
486
505
  body: err.body,
487
506
  };
@@ -0,0 +1,228 @@
1
+ import { classify, parseRetryAfterMs, ProviderError, type ErrorKind } from "./errors.ts";
2
+ import type { RateLimitWindow } from "./rate-limit.ts";
3
+ import type { Provider, ProviderChunk } from "./types.ts";
4
+
5
+ /** Retry intervals when the server supplies no deadline, not estimates of when
6
+ * it will recover. A server deadline always wins over these defaults. */
7
+ export const DEFAULT_FALLBACK_COOLDOWNS: Readonly<Partial<Record<ErrorKind, number>>> = Object.freeze({
8
+ rate: 60_000,
9
+ quota: 3_600_000,
10
+ timeout: 30_000,
11
+ network: 30_000,
12
+ overload: 30_000,
13
+ auth: 3_600_000,
14
+ entitlement: 3_600_000,
15
+ model: 3_600_000,
16
+ });
17
+
18
+ export interface FallbackOptions<T> {
19
+ /** Per-kind intervals in ms; null stops fallback for that kind. Adding a kind
20
+ * opts it into fallback. Cancellation is never overridable. */
21
+ cooldownMs?: Partial<Record<ErrorKind, number | null>>;
22
+ onCooldown?: (info: {
23
+ candidate: T;
24
+ error: unknown;
25
+ kind: ErrorKind;
26
+ retryAtMs: number;
27
+ window?: RateLimitWindow;
28
+ }) => void;
29
+ /** Inject a clock for deterministic tests. */
30
+ now?: () => number;
31
+ }
32
+
33
+ /** Standard options that any provider config can accept to configure fallbacks inline. */
34
+ export interface ProviderFallbackConfig {
35
+ /** Secondary and tertiary providers to try if this provider fails/exhausts. */
36
+ fallbacks?: readonly Provider[];
37
+ /** Custom cooldown or telemetry options for the fallback pool. */
38
+ fallbackOptions?: FallbackOptions<Provider>;
39
+ }
40
+
41
+ /**
42
+ * Wrap a primary provider with its fallbacks if any are specified.
43
+ * Returns the primary provider untouched when fallbacks is empty or omitted.
44
+ */
45
+ export function withConfiguredFallbacks(
46
+ primary: Provider,
47
+ config?: ProviderFallbackConfig,
48
+ ): Provider {
49
+ if (!config?.fallbacks || config.fallbacks.length === 0) return primary;
50
+ return withFallbackProviders([primary, ...config.fallbacks], config.fallbackOptions);
51
+ }
52
+
53
+ interface Cooldown {
54
+ retryAtMs: number;
55
+ kind: ErrorKind;
56
+ window?: RateLimitWindow;
57
+ probing: boolean;
58
+ }
59
+
60
+ /** The whole chain is unavailable. Fail promptly instead of holding a request
61
+ * open for hours. `retryAtMs` names its next opportunity, not a sleep to cap. */
62
+ export class NoAvailableProviderError extends ProviderError {
63
+ readonly retryAtMs: number;
64
+
65
+ constructor(retryAtMs: number, now: number) {
66
+ super("fallback", "rate", "All configured providers are unavailable. Retry after the cooldown.", {
67
+ resetAtMs: retryAtMs,
68
+ retryAfterMs: Math.max(0, retryAtMs - now),
69
+ });
70
+ this.name = "NoAvailableProviderError";
71
+ this.retryAtMs = retryAtMs;
72
+ }
73
+ }
74
+
75
+ /** Ordered candidates with cooldowns shared across calls. Keep one pool alive
76
+ * per credential set: rebuilding it per request forgets a weekly lockout.
77
+ * State is in-memory and process-local; `status` and `reset` expose it without
78
+ * a background timer, vendor SDK, database or global singleton. */
79
+ export class FallbackPool<T> {
80
+ private readonly candidates: readonly T[];
81
+ private readonly cooldowns = new Map<T, Cooldown>();
82
+ private readonly now: () => number;
83
+
84
+ constructor(candidates: readonly T[], private readonly options: FallbackOptions<T> = {}) {
85
+ if (candidates.length === 0) throw new Error("providerkit: supply a primary before its backups");
86
+ if (new Set(candidates).size !== candidates.length) {
87
+ throw new Error("providerkit: each fallback candidate must appear only once");
88
+ }
89
+ for (const value of Object.values(options.cooldownMs ?? {})) {
90
+ if (value !== null && value !== undefined && (!Number.isFinite(value) || value < 0)) {
91
+ throw new Error("providerkit: fallback cooldowns must be finite, non-negative milliseconds");
92
+ }
93
+ }
94
+ this.candidates = [...candidates];
95
+ this.now = options.now ?? Date.now;
96
+ }
97
+
98
+ /** Clear a cooldown after replacing a key or fixing configuration. Omit the
99
+ * candidate to clear all. Does not interrupt calls already in flight. */
100
+ reset(...selection: [] | [T]): void {
101
+ if (selection.length === 0) this.cooldowns.clear();
102
+ else this.cooldowns.delete(selection[0]);
103
+ }
104
+
105
+ status(): Array<{ candidate: T; retryAtMs: number; probing: boolean; kind?: ErrorKind; window?: RateLimitWindow }> {
106
+ return this.candidates.map((candidate) => ({
107
+ candidate,
108
+ retryAtMs: 0,
109
+ probing: false,
110
+ ...this.cooldowns.get(candidate),
111
+ }));
112
+ }
113
+
114
+ /** Buffered calls only: do not wrap an agent turn that has run side effects. */
115
+ async with<R>(attempt: (candidate: T, signal: AbortSignal) => Promise<R>, signal?: AbortSignal): Promise<R> {
116
+ for await (const result of this.stream(async function* (candidate, inner) {
117
+ yield await attempt(candidate, inner);
118
+ }, signal)) return result;
119
+ throw new Error("providerkit: fallback attempt ended without a result");
120
+ }
121
+
122
+ /** Only a pre-output failure can switch provider. A later failure still
123
+ * cools the endpoint for FUTURE calls but never restarts the current answer. */
124
+ async *stream<R>(
125
+ attempt: (candidate: T, signal: AbortSignal) => AsyncIterable<R>,
126
+ signal?: AbortSignal,
127
+ ): AsyncGenerator<R> {
128
+ signal?.throwIfAborted();
129
+ let hasFailed = false;
130
+ let lastError: unknown;
131
+ for (const candidate of this.candidates) {
132
+ signal?.throwIfAborted();
133
+ const previous = this.cooldowns.get(candidate);
134
+ if (previous && (previous.retryAtMs > this.now() || previous.probing)) continue;
135
+ // One recovery probe after expiry. Healthy endpoints still allow normal
136
+ // concurrency; only a known failed endpoint gets a single probe.
137
+ if (previous) previous.probing = true;
138
+ const controller = new AbortController();
139
+ const inner = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
140
+ let emitted = false;
141
+ let attemptFailed = false;
142
+ const startedWithCooldown = previous !== undefined;
143
+ try {
144
+ for await (const chunk of attempt(candidate, inner)) {
145
+ inner.throwIfAborted();
146
+ emitted = true;
147
+ // Only a call that was admitted as a recovery probe (started while on cooldown)
148
+ // is authorized to clear that cooldown upon succeeding.
149
+ if (startedWithCooldown && this.cooldowns.get(candidate) === previous) {
150
+ this.cooldowns.delete(candidate);
151
+ }
152
+ yield chunk;
153
+ }
154
+ inner.throwIfAborted();
155
+ if (startedWithCooldown && this.cooldowns.get(candidate) === previous) {
156
+ this.cooldowns.delete(candidate);
157
+ }
158
+ return;
159
+ } catch (error) {
160
+ signal?.throwIfAborted();
161
+ const kind = classify(error);
162
+ if (kind === "aborted") throw error;
163
+ const configured = this.options.cooldownMs?.[kind];
164
+ const interval = configured === undefined ? DEFAULT_FALLBACK_COOLDOWNS[kind] : configured;
165
+ if (interval === undefined || interval === null) throw error;
166
+ const now = this.now();
167
+ const after = parseRetryAfterMs(error);
168
+ const reset = error instanceof ProviderError ? error.resetAtMs : undefined;
169
+ const hints = [reset, after === undefined ? undefined : now + after].filter(
170
+ (value): value is number => value !== undefined && Number.isFinite(value),
171
+ );
172
+ const retryAtMs = Math.max(
173
+ now,
174
+ ...(hints.length ? hints : [now + interval]),
175
+ this.cooldowns.get(candidate)?.retryAtMs ?? 0,
176
+ );
177
+ const window = error instanceof ProviderError ? error.window : undefined;
178
+ this.cooldowns.set(candidate, { retryAtMs, kind, window, probing: false });
179
+ this.options.onCooldown?.({ candidate, error, kind, retryAtMs, window });
180
+ attemptFailed = true;
181
+ hasFailed = true;
182
+ lastError = error;
183
+ if (emitted) throw error;
184
+ } finally {
185
+ controller.abort();
186
+ const current = this.cooldowns.get(candidate);
187
+ if (current && current.probing && !attemptFailed) {
188
+ current.probing = false;
189
+ }
190
+ }
191
+ }
192
+ if (hasFailed) throw lastError;
193
+ // If all expired candidates are already being probed, the next opportunity
194
+ // is now. Other callers need not wait for those probes or start duplicates.
195
+ const retryAtMs = Math.min(...this.status().map((entry) => Math.max(this.now(), entry.retryAtMs)));
196
+ throw new NoAvailableProviderError(retryAtMs, this.now());
197
+ }
198
+ }
199
+
200
+ export interface FallbackProvider extends Provider {
201
+ readonly fallbacks: FallbackPool<Provider>;
202
+ }
203
+
204
+ /** Compose bound provider/model pairs. Identity names the configured primary;
205
+ * chunk.source names the endpoint that answered. Per-call model overrides
206
+ * apply ONLY to the primary because model ids are endpoint-specific.
207
+ * Keep this provider alive across calls so its cooldowns survive. */
208
+ export function withFallbackProviders(
209
+ providers: readonly Provider[],
210
+ options: FallbackOptions<Provider> = {},
211
+ ): FallbackProvider {
212
+ const fallbacks = new FallbackPool(providers, options);
213
+ const primary = providers[0]!;
214
+ return {
215
+ id: primary.id,
216
+ model: primary.model,
217
+ fallbacks,
218
+ createStream(messages, tools, opts = {}) {
219
+ return fallbacks.stream(async function* (provider, signal): AsyncGenerator<ProviderChunk> {
220
+ const { model: override, ...rest } = opts;
221
+ const model = provider === primary ? (override ?? provider.model) : provider.model;
222
+ for await (const chunk of provider.createStream(messages, tools, { ...rest, model, signal })) {
223
+ yield { ...chunk, source: chunk.source ?? { provider: provider.id, model } };
224
+ }
225
+ }, opts.signal);
226
+ },
227
+ };
228
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./types.ts";
2
2
  export * from "./errors.ts";
3
3
  export * from "./retry.ts";
4
+ export * from "./fallback.ts";
4
5
  export * from "./key-pool.ts";
5
6
  export * from "./watchdog.ts";
6
7
  export * from "./usage.ts";
@@ -3,6 +3,7 @@ import { streamError } from "../errors.ts";
3
3
  import { schemaPrompt } from "../schema.ts";
4
4
  import { parseToolArgs } from "../tool-args.ts";
5
5
  import { streamSse, apiUrl } from "../transport.ts";
6
+ import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
6
7
  import type {
7
8
  JsonOutput,
8
9
  ChatMessage,
@@ -15,7 +16,7 @@ import type {
15
16
  ToolDefinition,
16
17
  } from "../types.ts";
17
18
 
18
- export interface AnthropicConfig {
19
+ export interface AnthropicConfig extends ProviderFallbackConfig {
19
20
  apiKey: string;
20
21
  model: string;
21
22
  /** Any endpoint speaking the Anthropic Messages dialect — a proxy or gateway.
@@ -219,7 +220,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
219
220
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
220
221
  const id = config.id ?? "anthropic";
221
222
 
222
- return {
223
+ const provider: Provider = {
223
224
  id,
224
225
  model: config.model,
225
226
 
@@ -382,4 +383,5 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
382
383
  }
383
384
  },
384
385
  };
386
+ return withConfiguredFallbacks(provider, config);
385
387
  }
@@ -21,8 +21,9 @@ import {
21
21
  type ProviderPresetId,
22
22
  } from "../presets.ts";
23
23
  import type { Effort, Provider } from "../types.ts";
24
+ import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
24
25
 
25
- export interface PresetProviderConfig {
26
+ export interface PresetProviderConfig extends ProviderFallbackConfig {
26
27
  /** The credential: an API key for `key`/`bearer` presets, an ACCESS TOKEN
27
28
  * for `oauth` ones. Acquiring the token is the caller's job. */
28
29
  apiKey: string;
@@ -58,11 +59,15 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
58
59
  }
59
60
  const headers = { ...preset.headers, ...config.headers };
60
61
 
62
+ const { fallbacks: _fallbacks, fallbackOptions: _fallbackOptions, ...baseConfig } = config;
63
+
64
+ let provider: Provider;
61
65
  switch (preset.shape) {
62
66
  case "anthropic":
63
67
  // `key` rides Anthropic's native x-api-key; coding plans and OAuth
64
68
  // tokens read Bearer (measured across the family's coding endpoints).
65
- return createAnthropicProvider({
69
+ provider = createAnthropicProvider({
70
+ ...baseConfig,
66
71
  apiKey: config.apiKey,
67
72
  model,
68
73
  effort: config.effort,
@@ -76,8 +81,10 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
76
81
  ...(preset.explicitNone ? { explicitNone: true } : {}),
77
82
  fetchImpl: config.fetchImpl,
78
83
  });
84
+ break;
79
85
  case "openai":
80
- return createOpenAIProvider({
86
+ provider = createOpenAIProvider({
87
+ ...baseConfig,
81
88
  id,
82
89
  apiKey: config.apiKey,
83
90
  model,
@@ -88,8 +95,10 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
88
95
  ...(config.providerOrder ? { providerOrder: config.providerOrder } : {}),
89
96
  fetchImpl: config.fetchImpl,
90
97
  });
98
+ break;
91
99
  case "responses":
92
- return createResponsesProvider({
100
+ provider = createResponsesProvider({
101
+ ...baseConfig,
93
102
  apiKey: config.apiKey,
94
103
  model,
95
104
  effort: config.effort,
@@ -99,8 +108,10 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
99
108
  headers,
100
109
  fetchImpl: config.fetchImpl,
101
110
  });
111
+ break;
102
112
  case "gemini":
103
- return createGeminiProvider({
113
+ provider = createGeminiProvider({
114
+ ...baseConfig,
104
115
  apiKey: config.apiKey,
105
116
  model,
106
117
  effort: config.effort,
@@ -109,5 +120,8 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
109
120
  headers,
110
121
  fetchImpl: config.fetchImpl,
111
122
  });
123
+ break;
112
124
  }
125
+
126
+ return withConfiguredFallbacks(provider, config);
113
127
  }
@@ -9,6 +9,7 @@ import { streamError } from "../errors.ts";
9
9
  import { schemaPrompt } from "../schema.ts";
10
10
  import { parseToolArgs } from "../tool-args.ts";
11
11
  import { streamSse, apiUrl } from "../transport.ts";
12
+ import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
12
13
  import type {
13
14
  ChatMessage,
14
15
  Effort,
@@ -22,7 +23,7 @@ import type {
22
23
  ToolDefinition,
23
24
  } from "../types.ts";
24
25
 
25
- export interface GeminiConfig {
26
+ export interface GeminiConfig extends ProviderFallbackConfig {
26
27
  apiKey: string;
27
28
  model: string;
28
29
  /** Any endpoint speaking the Generative Language REST dialect — a proxy or
@@ -247,7 +248,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
247
248
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
248
249
  const id = config.id ?? "gemini";
249
250
 
250
- return {
251
+ const provider: Provider = {
251
252
  id,
252
253
  model: config.model,
253
254
 
@@ -409,4 +410,6 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
409
410
  }
410
411
  },
411
412
  };
413
+
414
+ return withConfiguredFallbacks(provider, config);
412
415
  }
@@ -18,8 +18,9 @@ import type {
18
18
  } from "../types.ts";
19
19
  import { toDataUri } from "../types.ts";
20
20
  import { isStrictSchema, schemaPrompt } from "../schema.ts";
21
+ import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
21
22
 
22
- export interface OpenAIConfig {
23
+ export interface OpenAIConfig extends ProviderFallbackConfig {
23
24
  apiKey: string;
24
25
  model: string;
25
26
  /** Any OpenAI-compatible endpoint. Defaults to OpenAI itself. */
@@ -305,7 +306,7 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
305
306
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
306
307
  const id = config.id ?? "openai";
307
308
 
308
- return {
309
+ const provider: Provider = {
309
310
  id,
310
311
  model: config.model,
311
312
 
@@ -489,4 +490,6 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
489
490
  }
490
491
  },
491
492
  };
493
+
494
+ return withConfiguredFallbacks(provider, config);
492
495
  }
@@ -25,8 +25,9 @@ import type {
25
25
  } from "../types.ts";
26
26
  import { toDataUri } from "../types.ts";
27
27
  import { isStrictSchema } from "../schema.ts";
28
+ import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
28
29
 
29
- export interface ResponsesConfig {
30
+ export interface ResponsesConfig extends ProviderFallbackConfig {
30
31
  apiKey: string;
31
32
  model: string;
32
33
  /** Any endpoint speaking the Responses format. Defaults to OpenAI itself. */
@@ -226,7 +227,7 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
226
227
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
227
228
  const id = config.id ?? "openai-responses";
228
229
 
229
- return {
230
+ const provider: Provider = {
230
231
  id,
231
232
  model: config.model,
232
233
 
@@ -463,4 +464,6 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
463
464
  // included, was already yielded as it arrived.
464
465
  },
465
466
  };
467
+
468
+ return withConfiguredFallbacks(provider, config);
466
469
  }
package/src/rate-limit.ts CHANGED
@@ -215,3 +215,25 @@ export function parseUsageLimitBody(bodyText: string, now = Date.now()): RateLim
215
215
  }
216
216
  return result;
217
217
  }
218
+
219
+ /** Read both places an endpoint can report its reset. A short Retry-After is
220
+ * not evidence that a longer subscription window has lifted. */
221
+ export function parseRateLimitResponse(
222
+ headers: Headers,
223
+ bodyText: string,
224
+ now = Date.now(),
225
+ ): RateLimitReset {
226
+ const header = parseRateLimitReset(headers, now);
227
+ const body = parseUsageLimitBody(bodyText, now);
228
+ const resetAtMs = Math.max(header.resetAtMs ?? 0, body.resetAtMs ?? 0);
229
+ const retryAfterMs = header.retryAfterMs ?? body.retryAfterMs;
230
+ const window =
231
+ body.resetAtMs !== undefined && body.resetAtMs > (header.resetAtMs ?? 0)
232
+ ? body.window
233
+ : (header.window ?? body.window);
234
+ return {
235
+ ...(resetAtMs > 0 ? { resetAtMs } : {}),
236
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
237
+ ...(window ? { window } : {}),
238
+ };
239
+ }
package/src/retry.ts CHANGED
@@ -13,12 +13,14 @@
13
13
  //
14
14
  // 3. Honour the provider's own number. When it says `Retry-After: 30`, a
15
15
  // one-second backoff is three wasted attempts before the same wait.
16
- import { classify, isBackupEligible, isTransient, parseRetryAfterMs } from "./errors.ts";
16
+ import { classify, isBackupEligible, isTransient, parseRetryAfterMs, ProviderError } from "./errors.ts";
17
17
 
18
18
  export interface RetryOptions {
19
19
  /** Total attempts including the first. Default 3. */
20
20
  maxAttempts?: number;
21
21
  baseDelayMs?: number;
22
+ /** Maximum acceptable wait. A longer server deadline ends retry so an outer
23
+ * fallback can switch provider; it is never shortened to retry too early. */
22
24
  maxDelayMs?: number;
23
25
  /** Aborts the wait as well as the work, so Stop lands promptly. */
24
26
  signal?: AbortSignal;
@@ -67,12 +69,17 @@ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
67
69
  });
68
70
  }
69
71
 
70
- /** The delay before the next attempt: the provider's own figure when it gave
71
- * one, capped, else full-jitter backoff. */
72
- function delayFor(err: unknown, attempt: number, opts: RetryOptions): number {
72
+ /** null means the server deadline exceeds this call's wait budget. */
73
+ function delayFor(err: unknown, attempt: number, opts: RetryOptions): number | null {
73
74
  const asked = parseRetryAfterMs(err);
74
75
  const cap = opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
75
- if (asked !== undefined) return Math.min(asked, cap);
76
+ const resetWait = err instanceof ProviderError && err.resetAtMs !== undefined
77
+ ? Math.max(0, err.resetAtMs - Date.now())
78
+ : undefined;
79
+ if (asked !== undefined || resetWait !== undefined) {
80
+ const wait = Math.max(asked ?? 0, resetWait ?? 0);
81
+ return wait > cap ? null : wait;
82
+ }
76
83
  return backoffMs(attempt, opts.baseDelayMs ?? DEFAULT_BASE_DELAY_MS, cap);
77
84
  }
78
85
 
@@ -101,6 +108,7 @@ export async function withRetry<T>(
101
108
  if (opts.signal?.aborted) throw err;
102
109
  if (attempt >= maxAttempts || !shouldRetry(err, attempt)) throw err;
103
110
  const delayMs = delayFor(err, attempt, opts);
111
+ if (delayMs === null) throw err;
104
112
  opts.onRetry?.({ error: err, attempt, delayMs });
105
113
  await nap(delayMs, opts.signal);
106
114
  }
@@ -149,6 +157,7 @@ export async function* withStreamRetry<T>(
149
157
  // Rule 2: past the first chunk there is no going back.
150
158
  if (emitted || attempt >= maxAttempts || !shouldRetry(err, attempt)) throw err;
151
159
  const delayMs = delayFor(err, attempt, opts);
160
+ if (delayMs === null) throw err;
152
161
  opts.onRetry?.({ error: err, attempt, delayMs });
153
162
  await nap(delayMs, opts.signal);
154
163
  } finally {
package/src/transport.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  //
5
5
  // Adapters keep only their per-event mapping; everything about being an HTTP
6
6
  // client lives here once.
7
- import { classifyHttp, isTransportFailure, ProviderError } from "./errors.ts";
7
+ import { classifyHttp, isTransportFailure, parseRetryAfterMs, ProviderError } from "./errors.ts";
8
+ import { parseRateLimitResponse } from "./rate-limit.ts";
8
9
 
9
10
  export interface RequestInit_ {
10
11
  url: string;
@@ -63,12 +64,14 @@ export function retryAfterFromHeaders(headers: Headers, now = Date.now()): numbe
63
64
  async function errorFor(provider: string, res: Response): Promise<ProviderError> {
64
65
  const text = await res.text().catch(() => "");
65
66
  const kind = classifyHttp(res.status, text);
67
+ const reset = parseRateLimitResponse(res.headers, text);
66
68
  const message = text
67
69
  ? `${provider} ${res.status}: ${text.slice(0, 500)}`
68
70
  : `${provider} ${res.status} ${res.statusText}`;
69
71
  return new ProviderError(provider, kind, message, {
70
72
  status: res.status,
71
- retryAfterMs: retryAfterFromHeaders(res.headers),
73
+ ...reset,
74
+ retryAfterMs: retryAfterFromHeaders(res.headers) ?? parseRetryAfterMs({}, text),
72
75
  body: text.slice(0, 2_000) || undefined,
73
76
  });
74
77
  }
package/src/types.ts CHANGED
@@ -139,6 +139,9 @@ export const EMPTY_USAGE: TokenUsage = {
139
139
  /** Normalized streaming chunk — every provider's shape collapses into this. */
140
140
  export interface ProviderChunk {
141
141
  type: "delta" | "usage" | "finish";
142
+ /** The selected endpoint and model when a fallback provider emits this chunk.
143
+ * Per-call metadata, never shared mutable state on the provider object. */
144
+ source?: { provider: string; model: string };
142
145
  content?: string;
143
146
  reasoning?: string;
144
147
  /** OpenRouter's normalized reasoning payload — hand it back on the next
@@ -230,6 +233,8 @@ export interface Provider {
230
233
 
231
234
  export interface Completion {
232
235
  text: string;
236
+ /** Present when the stream identifies the endpoint that answered. */
237
+ provider?: string;
233
238
  reasoning: string;
234
239
  /** Present only where the provider sent one. See ChatMessage.reasoningDetails. */
235
240
  reasoningDetails?: unknown[];
@@ -253,7 +258,9 @@ export async function drainStream(
253
258
  let reasoningDetails: unknown[] | undefined;
254
259
  let usage: TokenUsage = EMPTY_USAGE;
255
260
  let finishReason: FinishReason | null = null;
261
+ let source: ProviderChunk["source"];
256
262
  for await (const chunk of stream) {
263
+ if (chunk.source) source = chunk.source;
257
264
  if (chunk.type === "delta") {
258
265
  if (chunk.content) text += chunk.content;
259
266
  if (chunk.reasoning) reasoning += chunk.reasoning;
@@ -270,7 +277,8 @@ export async function drainStream(
270
277
  ...(reasoningDetails ? { reasoningDetails } : {}),
271
278
  usage,
272
279
  finishReason,
273
- model,
280
+ model: source?.model ?? model,
281
+ ...(source ? { provider: source.provider } : {}),
274
282
  };
275
283
  }
276
284