@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.
Files changed (137) hide show
  1. package/README.md +7 -7
  2. package/dist/attribution.d.ts +2 -2
  3. package/dist/auth/flows.d.ts.map +1 -1
  4. package/dist/auth/flows.js +5 -5
  5. package/dist/auth/flows.js.map +1 -1
  6. package/dist/auth/index.d.ts +1 -0
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +1 -0
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/auth/oauth.d.ts +12 -0
  11. package/dist/auth/oauth.d.ts.map +1 -1
  12. package/dist/auth/oauth.js +19 -1
  13. package/dist/auth/oauth.js.map +1 -1
  14. package/dist/auth/token-source.d.ts +9 -3
  15. package/dist/auth/token-source.d.ts.map +1 -1
  16. package/dist/auth/token-source.js +24 -9
  17. package/dist/auth/token-source.js.map +1 -1
  18. package/dist/auth/with-token.d.ts +29 -0
  19. package/dist/auth/with-token.d.ts.map +1 -0
  20. package/dist/auth/with-token.js +85 -0
  21. package/dist/auth/with-token.js.map +1 -0
  22. package/dist/capability.d.ts +9 -9
  23. package/dist/capability.js +9 -9
  24. package/dist/capability.js.map +1 -1
  25. package/dist/context.d.ts +43 -5
  26. package/dist/context.d.ts.map +1 -1
  27. package/dist/context.js +123 -4
  28. package/dist/context.js.map +1 -1
  29. package/dist/errors.d.ts +26 -17
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js +57 -18
  32. package/dist/errors.js.map +1 -1
  33. package/dist/fallback.d.ts +45 -1
  34. package/dist/fallback.d.ts.map +1 -1
  35. package/dist/fallback.js +138 -75
  36. package/dist/fallback.js.map +1 -1
  37. package/dist/image.d.ts +1 -1
  38. package/dist/image.d.ts.map +1 -1
  39. package/dist/jev.d.ts +9 -9
  40. package/dist/jev.d.ts.map +1 -1
  41. package/dist/jev.js +1 -1
  42. package/dist/key-pool.d.ts +14 -8
  43. package/dist/key-pool.d.ts.map +1 -1
  44. package/dist/key-pool.js +35 -18
  45. package/dist/key-pool.js.map +1 -1
  46. package/dist/presets.d.ts +33 -24
  47. package/dist/presets.d.ts.map +1 -1
  48. package/dist/presets.js +25 -14
  49. package/dist/presets.js.map +1 -1
  50. package/dist/providers/anthropic.d.ts +5 -5
  51. package/dist/providers/anthropic.d.ts.map +1 -1
  52. package/dist/providers/anthropic.js +89 -34
  53. package/dist/providers/anthropic.js.map +1 -1
  54. package/dist/providers/factory.d.ts +2 -2
  55. package/dist/providers/factory.d.ts.map +1 -1
  56. package/dist/providers/factory.js +2 -1
  57. package/dist/providers/factory.js.map +1 -1
  58. package/dist/providers/gemini.d.ts +8 -2
  59. package/dist/providers/gemini.d.ts.map +1 -1
  60. package/dist/providers/gemini.js +117 -41
  61. package/dist/providers/gemini.js.map +1 -1
  62. package/dist/providers/openai.d.ts +12 -9
  63. package/dist/providers/openai.d.ts.map +1 -1
  64. package/dist/providers/openai.js +72 -21
  65. package/dist/providers/openai.js.map +1 -1
  66. package/dist/providers/responses.d.ts +3 -3
  67. package/dist/providers/responses.d.ts.map +1 -1
  68. package/dist/providers/responses.js +103 -57
  69. package/dist/providers/responses.js.map +1 -1
  70. package/dist/providers/zai.d.ts +2 -2
  71. package/dist/providers/zai.js +2 -2
  72. package/dist/rate-limit.d.ts +13 -3
  73. package/dist/rate-limit.d.ts.map +1 -1
  74. package/dist/rate-limit.js +10 -4
  75. package/dist/rate-limit.js.map +1 -1
  76. package/dist/retry.d.ts +16 -5
  77. package/dist/retry.d.ts.map +1 -1
  78. package/dist/retry.js +29 -16
  79. package/dist/retry.js.map +1 -1
  80. package/dist/schema.d.ts +6 -6
  81. package/dist/schema.js +8 -8
  82. package/dist/tool-args.d.ts +23 -1
  83. package/dist/tool-args.d.ts.map +1 -1
  84. package/dist/tool-args.js +30 -2
  85. package/dist/tool-args.js.map +1 -1
  86. package/dist/tools.d.ts +3 -3
  87. package/dist/tools.js +2 -2
  88. package/dist/tools.js.map +1 -1
  89. package/dist/transport.d.ts +40 -10
  90. package/dist/transport.d.ts.map +1 -1
  91. package/dist/transport.js +82 -21
  92. package/dist/transport.js.map +1 -1
  93. package/dist/types.d.ts +37 -23
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/types.js +3 -11
  96. package/dist/types.js.map +1 -1
  97. package/dist/usage.d.ts +4 -4
  98. package/dist/usage.d.ts.map +1 -1
  99. package/dist/usage.js +3 -3
  100. package/dist/usage.js.map +1 -1
  101. package/dist/watchdog.d.ts +14 -12
  102. package/dist/watchdog.d.ts.map +1 -1
  103. package/dist/watchdog.js +35 -16
  104. package/dist/watchdog.js.map +1 -1
  105. package/dist/zod.d.ts +4 -4
  106. package/dist/zod.js +3 -3
  107. package/package.json +1 -1
  108. package/src/attribution.ts +2 -2
  109. package/src/auth/flows.ts +5 -4
  110. package/src/auth/index.ts +1 -0
  111. package/src/auth/oauth.ts +23 -1
  112. package/src/auth/token-source.ts +30 -11
  113. package/src/auth/with-token.ts +97 -0
  114. package/src/capability.ts +12 -12
  115. package/src/context.ts +141 -6
  116. package/src/errors.ts +63 -23
  117. package/src/fallback.ts +173 -75
  118. package/src/image.ts +1 -1
  119. package/src/jev.ts +11 -11
  120. package/src/key-pool.ts +37 -20
  121. package/src/presets.ts +38 -24
  122. package/src/providers/anthropic.ts +97 -38
  123. package/src/providers/factory.ts +4 -3
  124. package/src/providers/gemini.ts +121 -44
  125. package/src/providers/openai.ts +78 -25
  126. package/src/providers/responses.ts +113 -61
  127. package/src/providers/zai.ts +2 -2
  128. package/src/rate-limit.ts +35 -7
  129. package/src/retry.ts +46 -15
  130. package/src/schema.ts +8 -8
  131. package/src/tool-args.ts +46 -2
  132. package/src/tools.ts +4 -4
  133. package/src/transport.ts +103 -23
  134. package/src/types.ts +37 -23
  135. package/src/usage.ts +5 -5
  136. package/src/watchdog.ts +38 -19
  137. package/src/zod.ts +4 -4
