@providerkit/core 0.15.0 → 0.16.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 (56) hide show
  1. package/README.md +3 -0
  2. package/dist/errors.d.ts +17 -0
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/errors.js +55 -6
  5. package/dist/errors.js.map +1 -1
  6. package/dist/presets.d.ts +37 -2
  7. package/dist/presets.d.ts.map +1 -1
  8. package/dist/presets.js +37 -2
  9. package/dist/presets.js.map +1 -1
  10. package/dist/providers/anthropic.d.ts.map +1 -1
  11. package/dist/providers/anthropic.js +84 -33
  12. package/dist/providers/anthropic.js.map +1 -1
  13. package/dist/providers/gemini.d.ts.map +1 -1
  14. package/dist/providers/gemini.js +25 -1
  15. package/dist/providers/gemini.js.map +1 -1
  16. package/dist/providers/openai.d.ts.map +1 -1
  17. package/dist/providers/openai.js +44 -16
  18. package/dist/providers/openai.js.map +1 -1
  19. package/dist/providers/responses.d.ts.map +1 -1
  20. package/dist/providers/responses.js +131 -24
  21. package/dist/providers/responses.js.map +1 -1
  22. package/dist/rate-limit.d.ts.map +1 -1
  23. package/dist/rate-limit.js +53 -22
  24. package/dist/rate-limit.js.map +1 -1
  25. package/dist/schema.d.ts +10 -0
  26. package/dist/schema.d.ts.map +1 -1
  27. package/dist/schema.js +49 -0
  28. package/dist/schema.js.map +1 -1
  29. package/dist/transport.d.ts +30 -3
  30. package/dist/transport.d.ts.map +1 -1
  31. package/dist/transport.js +67 -28
  32. package/dist/transport.js.map +1 -1
  33. package/dist/types.d.ts +26 -2
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js.map +1 -1
  36. package/dist/usage.d.ts +5 -4
  37. package/dist/usage.d.ts.map +1 -1
  38. package/dist/usage.js +14 -5
  39. package/dist/usage.js.map +1 -1
  40. package/dist/watchdog.d.ts +22 -8
  41. package/dist/watchdog.d.ts.map +1 -1
  42. package/dist/watchdog.js +77 -27
  43. package/dist/watchdog.js.map +1 -1
  44. package/package.json +3 -2
  45. package/src/errors.ts +60 -5
  46. package/src/presets.ts +37 -2
  47. package/src/providers/anthropic.ts +94 -36
  48. package/src/providers/gemini.ts +30 -1
  49. package/src/providers/openai.ts +42 -16
  50. package/src/providers/responses.ts +158 -26
  51. package/src/rate-limit.ts +55 -24
  52. package/src/schema.ts +51 -0
  53. package/src/transport.ts +95 -24
  54. package/src/types.ts +26 -2
  55. package/src/usage.ts +14 -5
  56. package/src/watchdog.ts +89 -32
@@ -10,7 +10,7 @@
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 { fileRefused, streamError } from "../errors.ts";
13
+ import { fileRefused, ProviderError, streamCut, streamError } from "../errors.ts";
14
14
  import { streamSse, apiUrl } from "../transport.ts";
