@providerkit/core 0.15.1 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +55 -6
- package/dist/errors.js.map +1 -1
- package/dist/presets.d.ts +37 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +37 -2
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +46 -17
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +25 -1
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +44 -16
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +131 -24
- package/dist/providers/responses.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +53 -22
- package/dist/rate-limit.js.map +1 -1
- package/dist/schema.d.ts +10 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +49 -0
- package/dist/schema.js.map +1 -1
- package/dist/transport.d.ts +30 -3
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +67 -28
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +26 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +5 -4
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +14 -5
- package/dist/usage.js.map +1 -1
- package/dist/watchdog.d.ts +22 -8
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +81 -27
- package/dist/watchdog.js.map +1 -1
- package/package.json +3 -2
- package/src/errors.ts +60 -5
- package/src/presets.ts +37 -2
- package/src/providers/anthropic.ts +50 -17
- package/src/providers/gemini.ts +30 -1
- package/src/providers/openai.ts +42 -16
- package/src/providers/responses.ts +158 -26
- package/src/rate-limit.ts +55 -24
- package/src/schema.ts +51 -0
- package/src/transport.ts +95 -24
- package/src/types.ts +26 -2
- package/src/usage.ts +14 -5
- package/src/watchdog.ts +93 -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
|
|
331
|
-
//
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
445
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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.
|
|
499
|
-
//
|
|
500
|
-
//
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
*
|