@@ -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,
@@ -39,7 +42,7 @@ export interface ResponsesConfig extends ProviderFallbackConfig {
39
42
  effort?: Effort;
40
43
  maxTokens?: number;
41
44
  fetchImpl?: typeof fetch;
42
- /** Extra request headers — where a subscription backend's account id goes
45
+ /** Extra request headers, where a subscription backend's account id goes
43
46
  * (`ChatGPT-Account-Id`), which those backends reject the request without. */
44
47
  headers?: Record<string, string>;
45
48
  /** Header that carries `StreamOptions.sessionId`. When set and a call has no
@@ -50,7 +53,7 @@ export interface ResponsesConfig extends ProviderFallbackConfig {
50
53
  * The ChatGPT subscription surface serves it at `/backend-api/codex/responses`
51
54
  * with no version segment, so that backend needs
52
55
  * `{ baseUrl: "https://chatgpt.com/backend-api/codex", path: "/responses" }`.
53
- * Without the override the POST 404s, and a 404 classifies as "model" — the
56
+ * Without the override the POST 404s, and a 404 classifies as "model". The
54
57
  * user is told the model id does not exist when the path was the problem.
55
58
  */
56
59
  path?: string;
@@ -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 ───────────────────────────────────────────────────────────
@@ -141,8 +157,8 @@ function partsToResponses(
141
157
  });