15
15
  import type {
16
16
  ChatMessage,
@@ -24,7 +24,7 @@ import type {
24
24
  ToolDefinition,
25
25
  } from "../types.ts";
26
26
  import { toDataUri } from "../types.ts";
27
- import { isStrictSchema } from "../schema.ts";
27
+ import { isStrictSchema, withoutPatterns } from "../schema.ts";
28
28
  import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
29
29
 
30
30
  export interface ResponsesConfig extends ProviderFallbackConfig {
@@ -198,6 +198,7 @@ interface ResponsesUsage {
198
198
  input_tokens?: number;
199
199
  input_tokens_details?: { cached_tokens?: number };
200
200
  output_tokens?: number;
201
+ output_tokens_details?: { reasoning_tokens?: number };
201
202
  }
202
203
 
203
204
  interface ResponsesItem {
@@ -214,15 +215,66 @@ interface ResponsesEvent {
214
215
  type?: string;
215
216
  delta?: string;
216
217
  item_id?: string;
218
+ /** xAI keys argument events by the CALL id instead of the item id. */
219
+ call_id?: string;
220
+ /** The whole argument string, on `function_call_arguments.done`. */
221
+ arguments?: string;
217
222
  item?: ResponsesItem;
218
223
  response?: {
224
+ /** `completed`, `incomplete` or `failed` — stated on the terminal event,
225
+ * and not always the one its `type` implies. */
226
+ status?: string;
219
227
  usage?: ResponsesUsage;
220
- incomplete_details?: { reason?: string };
221
- error?: { message?: string; code?: string };
228
+ incomplete_details?: { reason?: string } | null;
229
+ error?: { message?: string; code?: string } | null;
222
230
  };
231
+ /** The ChatGPT backend's quota snapshot (`codex.rate_limits`). */
232
+ rate_limits?: CodexRateLimits;
233
+ credits?: { has_credits?: boolean; unlimited?: boolean };
223
234
  error?: { message?: string; code?: string };
224
235
  message?: string;
225
236
  code?: string;
237
+ /** The ChatGPT backend's error frame states the HTTP status it stands for,
238
+ * and mirrors its `x-codex-*` rate-limit headers, beside `error`. */
239
+ status_code?: number;
240
+ headers?: Record<string, unknown>;
241
+ }
242
+
243
+ interface CodexWindow {
244
+ used_percent?: number;
245
+ reset_after_seconds?: number;
246
+ }
247
+
248
+ interface CodexRateLimits {
249
+ limit_reached?: boolean;
250
+ allowed?: boolean;
251
+ primary?: CodexWindow;
252
+ secondary?: CodexWindow;
253
+ }
254
+
255
+ /**
256
+ * The quota wall a `codex.rate_limits` snapshot reports, if it is one.
257
+ *
258
+ * The snapshot rides ahead of the first output on every turn and is telemetry
259
+ * — a full window the account's credits cover, or that the backend still
260
+ * `allowed`, lets the turn proceed. It is only the reason when the stream then
261
+ * closes without a response: that close is a spent window, not a dropped
262
+ * socket, and retrying it walks every backup into the same wall. The fuller
263
+ * window binds; cc-proxy took the primary's clock first and told a weekly wall
264
+ * to come back in a few hours.
265
+ */
266
+ function quotaWall(event: ResponsesEvent, provider: string): ProviderError | undefined {
267
+ const limits = event.rate_limits;
268
+ if (!limits?.limit_reached || limits.allowed) return undefined;
269
+ if (event.credits?.has_credits || event.credits?.unlimited) return undefined;
270
+ const windows = [limits.primary, limits.secondary].filter((w): w is CodexWindow => !!w);
271
+ const binding = windows.sort((a, b) => (b.used_percent ?? 0) - (a.used_percent ?? 0))[0];
272
+ const after = binding?.reset_after_seconds;
273
+ return new ProviderError(provider, "quota", `${provider}: the usage limit has been reached`, {
274
+ ...(after !== undefined && after >= 0
275
+ ? { retryAfterMs: after * 1000, resetAtMs: Date.now() + after * 1000 }
276
+ : {}),
277
+ });
226
278
  }
227
279
 
228
280
  function usageChunk(usage: ResponsesUsage): ProviderChunk {
@@ -235,6 +287,9 @@ function usageChunk(usage: ResponsesUsage): ProviderChunk {
235
287
  cachedInputTokens: usage.input_tokens_details?.cached_tokens ?? 0,
236
288
  // Reasoning tokens are billed INSIDE output_tokens, not beside them.
237
289
  outputTokens: usage.output_tokens ?? 0,
290
+ ...(usage.output_tokens_details?.reasoning_tokens !== undefined
291
+ ? { reasoningTokens: usage.output_tokens_details.reasoning_tokens }
292
+ : {}),
238
293
  },
239
294
  };
240
295
  }
@@ -249,6 +304,15 @@ interface PendingCall {
249
304
  streamed: string;
250
305
  }
251
306
 
307
+ /** The part of an authoritative snapshot the deltas have not already sent.
308
+ * Re-emitting it whole concatenates the JSON with itself and every argument
309
+ * parse fails; all of it goes when no delta came. */
310
+ function unsentTail(snapshot: string, streamed: string): string {
311
+ return snapshot.length > streamed.length && snapshot.startsWith(streamed)
312
+ ? snapshot.slice(streamed.length)
313
+ : "";
314
+ }
315
+
252
316
  export function createResponsesProvider(config: ResponsesConfig): Provider {
253
317
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
254
318
  const id = config.id ?? "openai-responses";
@@ -301,9 +365,20 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
301
365
  type: "function",
302
366
  name: tool.name,
303
367
  description: tool.description,
304
- parameters: tool.inputSchema,
368
+ parameters: withoutPatterns(tool.inputSchema),
369
+ // Said either way, never left to the default. Strict demands every
370
+ // property required and every object closed; on a schema that is not
371
+ // already shaped that way the backend makes optional arguments
372
+ // mandatory, and the model starts filling in parameters nobody asked
373
+ // for (cc-proxy, 0.1.15). Where the schema already qualifies, the
374
+ // enforcement is free.
375
+ strict: isStrictSchema(tool.inputSchema),
305
376
  }));
