@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.
Files changed (97) hide show
  1. package/dist/auth/flows.d.ts.map +1 -1
  2. package/dist/auth/flows.js +5 -5
  3. package/dist/auth/flows.js.map +1 -1
  4. package/dist/auth/index.d.ts +1 -0
  5. package/dist/auth/index.d.ts.map +1 -1
  6. package/dist/auth/index.js +1 -0
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/auth/oauth.d.ts +12 -0
  9. package/dist/auth/oauth.d.ts.map +1 -1
  10. package/dist/auth/oauth.js +19 -1
  11. package/dist/auth/oauth.js.map +1 -1
  12. package/dist/auth/token-source.d.ts +9 -3
  13. package/dist/auth/token-source.d.ts.map +1 -1
  14. package/dist/auth/token-source.js +24 -9
  15. package/dist/auth/token-source.js.map +1 -1
  16. package/dist/auth/with-token.d.ts +29 -0
  17. package/dist/auth/with-token.d.ts.map +1 -0
  18. package/dist/auth/with-token.js +85 -0
  19. package/dist/auth/with-token.js.map +1 -0
  20. package/dist/context.d.ts +39 -1
  21. package/dist/context.d.ts.map +1 -1
  22. package/dist/context.js +119 -0
  23. package/dist/context.js.map +1 -1
  24. package/dist/errors.d.ts +12 -3
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +44 -5
  27. package/dist/errors.js.map +1 -1
  28. package/dist/fallback.d.ts +45 -1
  29. package/dist/fallback.d.ts.map +1 -1
  30. package/dist/fallback.js +138 -75
  31. package/dist/fallback.js.map +1 -1
  32. package/dist/key-pool.d.ts +10 -4
  33. package/dist/key-pool.d.ts.map +1 -1
  34. package/dist/key-pool.js +29 -12
  35. package/dist/key-pool.js.map +1 -1
  36. package/dist/presets.d.ts +7 -2
  37. package/dist/presets.d.ts.map +1 -1
  38. package/dist/presets.js +12 -2
  39. package/dist/presets.js.map +1 -1
  40. package/dist/providers/anthropic.d.ts.map +1 -1
  41. package/dist/providers/anthropic.js +80 -25
  42. package/dist/providers/anthropic.js.map +1 -1
  43. package/dist/providers/gemini.d.ts +6 -0
  44. package/dist/providers/gemini.d.ts.map +1 -1
  45. package/dist/providers/gemini.js +102 -27
  46. package/dist/providers/gemini.js.map +1 -1
  47. package/dist/providers/openai.d.ts.map +1 -1
  48. package/dist/providers/openai.js +63 -14
  49. package/dist/providers/openai.js.map +1 -1
  50. package/dist/providers/responses.d.ts.map +1 -1
  51. package/dist/providers/responses.js +98 -52
  52. package/dist/providers/responses.js.map +1 -1
  53. package/dist/rate-limit.d.ts +10 -0
  54. package/dist/rate-limit.d.ts.map +1 -1
  55. package/dist/rate-limit.js +7 -1
  56. package/dist/rate-limit.js.map +1 -1
  57. package/dist/retry.d.ts +11 -0
  58. package/dist/retry.d.ts.map +1 -1
  59. package/dist/retry.js +25 -12
  60. package/dist/retry.js.map +1 -1
  61. package/dist/tool-args.d.ts +22 -0
  62. package/dist/tool-args.d.ts.map +1 -1
  63. package/dist/tool-args.js +28 -0
  64. package/dist/tool-args.js.map +1 -1
  65. package/dist/transport.d.ts +34 -4
  66. package/dist/transport.d.ts.map +1 -1
  67. package/dist/transport.js +77 -16
  68. package/dist/transport.js.map +1 -1
  69. package/dist/types.d.ts +13 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/types.js +0 -8
  72. package/dist/types.js.map +1 -1
  73. package/dist/watchdog.d.ts +2 -0
  74. package/dist/watchdog.d.ts.map +1 -1
  75. package/dist/watchdog.js +25 -6
  76. package/dist/watchdog.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/auth/flows.ts +5 -4
  79. package/src/auth/index.ts +1 -0
  80. package/src/auth/oauth.ts +23 -1
  81. package/src/auth/token-source.ts +30 -11
  82. package/src/auth/with-token.ts +97 -0
  83. package/src/context.ts +137 -2
  84. package/src/errors.ts +46 -6
  85. package/src/fallback.ts +173 -75
  86. package/src/key-pool.ts +30 -13
  87. package/src/presets.ts +12 -2
  88. package/src/providers/anthropic.ts +83 -24
  89. package/src/providers/gemini.ts +102 -28
  90. package/src/providers/openai.ts +61 -13
  91. package/src/providers/responses.ts +102 -50
  92. package/src/rate-limit.ts +31 -3
  93. package/src/retry.ts +41 -10
  94. package/src/tool-args.ts +44 -0
  95. package/src/transport.ts +97 -17
  96. package/src/types.ts +13 -0
  97. 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
- * `response.incomplete` means the turn was cut short, and the seam has one word
86
- * for that: "length". `content_filter` is the only other reason this shape
87
- * documents; anything new stays on "length" rather than reporting a clean stop,
88
- * because a caller that believes a truncated answer finished will act on it.
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 mapIncompleteReason(reason: string | undefined): FinishReason {
91
- return reason === "content_filter" ? "content_filter" : "length";
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. The fuller
305
- * window binds; cc-proxy took the primary's clock first and told a weekly wall
306
- * to come back in a few hours.
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 windows = [limits.primary, limits.secondary].filter((w): w is CodexWindow => !!w);
313
- const binding = windows.sort((a, b) => (b.used_percent ?? 0) - (a.used_percent ?? 0))[0];
314
- const after = binding?.reset_after_seconds;
315
- return new ProviderError(provider, "quota", `${provider}: the usage limit has been reached`, {
316
- ...(after !== undefined && after >= 0
317
- ? { retryAfterMs: after * 1000, resetAtMs: Date.now() + after * 1000 }
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(messages, config.imageLimits) : messages,
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
- // This shape never states a stop reason on a clean finish, so it is
461
- // inferred from whether the turn produced a function call.
462
- let sawToolCall = false;
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
- let event: ResponsesEvent;
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. Without a `call_id`
586
- // there is nothing to answer it with: the caller's
587
- // `function_call_output` would quote `""` and take a 400 reading
588
- // "No tool output found for function call" one turn later, so the
589
- // call is dropped rather than handed over unrunnable. Dropping is
590
- // only possible here, where nothing has been streamed for it yet.
591
- if (!item.call_id) break;
592
- sawToolCall = true;
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: nextIndex++,
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
- if (response?.status === "incomplete") {
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(headers: Headers, slot: (typeof CODEX_SLOTS)[number], now: number) {
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((w) => w.utilization !== undefined && w.reset !== undefined && w.reset > now);
189
- const binding = windows.sort((a, b) => (b.utilization ?? 0) - (a.utilization ?? 0))[0];
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
- const nap = opts.sleep ?? sleep;
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
- const delayMs = delayFor(err, attempt, opts);
118
- if (delayMs === null) throw err;
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
- const nap = opts.sleep ?? sleep;
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
- const delayMs = delayFor(err, attempt, opts);
167
- if (delayMs === null) throw err;
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