@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.
- package/dist/errors.d.ts +6 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -1
- package/dist/fallback.d.ts +73 -0
- package/dist/fallback.d.ts.map +1 -0
- package/dist/fallback.js +182 -0
- package/dist/fallback.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/anthropic.d.ts +2 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +3 -1
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/factory.d.ts +2 -1
- package/dist/providers/factory.d.ts.map +1 -1
- package/dist/providers/factory.js +16 -4
- package/dist/providers/factory.js.map +1 -1
- package/dist/providers/gemini.d.ts +2 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +3 -1
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +2 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +3 -1
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +2 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +3 -1
- package/dist/providers/responses.js.map +1 -1
- package/dist/rate-limit.d.ts +3 -0
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +16 -0
- package/dist/rate-limit.js.map +1 -1
- package/dist/retry.d.ts +2 -0
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +13 -5
- package/dist/retry.js.map +1 -1
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +5 -2
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +5 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/errors.ts +19 -0
- package/src/fallback.ts +228 -0
- package/src/index.ts +1 -0
- package/src/providers/anthropic.ts +4 -2
- package/src/providers/factory.ts +19 -5
- package/src/providers/gemini.ts +5 -2
- package/src/providers/openai.ts +5 -2
- package/src/providers/responses.ts +5 -2
- package/src/rate-limit.ts +22 -0
- package/src/retry.ts +14 -5
- package/src/transport.ts +5 -2
- 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;
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
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
|
};
|
package/src/fallback.ts
ADDED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
}
|
package/src/providers/factory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/providers/gemini.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/providers/openai.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|