@providerkit/core 0.21.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/dist/attribution.d.ts +2 -2
- 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 +29 -0
- package/dist/auth/with-token.d.ts.map +1 -0
- package/dist/auth/with-token.js +85 -0
- package/dist/auth/with-token.js.map +1 -0
- package/dist/capability.d.ts +9 -9
- package/dist/capability.js +9 -9
- package/dist/capability.js.map +1 -1
- package/dist/context.d.ts +43 -5
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +123 -4
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +26 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +57 -18
- 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/image.d.ts +1 -1
- package/dist/image.d.ts.map +1 -1
- package/dist/jev.d.ts +9 -9
- package/dist/jev.d.ts.map +1 -1
- package/dist/jev.js +1 -1
- package/dist/key-pool.d.ts +14 -8
- package/dist/key-pool.d.ts.map +1 -1
- package/dist/key-pool.js +35 -18
- package/dist/key-pool.js.map +1 -1
- package/dist/presets.d.ts +33 -24
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +25 -14
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts +5 -5
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +89 -34
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/factory.d.ts +2 -2
- package/dist/providers/factory.d.ts.map +1 -1
- package/dist/providers/factory.js +2 -1
- package/dist/providers/factory.js.map +1 -1
- package/dist/providers/gemini.d.ts +8 -2
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +117 -41
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +12 -9
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +72 -21
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +3 -3
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +103 -57
- package/dist/providers/responses.js.map +1 -1
- package/dist/providers/zai.d.ts +2 -2
- package/dist/providers/zai.js +2 -2
- package/dist/rate-limit.d.ts +13 -3
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +10 -4
- package/dist/rate-limit.js.map +1 -1
- package/dist/retry.d.ts +16 -5
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +29 -16
- package/dist/retry.js.map +1 -1
- package/dist/schema.d.ts +6 -6
- package/dist/schema.js +8 -8
- package/dist/tool-args.d.ts +23 -1
- package/dist/tool-args.d.ts.map +1 -1
- package/dist/tool-args.js +30 -2
- package/dist/tool-args.js.map +1 -1
- package/dist/tools.d.ts +3 -3
- package/dist/tools.js +2 -2
- package/dist/tools.js.map +1 -1
- package/dist/transport.d.ts +40 -10
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +82 -21
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +37 -23
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -11
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +4 -4
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +3 -3
- package/dist/usage.js.map +1 -1
- package/dist/watchdog.d.ts +14 -12
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +35 -16
- package/dist/watchdog.js.map +1 -1
- package/dist/zod.d.ts +4 -4
- package/dist/zod.js +3 -3
- package/package.json +1 -1
- package/src/attribution.ts +2 -2
- 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 +97 -0
- package/src/capability.ts +12 -12
- package/src/context.ts +141 -6
- package/src/errors.ts +63 -23
- package/src/fallback.ts +173 -75
- package/src/image.ts +1 -1
- package/src/jev.ts +11 -11
- package/src/key-pool.ts +37 -20
- package/src/presets.ts +38 -24
- package/src/providers/anthropic.ts +97 -38
- package/src/providers/factory.ts +4 -3
- package/src/providers/gemini.ts +121 -44
- package/src/providers/openai.ts +78 -25
- package/src/providers/responses.ts +113 -61
- package/src/providers/zai.ts +2 -2
- package/src/rate-limit.ts +35 -7
- package/src/retry.ts +46 -15
- package/src/schema.ts +8 -8
- package/src/tool-args.ts +46 -2
- package/src/tools.ts +4 -4
- package/src/transport.ts +103 -23
- package/src/types.ts +37 -23
- package/src/usage.ts +5 -5
- package/src/watchdog.ts +38 -19
- package/src/zod.ts +4 -4
package/src/errors.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { FilePart } from "./types.ts";
|
|
|
15
15
|
|
|
16
16
|
/** What kind of failure this is, named by what actually fixes it. */
|
|
17
17
|
export type ErrorKind =
|
|
18
|
-
/** Our own cancel
|
|
18
|
+
/** Our own cancel: the caller pressed Stop. Never retried. */
|
|
19
19
|
| "aborted"
|
|
20
20
|
/** Our own deadline, or the provider's 408. Retry. */
|
|
21
21
|
| "timeout"
|
|
@@ -24,13 +24,13 @@ export type ErrorKind =
|
|
|
24
24
|
/** Theirs, and temporary: 5xx, Anthropic's 529, "overloaded". Retry, and
|
|
25
25
|
* worth trying a different model. */
|
|
26
26
|
| "overload"
|
|
27
|
-
/** 429 per-minute throttle. Wait
|
|
27
|
+
/** 429 per-minute throttle. Wait, or rotate to another key or model. */
|
|
28
28
|
| "rate"
|
|
29
29
|
/** Balance or usage window exhausted. Waiting minutes will not fix it. */
|
|
30
30
|
| "quota"
|
|
31
31
|
/** The plan never included this API. Neither a new key nor a top-up fixes it. */
|
|
32
32
|
| "entitlement"
|
|
33
|
-
/** 401/403
|
|
33
|
+
/** 401/403: the key is wrong, not the request. */
|
|
34
34
|
| "auth"
|
|
35
35
|
/** The model id does not exist or is not served here. */
|
|
36
36
|
| "model"
|
|
@@ -38,7 +38,7 @@ export type ErrorKind =
|
|
|
38
38
|
| "context"
|
|
39
39
|
/** Safety filter or refusal. */
|
|
40
40
|
| "content"
|
|
41
|
-
/** Any other 4xx
|
|
41
|
+
/** Any other 4xx: a bug in what we sent. */
|
|
42
42
|
| "invalid"
|
|
43
43
|
| "unknown";
|
|
44
44
|
|
|
@@ -81,7 +81,7 @@ export function isBackupEligible(kind: ErrorKind): boolean {
|
|
|
81
81
|
* 1. Caller aborts are never retryable.
|
|
82
82
|
* 2. An explicit `shouldRetry` directive on ProviderError (e.g. from server `x-should-retry`)
|
|
83
83
|
* 3. Non-retryable kinds (quota, context, model, entitlement, content, invalid) return false.
|
|
84
|
-
* 4. Excessive retry-after waits (exceeding `maxWaitMs`) return false
|
|
84
|
+
* 4. Excessive retry-after waits (exceeding `maxWaitMs`) return false. Long waits belong to fallbacks.
|
|
85
85
|
* 5. Transient kinds (rate, overload, network, timeout) return true.
|
|
86
86
|
* 6. Transport-level network faults (fetch failed, connection lost, SSL blips) return true.
|
|
87
87
|
*/
|
|
@@ -115,7 +115,7 @@ export class ProviderError extends Error {
|
|
|
115
115
|
readonly window?: RateLimitWindow;
|
|
116
116
|
/** Explicit server directive (`x-should-retry`), overriding default classification. */
|
|
117
117
|
readonly shouldRetry?: boolean;
|
|
118
|
-
/** The provider's own response body, truncated
|
|
118
|
+
/** The provider's own response body, truncated. It carries the actual reason,
|
|
119
119
|
* otherwise lost behind "400 status code (no body)". */
|
|
120
120
|
readonly body?: string;
|
|
121
121
|
|
|
@@ -213,7 +213,7 @@ export function messageOf(err: unknown): string {
|
|
|
213
213
|
* Everything readable about the failure as one searchable string: the message
|
|
214
214
|
* plus the parsed provider body. SDKs park the parsed body on `.error`, and
|
|
215
215
|
* that is where the real reason lives ("max_tokens too large", the quotaId,
|
|
216
|
-
* `"type": "billing_error"`). Scanned as
|
|
216
|
+
* `"type": "billing_error"`). Scanned as text: the error-type strings
|
|
217
217
|
* serialize into it, so no JSON walking is needed.
|
|
218
218
|
*/
|
|
219
219
|
function bodyTextOf(err: unknown): string {
|
|
@@ -293,7 +293,7 @@ function isAbort(err: unknown): boolean {
|
|
|
293
293
|
* A transport fault: the socket died before or during the response, so there
|
|
294
294
|
* is NO status and no body for the patterns below to read.
|
|
295
295
|
*
|
|
296
|
-
* The chain is walked because the useful code is rarely on the thrown error
|
|
296
|
+
* The chain is walked because the useful code is rarely on the thrown error:
|
|
297
297
|
* it sits on `cause`, sometimes several wrappers deep. Without this walk every
|
|
298
298
|
* network blip classifies as permanent, and a long run dies on its first
|
|
299
299
|
* hiccup, which is the single likeliest way to lose a minutes-long job.
|
|
@@ -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,
|
|
@@ -455,7 +461,7 @@ const CONTENT_PATTERNS: readonly RegExp[] = [
|
|
|
455
461
|
* A throttle said in words rather than in a 429.
|
|
456
462
|
*
|
|
457
463
|
* Every other kind has body evidence; rate had only the status, which is
|
|
458
|
-
* exactly the evidence an in-band failure lacks
|
|
464
|
+
* exactly the evidence an in-band failure lacks. An SSE response is already
|
|
459
465
|
* 200 when the throttle lands, so the reason arrives as a payload with no
|
|
460
466
|
* status line at all. Checked below the status branches, so it only ever
|
|
461
467
|
* catches what would otherwise fall through to "unknown".
|
|
@@ -501,18 +507,18 @@ const matches = (patterns: readonly RegExp[], text: string): boolean =>
|
|
|
501
507
|
* The kind of failure, from whatever was thrown.
|
|
502
508
|
*
|
|
503
509
|
* Body patterns outrank status for the 4xx family; within them, context beats
|
|
504
|
-
* entitlement beats quota beats auth
|
|
510
|
+
* entitlement beats quota beats auth. Each earlier category's fix is useless
|
|
505
511
|
* for the later ones.
|
|
506
512
|
*/
|
|
507
513
|
/**
|
|
508
|
-
* The kind of a failure that arrived as an HTTP response
|
|
514
|
+
* The kind of a failure that arrived as an HTTP response: a status and a body,
|
|
509
515
|
* with nothing thrown.
|
|
510
516
|
*
|
|
511
517
|
* `classify` below is for a caught error, and it reads `status` and the body
|
|
512
518
|
* text off that error when they are not passed separately. A response has no
|
|
513
519
|
* error object, so callers were inventing one to fill the slot: a bare body
|
|
514
520
|
* string, the same text twice, a `{ status, error }` literal. All three are
|
|
515
|
-
* inert
|
|
521
|
+
* inert (nothing on them can satisfy `isAbort` or `isTransportFailure`), so
|
|
516
522
|
* they were three spellings of `undefined`.
|
|
517
523
|
*/
|
|
518
524
|
export function classifyHttp(status: number | undefined, body: string): ErrorKind {
|
|
@@ -568,7 +574,7 @@ export function classify(err: unknown, status?: number, body?: string): ErrorKin
|
|
|
568
574
|
/**
|
|
569
575
|
* How long the provider asked us to wait, in ms. Two dialects: Gemini's
|
|
570
576
|
* RetryInfo (`"retryDelay": "52s"`, inside the body) and the `Retry-After`
|
|
571
|
-
* header, which SDKs keep on the error. Honouring it beats guessing
|
|
577
|
+
* header, which SDKs keep on the error. Honouring it beats guessing: a
|
|
572
578
|
* backoff shorter than the window just burns an attempt.
|
|
573
579
|
*/
|
|
574
580
|
export function parseRetryAfterMs(err: unknown, body?: string): number | undefined {
|
|
@@ -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
|
*
|
|
@@ -664,24 +689,39 @@ export function parseContextOverflow(err: unknown, body?: string): ContextOverfl
|
|
|
664
689
|
* completion: retry sees nothing to retry, and a key pool never rotates off an
|
|
665
690
|
* exhausted key.
|
|
666
691
|
*
|
|
667
|
-
* `error` is the vendor's own payload, whatever shape it came in
|
|
692
|
+
* `error` is the vendor's own payload, whatever shape it came in: the numeric
|
|
668
693
|
* HTTP `code` Gemini and OpenRouter use, the slug OpenAI and Anthropic put in
|
|
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,
|
|
@@ -697,7 +737,7 @@ export function streamError(provider: string, error: unknown): ProviderError {
|
|
|
697
737
|
}
|
|
698
738
|
|
|
699
739
|
/** The reset a stream error carries: the body's clock, plus the rate-limit
|
|
700
|
-
* headers the ChatGPT backend mirrors
|
|
740
|
+
* headers the ChatGPT backend mirrors into its error frame, the only place a
|
|
701
741
|
* failure after the 200 can say which window ran out. */
|
|
702
742
|
function readReset(error: unknown, body: string) {
|
|
703
743
|
const raw =
|
|
@@ -720,7 +760,7 @@ function readReset(error: unknown, body: string) {
|
|
|
720
760
|
* read it: a caller that never checks the finish keeps half an answer as if it
|
|
721
761
|
* were the whole one, the ledger records a billed turn as free, and no pool
|
|
722
762
|
* learns the endpoint is dropping streams. cc-proxy shipped the same fix
|
|
723
|
-
* (0.1.39) for the same reason
|
|
763
|
+
* (0.1.39) for the same reason: "instead of marking affected streamed
|
|
724
764
|
* responses as successful".
|
|
725
765
|
*
|
|
726
766
|
* `network`, because that is what it is: the same failure as a socket that
|
|
@@ -736,7 +776,7 @@ export function streamCut(provider: string): ProviderError {
|
|
|
736
776
|
);
|
|
737
777
|
}
|
|
738
778
|
|
|
739
|
-
/** The
|
|
779
|
+
/** The failure details you can log, so a dead run never reads
|
|
740
780
|
* "400 status code (no body)". */
|
|
741
781
|
export function describeProviderError(err: unknown): Record<string, unknown> {
|
|
742
782
|
if (err instanceof ProviderError) {
|
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
|
|
package/src/image.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface ImageLimits {
|
|
|
16
16
|
minSide: number;
|
|
17
17
|
/** Smallest area, in square pixels. */
|
|
18
18
|
minArea: number;
|
|
19
|
-
/** Largest decoded size
|
|
19
|
+
/** Largest decoded size in bytes (width × height × bytes per pixel). */
|
|
20
20
|
maxDecodedBytes: number;
|
|
21
21
|
/** Most images in one request. The newest win; older ones become notes. */
|
|
22
22
|
maxImages: number;
|
package/src/jev.ts
CHANGED
|
@@ -34,7 +34,7 @@ export type JevInstructions = string | Record<string, unknown> | readonly unknow
|
|
|
34
34
|
export interface ChoiceQuestion<Option extends string = string> {
|
|
35
35
|
type: "choice";
|
|
36
36
|
instructions?: JevInstructions;
|
|
37
|
-
/** Each option, mapped to what it means
|
|
37
|
+
/** Each option, mapped to what it means. Use `null` when its name says it all.
|
|
38
38
|
* TypeSafe takes up to 255. */
|
|
39
39
|
criteria: Record<Option, unknown>;
|
|
40
40
|
}
|
|
@@ -64,7 +64,7 @@ export interface ChoiceAnswer<Option extends string = string> {
|
|
|
64
64
|
* because a caller acting on it needs more than the pick: the runner-up,
|
|
65
65
|
* P(yes) when the pick was no, a joint probability across two questions. */
|
|
66
66
|
probabilities: Record<Option, number>;
|
|
67
|
-
/** How peaked `probabilities` is, 0 to 1
|
|
67
|
+
/** How peaked `probabilities` is, from 0 to 1. Not the probability of the pick.
|
|
68
68
|
* TypeSafe's own example spreads 0.57 / 0.43 and reports 0.35, so reading
|
|
69
69
|
* it as P(pick) would put the pick below the option it beat. Null when the
|
|
70
70
|
* host sent none. */
|
|
@@ -90,7 +90,7 @@ export interface ScoreAnswer {
|
|
|
90
90
|
|
|
91
91
|
export type JevAnswer = ChoiceAnswer | NoulAnswer | ScoreAnswer;
|
|
92
92
|
|
|
93
|
-
/** The answer type a question gets back
|
|
93
|
+
/** The answer type a question gets back. A choice's options carry through. */
|
|
94
94
|
export type AnswerTo<Q> =
|
|
95
95
|
Q extends ChoiceQuestion<infer Option extends string>
|
|
96
96
|
? ChoiceAnswer<Option>
|
|
@@ -101,12 +101,12 @@ export type AnswerTo<Q> =
|
|
|
101
101
|
: never;
|
|
102
102
|
|
|
103
103
|
export interface JevReply<Q extends Record<string, JevQuestion>> {
|
|
104
|
-
/** One per question
|
|
104
|
+
/** One per question. `null` when that answer did not pass the checks in
|
|
105
105
|
* `readAnswer`. Each fails alone: in a fan-out, a malformed answer to a
|
|
106
106
|
* question the caller never acts on must not cost the one it does. */
|
|
107
107
|
answers: { [Id in keyof Q]: AnswerTo<Q[Id]> | null };
|
|
108
108
|
usage: TokenUsage;
|
|
109
|
-
/** The model that answered, as the host names it
|
|
109
|
+
/** The model that answered, as the host names it: `jev-1.13.0` on
|
|
110
110
|
* TypeSafe, `typesafe/jev-1.13-20260917` on OpenRouter. */
|
|
111
111
|
model: string;
|
|
112
112
|
}
|
|
@@ -139,8 +139,8 @@ export interface JevClient {
|
|
|
139
139
|
): Promise<JevReply<Q>>;
|
|
140
140
|
/** Proves the key, the account and the model work, with one real question:
|
|
141
141
|
* about 40 input tokens, a few millionths of a dollar. A free read (a
|
|
142
|
-
* model list, a balance) proves only the key
|
|
143
|
-
* run this model
|
|
142
|
+
* model list, a balance) proves only the key, not that this account can
|
|
143
|
+
* run this model. Vercel has no such free read. Throws the classified failure. */
|
|
144
144
|
checkKey(opts?: { signal?: AbortSignal }): Promise<void>;
|
|
145
145
|
}
|
|
146
146
|
|
|
@@ -158,7 +158,7 @@ interface HostRow {
|
|
|
158
158
|
url(config: JevConfig): string;
|
|
159
159
|
headers(config: JevConfig, model: string): Record<string, string>;
|
|
160
160
|
body(model: string, state: unknown, questions: Record<string, JevQuestion>): unknown;
|
|
161
|
-
/** The reply in TypeSafe's own shape
|
|
161
|
+
/** The reply in TypeSafe's own shape (`{ model, answers, usage }`),
|
|
162
162
|
* whatever the host wrapped it in. */
|
|
163
163
|
unwrap(json: unknown): Record<string, unknown> | undefined;
|
|
164
164
|
}
|
|
@@ -228,7 +228,7 @@ const HOSTS: Record<JevHost, HostRow> = {
|
|
|
228
228
|
* `providerMetadata.typesafe.confidence`, and usage is camelCase.
|
|
229
229
|
*
|
|
230
230
|
* ponytail: read from @jkudish/jev-agent-tools' adapter, not from a recorded
|
|
231
|
-
* reply
|
|
231
|
+
* reply. No Vercel key has run it here. If a real reply disagrees, this
|
|
232
232
|
* function is the whole fix.
|
|
233
233
|
*/
|
|
234
234
|
function fromVercel(json: unknown): Record<string, unknown> | undefined {
|
|
@@ -282,7 +282,7 @@ function distribution(value: unknown, keys: readonly string[]): Record<string, n
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
/**
|
|
285
|
-
* One answer, checked against the question that asked it
|
|
285
|
+
* One answer, checked against the question that asked it, or null.
|
|
286
286
|
*
|
|
287
287
|
* A Jev answer usually becomes an action (a click, a label, a route), so it is
|
|
288
288
|
* read fail-closed: the right type; for a choice, a pick among the offered
|
|
@@ -291,7 +291,7 @@ function distribution(value: unknown, keys: readonly string[]): Record<string, n
|
|
|
291
291
|
* the pick.
|
|
292
292
|
*
|
|
293
293
|
* `ask` runs this on every answer. It is exported for the app that has to
|
|
294
|
-
* keep its own request
|
|
294
|
+
* keep its own request (its own error copy, its own auth refresh) and can
|
|
295
295
|
* still take the checks.
|
|
296
296
|
*/
|
|
297
297
|
export function readAnswer<Q extends JevQuestion>(question: Q, raw: unknown): AnswerTo<Q> | null;
|