@providerkit/core 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -7
- package/dist/auth/flows.d.ts.map +1 -1
- package/dist/auth/flows.js +5 -5
- package/dist/auth/flows.js.map +1 -1
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/oauth.d.ts +12 -0
- package/dist/auth/oauth.d.ts.map +1 -1
- package/dist/auth/oauth.js +19 -1
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/token-source.d.ts +9 -3
- package/dist/auth/token-source.d.ts.map +1 -1
- package/dist/auth/token-source.js +24 -9
- package/dist/auth/token-source.js.map +1 -1
- package/dist/auth/with-token.d.ts +32 -0
- package/dist/auth/with-token.d.ts.map +1 -0
- package/dist/auth/with-token.js +88 -0
- package/dist/auth/with-token.js.map +1 -0
- package/dist/context.d.ts +39 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +119 -0
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +12 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +44 -5
- package/dist/errors.js.map +1 -1
- package/dist/fallback.d.ts +45 -1
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +138 -75
- package/dist/fallback.js.map +1 -1
- package/dist/gate.d.ts +29 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +96 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/key-pool.d.ts +21 -4
- package/dist/key-pool.d.ts.map +1 -1
- package/dist/key-pool.js +43 -12
- package/dist/key-pool.js.map +1 -1
- package/dist/presets.d.ts +7 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +12 -2
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts +7 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +83 -26
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/factory.d.ts +26 -0
- package/dist/providers/factory.d.ts.map +1 -1
- package/dist/providers/factory.js +41 -9
- package/dist/providers/factory.js.map +1 -1
- package/dist/providers/gemini.d.ts +6 -0
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +102 -27
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +7 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +66 -15
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +11 -0
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +101 -53
- package/dist/providers/responses.js.map +1 -1
- package/dist/rate-limit.d.ts +10 -0
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +7 -1
- package/dist/rate-limit.js.map +1 -1
- package/dist/registry.d.ts +105 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +291 -0
- package/dist/registry.js.map +1 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +25 -12
- package/dist/retry.js.map +1 -1
- package/dist/tool-args.d.ts +22 -0
- package/dist/tool-args.d.ts.map +1 -1
- package/dist/tool-args.js +28 -0
- package/dist/tool-args.js.map +1 -1
- package/dist/tools.d.ts +8 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +2 -2
- package/dist/tools.js.map +1 -1
- package/dist/transport.d.ts +34 -4
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +77 -16
- package/dist/transport.js.map +1 -1
- package/dist/turn.d.ts +55 -0
- package/dist/turn.d.ts.map +1 -0
- package/dist/turn.js +125 -0
- package/dist/turn.js.map +1 -0
- package/dist/types.d.ts +21 -22
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -53
- package/dist/types.js.map +1 -1
- package/dist/watchdog.d.ts +2 -0
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +25 -6
- package/dist/watchdog.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/flows.ts +5 -4
- package/src/auth/index.ts +1 -0
- package/src/auth/oauth.ts +23 -1
- package/src/auth/token-source.ts +30 -11
- package/src/auth/with-token.ts +100 -0
- package/src/context.ts +137 -2
- package/src/errors.ts +46 -6
- package/src/fallback.ts +173 -75
- package/src/gate.ts +152 -0
- package/src/index.ts +3 -0
- package/src/key-pool.ts +45 -13
- package/src/presets.ts +12 -2
- package/src/providers/anthropic.ts +93 -25
- package/src/providers/factory.ts +66 -21
- package/src/providers/gemini.ts +102 -28
- package/src/providers/openai.ts +71 -14
- package/src/providers/responses.ts +116 -51
- package/src/rate-limit.ts +31 -3
- package/src/registry.ts +420 -0
- package/src/retry.ts +41 -10
- package/src/tool-args.ts +44 -0
- package/src/tools.ts +10 -2
- package/src/transport.ts +97 -17
- package/src/turn.ts +166 -0
- package/src/types.ts +21 -62
- package/src/watchdog.ts +25 -6
package/src/context.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// Deciding when a conversation no longer fits,
|
|
1
|
+
// Deciding when a conversation no longer fits, where to cut it, and how to
|
|
2
|
+
// make a saved one valid to send again.
|
|
2
3
|
//
|
|
3
4
|
// Everything here is pure and testable. The model call that writes the summary
|
|
4
5
|
// and the row that stores it belong to the caller — what is decidable without
|
|
5
6
|
// I/O is decided here.
|
|
6
|
-
import type { ChatMessage } from "./types.ts";
|
|
7
|
+
import type { ChatMessage, ToolCall } from "./types.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* What the answer needs after the prompt: an output budget, plus the tools'
|
|
@@ -153,6 +154,140 @@ export function applyCompaction(
|
|
|
153
154
|
];
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
type ToolResult = Extract<ChatMessage, { role: "tool" }>;
|
|
158
|
+
|
|
159
|
+
/** What a call with no result answers, by whether running it again is safe.
|
|
160
|
+
* The model reads this and decides whether to call again, so the two must
|
|
161
|
+
* differ on exactly that. */
|
|
162
|
+
const INTERRUPTED_READ_ONLY =
|
|
163
|
+
"This call was interrupted before it returned. It changed nothing, so it is safe to call again.";
|
|
164
|
+
const INTERRUPTED =
|
|
165
|
+
"This call was interrupted before it returned. It may have partly run, so check its effect before calling it again.";
|
|
166
|
+
|
|
167
|
+
/** `id`, or `id_2`, `id_3`… whichever is free first. Only ids already handed
|
|
168
|
+
* out are consulted, so an id never depends on what comes after it. */
|
|
169
|
+
function freshId(id: string, used: Set<string>): string {
|
|
170
|
+
let fresh = id;
|
|
171
|
+
for (let k = 2; used.has(fresh); k++) fresh = `${id}_${k}`;
|
|
172
|
+
used.add(fresh);
|
|
173
|
+
return fresh;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface Span {
|
|
177
|
+
calls: ToolCall[];
|
|
178
|
+
vendorIds: string[];
|
|
179
|
+
results: (ToolResult | undefined)[];
|
|
180
|
+
rest: ChatMessage[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The first call in the span asked under `id` that has no result yet, or -1.
|
|
184
|
+
* First unanswered rather than first: two calls in one reply can share an id,
|
|
185
|
+
* and their results then pair in order. */
|
|
186
|
+
function openSlot(span: Span, id: string): number {
|
|
187
|
+
return span.vendorIds.findIndex((asked, i) => asked === id && !span.results[i]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A result with no call to answer, kept as words the model can still read
|
|
191
|
+
* instead of a `tool` message every provider answers with a 400. */
|
|
192
|
+
function orphanNote(result: ToolResult): ChatMessage {
|
|
193
|
+
const text = `[Tool result for ${result.toolCallId}; the tool call is missing or already has a result]\n${result.content}`;
|
|
194
|
+
return {
|
|
195
|
+
role: "user",
|
|
196
|
+
content: result.images?.length ? [{ type: "text", text }, ...result.images] : text,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Make a saved, resumed or interrupted history valid to send: every call has
|
|
202
|
+
* exactly one result, right after it, under an id no other call uses.
|
|
203
|
+
*
|
|
204
|
+
* A span runs from an assistant message with tool calls to the next assistant
|
|
205
|
+
* message. Its results move to just after the calls, in call order, and the
|
|
206
|
+
* rest of the span (a steer the user typed while the tools ran) keeps its
|
|
207
|
+
* order after them. Then:
|
|
208
|
+
*
|
|
209
|
+
* - A call with no result in its span gets one under the same id, saying it was
|
|
210
|
+
* interrupted. `isReadOnly` picks the wording; without it every tool may
|
|
211
|
+
* have partly run. These calls come back as `interrupted`, under the ids
|
|
212
|
+
* the request carries.
|
|
213
|
+
* - A result whose call is not in its span becomes a user note, images kept.
|
|
214
|
+
* Paired against the span only, because chat and Messages both require the
|
|
215
|
+
* result in the turn right after its call: a looser pairing still 400s.
|
|
216
|
+
* - A repeated id becomes `{id}_{k}`, the smallest free k, call and result
|
|
217
|
+
* together. Kimi and Gemini restart their ids every reply
|
|
218
|
+
* (`functions.search:0`, `call_0`), and Anthropic and Responses reject the
|
|
219
|
+
* second use.
|
|
220
|
+
* - An empty id becomes `call_{message}_{call}`, never `normalizeToolId`'s
|
|
221
|
+
* random fallback.
|
|
222
|
+
* - An assistant message with no calls and only whitespace is dropped, as if
|
|
223
|
+
* it were never there, so a placeholder saved before the tools ran orphans
|
|
224
|
+
* nothing.
|
|
225
|
+
*
|
|
226
|
+
* No side effects, and the same input always gives the same bytes: a span's
|
|
227
|
+
* output and every id depend only on what came before, so repairing a longer
|
|
228
|
+
* history keeps the shorter one's bytes and the prompt cache keeps hitting.
|
|
229
|
+
* Repairing a repaired history changes nothing. Every adapter here runs this
|
|
230
|
+
* on what it sends; call it yourself to know what `interrupted` holds.
|
|
231
|
+
*/
|
|
232
|
+
export function repairHistory(
|
|
233
|
+
messages: readonly ChatMessage[],
|
|
234
|
+
opts: { isReadOnly?: (toolName: string) => boolean } = {},
|
|
235
|
+
): { messages: ChatMessage[]; interrupted: ToolCall[] } {
|
|
236
|
+
const out: ChatMessage[] = [];
|
|
237
|
+
const interrupted: ToolCall[] = [];
|
|
238
|
+
const used = new Set<string>();
|
|
239
|
+
// The open span: its calls as sent, the id the vendor gave each, the result
|
|
240
|
+
// each has found so far, and everything else in arrival order.
|
|
241
|
+
let span: Span | undefined;
|
|
242
|
+
|
|
243
|
+
const close = () => {
|
|
244
|
+
if (!span) return;
|
|
245
|
+
for (const [i, call] of span.calls.entries()) {
|
|
246
|
+
const result = span.results[i];
|
|
247
|
+
if (result) {
|
|
248
|
+
out.push(result);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
interrupted.push(call);
|
|
252
|
+
const content = opts.isReadOnly?.(call.name) ? INTERRUPTED_READ_ONLY : INTERRUPTED;
|
|
253
|
+
out.push({ role: "tool", toolCallId: call.id, name: call.name, content });
|
|
254
|
+
}
|
|
255
|
+
out.push(...span.rest);
|
|
256
|
+
span = undefined;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
for (const [index, message] of messages.entries()) {
|
|
260
|
+
if (message.role === "assistant") {
|
|
261
|
+
const calls = message.toolCalls ?? [];
|
|
262
|
+
if (calls.length === 0 && !message.content.trim()) continue;
|
|
263
|
+
close();
|
|
264
|
+
if (calls.length === 0) {
|
|
265
|
+
out.push(message);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const sent = calls.map((call, c) => {
|
|
269
|
+
const id = freshId(call.id.trim() ? call.id : `call_${index}_${c}`, used);
|
|
270
|
+
return id === call.id ? call : { ...call, id };
|
|
271
|
+
});
|
|
272
|
+
const renamed = sent.some((call, c) => call !== calls[c]);
|
|
273
|
+
out.push(renamed ? { ...message, toolCalls: sent } : message);
|
|
274
|
+
span = { calls: sent, vendorIds: calls.map((call) => call.id), results: [], rest: [] };
|
|
275
|
+
} else if (message.role === "tool") {
|
|
276
|
+
const slot = span ? openSlot(span, message.toolCallId) : -1;
|
|
277
|
+
if (span && slot !== -1) {
|
|
278
|
+
const id = span.calls[slot]!.id;
|
|
279
|
+
span.results[slot] = id === message.toolCallId ? message : { ...message, toolCallId: id };
|
|
280
|
+
} else {
|
|
281
|
+
(span?.rest ?? out).push(orphanNote(message));
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
(span?.rest ?? out).push(message);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
close();
|
|
288
|
+
return { messages: out, interrupted };
|
|
289
|
+
}
|
|
290
|
+
|
|
156
291
|
/**
|
|
157
292
|
* The context window for a model, when nothing volunteered one.
|
|
158
293
|
*
|
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.
|
|
675
|
-
*
|
|
676
|
-
*
|
|
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")
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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 (
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|