377
+ if (opts.parallelToolCalls === false) request.parallel_tool_calls = false;
306
378
  }
379
+ // The cache affinity key. Without one the backend spreads a
380
+ // conversation's turns across cache shards, and a re-sent prefix misses.
381
+ if (opts.sessionId) request.prompt_cache_key = opts.sessionId;
307
382
  // Muse Contributor accepts only `auto`. `none` has an exact wire-level
308
383
  // equivalent: send no tools. Required/named choices stay explicit and let
309
384
  // the endpoint refuse a promise it cannot keep.
@@ -327,13 +402,18 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
327
402
  }
328
403
 
329
404
  // Tool calls arrive as an item skeleton plus argument deltas; keyed by the
330
- // output item id so parallel calls never cross wires. The seam's index is
331
- // ours to assign — `output_index` counts reasoning and message items too.
405
+ // output item id AND the call id, whichever the backend quotes, so
406
+ // parallel calls never cross wires. The seam's index is ours to assign —
407
+ // `output_index` counts reasoning and message items too.
332
408
  const pending = new Map<string, PendingCall>();
409
+ const pendingFor = (itemId?: string, callId?: string) =>
410
+ (itemId ? pending.get(itemId) : undefined) ?? (callId ? pending.get(callId) : undefined);
333
411
  let nextIndex = 0;
334
412
  // This shape never states a stop reason on a clean finish, so it is
335
413
  // inferred from whether the turn produced a function call.
336
414
  let sawToolCall = false;
415
+ let wall: ProviderError | undefined;
416
+ let produced = false;
337
417
 
