@providerkit/core 0.1.0 → 0.2.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 (50) hide show
  1. package/README.md +33 -157
  2. package/dist/context.d.ts.map +1 -1
  3. package/dist/context.js +8 -0
  4. package/dist/context.js.map +1 -1
  5. package/dist/errors.d.ts +36 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +72 -1
  8. package/dist/errors.js.map +1 -1
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/key-pool.d.ts +60 -0
  14. package/dist/key-pool.d.ts.map +1 -0
  15. package/dist/key-pool.js +235 -0
  16. package/dist/key-pool.js.map +1 -0
  17. package/dist/providers/anthropic.d.ts +9 -0
  18. package/dist/providers/anthropic.d.ts.map +1 -1
  19. package/dist/providers/anthropic.js +12 -13
  20. package/dist/providers/anthropic.js.map +1 -1
  21. package/dist/providers/gemini.d.ts +40 -0
  22. package/dist/providers/gemini.d.ts.map +1 -0
  23. package/dist/providers/gemini.js +303 -0
  24. package/dist/providers/gemini.js.map +1 -0
  25. package/dist/providers/openai.d.ts.map +1 -1
  26. package/dist/providers/openai.js +9 -0
  27. package/dist/providers/openai.js.map +1 -1
  28. package/dist/providers/responses.d.ts +38 -0
  29. package/dist/providers/responses.d.ts.map +1 -0
  30. package/dist/providers/responses.js +341 -0
  31. package/dist/providers/responses.js.map +1 -0
  32. package/dist/rate-limit.d.ts +29 -0
  33. package/dist/rate-limit.d.ts.map +1 -0
  34. package/dist/rate-limit.js +194 -0
  35. package/dist/rate-limit.js.map +1 -0
  36. package/dist/transport.d.ts +18 -5
  37. package/dist/transport.d.ts.map +1 -1
  38. package/dist/transport.js +61 -35
  39. package/dist/transport.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/context.ts +7 -0
  42. package/src/errors.ts +79 -1
  43. package/src/index.ts +4 -0
  44. package/src/key-pool.ts +272 -0
  45. package/src/providers/anthropic.ts +21 -18
  46. package/src/providers/gemini.ts +386 -0
  47. package/src/providers/openai.ts +12 -0
  48. package/src/providers/responses.ts +455 -0
  49. package/src/rate-limit.ts +217 -0
  50. package/src/transport.ts +61 -35