142
158
  }
143
159
 
144
- /** A tool result is a bare string unless it carried images — then the content-
145
- * array form, the only way to hand this shape a screenshot back. */
160
+ /** A tool result is a bare string unless it carried images. Then it uses the
161
+ * content-array form, the only way to hand this shape a screenshot back. */
146
162
  function toolOutput(
147
163
  content: string,
148
164
  images: readonly ImagePart[],
@@ -157,7 +173,7 @@ function toolOutput(
157
173
  /**
158
174
  * Flatten a history into `instructions` plus the input item list.
159
175
  *
160
- * This shape has no system ROLE — the system prompt is a top-level
176
+ * This shape has no system role. The system prompt is a top-level
161
177
  * `instructions` string, and everything else is items. One assistant turn can
162
178
  * become several items (its text, then one `function_call` per tool it asked
163
179
  * for), which is why a message maps to a list rather than to one item.
@@ -244,7 +260,7 @@ interface ResponsesUsage {
244
260
 
245
261
  interface ResponsesItem {
246
262
  type?: string;
247
- /** The output item's own id (`fc_…`) — what the argument deltas reference. */
263
+ /** The output item's own id (`fc_…`), which the argument deltas reference. */
248
264
  id?: string;
249
265
  /** The id a `function_call_output` must quote on the next turn. */
250
266
  call_id?: string;
@@ -263,7 +279,7 @@ interface ResponsesEvent {
263
279
  arguments?: string;
264
280
  item?: ResponsesItem;
265
281
  response?: {
266
- /** `completed`, `incomplete` or `failed` — stated on the terminal event,
282
+ /** `completed`, `incomplete` or `failed`, stated on the terminal event,
267
283
  * and not always the one its `type` implies. */
268
284
  status?: string;
269
285
  usage?: ResponsesUsage;
@@ -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
 
@@ -297,28 +316,46 @@ interface CodexRateLimits {
297
316
  /**
298
317
  * The quota wall a `codex.rate_limits` snapshot reports, if it is one.
299
318
  *
300
- * The snapshot rides ahead of the first output on every turn and is telemetry
301
- * — a full window the account's credits cover, or that the backend still
319
+ * The snapshot rides ahead of the first output on every turn and is telemetry.
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",
@@ -336,8 +373,8 @@ function usageChunk(usage: ResponsesUsage): ProviderChunk {
336
373
  };
337
374
  }
338
375
 
339
- /** What has already gone out for one in-flight function call — identity and
340
- * arguments both — so the authoritative snapshot on `.done` can be diffed
376
+ /** What has already gone out for one in-flight function call (identity and
377
+ * arguments), so the authoritative snapshot on `.done` can be diffed
341
378
  * against it instead of duplicated. */
342
379
  interface PendingCall {
343
380
  index: number;
@@ -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
 
@@ -13,8 +13,8 @@ export type ZaiCodingConfig = Omit<AnthropicConfig, "baseUrl" | "id" | "bearer">
13
13
  * adapter.
14
14
  *
15
15
  * Dialect notes, measured 2026-09-13 against the live endpoint on
16
- * `glm-5.3-flash`: an absent thinking field means the MODEL's default —
17
- * thinking ON — so `effort: "none"` is sent as an explicit
16
+ * `glm-5.3-flash`: an absent thinking field means the model's default
17
+ * (thinking on), so `effort: "none"` is sent as an explicit
18
18
  * `thinking: { type: "disabled" }`; `low`–`max` ride the standard thinking
19
19
  * budgets, which the endpoint accepts. Images force a thinking block even
20
20
  * under `disabled` (the model's choice, billed as output). */
package/src/rate-limit.ts CHANGED
@@ -32,12 +32,20 @@ export type RateLimitWindow = "5h" | "weekly" | "monthly";
32
32
  export interface RateLimitReset {
33
33
  /** Absolute time the binding window resets, when any header disclosed it. */
34
34
  resetAtMs?: number;
35
- /** Server-requested wait (`retry-after`) — what a retry policy honours. */
35
+ /** Server-requested wait (`retry-after`), which a retry policy honours. */
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-";
@@ -58,7 +66,7 @@ const RESET_HEADERS = [
58
66
  ];
59
67
 
60
68
  /**
61
- * The ChatGPT backend's two windows. Both ride every response — the slots are
69
+ * The ChatGPT backend's two windows. Both ride every response. The slots are
62
70
  * "primary" and "secondary", NOT "5h" and "weekly", so the window is named by
63
71
  * its own `-window-minutes` (300 and 10080 in every capture), and its
64
72
  * `-used-percent` says which one is full. Recorded by cc-proxy off a live turn
@@ -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"));
@@ -154,7 +174,7 @@ function parseRetryAfter(value: string | null, now: number): number | undefined
154
174
  * Anthropic's `anthropic-ratelimit-unified-*` pair rides on every OAuth
155
175
  * response, so both windows are always reported and only one of them is the
156
176
  * reason for this 429. API-key accounts get the `anthropic-ratelimit-*-reset`
157
- * RFC 3339 timestamps instead. Everything is optional — an unrecognized shape
177
+ * RFC 3339 timestamps instead. Everything is optional: an unrecognized shape
158
178
  * yields an empty result, and the caller falls back to its generic message.
159
179
  */
160
180
  export function parseRateLimitReset(headers: Headers, now = Date.now()): RateLimitReset {
@@ -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;
@@ -270,7 +298,7 @@ function parseProseRetryMs(text: string): number | undefined {
270
298
  * Other providers and gateways (LiteLLM, Cloudflare, Ollama) report countdowns in prose:
271
299
  * `Rate limit exceeded. Try again in 20s.`
272
300
  * The window name is inferred from the wait itself, and only past the floor
273
- * above — a sub-minute retry is a throttle, not a subscription window.
301
+ * above. A sub-minute retry is a throttle, not a subscription window.
274
302
  */
275
303
  export function parseUsageLimitBody(bodyText: string, now = Date.now()): RateLimitReset {
276
304
  let body: unknown;
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,9 +104,32 @@ 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
- * Run `fn`, retrying transient failures with backoff. For one-shot calls —
97
- * a title, a summary, a compaction pass.
131
+ * Run `fn`, retrying transient failures with backoff. For one-shot calls such
132
+ * as a title, a summary or a compaction pass.
98
133
  */
99
134
  export async function withRetry<T>(
100
135
  fn: (attempt: number) => Promise<T>,
@@ -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,17 +149,15 @@ 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;
124
157
  }
125
158
 
126
159
  /**
127
- * The streaming twin — with the rule that makes it safe: a retry happens only
160
+ * The streaming twin of `withRetry`. A retry happens only
128
161
  * while NOTHING has been yielded yet.
129
162
  *
130
163
  * `factory` is re-invoked per attempt and gets a fresh signal, so an abandoned
@@ -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)
@@ -182,7 +213,7 @@ export interface BackupModelOptions {
182
213
  models: string[];
183
214
  /**
184
215
  * Whether a failure may fall through to the remaining models. Default:
185
- * overload and rate limits only — those are per-model-endpoint, and nothing
216
+ * overload and rate limits only. Those are per-model-endpoint, and nothing
186
217
  * else on the list is. An auth failure or an invalid request would land
187
218
  * identically on every backup.
188
219
  */
@@ -226,7 +257,7 @@ export async function withBackupModels<T>(
226
257
  }
227
258
 
228
259
  /**
229
- * The streaming twin — carrying the same commitment rule as `withStreamRetry`.
260
+ * The streaming twin of `withBackupModels`, with the same commitment rule as `withStreamRetry`.
230
261
  *
231
262
  * This is the correction worth naming: walking to a backup model AFTER chunks
232
263
  * have already reached the consumer replays the answer from the top, in a
package/src/schema.ts CHANGED
@@ -70,19 +70,19 @@ export function clampToSchema(value: unknown, node: unknown): unknown {
70
70
  * Whether OpenAI's `strict` schema mode will accept this schema.
71
71
  *
72
72
  * Strict is the only JSON mode that actually guarantees the shape, so it is
73
- * worth having — but it demands more than JSON Schema does: every property an
73
+ * worth having, but it demands more than JSON Schema does: every property an
74
74
  * object lists must ALSO be required, and every object must close itself with
75
75
  * `additionalProperties: false`, all the way down. A schema with one optional
76
76
  * field is not "mostly strict"; it is a flat 400 naming a nested path rather
77
77
  * than the rule it broke.
78
78
  *
79
79
  * That trap is the reason this exists. An agent's response schema grows
80
- * optional fields naturally — a `data` block only some flows fill, the fields
81
- * one step collects — and a caller who adds one wants their answer, not a
80
+ * optional fields naturally (a `data` block only some flows fill, the fields
81
+ * one step collects). A caller who adds one wants their answer, not a
82
82
  * lecture about a mode they never asked for. So the OpenAI-shape adapters ask
83
83
  * this and drop to plain (unenforced) schema mode instead of failing the turn.
84
84
  *
85
- * Anything it cannot verify — a `$ref`, a composed `allOf` — answers false:
85
+ * Anything it cannot verify (a `$ref`, a composed `allOf`) answers false:
86
86
  * the cost of guessing wrong that way is unenforced output, and the cost of
87
87
  * guessing wrong the other way is a request that cannot succeed at all.
88
88
  */
@@ -97,7 +97,7 @@ export function clampToSchema(value: unknown, node: unknown): unknown {
97
97
  *
98
98
  * Two shapes need it, which is why the wording lives here rather than in one of
99
99
  * them. Anthropic has never had a schema mode. The OpenAI dialect falls back to
100
- * plain JSON mode on every gateway — `json_object` asks for valid JSON and says
100
+ * plain JSON mode on every gateway: `json_object` asks for valid JSON and says
101
101
  * nothing whatsoever about its shape.
102
102
  */
103
103
  export function schemaPrompt(schema: unknown): string {
@@ -134,8 +134,8 @@ export function isStrictSchema(node: unknown): boolean {
134
134
  );
135
135
  }
136
136
 
137
- /** Keywords whose value is a schema, a list of schemas, or a map of them —
138
- * the only places a `pattern` can mean a regex. Everything else (`default`,
137
+ /** Keywords whose value is a schema, a list of schemas, or a map of them.
138
+ * These are the only places a `pattern` can mean a regex. Everything else (`default`,
139
139
  * `examples`, `enum`, property NAMES) is data and is left alone. */
140
140
  const SCHEMA_SLOTS = [
141
141
  "items",
@@ -156,7 +156,7 @@ const SCHEMA_MAPS = ["properties", "patternProperties", "$defs", "definitions",
156
156
  * A tool schema with every `pattern` constraint removed.
157
157
  *
158
158
  * The ChatGPT backend compiles tool schemas with OpenAI's own regex dialect and
159
- * answers a pattern it cannot read with a 400 for the WHOLE request — strict
159
+ * answers a pattern it cannot read with a 400 for the whole request, strict
160
160
  * or not. Claude Code's Artifact tool carries one, and every request that
161
161
  * offered it failed (cc-proxy #141/#142). Losing the constraint costs a
162
162
  * validation the caller's tool runner repeats anyway; the 400 costs the turn.