338
418
  for await (const data of streamSse({
339
419
  url: apiUrl(baseUrl, config.path ?? DEFAULT_PATH),
@@ -348,6 +428,7 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
348
428
  provider: id,
349
429
  ...(opts.signal ? { signal: opts.signal } : {}),
350
430
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
431
+ ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
351
432
  })) {
352
433
  let event: ResponsesEvent;
353
434
  try {
@@ -355,6 +436,9 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
355
436
  } catch {
356
437
  continue; // a keep-alive or a frame we do not model
357
438
  }
439
+ if (event.type?.endsWith(".delta") || event.type === "response.output_item.added") {
440
+ produced = true;
441
+ }
358
442
 
359
443
  switch (event.type) {
360
444
  case "response.output_text.delta":
@@ -371,18 +455,26 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
371
455
 
372
456
  case "response.output_item.added": {
373
457
  const item = event.item;
374
- if (item?.type !== "function_call" || !item.id) break;
458
+ if (item?.type !== "function_call") break;
459
+ // OpenAI names the item (`fc_…`) and keys argument deltas by it;
460
+ // xAI's Grok sends no item id at all and keys them by `call_id`.
461
+ // Requiring the item id dropped every Grok call here, and the
462
+ // turn came back as a nameless call with empty arguments.
463
+ const key = item.id ?? item.call_id;
464
+ if (!key) break;
375
465
  sawToolCall = true;
376
466
  const index = nextIndex++;
377
467
  // Normally empty here, but a backend that already has the whole
378
468
  // call sends it in the skeleton.
379
469
  const seeded = item.arguments ?? "";
380
- pending.set(item.id, {
470
+ const call: PendingCall = {
381
471
  index,
382
472
  id: item.call_id ?? "",
383
473
  name: item.name ?? "",
384
474
  streamed: seeded,
385
- });
475
+ };
476
+ pending.set(key, call);
477
+ if (item.call_id) pending.set(item.call_id, call);
386
478
  // Only the fields the skeleton actually states. An empty `id` here
387
479
  // is not "unknown", it is a wrong answer: a consumer takes the last
388
480
  // stated value, so `""` written into the slot survives the real
@@ -402,18 +494,31 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
402
494
  }
403
495
 
404
496
  case "response.function_call_arguments.delta": {
405
- const call = event.item_id ? pending.get(event.item_id) : undefined;
497
+ const call = pendingFor(event.item_id, event.call_id);
406
498
  if (!call || !event.delta) break;
407
499
  call.streamed += event.delta;
408
500
  yield { type: "delta", toolCalls: [{ index: call.index, arguments: event.delta }] };
409
501
  break;
410
502
  }
411
503
 
504
+ // xAI states the finished argument string here and NOT on the
505
+ // closing item, which carries only `call_id`. Skipped, a call whose
506
+ // deltas never came reaches the caller with empty arguments.
507
+ case "response.function_call_arguments.done": {
508
+ const call = pendingFor(event.item_id, event.call_id);
509
+ const tail = call ? unsentTail(event.arguments ?? "", call.streamed) : "";
510
+ if (!call || !tail) break;
511
+ call.streamed += tail;
512
+ yield { type: "delta", toolCalls: [{ index: call.index, arguments: tail }] };
513
+ break;
514
+ }
515
+
412
516
  case "response.output_item.done": {
413
517
  const item = event.item;
414
518
  if (item?.type !== "function_call") break;
415
- const known = item.id ? pending.get(item.id) : undefined;
519
+ const known = pendingFor(item.id, item.call_id);
416
520
  if (item.id) pending.delete(item.id);
521
+ if (item.call_id) pending.delete(item.call_id);
417
522
  const snapshot = item.arguments ?? "";
418
523
 
419
524
  if (!known) {
@@ -441,13 +546,8 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
441
546
  }
442
547
 
443
548
  // The snapshot is authoritative, but the fragments already went
444
- // out: re-emitting it whole concatenates the JSON with itself and
445
- // every argument parse fails. Send only what the deltas missed —
446
- // which is all of it when they never came.
447
- const tail =
448
- snapshot.length > known.streamed.length && snapshot.startsWith(known.streamed)
449
- ? snapshot.slice(known.streamed.length)
450
- : "";
549
+ // out: send only what the deltas missed.
550
+ const tail = unsentTail(snapshot, known.streamed);
451
551
  // `.done` restates the identity, and on a backend that leaves it
452
552
  // out of the skeleton this is the only frame that carries it. It
453
553
  // rides last so it WINS: the alternative is a caller assembling a
@@ -464,13 +564,34 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
464
564
  break;
465
565
  }
466
566
 
467
- case "response.completed": {
468
- const usage = event.response?.usage;
469
- if (usage) yield usageChunk(usage);
567
+ // `response.done` is the same terminal event under the name
568
+ // OpenCode's gateway uses.
569
+ case "response.completed":
570
+ case "response.done": {
571
+ const response = event.response;
572
+ // A terminal event names its own outcome, and the type is not
573
+ // always it: a `completed` that states `failed` or carries an
574
+ // error failed, and one that states `incomplete` was cut. Read as
575
+ // a clean stop, either is a broken turn reported as a finished one.
576
+ if (response?.status === "failed" || response?.error) {
577
+ throw streamError(id, response.error ?? { message: "response failed" });
578
+ }
579
+ if (response?.usage) yield usageChunk(response.usage);
580
+ if (response?.status === "incomplete") {
581
+ yield {
582
+ type: "finish",
583
+ finishReason: mapIncompleteReason(response.incomplete_details?.reason),
584
+ };
585
+ return;
586
+ }
470
587
  yield { type: "finish", finishReason: sawToolCall ? "tool_calls" : "stop" };
471
588
  return;
472
589
  }
473
590
 
591
+ case "codex.rate_limits":
592
+ wall = quotaWall(event, id);
593
+ break;
594
+
474
595
  case "response.incomplete": {
475
596
  // A turn that ran out of output tokens still billed for its input,
476
597
  // and this event carries usage in the same shape as `completed`.
@@ -491,13 +612,24 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
491
612
 
492
613
  case "error":
493
614
  case "response.error":
494
- throw streamError(id, event.error ?? { message: event.message, code: event.code });
615
+ // The status and the window clocks ride beside `error`. Without
616
+ // them a spent Codex window reads as an unnamed in-stream failure:
617
+ // floored to overload, then retried and walked across every backup
618
+ // on the same account wall.
619
+ throw streamError(
620
+ id,
621
+ event.error
622
+ ? { ...event.error, status_code: event.status_code, headers: event.headers }
623
+ : { message: event.message, code: event.code },
624
+ );
495
625
  }
496
626
  }
497
627
 
498
- // The stream closed without a terminal event. Nothing is lost but the
499
- // finish reason and the usage record — every delta, tool-call fragment
500
- // included, was already yielded as it arrived.
628
+ // The stream closed without a terminal event. If the backend's last word
629
+ // was a spent window and nothing was produced, that is why; otherwise
630
+ // the stream was cut (see `streamCut`).
631
+ if (wall && !produced) throw wall;
632
+ throw streamCut(id);
501
633
  },
502
634
  };
