@providerkit/core 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/key-pool.d.ts +10 -4
- package/dist/key-pool.d.ts.map +1 -1
- package/dist/key-pool.js +29 -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.map +1 -1
- package/dist/providers/anthropic.js +80 -25
- package/dist/providers/anthropic.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.map +1 -1
- package/dist/providers/openai.js +63 -14
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +98 -52
- 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/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/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/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -8
- 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 +97 -0
- package/src/context.ts +137 -2
- package/src/errors.ts +46 -6
- package/src/fallback.ts +173 -75
- package/src/key-pool.ts +30 -13
- package/src/presets.ts +12 -2
- package/src/providers/anthropic.ts +83 -24
- package/src/providers/gemini.ts +102 -28
- package/src/providers/openai.ts +61 -13
- package/src/providers/responses.ts +102 -50
- package/src/rate-limit.ts +31 -3
- package/src/retry.ts +41 -10
- package/src/tool-args.ts +44 -0
- package/src/transport.ts +97 -17
- package/src/types.ts +13 -0
- package/src/watchdog.ts +25 -6
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
// The event names arrive on the SSE `event:` line and are repeated inside each
|
|
11
11
|
// payload's own `type`. The transport yields only `data:` payloads, so this
|
|
12
12
|
// adapter reads `type` — which is what survives, and what gateways agree on.
|
|
13
|
+
import { repairHistory } from "../context.ts";
|
|
13
14
|
import { fileRefused, ProviderError, streamCut, streamError } from "../errors.ts";
|
|
14
|
-
import { streamSse, apiUrl } from "../transport.ts";
|
|
15
|
+
import { streamSse, apiUrl, parseFrame } from "../transport.ts";
|
|
16
|
+
import { parseRateLimitReset } from "../rate-limit.ts";
|
|
17
|
+
import { settleToolCalls } from "../tool-args.ts";
|
|
15
18
|
import type {
|
|
16
19
|
ChatMessage,
|
|
17
20
|
ContentPart,
|
|
@@ -82,13 +85,26 @@ function reasoningEffort(model: string, effort: Effort): string {
|
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/**
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
88
|
+
* The finish an `incomplete` turn stands for. This shape documents two
|
|
89
|
+
* reasons, and each has its own finish: `max_output_tokens` is the output cap
|
|
90
|
+
* (`length`) and `content_filter` the filter.
|
|
91
|
+
*
|
|
92
|
+
* Any other reason, or none, says only that the turn did not end on purpose.
|
|
93
|
+
* It used to read as `length`, so an empty turn was told to raise a maxTokens
|
|
94
|
+
* it never hit. It is thrown as `overload` instead, the transient fault
|
|
95
|
+
* cc-proxy answers it with (a 503). Nothing about the request is wrong, so it
|
|
96
|
+
* is retried if nothing was emitted yet; if something was, the error reaches
|
|
97
|
+
* the caller after it (invariant 1). It is read before reasoning and usage go
|
|
98
|
+
* out, like a failed terminal, so that retry stays open.
|
|
89
99
|
*/
|
|
90
|
-
function
|
|
91
|
-
|
|
100
|
+
function incompleteFinish(provider: string, reason: string | undefined): FinishReason {
|
|
101
|
+
if (reason === "max_output_tokens") return "length";
|
|
102
|
+
if (reason === "content_filter") return "content_filter";
|
|
103
|
+
throw new ProviderError(
|
|
104
|
+
provider,
|
|
105
|
+
"overload",
|
|
106
|
+
`${provider}: the turn stopped early, reason: ${reason ?? "none given"}`,
|
|
107
|
+
);
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
// ── input items ───────────────────────────────────────────────────────────
|
|
@@ -282,8 +298,11 @@ interface ResponsesEvent {
|
|
|
282
298
|
headers?: Record<string, unknown>;
|
|
283
299
|
}
|
|
284
300
|
|
|
301
|
+
/** One window of the snapshot: the `x-codex-<slot>-*` header numbers as JSON. */
|
|
285
302
|
interface CodexWindow {
|
|
286
303
|
used_percent?: number;
|
|
304
|
+
window_minutes?: number;
|
|
305
|
+
reset_at?: number;
|
|
287
306
|
reset_after_seconds?: number;
|
|
288
307
|
}
|
|
289
308
|
|
|
@@ -301,24 +320,42 @@ interface CodexRateLimits {
|
|
|
301
320
|
* A full window the account's credits cover, or that the backend still
|
|
302
321
|
* `allowed`, lets the turn proceed. It is only the reason when the stream then
|
|
303
322
|
* closes without a response: that close is a spent window, not a dropped
|
|
304
|
-
* socket, and retrying it walks every backup into the same wall.
|
|
305
|
-
*
|
|
306
|
-
*
|
|
323
|
+
* socket, and retrying it walks every backup into the same wall.
|
|
324
|
+
*
|
|
325
|
+
* Which window, and when it lifts, comes from the same parser as the headers
|
|
326
|
+
* the snapshot mirrors: the fuller window binds, and the later reset when both
|
|
327
|
+
* are full. cc-proxy took the primary's clock first and told a weekly wall to
|
|
328
|
+
* come back in a few hours. The window is named so a caller can say "weekly"
|
|
329
|
+
* instead of "the limit", which reads as the 5-hour one.
|
|
307
330
|
*/
|
|
308
331
|
function quotaWall(event: ResponsesEvent, provider: string): ProviderError | undefined {
|
|
309
332
|
const limits = event.rate_limits;
|
|
310
333
|
if (!limits?.limit_reached || limits.allowed) return undefined;
|
|
311
334
|
if (event.credits?.has_credits || event.credits?.unlimited) return undefined;
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
335
|
+
const now = Date.now();
|
|
336
|
+
const { window, resetAtMs } = parseRateLimitReset(codexHeaders(limits), now);
|
|
337
|
+
const named = window ? ` (${window} window)` : "";
|
|
338
|
+
const message = `${provider}: the usage limit has been reached${named}`;
|
|
339
|
+
return new ProviderError(provider, "quota", message, {
|
|
340
|
+
...(resetAtMs !== undefined ? { retryAfterMs: resetAtMs - now, resetAtMs } : {}),
|
|
341
|
+
window,
|
|
319
342
|
});
|
|
320
343
|
}
|
|
321
344
|
|
|
345
|
+
/** The snapshot as the `x-codex-*` headers it mirrors, rebuilt the way
|
|
346
|
+
* cc-proxy's `event_headers` does, so one parser reads both. */
|
|
347
|
+
function codexHeaders(limits: CodexRateLimits): Headers {
|
|
348
|
+
const headers = new Headers();
|
|
349
|
+
for (const slot of ["primary", "secondary"] as const) {
|
|
350
|
+
for (const [field, value] of Object.entries(limits[slot] ?? {})) {
|
|
351
|
+
if (typeof value === "number") {
|
|
352
|
+
headers.set(`x-codex-${slot}-${field.replaceAll("_", "-")}`, String(value));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return headers;
|
|
357
|
+
}
|
|
358
|
+
|
|
322
359
|
function usageChunk(usage: ResponsesUsage): ProviderChunk {
|
|
323
360
|
return {
|
|
324
361
|
type: "usage",
|
|
@@ -373,8 +410,11 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
373
410
|
const model = opts.model ?? config.model;
|
|
374
411
|
const museContributor = isMuseSparkContributor(model);
|
|
375
412
|
const requestTools = museContributor && opts.toolChoice === "none" ? [] : tools;
|
|
413
|
+
// Repaired before the images are gated: an orphaned result becomes a user
|
|
414
|
+
// note that keeps its images, and those must meet the limits too.
|
|
415
|
+
const sent = repairHistory(messages).messages;
|
|
376
416
|
const { instructions, input } = toResponsesInput(
|
|
377
|
-
config.imageLimits ? gateImages(
|
|
417
|
+
config.imageLimits ? gateImages(sent, config.imageLimits) : sent,
|
|
378
418
|
id,
|
|
379
419
|
);
|
|
380
420
|
|
|
@@ -457,9 +497,10 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
457
497
|
const pendingFor = (itemId?: string, callId?: string) =>
|
|
458
498
|
(itemId ? pending.get(itemId) : undefined) ?? (callId ? pending.get(callId) : undefined);
|
|
459
499
|
let nextIndex = 0;
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
500
|
+
// Every call the turn opened, kept past its `.done`. This shape never
|
|
501
|
+
// states a stop reason on a clean finish, so it is inferred from whether
|
|
502
|
+
// there are any, and the terminal event checks each one can run.
|
|
503
|
+
const calls: PendingCall[] = [];
|
|
463
504
|
let wall: ProviderError | undefined;
|
|
464
505
|
let produced = false;
|
|
465
506
|
const reasoningItems: unknown[] = [];
|
|
@@ -482,13 +523,9 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
482
523
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
483
524
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
484
525
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
526
|
+
...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
|
|
485
527
|
})) {
|
|
486
|
-
|
|
487
|
-
try {
|
|
488
|
-
event = JSON.parse(data) as ResponsesEvent;
|
|
489
|
-
} catch {
|
|
490
|
-
continue; // a keep-alive or a frame we do not model
|
|
491
|
-
}
|
|
528
|
+
const event = parseFrame<ResponsesEvent>(data, id);
|
|
492
529
|
if (event.type?.endsWith(".delta") || event.type === "response.output_item.added") {
|
|
493
530
|
produced = true;
|
|
494
531
|
}
|
|
@@ -515,7 +552,6 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
515
552
|
// turn came back as a nameless call with empty arguments.
|
|
516
553
|
const key = item.id ?? item.call_id;
|
|
517
554
|
if (!key) break;
|
|
518
|
-
sawToolCall = true;
|
|
519
555
|
const index = nextIndex++;
|
|
520
556
|
// Normally empty here, but a backend that already has the whole
|
|
521
557
|
// call sends it in the skeleton.
|
|
@@ -526,6 +562,7 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
526
562
|
name: item.name ?? "",
|
|
527
563
|
streamed: seeded,
|
|
528
564
|
};
|
|
565
|
+
calls.push(call);
|
|
529
566
|
pending.set(key, call);
|
|
530
567
|
if (item.call_id) pending.set(item.call_id, call);
|
|
531
568
|
// Only the fields the skeleton actually states. An empty `id` here
|
|
@@ -582,20 +619,24 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
582
619
|
|
|
583
620
|
if (!known) {
|
|
584
621
|
// A backend that emits neither the skeleton nor the deltas — the
|
|
585
|
-
// whole call arrives here or not at all.
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
622
|
+
// whole call arrives here or not at all. A call with no `call_id`
|
|
623
|
+
// used to be dropped, because the caller's `function_call_output`
|
|
624
|
+
// would quote `""` and take a 400 one turn later. Dropped, the
|
|
625
|
+
// model's call silently never happened. It gets an id at the
|
|
626
|
+
// terminal event instead (`settleToolCalls`).
|
|
627
|
+
const call: PendingCall = {
|
|
628
|
+
index: nextIndex++,
|
|
629
|
+
id: item.call_id ?? "",
|
|
630
|
+
name: item.name ?? "",
|
|
631
|
+
streamed: snapshot,
|
|
632
|
+
};
|
|
633
|
+
calls.push(call);
|
|
593
634
|
yield {
|
|
594
635
|
type: "delta",
|
|
595
636
|
toolCalls: [
|
|
596
637
|
{
|
|
597
|
-
index:
|
|
598
|
-
id: item.call_id,
|
|
638
|
+
index: call.index,
|
|
639
|
+
...(item.call_id ? { id: item.call_id } : {}),
|
|
599
640
|
...(item.name ? { name: item.name } : {}),
|
|
600
641
|
arguments: snapshot,
|
|
601
642
|
},
|
|
@@ -617,6 +658,8 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
617
658
|
...(item.name && item.name !== known.name ? { name: item.name } : {}),
|
|
618
659
|
...(tail ? { arguments: tail } : {}),
|
|
619
660
|
};
|
|
661
|
+
known.id = item.call_id || known.id;
|
|
662
|
+
known.name = item.name || known.name;
|
|
620
663
|
if (Object.keys(restated).length > 0) {
|
|
621
664
|
yield { type: "delta", toolCalls: [{ index: known.index, ...restated }] };
|
|
622
665
|
}
|
|
@@ -635,16 +678,27 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
635
678
|
if (response?.status === "failed" || response?.error) {
|
|
636
679
|
throw streamError(id, response.error ?? { message: "response failed" });
|
|
637
680
|
}
|
|
681
|
+
// Any other status (`cancelled`, `queued`, one added later) says
|
|
682
|
+
// nothing about the answer being whole. Read as a clean stop, it
|
|
683
|
+
// was a finished turn nobody retried. Theirs, so `overload`.
|
|
684
|
+
const status = response?.status;
|
|
685
|
+
if (status !== undefined && status !== "completed" && status !== "incomplete") {
|
|
686
|
+
throw new ProviderError(id, "overload", `${id} ended the turn as "${status}"`, {
|
|
687
|
+
code: status,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
let finishReason: FinishReason = calls.length > 0 ? "tool_calls" : "stop";
|
|
691
|
+
if (status === "incomplete") {
|
|
692
|
+
finishReason = incompleteFinish(id, response?.incomplete_details?.reason);
|
|
693
|
+
} else {
|
|
694
|
+
// Checked on a whole turn only: a cut one may have lost a call's
|
|
695
|
+
// name to the cut, and nobody runs a cut turn's calls.
|
|
696
|
+
const minted = settleToolCalls(id, calls);
|
|
697
|
+
if (minted.length > 0) yield { type: "delta", toolCalls: minted };
|
|
698
|
+
}
|
|
638
699
|
yield* reasoningChunk();
|
|
639
700
|
if (response?.usage) yield usageChunk(response.usage);
|
|
640
|
-
|
|
641
|
-
yield {
|
|
642
|
-
type: "finish",
|
|
643
|
-
finishReason: mapIncompleteReason(response.incomplete_details?.reason),
|
|
644
|
-
};
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
yield { type: "finish", finishReason: sawToolCall ? "tool_calls" : "stop" };
|
|
701
|
+
yield { type: "finish", finishReason };
|
|
648
702
|
return;
|
|
649
703
|
}
|
|
650
704
|
|
|
@@ -655,13 +709,11 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
655
709
|
case "response.incomplete": {
|
|
656
710
|
// A turn that ran out of output tokens still billed for its input,
|
|
657
711
|
// and this event carries usage in the same shape as `completed`.
|
|
712
|
+
const finishReason = incompleteFinish(id, event.response?.incomplete_details?.reason);
|
|
658
713
|
yield* reasoningChunk();
|
|
659
714
|
const usage = event.response?.usage;
|
|
660
715
|
if (usage) yield usageChunk(usage);
|
|
661
|
-
yield {
|
|
662
|
-
type: "finish",
|
|
663
|
-
finishReason: mapIncompleteReason(event.response?.incomplete_details?.reason),
|
|
664
|
-
};
|
|
716
|
+
yield { type: "finish", finishReason };
|
|
665
717
|
return;
|
|
666
718
|
}
|
|
667
719
|
|
package/src/rate-limit.ts
CHANGED
|
@@ -36,8 +36,16 @@ export interface RateLimitReset {
|
|
|
36
36
|
retryAfterMs?: number;
|
|
37
37
|
/** Which subscription window bound, when it could be named at all. */
|
|
38
38
|
window?: RateLimitWindow;
|
|
39
|
+
/** How full the binding window is, as the headers report it (1 is full).
|
|
40
|
+
* Set only when a window's fill rode the response: Anthropic's unified
|
|
41
|
+
* 5h/7d headers, or the ChatGPT backend's `x-codex-*` ones. */
|
|
42
|
+
utilization?: number;
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
/** A window's fill off a response that reported one: what
|
|
46
|
+
* `StreamOptions.onRateLimit` receives. */
|
|
47
|
+
export type RateLimitReading = RateLimitReset & { utilization: number; resetAtMs: number };
|
|
48
|
+
|
|
41
49
|
const RETRY_AFTER = "retry-after";
|
|
42
50
|
const RETRY_AFTER_MS = "retry-after-ms";
|
|
43
51
|
const UNIFIED_5H = "anthropic-ratelimit-unified-5h-";
|
|
@@ -66,11 +74,23 @@ const RESET_HEADERS = [
|
|
|
66
74
|
*/
|
|
67
75
|
const CODEX_SLOTS = ["primary", "secondary"] as const;
|
|
68
76
|
|
|
77
|
+
/** One window as a response reports it: how full, and when it resets. */
|
|
78
|
+
interface WindowReading {
|
|
79
|
+
window?: RateLimitWindow;
|
|
80
|
+
utilization?: number;
|
|
81
|
+
reset?: number;
|
|
82
|
+
}
|
|
83
|
+
type Dated = WindowReading & { utilization: number; reset: number };
|
|
84
|
+
|
|
69
85
|
function windowForMinutes(minutes: number | undefined): RateLimitWindow | undefined {
|
|
70
86
|
return minutes !== undefined && minutes > 0 ? windowForMs(minutes * 60_000) : undefined;
|
|
71
87
|
}
|
|
72
88
|
|
|
73
|
-
function codexWindow(
|
|
89
|
+
function codexWindow(
|
|
90
|
+
headers: Headers,
|
|
91
|
+
slot: (typeof CODEX_SLOTS)[number],
|
|
92
|
+
now: number,
|
|
93
|
+
): WindowReading {
|
|
74
94
|
const name = (suffix: string) => headers.get(`x-codex-${slot}-${suffix}`);
|
|
75
95
|
const percent = numeric(name("used-percent"));
|
|
76
96
|
const after = numeric(name("reset-after-seconds"));
|
|
@@ -185,10 +205,18 @@ export function parseRateLimitReset(headers: Headers, now = Date.now()): RateLim
|
|
|
185
205
|
reset: unixSecondsToMs(headers.get(`${UNIFIED_7D}reset`)),
|
|
186
206
|
},
|
|
187
207
|
...CODEX_SLOTS.map((slot) => codexWindow(headers, slot, now)),
|
|
188
|
-
].filter(
|
|
189
|
-
|
|
208
|
+
].filter(
|
|
209
|
+
(w): w is Dated => w.utilization !== undefined && w.reset !== undefined && w.reset > now,
|
|
210
|
+
);
|
|
211
|
+
// The fuller window binds. Between two as full as each other (both spent,
|
|
212
|
+
// usually) the later reset does: the request passes only once both have
|
|
213
|
+
// cleared, and waiting out the earlier one walks into the other wall. On a
|
|
214
|
+
// dead heat the later slot wins (the sort is stable), as cc-proxy's `max_by`
|
|
215
|
+
// does.
|
|
216
|
+
const binding = windows.sort((a, b) => a.utilization - b.utilization || a.reset - b.reset).at(-1);
|
|
190
217
|
if (binding) {
|
|
191
218
|
if (binding.window) result.window = binding.window;
|
|
219
|
+
result.utilization = binding.utilization;
|
|
192
220
|
// The window's own reset outranks retry-after: it names the real horizon,
|
|
193
221
|
// where retry-after names the next polite attempt.
|
|
194
222
|
if (binding.reset !== undefined) result.resetAtMs = binding.reset;
|
package/src/retry.ts
CHANGED
|
@@ -28,6 +28,17 @@ export interface RetryOptions {
|
|
|
28
28
|
/** Maximum acceptable wait. A longer server deadline ends retry so an outer
|
|
29
29
|
* fallback can switch provider; it is never shortened to retry too early. */
|
|
30
30
|
maxDelayMs?: number;
|
|
31
|
+
/**
|
|
32
|
+
* How long recovery may go on, counted from the FIRST failure. A retry
|
|
33
|
+
* starts only if it would start before then, its wait included. An attempt
|
|
34
|
+
* already running is never cut. Default 60s; `Infinity` turns it off.
|
|
35
|
+
*
|
|
36
|
+
* Without it the worst case is every attempt in full plus every wait: three
|
|
37
|
+
* stalled streams at the watchdog's 300s each is a quarter of an hour before
|
|
38
|
+
* the caller hears anything. The clock starts at the first failure, not at
|
|
39
|
+
* the call, so a long healthy run that then fails still gets its recovery.
|
|
40
|
+
*/
|
|
41
|
+
budgetMs?: number;
|
|
31
42
|
/** Aborts the wait as well as the work, so Stop lands promptly. */
|
|
32
43
|
signal?: AbortSignal;
|
|
33
44
|
/** Decide retryability. Default: transient kinds only. */
|
|
@@ -40,6 +51,7 @@ export interface RetryOptions {
|
|
|
40
51
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
41
52
|
const DEFAULT_BASE_DELAY_MS = 1_000;
|
|
42
53
|
const DEFAULT_MAX_DELAY_MS = 30_000;
|
|
54
|
+
const DEFAULT_BUDGET_MS = 60_000;
|
|
43
55
|
|
|
44
56
|
/**
|
|
45
57
|
* Full-jitter exponential backoff: a random delay in
|
|
@@ -92,6 +104,29 @@ function delayFor(err: unknown, attempt: number, opts: RetryOptions): number | n
|
|
|
92
104
|
|
|
93
105
|
const defaultShouldRetry = (err: unknown, _attempt: number): boolean => isRetryable(err);
|
|
94
106
|
|
|
107
|
+
/** The moment the recovery budget runs out, fixed at the first failure. */
|
|
108
|
+
const budgetEnd = (opts: RetryOptions): number => Date.now() + (opts.budgetMs ?? DEFAULT_BUDGET_MS);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Wait out the delay before the next attempt, or rethrow `err` unchanged when
|
|
112
|
+
* there is none: the server's deadline is past `maxDelayMs`, or the next
|
|
113
|
+
* attempt would start past the budget.
|
|
114
|
+
*/
|
|
115
|
+
async function waitToRetry(
|
|
116
|
+
err: unknown,
|
|
117
|
+
attempt: number,
|
|
118
|
+
opts: RetryOptions,
|
|
119
|
+
budgetEndsAt: number,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const delayMs = delayFor(err, attempt, opts);
|
|
122
|
+
if (delayMs === null || Date.now() + delayMs >= budgetEndsAt) throw err;
|
|
123
|
+
opts.onRetry?.({ error: err, attempt, delayMs });
|
|
124
|
+
await (opts.sleep ?? sleep)(delayMs, opts.signal);
|
|
125
|
+
// A timer can wake long after it was due (a suspended worker, a throttled
|
|
126
|
+
// tab). The budget is about when the attempt starts, not when it was meant to.
|
|
127
|
+
if (Date.now() >= budgetEndsAt) throw err;
|
|
128
|
+
}
|
|
129
|
+
|
|
95
130
|
/**
|
|
96
131
|
* Run `fn`, retrying transient failures with backoff. For one-shot calls such
|
|
97
132
|
* as a title, a summary or a compaction pass.
|
|
@@ -102,7 +137,7 @@ export async function withRetry<T>(
|
|
|
102
137
|
): Promise<T> {
|
|
103
138
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
104
139
|
const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;
|
|
105
|
-
|
|
140
|
+
let budgetEndsAt: number | undefined;
|
|
106
141
|
|
|
107
142
|
let lastError: unknown;
|
|
108
143
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -114,10 +149,8 @@ export async function withRetry<T>(
|
|
|
114
149
|
// A caller's Stop is never a transient failure, whatever it looks like.
|
|
115
150
|
if (opts.signal?.aborted) throw err;
|
|
116
151
|
if (attempt >= maxAttempts || !shouldRetry(err, attempt)) throw err;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
opts.onRetry?.({ error: err, attempt, delayMs });
|
|
120
|
-
await nap(delayMs, opts.signal);
|
|
152
|
+
budgetEndsAt ??= budgetEnd(opts);
|
|
153
|
+
await waitToRetry(err, attempt, opts, budgetEndsAt);
|
|
121
154
|
}
|
|
122
155
|
}
|
|
123
156
|
throw lastError;
|
|
@@ -138,7 +171,7 @@ export async function* withStreamRetry<T>(
|
|
|
138
171
|
): AsyncGenerator<T> {
|
|
139
172
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
140
173
|
const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;
|
|
141
|
-
|
|
174
|
+
let budgetEndsAt: number | undefined;
|
|
142
175
|
|
|
143
176
|
let lastError: unknown;
|
|
144
177
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -163,10 +196,8 @@ export async function* withStreamRetry<T>(
|
|
|
163
196
|
if (opts.signal?.aborted) throw err;
|
|
164
197
|
// Rule 2: past the first chunk there is no going back.
|
|
165
198
|
if (emitted || attempt >= maxAttempts || !shouldRetry(err, attempt)) throw err;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
opts.onRetry?.({ error: err, attempt, delayMs });
|
|
169
|
-
await nap(delayMs, opts.signal);
|
|
199
|
+
budgetEndsAt ??= budgetEnd(opts);
|
|
200
|
+
await waitToRetry(err, attempt, opts, budgetEndsAt);
|
|
170
201
|
} finally {
|
|
171
202
|
opts.signal?.removeEventListener("abort", onOuterAbort);
|
|
172
203
|
// Abandoning mid-iteration (the consumer broke out, or we are retrying)
|
package/src/tool-args.ts
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// `\uXXXX`, which JSON.parse decodes correctly. Some escape it twice and
|
|
13
13
|
// emit `\\u00e7`, so even a clean parse leaves six literal characters
|
|
14
14
|
// standing and a Portuguese answer reaches the user as `atenção`.
|
|
15
|
+
import { ProviderError } from "./errors.ts";
|
|
16
|
+
import type { ToolCallDelta } from "./types.ts";
|
|
15
17
|
|
|
16
18
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
19
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -129,6 +131,48 @@ export function normalizeToolId(id: string | undefined | null, prefix = "call_pk
|
|
|
129
131
|
return clean;
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
/** A tool call's identity as a stream stated it: the last id and name it gave. */
|
|
135
|
+
export interface ToolCallIdentity {
|
|
136
|
+
index: number;
|
|
137
|
+
id: string;
|
|
138
|
+
name: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Make a turn's tool calls runnable before its finish goes out, or fail the turn.
|
|
143
|
+
*
|
|
144
|
+
* A call with no name can't run: nothing says which tool to dispatch. It used to
|
|
145
|
+
* pass through (the Messages reader wrote `""` for both fields), and the loop
|
|
146
|
+
* failed one step later with nothing to say why. It throws `overload` now, a
|
|
147
|
+
* broken turn the same request usually does not repeat; cc-proxy answers it
|
|
148
|
+
* with a 502.
|
|
149
|
+
*
|
|
150
|
+
* A call with no id still can run. It gets one from `normalizeToolId`, written
|
|
151
|
+
* back into `calls` and returned as the deltas to send, so the result has an id
|
|
152
|
+
* to quote. An id the vendor did send is never rewritten: the four wires must
|
|
153
|
+
* agree on it (invariant 10), and repeated ids are history repair's job.
|
|
154
|
+
*/
|
|
155
|
+
export function settleToolCalls(
|
|
156
|
+
provider: string,
|
|
157
|
+
calls: Iterable<ToolCallIdentity>,
|
|
158
|
+
): ToolCallDelta[] {
|
|
159
|
+
const minted: ToolCallDelta[] = [];
|
|
160
|
+
for (const call of calls) {
|
|
161
|
+
if (!call.name.trim()) {
|
|
162
|
+
throw new ProviderError(
|
|
163
|
+
provider,
|
|
164
|
+
"overload",
|
|
165
|
+
`${provider}: tool call ${call.index} came with no name, so no tool can run it`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (!call.id.trim()) {
|
|
169
|
+
call.id = normalizeToolId(call.id);
|
|
170
|
+
minted.push({ index: call.index, id: call.id });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return minted;
|
|
174
|
+
}
|
|
175
|
+
|
|
132
176
|
/**
|
|
133
177
|
* Parse a tool call's raw argument string into an object, salvaging what a
|
|
134
178
|
* truncated stream left behind, resolving concatenated JSON, and healing
|