@@ -0,0 +1,455 @@
1
+ // Responses-shape adapter — SSE from POST /v1/responses.
2
+ //
3
+ // OpenAI's second wire format, and the only one some backends expose: the
4
+ // ChatGPT subscription surface (chatgpt.com/backend-api/codex) has no
5
+ // chat/completions endpoint at all. It is not chat/completions with a new path
6
+ // — the input is an ITEM LIST rather than a message list, the stream is
7
+ // event-typed rather than choice-delta'd, and the terminal event carries the
8
+ // usage record instead of a trailing usage-only frame.
9
+ //
10
+ // The event names arrive on the SSE `event:` line and are repeated inside each
11
+ // payload's own `type`. The transport yields only `data:` payloads, so this
12
+ // adapter reads `type` — which is what survives, and what gateways agree on.
13
+ import { streamError } from "../errors.ts";
14
+ import { streamSse, apiUrl } from "../transport.ts";
15
+ import type {
16
+ ChatMessage,
17
+ ContentPart,
18
+ Effort,
19
+ FinishReason,
20
+ ImagePart,
21
+ Provider,
22
+ ProviderChunk,
23
+ StreamOptions,
24
+ ToolDefinition,
25
+ } from "../types.ts";
26
+ import { toDataUri } from "../types.ts";
27
+
28
+ export interface ResponsesConfig {
29
+ apiKey: string;
30
+ model: string;
31
+ /** Any endpoint speaking the Responses format. Defaults to OpenAI itself. */
32
+ baseUrl?: string;
33
+ /** Names the provider in errors and logs. */
34
+ id?: string;
35
+ effort?: Effort;
36
+ maxTokens?: number;
37
+ fetchImpl?: typeof fetch;
38
+ /** Extra request headers — where a subscription backend's account id goes
39
+ * (`ChatGPT-Account-Id`), which those backends reject the request without. */
40
+ headers?: Record<string, string>;
41
+ /**
42
+ * Where this backend serves the endpoint, when it is not `/v1/responses`.
43
+ * The ChatGPT subscription surface serves it at `/backend-api/codex/responses`
44
+ * with no version segment, so that backend needs
45
+ * `{ baseUrl: "https://chatgpt.com/backend-api/codex", path: "/responses" }`.
46
+ * Without the override the POST 404s, and a 404 classifies as "model" — the
47
+ * user is told the model id does not exist when the path was the problem.
48
+ */
49
+ path?: string;
50
+ }
51
+
52
+ const DEFAULT_BASE_URL = "https://api.openai.com";
53
+ const DEFAULT_PATH = "/v1/responses";
54
+
55
+ /**
56
+ * `response.incomplete` means the turn was cut short, and the seam has one word
57
+ * for that: "length". `content_filter` is the only other reason this shape
58
+ * documents; anything new stays on "length" rather than reporting a clean stop,
59
+ * because a caller that believes a truncated answer finished will act on it.
60
+ */
61
+ function mapIncompleteReason(reason: string | undefined): FinishReason {
62
+ return reason === "content_filter" ? "content_filter" : "length";
63
+ }
64
+
65
+ // ── input items ───────────────────────────────────────────────────────────
66
+
67
+ type ResponsesContentPart =
68
+ | { type: "input_text"; text: string }
69
+ | { type: "output_text"; text: string }
70
+ | { type: "input_image"; image_url: string };
71
+
72
+ type ResponsesInputItem =
73
+ | { type: "message"; role: "user" | "assistant"; content: ResponsesContentPart[] }
74
+ | { type: "function_call"; call_id: string; name: string; arguments: string }
75
+ | { type: "function_call_output"; call_id: string; output: string | ResponsesContentPart[] };
76
+
77
+ function partsToResponses(content: string | ContentPart[]): ResponsesContentPart[] {
78
+ if (typeof content === "string") return [{ type: "input_text", text: content }];
79
+ return content.map((part): ResponsesContentPart =>
80
+ part.type === "text"
81
+ ? { type: "input_text", text: part.text }
82
+ : { type: "input_image", image_url: toDataUri(part) },
83
+ );
84
+ }
85
+
86
+ /** A tool result is a bare string unless it carried images — then the content-
87
+ * array form, the only way to hand this shape a screenshot back. */
88
+ function toolOutput(
89
+ content: string,
90
+ images: readonly ImagePart[],
91
+ ): string | ResponsesContentPart[] {
92
+ if (images.length === 0) return content;
93
+ const parts: ResponsesContentPart[] = [];
94
+ if (content) parts.push({ type: "input_text", text: content });
95
+ for (const image of images) parts.push({ type: "input_image", image_url: toDataUri(image) });
96
+ return parts;
97
+ }
98
+
99
+ /**
100
+ * Flatten a history into `instructions` plus the input item list.
101
+ *
102
+ * This shape has no system ROLE — the system prompt is a top-level
103
+ * `instructions` string, and everything else is items. One assistant turn can
104
+ * become several items (its text, then one `function_call` per tool it asked
105
+ * for), which is why a message maps to a list rather than to one item.
106
+ */
107
+ export function toResponsesInput(messages: readonly ChatMessage[]): {
108
+ instructions?: string;
109
+ input: unknown[];
110
+ } {
111
+ const instructions = messages
112
+ .filter((message) => message.role === "system")
113
+ .map((message) => message.content)
114
+ .join("\n\n");
115
+
116
+ const input: ResponsesInputItem[] = [];
117
+ for (const message of messages) {
118
+ switch (message.role) {
119
+ case "system":
120
+ break; // lifted into `instructions` above
121
+
122
+ case "user":
123
+ input.push({ type: "message", role: "user", content: partsToResponses(message.content) });
124
+ break;
125
+
126
+ case "tool":
127
+ input.push({
128
+ // `call_id` — the id the model coined for the CALL, not the `fc_…` id
129
+ // of the output item that carried it. Sending the wrong one is a 400
130
+ // reading "No tool output found for function call", one turn later.
131
+ type: "function_call_output",
132
+ call_id: message.toolCallId,
133
+ output: toolOutput(message.content, message.images ?? []),
134
+ });
135
+ break;
136
+
137
+ case "assistant": {
138
+ // Reasoning is deliberately NOT replayed. This shape wants the ORIGINAL
139
+ // reasoning item back — its `rs_…` id, and under `store: false` its
140
+ // `encrypted_content` blob — and the seam carries neither, only the
141
+ // plain summary text a caller renders. A synthesized reasoning item is
142
+ // rejected; omitting it costs only the model re-deriving its own chain
143
+ // of thought, which is what every stateless caller already lives with.
144
+ if (message.content) {
145
+ input.push({
146
+ type: "message",
147
+ role: "assistant",
148
+ content: [{ type: "output_text", text: message.content }],
149
+ });
150
+ }
151
+ for (const call of message.toolCalls ?? []) {
152
+ input.push({
153
+ type: "function_call",
154
+ call_id: call.id,
155
+ name: call.name,
156
+ arguments: call.arguments,
157
+ });
158
+ }
159
+ break;
160
+ }
161
+ }
162
+ }
163
+
164
+ return { ...(instructions ? { instructions } : {}), input };
165
+ }
166
+
167
+ // ── stream events ─────────────────────────────────────────────────────────
168
+
169
+ interface ResponsesUsage {
170
+ input_tokens?: number;
171
+ input_tokens_details?: { cached_tokens?: number };
172
+ output_tokens?: number;
173
+ }
174
+
175
+ interface ResponsesItem {
176
+ type?: string;
177
+ /** The output item's own id (`fc_…`) — what the argument deltas reference. */
178
+ id?: string;
179
+ /** The id a `function_call_output` must quote on the next turn. */
180
+ call_id?: string;
181
+ name?: string;
182
+ arguments?: string;
183
+ }
184
+
185
+ interface ResponsesEvent {
186
+ type?: string;
187
+ delta?: string;
188
+ item_id?: string;
189
+ item?: ResponsesItem;
190
+ response?: {
191
+ usage?: ResponsesUsage;
192
+ incomplete_details?: { reason?: string };
193
+ error?: { message?: string; code?: string };
194
+ };
195
+ error?: { message?: string; code?: string };
196
+ message?: string;
197
+ code?: string;
198
+ }
199
+
200
+ function usageChunk(usage: ResponsesUsage): ProviderChunk {
201
+ return {
202
+ type: "usage",
203
+ usage: {
204
+ inputTokens: usage.input_tokens ?? 0,
205
+ // Already a SUBSET of input_tokens on this shape, as on chat/completions
206
+ // — and the only signal that its automatic prefix caching is working.
207
+ cachedInputTokens: usage.input_tokens_details?.cached_tokens ?? 0,
208
+ // Reasoning tokens are billed INSIDE output_tokens, not beside them.
209
+ outputTokens: usage.output_tokens ?? 0,
210
+ },
211
+ };
212
+ }
213
+
214
+ /** What has already gone out for one in-flight function call — identity and
215
+ * arguments both — so the authoritative snapshot on `.done` can be diffed
216
+ * against it instead of duplicated. */
217
+ interface PendingCall {
218
+ index: number;
219
+ id: string;
220
+ name: string;
221
+ streamed: string;
222
+ }
223
+
224
+ export function createResponsesProvider(config: ResponsesConfig): Provider {
225
+ const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
226
+ const id = config.id ?? "openai-responses";
227
+
228
+ return {
229
+ id,
230
+ model: config.model,
231
+
232
+ async *createStream(
233
+ messages: ChatMessage[],
234
+ tools: ToolDefinition[],
235
+ opts: StreamOptions = {},
236
+ ): AsyncIterable<ProviderChunk> {
237
+ const effort = opts.effort ?? config.effort;
238
+ const { instructions, input } = toResponsesInput(messages);
239
+
240
+ const request: Record<string, unknown> = {
241
+ model: opts.model ?? config.model,
242
+ input,
243
+ stream: true,
244
+ // The caller's history is the entire state of a run. Server-side
245
+ // storage adds a retention surface nobody asked for, and is refused
246
+ // outright on zero-data-retention accounts.
247
+ store: false,
248
+ };
249
+ if (instructions) request.instructions = instructions;
250
+ const maxTokens = opts.maxTokens ?? config.maxTokens;
251
+ if (maxTokens !== undefined) request.max_output_tokens = maxTokens;
252
+ if (opts.temperature !== undefined) request.temperature = opts.temperature;
253
+ if (effort && effort !== "none") {
254
+ // `summary` is what switches the reasoning stream ON. Without it this
255
+ // shape emits no reasoning_summary_text events at all, and a caller
256
+ // rendering a thinking pane silently gets nothing while the tokens are
257
+ // billed either way.
258
+ request.reasoning = { effort, summary: "auto" };
259
+ }
260
+ if (tools.length > 0) {
261
+ // Flat here — no nested `function` envelope, unlike chat/completions.
262
+ request.tools = tools.map((tool) => ({
263
+ type: "function",
264
+ name: tool.name,
265
+ description: tool.description,
266
+ parameters: tool.inputSchema,
267
+ }));
268
+ }
269
+ if (opts.toolChoice && opts.toolChoice !== "auto") {
270
+ request.tool_choice =
271
+ typeof opts.toolChoice === "string"
272
+ ? opts.toolChoice
273
+ : { type: "function", name: opts.toolChoice.name };
274
+ }
275
+ if (opts.json) {
276
+ // The schema rides in `text.format`, not `response_format`.
277
+ request.text = {
278
+ format: {
279
+ type: "json_schema",
280
+ name: opts.json.name,
281
+ schema: opts.json.schema,
282
+ strict: true,
283
+ },
284
+ };
285
+ }
286
+
287
+ // Tool calls arrive as an item skeleton plus argument deltas; keyed by the
288
+ // output item id so parallel calls never cross wires. The seam's index is
289
+ // ours to assign — `output_index` counts reasoning and message items too.
290
+ const pending = new Map<string, PendingCall>();
291
+ let nextIndex = 0;
292
+ // This shape never states a stop reason on a clean finish, so it is
293
+ // inferred from whether the turn produced a function call.
294
+ let sawToolCall = false;
295
+
296
+ for await (const data of streamSse({
297
+ url: apiUrl(baseUrl, config.path ?? DEFAULT_PATH),
298
+ headers: { authorization: `Bearer ${config.apiKey}`, ...config.headers },
299
+ body: request,
300
+ provider: id,
301
+ ...(opts.signal ? { signal: opts.signal } : {}),
302
+ ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
303
+ })) {
304
+ let event: ResponsesEvent;
305
+ try {
306
+ event = JSON.parse(data) as ResponsesEvent;
307
+ } catch {
308
+ continue; // a keep-alive or a frame we do not model
309
+ }
310
+
311
+ switch (event.type) {
312
+ case "response.output_text.delta":
313
+ if (event.delta) yield { type: "delta", content: event.delta };
314
+ break;
315
+
316
+ // Two names for the same stream: `reasoning_summary_text` is the
317
+ // redacted summary the API returns, `reasoning_text` the raw trace
318
+ // the ChatGPT backend streams. A caller wants whichever it gets.
319
+ case "response.reasoning_summary_text.delta":
320
+ case "response.reasoning_text.delta":
321
+ if (event.delta) yield { type: "delta", reasoning: event.delta };
322
+ break;
323
+
324
+ case "response.output_item.added": {
325
+ const item = event.item;
326
+ if (item?.type !== "function_call" || !item.id) break;
327
+ sawToolCall = true;
328
+ const index = nextIndex++;
329
+ // Normally empty here, but a backend that already has the whole
330
+ // call sends it in the skeleton.
331
+ const seeded = item.arguments ?? "";
332
+ pending.set(item.id, {
333
+ index,
334
+ id: item.call_id ?? "",
335
+ name: item.name ?? "",
336
+ streamed: seeded,
337
+ });
338
+ // Only the fields the skeleton actually states. An empty `id` here
339
+ // is not "unknown", it is a wrong answer: a consumer takes the last
340
+ // stated value, so `""` written into the slot survives the real
341
+ // `call_id` arriving on `.done`.
342
+ yield {
343
+ type: "delta",
344
+ toolCalls: [
345
+ {
346
+ index,
347
+ ...(item.call_id ? { id: item.call_id } : {}),
348
+ ...(item.name ? { name: item.name } : {}),
349
+ ...(seeded ? { arguments: seeded } : {}),
350
+ },
351
+ ],
352
+ };
353
+ break;
354
+ }
355
+
356
+ case "response.function_call_arguments.delta": {
357
+ const call = event.item_id ? pending.get(event.item_id) : undefined;
358
+ if (!call || !event.delta) break;
359
+ call.streamed += event.delta;
360
+ yield { type: "delta", toolCalls: [{ index: call.index, arguments: event.delta }] };
361
+ break;
362
+ }
363
+
364
+ case "response.output_item.done": {
365
+ const item = event.item;
366
+ if (item?.type !== "function_call") break;
367
+ const known = item.id ? pending.get(item.id) : undefined;
368
+ if (item.id) pending.delete(item.id);
369
+ const snapshot = item.arguments ?? "";
370
+
371
+ if (!known) {
372
+ // A backend that emits neither the skeleton nor the deltas — the
373
+ // whole call arrives here or not at all. Without a `call_id`
374
+ // there is nothing to answer it with: the caller's
375
+ // `function_call_output` would quote `""` and take a 400 reading
376
+ // "No tool output found for function call" one turn later, so the
377
+ // call is dropped rather than handed over unrunnable. Dropping is
378
+ // only possible here, where nothing has been streamed for it yet.
379
+ if (!item.call_id) break;
380
+ sawToolCall = true;
381
+ yield {
382
+ type: "delta",
383
+ toolCalls: [
384
+ {
385
+ index: nextIndex++,
386
+ id: item.call_id,
387
+ ...(item.name ? { name: item.name } : {}),
388
+ arguments: snapshot,
389
+ },
390
+ ],
391
+ };
392
+ break;
393
+ }
394
+
395
+ // The snapshot is authoritative, but the fragments already went
396
+ // out: re-emitting it whole concatenates the JSON with itself and
397
+ // every argument parse fails. Send only what the deltas missed —
398
+ // which is all of it when they never came.
399
+ const tail =
400
+ snapshot.length > known.streamed.length && snapshot.startsWith(known.streamed)
401
+ ? snapshot.slice(known.streamed.length)
402
+ : "";
403
+ // `.done` restates the identity, and on a backend that leaves it
404
+ // out of the skeleton this is the only frame that carries it. It
405
+ // rides last so it WINS: the alternative is a caller assembling a
406
+ // nameless call it has no tool to dispatch, quoting an empty
407
+ // `call_id` back on the turn after.
408
+ const restated = {
409
+ ...(item.call_id && item.call_id !== known.id ? { id: item.call_id } : {}),
410
+ ...(item.name && item.name !== known.name ? { name: item.name } : {}),
411
+ ...(tail ? { arguments: tail } : {}),
412
+ };
413
+ if (Object.keys(restated).length > 0) {
414
+ yield { type: "delta", toolCalls: [{ index: known.index, ...restated }] };
415
+ }
416
+ break;
417
+ }
418
+
419
+ case "response.completed": {
420
+ const usage = event.response?.usage;
421
+ if (usage) yield usageChunk(usage);
422
+ yield { type: "finish", finishReason: sawToolCall ? "tool_calls" : "stop" };
423
+ return;
424
+ }
425
+
426
+ case "response.incomplete": {
427
+ // A turn that ran out of output tokens still billed for its input,
428
+ // and this event carries usage in the same shape as `completed`.
429
+ const usage = event.response?.usage;
430
+ if (usage) yield usageChunk(usage);
431
+ yield {
432
+ type: "finish",
433
+ finishReason: mapIncompleteReason(event.response?.incomplete_details?.reason),
434
+ };
435
+ return;
436
+ }
437
+
438
+ // The two ways this shape reports a failure after its headers went
439
+ // out: a terminal `response.failed`, or a bare error frame from a
440
+ // gateway in front of it.
441
+ case "response.failed":
442
+ throw streamError(id, event.response?.error);
443
+
444
+ case "error":
445
+ case "response.error":
446
+ throw streamError(id, event.error ?? { message: event.message, code: event.code });
447
+ }
448
+ }
449
+
450
+ // The stream closed without a terminal event. Nothing is lost but the
451
+ // finish reason and the usage record — every delta, tool-call fragment
452
+ // included, was already yielded as it arrived.
453
+ },
454
+ };
455
+ }
@@ -0,0 +1,217 @@
1
+ // Rate-limit headers, read for the one thing a 429 never says in its body: WHEN.
2
+ //
3
+ // A 429 discloses that a limit was hit, never which limit. A per-minute
4
+ // throttle and a Claude OAuth subscription window (5-hour, or weekly) arrive
5
+ // as the same status with the same wording, and the second one resets in DAYS.
6
+ // Only the headers tell them apart. Without them a caller can say nothing
7
+ // better than "try again in a moment", which is a lie for the multi-day case.
8
+ //
9
+ // Parsing only. Rendering `resetAtMs` as "in 4 hours (6:47 PM)" belongs to the
10
+ // caller: relative-time wording is locale work, and a library that ships it
11
+ // either drags in an i18n dependency or hardcodes English.
12
+ //
13
+ // The overlap with `retryAfterFromHeaders` in transport.ts is deliberate, and
14
+ // the two are not interchangeable. That one answers the retry policy's
15
+ // question — one number, how long to sleep — so it treats `Retry-After` as
16
+ // authoritative whenever it is present, and falls back to any vendor reset
17
+ // header it can find. This one answers the user-facing question, where the
18
+ // unified window's reset OUTRANKS `Retry-After`: Anthropic sends
19
+ // `retry-after: 60` beside a weekly window that lifts in three days, and 60
20
+ // seconds is the correct sleep and the wrong horizon. Delegating here would
21
+ // also fill `retryAfterMs` — documented as what the SERVER asked for — with a
22
+ // vendor reset header on every response that carries no `Retry-After` at all.
23
+ // What must NOT differ is which headers the two can date: the fallback list
24
+ // below is transport's list, because a header only one of them reads is a 429
25
+ // whose `ProviderError.retryAfterMs` knows the wait is three days while the
26
+ // user-facing answer is empty.
27
+
28
+ /** Which subscription window bound. Only Anthropic's unified headers name one
29
+ * outright; the codex body shape has it inferred from the wait. */
30
+ export type RateLimitWindow = "5h" | "weekly" | "monthly";
31
+
32
+ export interface RateLimitReset {
33
+ /** Absolute time the binding window resets, when any header disclosed it. */
34
+ resetAtMs?: number;
35
+ /** Server-requested wait (`retry-after`) — what a retry policy honours. */
36
+ retryAfterMs?: number;
37
+ /** Which subscription window bound, when it could be named at all. */
38
+ window?: RateLimitWindow;
39
+ }
40
+
41
+ const RETRY_AFTER = "retry-after";
42
+ const UNIFIED_5H = "anthropic-ratelimit-unified-5h-";
43
+ const UNIFIED_7D = "anthropic-ratelimit-unified-7d-";
44
+
45
+ /** Reset headers that name a time rather than a window, in the order
46
+ * transport.ts reads them and kept in step with that list. The singular
47
+ * `anthropic-ratelimit-unified-reset` rides on OAuth responses that carry no
48
+ * 5h/7d pair, and the `x-ratelimit-*` spellings are what every non-Anthropic
49
+ * provider and gateway sends. */
50
+ const RESET_HEADERS = [
51
+ "anthropic-ratelimit-unified-reset",
52
+ "anthropic-ratelimit-requests-reset",
53
+ "anthropic-ratelimit-tokens-reset",
54
+ "x-ratelimit-reset-requests",
55
+ "x-ratelimit-reset-tokens",
56
+ "x-ratelimit-reset",
57
+ ];
58
+
59
+ /** Seconds since 1970 passed a billion in 2001, so a bare integer above this is
60
+ * a timestamp and anything below it is a countdown. Vendors disagree on which
61
+ * they send under the same header name, so magnitude decides. */
62
+ const EPOCH_SECONDS_FLOOR = 1_000_000_000;
63
+
64
+ /** A non-negative finite number, or nothing. The null guard carries weight:
65
+ * `Number("")` is 0, so an absent header would otherwise read as a real zero. */
66
+ function numeric(value: string | null): number | undefined {
67
+ if (!value) return undefined;
68
+ const n = Number(value);
69
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
70
+ }
71
+
72
+ function unixSecondsToMs(value: string | null): number | undefined {
73
+ const n = numeric(value);
74
+ return n === undefined ? undefined : n * 1000;
75
+ }
76
+
77
+ /** Absolute reset time from a header that spells it either way: epoch seconds
78
+ * (the unified reset, most gateways) or an RFC 3339 / HTTP-date string (the
79
+ * API-key reset headers). The digit test runs first because `Date.parse("120")`
80
+ * is not a rejection — it is the year 120, two millennia in the past. */
81
+ function resetHeaderMs(value: string | null, now: number): number | undefined {
82
+ if (!value) return undefined;
83
+ if (/^\d+$/.test(value)) {
84
+ const seconds = Number(value);
85
+ return seconds > EPOCH_SECONDS_FLOOR ? seconds * 1000 : now + seconds * 1000;
86
+ }
87
+ const at = Date.parse(value);
88
+ return Number.isNaN(at) ? undefined : at;
89
+ }
90
+
91
+ /** `retry-after` is delay-seconds, but RFC 9110 also allows an absolute date,
92
+ * and both forms occur across the providers this package speaks to. */
93
+ function parseRetryAfter(value: string | null, now: number): number | undefined {
94
+ if (!value) return undefined;
95
+ const n = Number(value);
96
+ if (Number.isFinite(n) && n >= 0) return n * 1000;
97
+ const at = Date.parse(value);
98
+ return Number.isNaN(at) ? undefined : Math.max(0, at - now);
99
+ }
100
+
101
+ /**
102
+ * Parse the rate-limit headers off a (usually 429) response.
103
+ *
104
+ * Anthropic's `anthropic-ratelimit-unified-*` pair rides on every OAuth
105
+ * response, so both windows are always reported and only one of them is the
106
+ * reason for this 429. API-key accounts get the `anthropic-ratelimit-*-reset`
107
+ * RFC 3339 timestamps instead. Everything is optional — an unrecognized shape
108
+ * yields an empty result, and the caller falls back to its generic message.
109
+ */
110
+ export function parseRateLimitReset(headers: Headers, now = Date.now()): RateLimitReset {
111
+ const result: RateLimitReset = {};
112
+
113
+ const after = parseRetryAfter(headers.get(RETRY_AFTER), now);
114
+ if (after !== undefined) {
115
+ result.retryAfterMs = after;
116
+ result.resetAtMs = now + after;
117
+ }
118
+
119
+ // The window with the higher utilization is the one a "would exceed your
120
+ // rate limit" 429 is about. A reset already in the past belongs to a window
121
+ // that rolled over between the response and this parse — naming it would put
122
+ // a past time in front of the user — so it drops out here.
123
+ const windows = [
124
+ {
125
+ window: "5h" as const,
126
+ utilization: numeric(headers.get(`${UNIFIED_5H}utilization`)),
127
+ reset: unixSecondsToMs(headers.get(`${UNIFIED_5H}reset`)),
128
+ },
129
+ {
130
+ window: "weekly" as const,
131
+ utilization: numeric(headers.get(`${UNIFIED_7D}utilization`)),
132
+ reset: unixSecondsToMs(headers.get(`${UNIFIED_7D}reset`)),
133
+ },
134
+ ].filter((w) => w.utilization !== undefined && w.reset !== undefined && w.reset > now);
135
+ const binding = windows.sort((a, b) => (b.utilization ?? 0) - (a.utilization ?? 0))[0];
136
+ if (binding) {
137
+ result.window = binding.window;
138
+ // The window's own reset outranks retry-after: it names the real horizon,
139
+ // where retry-after names the next polite attempt.
140
+ if (binding.reset !== undefined) result.resetAtMs = binding.reset;
141
+ }
142
+
143
+ // No window named the horizon, so take the first reset header that dates the
144
+ // future. These stay below `retry-after`, which is the server speaking about
145
+ // this request; they only fill a silence.
146
+ if (result.resetAtMs === undefined) {
147
+ for (const name of RESET_HEADERS) {
148
+ const at = resetHeaderMs(headers.get(name), now);
149
+ if (at !== undefined && at > now) {
150
+ result.resetAtMs = at;
151
+ break;
152
+ }
153
+ }
154
+ }
155
+
156
+ return result;
157
+ }
158
+
159
+ /** Under ten minutes the wait is a per-minute throttle whatever the body calls
160
+ * it, and calling a 40-second retry a "5-hour window" would be its own lie. */
161
+ const WINDOW_FLOOR_MS = 10 * 60_000;
162
+ /** Bucket edges, padded: a window's reset lands wherever inside it the first
163
+ * request fell, so a 5-hour window routinely reports four hours and change. */
164
+ const FIVE_HOUR_MAX_MS = 5.5 * 3_600_000;
165
+ const WEEKLY_MAX_MS = 7.5 * 86_400_000;
166
+
167
+ /** A finite number off a JSON field, the string form a relay may stringify it
168
+ * into included. Bare `Number()` is the trap `numeric()` guards against one
169
+ * layer up: `Number(null)`, `Number("")`, `Number([])` and `Number(false)` are
170
+ * all 0, and 0 is finite and non-negative, so a relay that nulls out
171
+ * `resets_in_seconds` beside a real `resets_at` would win the branch below and
172
+ * report a three-day lockout as "retry now". */
173
+ function jsonSeconds(value: unknown): number | undefined {
174
+ const usable = typeof value === "number" || (typeof value === "string" && value.trim() !== "");
175
+ const n = usable ? Number(value) : NaN;
176
+ return Number.isFinite(n) ? n : undefined;
177
+ }
178
+
179
+ /**
180
+ * ChatGPT's codex backend puts the reset IN THE 429 BODY, not the headers:
181
+ * `{"error":{"type":"usage_limit_reached","resets_at":1788801754,"resets_in_seconds":2501465}}`.
182
+ * The window name is inferred from the wait itself, and only past the floor
183
+ * above — a sub-minute retry is a throttle, not a subscription window.
184
+ */
185
+ export function parseUsageLimitBody(bodyText: string, now = Date.now()): RateLimitReset {
186
+ let body: unknown;
187
+ try {
188
+ body = JSON.parse(bodyText);
189
+ } catch {
190
+ return {};
191
+ }
192
+ if (typeof body !== "object" || body === null) return {};
193
+ // The fields sit under `error` on the documented shape and at the top level
194
+ // on some gateway relays of it. Both are read rather than guessed between.
195
+ const source = (body as Record<string, unknown>).error;
196
+ const error =
197
+ typeof source === "object" && source !== null
198
+ ? (source as Record<string, unknown>)
199
+ : (body as Record<string, unknown>);
200
+
201
+ const inSeconds = jsonSeconds(error.resets_in_seconds);
202
+ const atSeconds = jsonSeconds(error.resets_at);
203
+ const waitMs =
204
+ inSeconds !== undefined && inSeconds >= 0
205
+ ? inSeconds * 1000
206
+ : atSeconds !== undefined && atSeconds > 0
207
+ ? Math.max(0, atSeconds * 1000 - now)
208
+ : undefined;
209
+ if (waitMs === undefined) return {};
210
+
211
+ const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
212
+ if (waitMs > WINDOW_FLOOR_MS) {
213
+ result.window =
214
+ waitMs <= FIVE_HOUR_MAX_MS ? "5h" : waitMs <= WEEKLY_MAX_MS ? "weekly" : "monthly";
215
+ }
216
+ return result;
217
+ }