503
635
 
package/src/rate-limit.ts CHANGED
@@ -57,6 +57,32 @@ const RESET_HEADERS = [
57
57
  "x-ratelimit-reset",
58
58
  ];
59
59
 
60
+ /**
61
+ * The ChatGPT backend's two windows. Both ride every response — the slots are
62
+ * "primary" and "secondary", NOT "5h" and "weekly", so the window is named by
63
+ * its own `-window-minutes` (300 and 10080 in every capture), and its
64
+ * `-used-percent` says which one is full. Recorded by cc-proxy off a live turn
65
+ * that spent the 5-hour window, 2026-09.
66
+ */
67
+ const CODEX_SLOTS = ["primary", "secondary"] as const;
68
+
69
+ function windowForMinutes(minutes: number | undefined): RateLimitWindow | undefined {
70
+ return minutes !== undefined && minutes > 0 ? windowForMs(minutes * 60_000) : undefined;
71
+ }
72
+
73
+ function codexWindow(headers: Headers, slot: (typeof CODEX_SLOTS)[number], now: number) {
74
+ const name = (suffix: string) => headers.get(`x-codex-${slot}-${suffix}`);
75
+ const percent = numeric(name("used-percent"));
76
+ const after = numeric(name("reset-after-seconds"));
77
+ return {
78
+ window: windowForMinutes(numeric(name("window-minutes"))),
79
+ // A percentage here; Anthropic's unified utilization is a fraction.
80
+ utilization: percent === undefined ? undefined : percent / 100,
81
+ reset:
82
+ unixSecondsToMs(name("reset-at")) ?? (after === undefined ? undefined : now + after * 1000),
83
+ };
84
+ }
85
+
60
86
  /** Seconds since 1970 passed a billion in 2001, so a bare integer above this is
61
87
  * a timestamp and anything below it is a countdown. Vendors disagree on which
62
88
  * they send under the same header name, so magnitude decides. */
@@ -158,10 +184,11 @@ export function parseRateLimitReset(headers: Headers, now = Date.now()): RateLim
158
184
  utilization: numeric(headers.get(`${UNIFIED_7D}utilization`)),
159
185
  reset: unixSecondsToMs(headers.get(`${UNIFIED_7D}reset`)),
160
186
  },
187
+ ...CODEX_SLOTS.map((slot) => codexWindow(headers, slot, now)),
161
188
  ].filter((w) => w.utilization !== undefined && w.reset !== undefined && w.reset > now);
162
189
  const binding = windows.sort((a, b) => (b.utilization ?? 0) - (a.utilization ?? 0))[0];
