@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.
- package/README.md +7 -7
- package/dist/attribution.d.ts +2 -2
- package/dist/auth/flows.d.ts.map +1 -1
- package/dist/auth/flows.js +5 -5
- package/dist/auth/flows.js.map +1 -1
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/oauth.d.ts +12 -0
- package/dist/auth/oauth.d.ts.map +1 -1
- package/dist/auth/oauth.js +19 -1
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/token-source.d.ts +9 -3
- package/dist/auth/token-source.d.ts.map +1 -1
- package/dist/auth/token-source.js +24 -9
- package/dist/auth/token-source.js.map +1 -1
- package/dist/auth/with-token.d.ts +29 -0
- package/dist/auth/with-token.d.ts.map +1 -0
- package/dist/auth/with-token.js +85 -0
- package/dist/auth/with-token.js.map +1 -0
- package/dist/capability.d.ts +9 -9
- package/dist/capability.js +9 -9
- package/dist/capability.js.map +1 -1
- package/dist/context.d.ts +43 -5
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +123 -4
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +26 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +57 -18
- package/dist/errors.js.map +1 -1
- package/dist/fallback.d.ts +45 -1
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +138 -75
- package/dist/fallback.js.map +1 -1
- package/dist/image.d.ts +1 -1
- package/dist/image.d.ts.map +1 -1
- package/dist/jev.d.ts +9 -9
- package/dist/jev.d.ts.map +1 -1
- package/dist/jev.js +1 -1
- package/dist/key-pool.d.ts +14 -8
- package/dist/key-pool.d.ts.map +1 -1
- package/dist/key-pool.js +35 -18
- package/dist/key-pool.js.map +1 -1
- package/dist/presets.d.ts +33 -24
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +25 -14
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts +5 -5
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +89 -34
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/factory.d.ts +2 -2
- package/dist/providers/factory.d.ts.map +1 -1
- package/dist/providers/factory.js +2 -1
- package/dist/providers/factory.js.map +1 -1
- package/dist/providers/gemini.d.ts +8 -2
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +117 -41
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +12 -9
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +72 -21
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +3 -3
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +103 -57
- package/dist/providers/responses.js.map +1 -1
- package/dist/providers/zai.d.ts +2 -2
- package/dist/providers/zai.js +2 -2
- package/dist/rate-limit.d.ts +13 -3
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +10 -4
- package/dist/rate-limit.js.map +1 -1
- package/dist/retry.d.ts +16 -5
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +29 -16
- package/dist/retry.js.map +1 -1
- package/dist/schema.d.ts +6 -6
- package/dist/schema.js +8 -8
- package/dist/tool-args.d.ts +23 -1
- package/dist/tool-args.d.ts.map +1 -1
- package/dist/tool-args.js +30 -2
- package/dist/tool-args.js.map +1 -1
- package/dist/tools.d.ts +3 -3
- package/dist/tools.js +2 -2
- package/dist/tools.js.map +1 -1
- package/dist/transport.d.ts +40 -10
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +82 -21
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +37 -23
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -11
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +4 -4
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +3 -3
- package/dist/usage.js.map +1 -1
- package/dist/watchdog.d.ts +14 -12
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +35 -16
- package/dist/watchdog.js.map +1 -1
- package/dist/zod.d.ts +4 -4
- package/dist/zod.js +3 -3
- package/package.json +1 -1
- package/src/attribution.ts +2 -2
- package/src/auth/flows.ts +5 -4
- package/src/auth/index.ts +1 -0
- package/src/auth/oauth.ts +23 -1
- package/src/auth/token-source.ts +30 -11
- package/src/auth/with-token.ts +97 -0
- package/src/capability.ts +12 -12
- package/src/context.ts +141 -6
- package/src/errors.ts +63 -23
- package/src/fallback.ts +173 -75
- package/src/image.ts +1 -1
- package/src/jev.ts +11 -11
- package/src/key-pool.ts +37 -20
- package/src/presets.ts +38 -24
- package/src/providers/anthropic.ts +97 -38
- package/src/providers/factory.ts +4 -3
- package/src/providers/gemini.ts +121 -44
- package/src/providers/openai.ts +78 -25
- package/src/providers/responses.ts +113 -61
- package/src/providers/zai.ts +2 -2
- package/src/rate-limit.ts +35 -7
- package/src/retry.ts +46 -15
- package/src/schema.ts +8 -8
- package/src/tool-args.ts +46 -2
- package/src/tools.ts +4 -4
- package/src/transport.ts +103 -23
- package/src/types.ts +37 -23
- package/src/usage.ts +5 -5
- package/src/watchdog.ts +38 -19
- 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
|
|
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"
|
|
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
|
-
* `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
88
|
+
* The finish an `incomplete` turn stands for. This shape documents two
|
|
89
|
+
* reasons, and each has its own finish: `max_output_tokens` is the output cap
|
|
90
|
+
* (`length`) and `content_filter` the filter.
|
|
91
|
+
*
|
|
92
|
+
* Any other reason, or none, says only that the turn did not end on purpose.
|
|
93
|
+
* It used to read as `length`, so an empty turn was told to raise a maxTokens
|
|
94
|
+
* it never hit. It is thrown as `overload` instead, the transient fault
|
|
95
|
+
* cc-proxy answers it with (a 503). Nothing about the request is wrong, so it
|
|
96
|
+
* is retried if nothing was emitted yet; if something was, the error reaches
|
|
97
|
+
* the caller after it (invariant 1). It is read before reasoning and usage go
|
|
98
|
+
* out, like a failed terminal, so that retry stays open.
|
|
89
99
|
*/
|
|
90
|
-
function
|
|
91
|
-
|
|
100
|
+
function incompleteFinish(provider: string, reason: string | undefined): FinishReason {
|
|
101
|
+
if (reason === "max_output_tokens") return "length";
|
|
102
|
+
if (reason === "content_filter") return "content_filter";
|
|
103
|
+
throw new ProviderError(
|
|
104
|
+
provider,
|
|
105
|
+
"overload",
|
|
106
|
+
`${provider}: the turn stopped early, reason: ${reason ?? "none given"}`,
|
|
107
|
+
);
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
// ── input items ───────────────────────────────────────────────────────────
|
|
@@ -141,8 +157,8 @@ function partsToResponses(
|
|
|
141
157
|
});
|
|
142
158
|
}
|
|
143
159
|
|
|
144
|
-
/** A tool result is a bare string unless it carried images
|
|
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
|
|
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_…`)
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
305
|
-
*
|
|
306
|
-
*
|
|
323
|
+
* socket, and retrying it walks every backup into the same wall.
|
|
324
|
+
*
|
|
325
|
+
* Which window, and when it lifts, comes from the same parser as the headers
|
|
326
|
+
* the snapshot mirrors: the fuller window binds, and the later reset when both
|
|
327
|
+
* are full. cc-proxy took the primary's clock first and told a weekly wall to
|
|
328
|
+
* come back in a few hours. The window is named so a caller can say "weekly"
|
|
329
|
+
* instead of "the limit", which reads as the 5-hour one.
|
|
307
330
|
*/
|
|
308
331
|
function quotaWall(event: ResponsesEvent, provider: string): ProviderError | undefined {
|
|
309
332
|
const limits = event.rate_limits;
|
|
310
333
|
if (!limits?.limit_reached || limits.allowed) return undefined;
|
|
311
334
|
if (event.credits?.has_credits || event.credits?.unlimited) return undefined;
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
335
|
+
const now = Date.now();
|
|
336
|
+
const { window, resetAtMs } = parseRateLimitReset(codexHeaders(limits), now);
|
|
337
|
+
const named = window ? ` (${window} window)` : "";
|
|
338
|
+
const message = `${provider}: the usage limit has been reached${named}`;
|
|
339
|
+
return new ProviderError(provider, "quota", message, {
|
|
340
|
+
...(resetAtMs !== undefined ? { retryAfterMs: resetAtMs - now, resetAtMs } : {}),
|
|
341
|
+
window,
|
|
319
342
|
});
|
|
320
343
|
}
|
|
321
344
|
|
|
345
|
+
/** The snapshot as the `x-codex-*` headers it mirrors, rebuilt the way
|
|
346
|
+
* cc-proxy's `event_headers` does, so one parser reads both. */
|
|
347
|
+
function codexHeaders(limits: CodexRateLimits): Headers {
|
|
348
|
+
const headers = new Headers();
|
|
349
|
+
for (const slot of ["primary", "secondary"] as const) {
|
|
350
|
+
for (const [field, value] of Object.entries(limits[slot] ?? {})) {
|
|
351
|
+
if (typeof value === "number") {
|
|
352
|
+
headers.set(`x-codex-${slot}-${field.replaceAll("_", "-")}`, String(value));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return headers;
|
|
357
|
+
}
|
|
358
|
+
|
|
322
359
|
function usageChunk(usage: ResponsesUsage): ProviderChunk {
|
|
323
360
|
return {
|
|
324
361
|
type: "usage",
|
|
@@ -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
|
|
340
|
-
* arguments
|
|
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(
|
|
417
|
+
config.imageLimits ? gateImages(sent, config.imageLimits) : sent,
|
|
378
418
|
id,
|
|
379
419
|
);
|
|
380
420
|
|
|
@@ -457,9 +497,10 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
457
497
|
const pendingFor = (itemId?: string, callId?: string) =>
|
|
458
498
|
(itemId ? pending.get(itemId) : undefined) ?? (callId ? pending.get(callId) : undefined);
|
|
459
499
|
let nextIndex = 0;
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
500
|
+
// Every call the turn opened, kept past its `.done`. This shape never
|
|
501
|
+
// states a stop reason on a clean finish, so it is inferred from whether
|
|
502
|
+
// there are any, and the terminal event checks each one can run.
|
|
503
|
+
const calls: PendingCall[] = [];
|
|
463
504
|
let wall: ProviderError | undefined;
|
|
464
505
|
let produced = false;
|
|
465
506
|
const reasoningItems: unknown[] = [];
|
|
@@ -482,13 +523,9 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
482
523
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
483
524
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
484
525
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
526
|
+
...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
|
|
485
527
|
})) {
|
|
486
|
-
|
|
487
|
-
try {
|
|
488
|
-
event = JSON.parse(data) as ResponsesEvent;
|
|
489
|
-
} catch {
|
|
490
|
-
continue; // a keep-alive or a frame we do not model
|
|
491
|
-
}
|
|
528
|
+
const event = parseFrame<ResponsesEvent>(data, id);
|
|
492
529
|
if (event.type?.endsWith(".delta") || event.type === "response.output_item.added") {
|
|
493
530
|
produced = true;
|
|
494
531
|
}
|
|
@@ -515,7 +552,6 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
515
552
|
// turn came back as a nameless call with empty arguments.
|
|
516
553
|
const key = item.id ?? item.call_id;
|
|
517
554
|
if (!key) break;
|
|
518
|
-
sawToolCall = true;
|
|
519
555
|
const index = nextIndex++;
|
|
520
556
|
// Normally empty here, but a backend that already has the whole
|
|
521
557
|
// call sends it in the skeleton.
|
|
@@ -526,6 +562,7 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
526
562
|
name: item.name ?? "",
|
|
527
563
|
streamed: seeded,
|
|
528
564
|
};
|
|
565
|
+
calls.push(call);
|
|
529
566
|
pending.set(key, call);
|
|
530
567
|
if (item.call_id) pending.set(item.call_id, call);
|
|
531
568
|
// Only the fields the skeleton actually states. An empty `id` here
|
|
@@ -582,20 +619,24 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
582
619
|
|
|
583
620
|
if (!known) {
|
|
584
621
|
// A backend that emits neither the skeleton nor the deltas — the
|
|
585
|
-
// whole call arrives here or not at all.
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
622
|
+
// whole call arrives here or not at all. A call with no `call_id`
|
|
623
|
+
// used to be dropped, because the caller's `function_call_output`
|
|
624
|
+
// would quote `""` and take a 400 one turn later. Dropped, the
|
|
625
|
+
// model's call silently never happened. It gets an id at the
|
|
626
|
+
// terminal event instead (`settleToolCalls`).
|
|
627
|
+
const call: PendingCall = {
|
|
628
|
+
index: nextIndex++,
|
|
629
|
+
id: item.call_id ?? "",
|
|
630
|
+
name: item.name ?? "",
|
|
631
|
+
streamed: snapshot,
|
|
632
|
+
};
|
|
633
|
+
calls.push(call);
|
|
593
634
|
yield {
|
|
594
635
|
type: "delta",
|
|
595
636
|
toolCalls: [
|
|
596
637
|
{
|
|
597
|
-
index:
|
|
598
|
-
id: item.call_id,
|
|
638
|
+
index: call.index,
|
|
639
|
+
...(item.call_id ? { id: item.call_id } : {}),
|
|
599
640
|
...(item.name ? { name: item.name } : {}),
|
|
600
641
|
arguments: snapshot,
|
|
601
642
|
},
|
|
@@ -617,6 +658,8 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
617
658
|
...(item.name && item.name !== known.name ? { name: item.name } : {}),
|
|
618
659
|
...(tail ? { arguments: tail } : {}),
|
|
619
660
|
};
|
|
661
|
+
known.id = item.call_id || known.id;
|
|
662
|
+
known.name = item.name || known.name;
|
|
620
663
|
if (Object.keys(restated).length > 0) {
|
|
621
664
|
yield { type: "delta", toolCalls: [{ index: known.index, ...restated }] };
|
|
622
665
|
}
|
|
@@ -635,16 +678,27 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
635
678
|
if (response?.status === "failed" || response?.error) {
|
|
636
679
|
throw streamError(id, response.error ?? { message: "response failed" });
|
|
637
680
|
}
|
|
681
|
+
// Any other status (`cancelled`, `queued`, one added later) says
|
|
682
|
+
// nothing about the answer being whole. Read as a clean stop, it
|
|
683
|
+
// was a finished turn nobody retried. Theirs, so `overload`.
|
|
684
|
+
const status = response?.status;
|
|
685
|
+
if (status !== undefined && status !== "completed" && status !== "incomplete") {
|
|
686
|
+
throw new ProviderError(id, "overload", `${id} ended the turn as "${status}"`, {
|
|
687
|
+
code: status,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
let finishReason: FinishReason = calls.length > 0 ? "tool_calls" : "stop";
|
|
691
|
+
if (status === "incomplete") {
|
|
692
|
+
finishReason = incompleteFinish(id, response?.incomplete_details?.reason);
|
|
693
|
+
} else {
|
|
694
|
+
// Checked on a whole turn only: a cut one may have lost a call's
|
|
695
|
+
// name to the cut, and nobody runs a cut turn's calls.
|
|
696
|
+
const minted = settleToolCalls(id, calls);
|
|
697
|
+
if (minted.length > 0) yield { type: "delta", toolCalls: minted };
|
|
698
|
+
}
|
|
638
699
|
yield* reasoningChunk();
|
|
639
700
|
if (response?.usage) yield usageChunk(response.usage);
|
|
640
|
-
|
|
641
|
-
yield {
|
|
642
|
-
type: "finish",
|
|
643
|
-
finishReason: mapIncompleteReason(response.incomplete_details?.reason),
|
|
644
|
-
};
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
yield { type: "finish", finishReason: sawToolCall ? "tool_calls" : "stop" };
|
|
701
|
+
yield { type: "finish", finishReason };
|
|
648
702
|
return;
|
|
649
703
|
}
|
|
650
704
|
|
|
@@ -655,13 +709,11 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
655
709
|
case "response.incomplete": {
|
|
656
710
|
// A turn that ran out of output tokens still billed for its input,
|
|
657
711
|
// and this event carries usage in the same shape as `completed`.
|
|
712
|
+
const finishReason = incompleteFinish(id, event.response?.incomplete_details?.reason);
|
|
658
713
|
yield* reasoningChunk();
|
|
659
714
|
const usage = event.response?.usage;
|
|
660
715
|
if (usage) yield usageChunk(usage);
|
|
661
|
-
yield {
|
|
662
|
-
type: "finish",
|
|
663
|
-
finishReason: mapIncompleteReason(event.response?.incomplete_details?.reason),
|
|
664
|
-
};
|
|
716
|
+
yield { type: "finish", finishReason };
|
|
665
717
|
return;
|
|
666
718
|
}
|
|
667
719
|
|
package/src/providers/zai.ts
CHANGED
|
@@ -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
|
|
17
|
-
* thinking
|
|
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`)
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
189
|
-
|
|
208
|
+
].filter(
|
|
209
|
+
(w): w is Dated => w.utilization !== undefined && w.reset !== undefined && w.reset > now,
|
|
210
|
+
);
|
|
211
|
+
// The fuller window binds. Between two as full as each other (both spent,
|
|
212
|
+
// usually) the later reset does: the request passes only once both have
|
|
213
|
+
// cleared, and waiting out the earlier one walks into the other wall. On a
|
|
214
|
+
// dead heat the later slot wins (the sort is stable), as cc-proxy's `max_by`
|
|
215
|
+
// does.
|
|
216
|
+
const binding = windows.sort((a, b) => a.utilization - b.utilization || a.reset - b.reset).at(-1);
|
|
190
217
|
if (binding) {
|
|
191
218
|
if (binding.window) result.window = binding.window;
|
|
219
|
+
result.utilization = binding.utilization;
|
|
192
220
|
// The window's own reset outranks retry-after: it names the real horizon,
|
|
193
221
|
// where retry-after names the next polite attempt.
|
|
194
222
|
if (binding.reset !== undefined) result.resetAtMs = binding.reset;
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
opts.onRetry?.({ error: err, attempt, delayMs });
|
|
120
|
-
await nap(delayMs, opts.signal);
|
|
152
|
+
budgetEndsAt ??= budgetEnd(opts);
|
|
153
|
+
await waitToRetry(err, attempt, opts, budgetEndsAt);
|
|
121
154
|
}
|
|
122
155
|
}
|
|
123
156
|
throw lastError;
|
|
124
157
|
}
|
|
125
158
|
|
|
126
159
|
/**
|
|
127
|
-
* The streaming twin
|
|
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
|
-
|
|
174
|
+
let budgetEndsAt: number | undefined;
|
|
142
175
|
|
|
143
176
|
let lastError: unknown;
|
|
144
177
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -163,10 +196,8 @@ export async function* withStreamRetry<T>(
|
|
|
163
196
|
if (opts.signal?.aborted) throw err;
|
|
164
197
|
// Rule 2: past the first chunk there is no going back.
|
|
165
198
|
if (emitted || attempt >= maxAttempts || !shouldRetry(err, attempt)) throw err;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
opts.onRetry?.({ error: err, attempt, delayMs });
|
|
169
|
-
await nap(delayMs, opts.signal);
|
|
199
|
+
budgetEndsAt ??= budgetEnd(opts);
|
|
200
|
+
await waitToRetry(err, attempt, opts, budgetEndsAt);
|
|
170
201
|
} finally {
|
|
171
202
|
opts.signal?.removeEventListener("abort", onOuterAbort);
|
|
172
203
|
// Abandoning mid-iteration (the consumer broke out, or we are retrying)
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
81
|
-
* one step collects
|
|
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
|
|
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
|
|
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
|
|
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.
|