163
190
  if (binding) {
164
- result.window = binding.window;
191
+ if (binding.window) result.window = binding.window;
165
192
  // The window's own reset outranks retry-after: it names the real horizon,
166
193
  // where retry-after names the next polite attempt.
167
194
  if (binding.reset !== undefined) result.resetAtMs = binding.reset;
@@ -191,6 +218,20 @@ const WINDOW_FLOOR_MS = 10 * 60_000;
191
218
  const FIVE_HOUR_MAX_MS = 5.5 * 3_600_000;
192
219
  const WEEKLY_MAX_MS = 7.5 * 86_400_000;
193
220
 
221
+ /** The window a span this long belongs to. */
222
+ function windowForMs(ms: number): RateLimitWindow {
223
+ if (ms <= FIVE_HOUR_MAX_MS) return "5h";
224
+ if (ms <= WEEKLY_MAX_MS) return "weekly";
225
+ return "monthly";
226
+ }
227
+
228
+ /** A wait read off a body, named as a window once it is long enough to be one. */
229
+ function resetAfter(waitMs: number, now: number): RateLimitReset {
230
+ const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
231
+ if (waitMs > WINDOW_FLOOR_MS) result.window = windowForMs(waitMs);
232
+ return result;
233
+ }
234
+
194
235
  /** A finite number off a JSON field, the string form a relay may stringify it
195
236
  * into included. Bare `Number()` is the trap `numeric()` guards against one
196
237
  * layer up: `Number(null)`, `Number("")`, `Number([])` and `Number(false)` are
@@ -248,34 +289,24 @@ export function parseUsageLimitBody(bodyText: string, now = Date.now()): RateLim
248
289
 
249
290
  const inSeconds = jsonSeconds(error.resets_in_seconds);
250
291
  const atSeconds = jsonSeconds(error.resets_at);
251
- const waitMs =
252
- inSeconds !== undefined && inSeconds >= 0
253
- ? inSeconds * 1000
254
- : atSeconds !== undefined && atSeconds > 0
255
- ? Math.max(0, atSeconds * 1000 - now)
256
- : undefined;
257
- if (waitMs !== undefined) {
258
- const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
259
- if (waitMs > WINDOW_FLOOR_MS) {
260
- result.window =
261
- waitMs <= FIVE_HOUR_MAX_MS ? "5h" : waitMs <= WEEKLY_MAX_MS ? "weekly" : "monthly";
262
- }
263
- return result;
292
+ // The plain wait some gateways put in the error body instead of a header,
293
+ // under either name (cc-proxy reads both off the ChatGPT backend's frames).
294
+ const afterSeconds =
295
+ jsonSeconds(error.retry_after) ??
296
+ jsonSeconds(error.retry_after_seconds) ??
297
+ jsonSeconds((body as Record<string, unknown>).retry_after_seconds);
298
+ if (inSeconds !== undefined && inSeconds >= 0) return resetAfter(inSeconds * 1000, now);
299
+ if (atSeconds !== undefined && atSeconds > 0) {
300
+ return resetAfter(Math.max(0, atSeconds * 1000 - now), now);
301
+ }
302
+ if (afterSeconds !== undefined && afterSeconds >= 0) {
303
+ return resetAfter(afterSeconds * 1000, now);
264
304
  }
265
305
  }
266
306
 
267
307
  // If structured JSON did not yield a reset, check prose wording
268
308
  const waitMs = parseProseRetryMs(bodyText);
269
- if (waitMs !== undefined) {
270
- const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
271
- if (waitMs > WINDOW_FLOOR_MS) {
272
- result.window =
273
- waitMs <= FIVE_HOUR_MAX_MS ? "5h" : waitMs <= WEEKLY_MAX_MS ? "weekly" : "monthly";
274
- }
275
- return result;
276
- }
277
-
278
- return {};
309
+ return waitMs === undefined ? {} : resetAfter(waitMs, now);
279
310
  }
280
311
 
281
312
  /** Read both places an endpoint can report its reset. A short Retry-After is
package/src/schema.ts CHANGED
@@ -134,6 +134,57 @@ 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`,
139
+ * `examples`, `enum`, property NAMES) is data and is left alone. */
140
+ const SCHEMA_SLOTS = [
141
+ "items",
142
+ "additionalProperties",
143
+ "not",
144
+ "if",
145
+ "then",
146
+ "else",
147
+ "contains",
148
+ "propertyNames",
149
+ "unevaluatedItems",
150
+ "unevaluatedProperties",
151
+ ];
152
+ const SCHEMA_LISTS = ["anyOf", "oneOf", "allOf", "prefixItems"];
153
+ const SCHEMA_MAPS = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"];
154
+
155
+ /**
156
+ * A tool schema with every `pattern` constraint removed.
157
+ *
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
160
+ * or not. Claude Code's Artifact tool carries one, and every request that
161
+ * offered it failed (cc-proxy #141/#142). Losing the constraint costs a
162
+ * validation the caller's tool runner repeats anyway; the 400 costs the turn.
163
+ */
164
+ export function withoutPatterns(node: unknown): unknown {
165
+ if (!node || typeof node !== "object" || Array.isArray(node)) return node;
166
+ const { pattern: _pattern, ...schema } = node as Record<string, unknown>;
167
+ for (const key of SCHEMA_SLOTS) {
168
+ const value = schema[key];
169
+ if (value !== undefined) {
170
+ schema[key] = Array.isArray(value) ? value.map(withoutPatterns) : withoutPatterns(value);
171
+ }
172
+ }
173
+ for (const key of SCHEMA_LISTS) {
174
+ const value = schema[key];
175
+ if (Array.isArray(value)) schema[key] = value.map(withoutPatterns);
176
+ }
177
+ for (const key of SCHEMA_MAPS) {
178
+ const value = schema[key];
179
+ if (value && typeof value === "object" && !Array.isArray(value)) {
180
+ schema[key] = Object.fromEntries(
181
+ Object.entries(value).map(([name, child]) => [name, withoutPatterns(child)]),
182
+ );
183
+ }
184
+ }
185
+ return schema;
186
+ }
187
+
137
188
  /**
138
189
  * Sanitize a JSON Schema for Google Gemini's REST API.
139
190
  *