@providerkit/core 0.8.1 → 0.9.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/src/presets.ts CHANGED
@@ -37,9 +37,11 @@ export type PresetAuth = "key" | "bearer" | "oauth";
37
37
  export interface ProviderPreset {
38
38
  /** Adapter wire format. */
39
39
  shape: PresetShape;
40
- /** API root — the adapter appends its own path (/v1/chat/completions,
41
- * /v1/messages, …), so this must NOT carry the version. */
40
+ /** API root — if it ends with /v1, /v4 or another version segment, the
41
+ * adapter automatically routes to /chat/completions instead of /v1/chat/completions. */
42
42
  baseUrl: string;
43
+ /** Explicit path override when a gateway has custom routing. */
44
+ path?: string;
43
45
  auth: PresetAuth;
44
46
  /** Fallback when the caller passes no model. */
45
47
  defaultModel?: string;
@@ -65,15 +67,15 @@ export const PROVIDER_PRESETS = {
65
67
  shape: "anthropic",
66
68
  baseUrl: "https://api.anthropic.com",
67
69
  auth: "key",
68
- defaultModel: "claude-sonnet-4-6",
69
- models: ["claude-sonnet-4-6", "claude-opus-5", "claude-fable-5-1"],
70
+ defaultModel: "claude-sonnet-5",
71
+ models: ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1", "claude-haiku-4-5"],
70
72
  },
71
73
  openai: {
72
74
  shape: "openai",
73
75
  baseUrl: "https://api.openai.com",
74
76
  auth: "key",
75
77
  defaultModel: "gpt-5.6-sol",
76
- models: ["gpt-5.6-sol", "gpt-5-pro", "gpt-5-nano"],
78
+ models: ["gpt-5.6-sol", "gpt-5.5-pro", "gpt-5.4-mini"],
77
79
  },
78
80
  google: {
79
81
  // Native Generative Language REST — the adapter authenticates with
@@ -81,8 +83,8 @@ export const PROVIDER_PRESETS = {
81
83
  shape: "gemini",
82
84
  baseUrl: "https://generativelanguage.googleapis.com",
83
85
  auth: "key",
84
- defaultModel: "gemini-3.1-pro",
85
- models: ["gemini-3.1-pro", "gemini-2.5-flash"],
86
+ defaultModel: "gemini-3.1-pro-preview",
87
+ models: ["gemini-3.1-pro-preview", "gemini-3.8-flash", "gemini-flash-latest"],
86
88
  },
87
89
  /** Gemini behind its OpenAI-compatible route — the drop-in for codebases
88
90
  * that only speak the openai shape. */
@@ -90,15 +92,17 @@ export const PROVIDER_PRESETS = {
90
92
  shape: "openai",
91
93
  baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
92
94
  auth: "key",
93
- defaultModel: "gemini-3.1-pro",
94
- models: ["gemini-3.1-pro", "gemini-2.5-flash"],
95
+ defaultModel: "gemini-3.1-pro-preview",
96
+ models: ["gemini-3.1-pro-preview", "gemini-3.8-flash", "gemini-flash-latest"],
95
97
  },
96
98
  deepseek: {
97
99
  shape: "openai",
98
100
  baseUrl: "https://api.deepseek.com",
99
101
  auth: "key",
100
- defaultModel: "deepseek-v4-flash",
101
- models: ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-flash"],
102
+ // Measured 2026-09-13 via /models on live key: first-party serves only
103
+ // deepseek-flash and deepseek-v4-pro. v4.1-flash is OpenRouter/Baseten.
104
+ defaultModel: "deepseek-flash",
105
+ models: ["deepseek-flash", "deepseek-v4-pro"],
102
106
  // Text-only API — an image part in the body is a hard 400 (measured).
103
107
  textOnly: true,
104
108
  },
@@ -107,7 +111,7 @@ export const PROVIDER_PRESETS = {
107
111
  baseUrl: "https://openrouter.ai/api",
108
112
  auth: "key",
109
113
  defaultModel: "deepseek/deepseek-v4.1-flash",
110
- models: ["deepseek/deepseek-v4.1-flash", "z-ai/glm-5.3-flash", "qwen/qwen3.7-max"],
114
+ models: ["deepseek/deepseek-v4.1-flash", "z-ai/glm-5.3-flash", "x-ai/grok-4.6", "openai/gpt-5.6-luna-pro"],
111
115
  },
112
116
  groq: {
113
117
  shape: "openai",
@@ -121,25 +125,28 @@ export const PROVIDER_PRESETS = {
121
125
  baseUrl: "https://api.mistral.ai/v1",
122
126
  auth: "key",
123
127
  defaultModel: "devstral-2512",
124
- models: ["devstral-2512", "magistral-small", "mistral-small-2506"],
128
+ models: ["devstral-2512", "mistral-large-latest", "magistral-small"],
125
129
  },
126
130
  xai: {
127
131
  shape: "openai",
128
132
  baseUrl: "https://api.x.ai/v1",
129
133
  auth: "key",
130
- defaultModel: "grok-4.5",
131
- models: ["grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"],
134
+ defaultModel: "grok-4.6",
135
+ models: ["grok-4.6", "grok-4.5", "grok-4.3"],
132
136
  },
133
137
  together: {
134
138
  shape: "openai",
135
139
  baseUrl: "https://api.together.xyz/v1",
136
140
  auth: "key",
137
- defaultModel: "deepseek-ai/DeepSeek-V3",
141
+ defaultModel: "MiniMaxAI/MiniMax-M3",
142
+ models: ["MiniMaxAI/MiniMax-M3", "Qwen/Qwen3-Coder-Next-FP8"],
138
143
  },
139
144
  fireworks: {
140
145
  shape: "openai",
141
146
  baseUrl: "https://api.fireworks.ai/inference/v1",
142
147
  auth: "key",
148
+ defaultModel: "accounts/fireworks/models/deepseek-v4p1-flash",
149
+ models: ["accounts/fireworks/models/deepseek-v4p1-flash", "accounts/fireworks/models/glm-5p3-flash"],
143
150
  },
144
151
  cerebras: {
145
152
  shape: "openai",
@@ -158,16 +165,112 @@ export const PROVIDER_PRESETS = {
158
165
  zhipu: {
159
166
  shape: "openai",
160
167
  baseUrl: "https://api.z.ai/api/paas/v4",
168
+ path: "/chat/completions",
161
169
  auth: "key",
162
170
  defaultModel: "glm-5.3-flash",
163
- models: ["glm-5.3-flash", "glm-5.2", "glm-4.7"],
171
+ models: ["glm-5.3-flash", "glm-5.3", "glm-5.2"],
164
172
  },
165
173
  minimax: {
166
174
  shape: "anthropic",
167
175
  baseUrl: "https://api.minimax.io/anthropic",
168
176
  auth: "key",
169
- defaultModel: "MiniMax-M2.5",
170
- models: ["MiniMax-M3", "MiniMax-M2.5", "MiniMax-M2.1"],
177
+ defaultModel: "MiniMax-M3",
178
+ models: ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"],
179
+ },
180
+
181
+ // ── Popular gateways & cloud providers ──────────────────────────────────
182
+ nvidia: {
183
+ shape: "openai",
184
+ baseUrl: "https://integrate.api.nvidia.com/v1",
185
+ auth: "key",
186
+ defaultModel: "deepseek-ai/deepseek-v4-flash",
187
+ models: ["deepseek-ai/deepseek-v4-flash", "deepseek-ai/deepseek-v4-pro"],
188
+ },
189
+ perplexity: {
190
+ shape: "openai",
191
+ baseUrl: "https://api.perplexity.ai",
192
+ auth: "key",
193
+ defaultModel: "sonar",
194
+ models: ["sonar", "sonar-pro", "sonar-reasoning-pro", "sonar-deep-research"],
195
+ },
196
+ deepinfra: {
197
+ shape: "openai",
198
+ baseUrl: "https://api.deepinfra.com/v1/openai",
199
+ path: "/chat/completions",
200
+ auth: "key",
201
+ defaultModel: "MiniMaxAI/MiniMax-M3",
202
+ models: ["MiniMaxAI/MiniMax-M3", "ByteDance/Seed-2.0-pro"],
203
+ },
204
+ nebius: {
205
+ shape: "openai",
206
+ baseUrl: "https://api.tokenfactory.nebius.com/v1",
207
+ auth: "key",
208
+ defaultModel: "Qwen/Qwen3.5-397B-A17B",
209
+ models: ["Qwen/Qwen3.5-397B-A17B", "deepseek-ai/DeepSeek-V4-Flash-0731"],
210
+ },
211
+ novita: {
212
+ shape: "openai",
213
+ baseUrl: "https://api.novita.ai/openai",
214
+ path: "/v1/chat/completions",
215
+ auth: "key",
216
+ defaultModel: "baichuan/baichuan-m2-32b",
217
+ models: ["baichuan/baichuan-m2-32b", "baidu/ernie-4.5-300b-a47b-paddle"],
218
+ },
219
+ chutes: {
220
+ shape: "openai",
221
+ baseUrl: "https://llm.chutes.ai/v1",
222
+ auth: "key",
223
+ defaultModel: "Qwen/Qwen3.8-27B-TEE",
224
+ models: ["Qwen/Qwen3.8-27B-TEE", "Qwen/Qwen3.5-397B-A17B-TEE"],
225
+ },
226
+ siliconflow: {
227
+ shape: "openai",
228
+ baseUrl: "https://api.siliconflow.com/v1",
229
+ auth: "key",
230
+ defaultModel: "Qwen/Qwen3-235B-A22B-Thinking-2507",
231
+ models: ["Qwen/Qwen3-235B-A22B-Thinking-2507", "MiniMaxAI/MiniMax-M2.5"],
232
+ },
233
+ volcengine: {
234
+ shape: "openai",
235
+ baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
236
+ auth: "key",
237
+ defaultModel: "doubao-seed-1-8-251228",
238
+ models: ["doubao-seed-1-8-251228", "deepseek-v4-flash-ga-260731"],
239
+ },
240
+ stepfun: {
241
+ shape: "openai",
242
+ baseUrl: "https://api.stepfun.com/v1",
243
+ auth: "key",
244
+ defaultModel: "step-3.7-flash",
245
+ models: ["step-3.7-flash", "step-3.5-flash"],
246
+ },
247
+ digitalocean: {
248
+ shape: "openai",
249
+ baseUrl: "https://inference.do-ai.run/v1",
250
+ auth: "key",
251
+ defaultModel: "anthropic-claude-3.7-sonnet",
252
+ models: ["anthropic-claude-3.7-sonnet", "alibaba-qwen3-32b"],
253
+ },
254
+ crusoe: {
255
+ shape: "openai",
256
+ baseUrl: "https://api.inference.crusoecloud.com/v1",
257
+ auth: "key",
258
+ defaultModel: "Qwen/Qwen3-235B-A22B-Instruct-2507",
259
+ models: ["Qwen/Qwen3-235B-A22B-Instruct-2507", "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B"],
260
+ },
261
+ baseten: {
262
+ shape: "openai",
263
+ baseUrl: "https://inference.baseten.co/v1",
264
+ auth: "key",
265
+ defaultModel: "deepseek-ai/DeepSeek-V4.1-Flash",
266
+ models: ["deepseek-ai/DeepSeek-V4.1-Flash", "MiniMaxAI/MiniMax-M2.5"],
267
+ },
268
+ huggingface: {
269
+ shape: "openai",
270
+ baseUrl: "https://router.huggingface.co/v1",
271
+ auth: "key",
272
+ defaultModel: "MiniMaxAI/MiniMax-M3",
273
+ models: ["MiniMaxAI/MiniMax-M3", "Qwen/Qwen2.5-Coder-32B-Instruct"],
171
274
  },
172
275
 
173
276
  // ── Coding plans — Anthropic wire + Bearer (measured family trait) ─────
@@ -176,7 +279,7 @@ export const PROVIDER_PRESETS = {
176
279
  baseUrl: "https://api.z.ai/api/anthropic",
177
280
  auth: "bearer",
178
281
  defaultModel: "glm-5.3-flash",
179
- models: ["glm-5.3-flash", "glm-5.3-highspeed", "glm-5.2", "glm-5-turbo"],
282
+ models: ["glm-5.3-flash", "glm-5.3-highspeed", "glm-5.3", "glm-5.2"],
180
283
  // Model ids are BARE here — the gateway-prefixed spelling
181
284
  // (`z-ai/glm-5.3-flash`) answers 400 [1211] Unknown Model.
182
285
  explicitNone: true,
@@ -186,15 +289,15 @@ export const PROVIDER_PRESETS = {
186
289
  baseUrl: "https://api.kimi.ai/coding",
187
290
  auth: "bearer",
188
291
  defaultModel: "kimi-for-coding",
189
- models: ["kimi-for-coding", "kimi-for-coding-highspeed", "k3"],
292
+ models: ["kimi-for-coding", "kimi-for-coding-highspeed", "k3", "k3-256k"],
190
293
  },
191
294
  /** Alibaba's dashscope coding plan — OpenAI-compatible per its registry. */
192
295
  "alibaba-coding-plan": {
193
296
  shape: "openai",
194
297
  baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
195
298
  auth: "bearer",
196
- defaultModel: "qwen3.7-max",
197
- models: ["qwen3.7-max", "qwen3-coder-next", "glm-4.7"],
299
+ defaultModel: "qwen3.5-plus",
300
+ models: ["qwen3.5-plus", "qwen3-coder-next", "glm-5"],
198
301
  },
199
302
  /** QwenCloud's token plan — Anthropic wire (measured by tabrunner). */
200
303
  qwen: {
@@ -202,7 +305,7 @@ export const PROVIDER_PRESETS = {
202
305
  baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic",
203
306
  auth: "bearer",
204
307
  defaultModel: "qwen3.8-max",
205
- models: ["qwen3.8-max", "qwen3.6-flash"],
308
+ models: ["qwen3.8-max", "qwen3.8-flash", "qwen3.6-flash"],
206
309
  },
207
310
 
208
311
  // ── Local runtimes ──────────────────────────────────────────────────────
@@ -227,8 +330,8 @@ export const PROVIDER_PRESETS = {
227
330
  shape: "anthropic",
228
331
  baseUrl: "https://api.anthropic.com",
229
332
  auth: "oauth",
230
- defaultModel: "claude-sonnet-4-6",
231
- models: ["claude-sonnet-4-6", "claude-opus-5"],
333
+ defaultModel: "claude-sonnet-5",
334
+ models: ["claude-sonnet-5", "claude-opus-5", "claude-fable-5"],
232
335
  // The beta flag is what switches the API into OAuth-token mode (measured
233
336
  // by tabrunner's Claude plan sign-in).
234
337
  headers: { "anthropic-beta": "claude-code-20250219,oauth-2025-04-20" },
@@ -240,7 +343,7 @@ export const PROVIDER_PRESETS = {
240
343
  baseUrl: "https://chatgpt.com/backend-api/codex",
241
344
  auth: "oauth",
242
345
  defaultModel: "gpt-5.3-codex",
243
- models: ["gpt-5.3-codex", "gpt-5.5", "gpt-5.4-mini"],
346
+ models: ["gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.5", "gpt-5.4-mini"],
244
347
  },
245
348
  /** Kimi's coding endpoint reached with a subscription token instead of a
246
349
  * key — Kimi bills the two separately. */
@@ -255,7 +358,7 @@ export const PROVIDER_PRESETS = {
255
358
  baseUrl: "https://api.githubcopilot.com",
256
359
  auth: "oauth",
257
360
  defaultModel: "gpt-5.6-sol",
258
- models: ["gpt-5.6-sol", "claude-opus-5", "claude-opus-4.8", "kimi-k2.7-code"],
361
+ models: ["gpt-5.6-sol", "claude-opus-5", "claude-sonnet-5", "gpt-6-astra"],
259
362
  },
260
363
  } as const satisfies Record<string, ProviderPreset>;
261
364
 
@@ -91,6 +91,7 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
91
91
  effort: config.effort,
92
92
  maxTokens: config.maxTokens,
93
93
  baseUrl: preset.baseUrl,
94
+ ...(preset.path ? { path: preset.path } : {}),
94
95
  headers,
95
96
  ...(config.providerOrder ? { providerOrder: config.providerOrder } : {}),
96
97
  fetchImpl: config.fetchImpl,
@@ -402,8 +402,12 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
402
402
  request.provider = { order: config.providerOrder, allow_fallbacks: true };
403
403
  }
404
404
 
405
+ const defaultPath = /\/v\d+[^/]*$/i.test(baseUrl.replace(/\/+$/, ""))
406
+ ? "/chat/completions"
407
+ : "/v1/chat/completions";
408
+
405
409
  for await (const data of streamSse({
406
- url: apiUrl(baseUrl, config.path ?? "/v1/chat/completions"),
410
+ url: apiUrl(baseUrl, config.path ?? defaultPath),
407
411
  headers: { authorization: `Bearer ${config.apiKey}`, ...config.headers },
408
412
  body: request,
409
413
  provider: id,
package/src/rate-limit.ts CHANGED
@@ -39,6 +39,7 @@ export interface RateLimitReset {
39
39
  }
40
40
 
41
41
  const RETRY_AFTER = "retry-after";
42
+ const RETRY_AFTER_MS = "retry-after-ms";
42
43
  const UNIFIED_5H = "anthropic-ratelimit-unified-5h-";
43
44
  const UNIFIED_7D = "anthropic-ratelimit-unified-7d-";
44
45
 
@@ -74,27 +75,50 @@ function unixSecondsToMs(value: string | null): number | undefined {
74
75
  return n === undefined ? undefined : n * 1000;
75
76
  }
76
77
 
78
+ /** Matches Go-style duration strings like "2m59.56s", "7.66s", "500ms", "1h30m". */
79
+ const DURATION_PATTERN =
80
+ /^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m(?!s))?(?:(\d+(?:\.\d+)?)m?s)?$/i;
81
+
82
+ function parseDurationMs(text: string): number | undefined {
83
+ const match = DURATION_PATTERN.exec(text.trim());
84
+ if (!match || !match.slice(1).some(Boolean)) return undefined;
85
+ const hours = Number(match[1]) || 0;
86
+ const minutes = Number(match[2]) || 0;
87
+ const seconds = Number(match[3]) || 0;
88
+ const isMs = text.toLowerCase().endsWith("ms");
89
+ const secMs = isMs ? seconds : seconds * 1000;
90
+ const total = Math.round(hours * 3_600_000 + minutes * 60_000 + secMs);
91
+ return total >= 0 ? total : undefined;
92
+ }
93
+
77
94
  /** 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. */
95
+ * (the unified reset, most gateways), Go-style duration ("2m59s" from OpenAI),
96
+ * or an RFC 3339 / HTTP-date string (the API-key reset headers). */
81
97
  function resetHeaderMs(value: string | null, now: number): number | undefined {
82
98
  if (!value) return undefined;
83
- if (/^\d+$/.test(value)) {
84
- const seconds = Number(value);
99
+ const trimmed = value.trim();
100
+ if (/^\d+$/.test(trimmed)) {
101
+ const seconds = Number(trimmed);
85
102
  return seconds > EPOCH_SECONDS_FLOOR ? seconds * 1000 : now + seconds * 1000;
86
103
  }
87
- const at = Date.parse(value);
104
+ const duration = parseDurationMs(trimmed);
105
+ if (duration !== undefined) {
106
+ return now + duration;
107
+ }
108
+ const at = Date.parse(trimmed);
88
109
  return Number.isNaN(at) ? undefined : at;
89
110
  }
90
111
 
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. */
112
+ /** `retry-after` is delay-seconds, Go-style duration ("60s"), or an absolute
113
+ * HTTP date, and all three occur across providers this package speaks to. */
93
114
  function parseRetryAfter(value: string | null, now: number): number | undefined {
94
115
  if (!value) return undefined;
95
- const n = Number(value);
116
+ const trimmed = value.trim();
117
+ const n = Number(trimmed);
96
118
  if (Number.isFinite(n) && n >= 0) return n * 1000;
97
- const at = Date.parse(value);
119
+ const duration = parseDurationMs(trimmed);
120
+ if (duration !== undefined) return duration;
121
+ const at = Date.parse(trimmed);
98
122
  return Number.isNaN(at) ? undefined : Math.max(0, at - now);
99
123
  }
100
124
 
@@ -110,7 +134,10 @@ function parseRetryAfter(value: string | null, now: number): number | undefined
110
134
  export function parseRateLimitReset(headers: Headers, now = Date.now()): RateLimitReset {
111
135
  const result: RateLimitReset = {};
112
136
 
113
- const after = parseRetryAfter(headers.get(RETRY_AFTER), now);
137
+ // `retry-after-ms` is Anthropic's millisecond spelling of the same header;
138
+ // when both ride a response the coarser one is the same deadline rounded.
139
+ const afterMs = numeric(headers.get(RETRY_AFTER_MS));
140
+ const after = afterMs ?? parseRetryAfter(headers.get(RETRY_AFTER), now);
114
141
  if (after !== undefined) {
115
142
  result.retryAfterMs = after;
116
143
  result.resetAtMs = now + after;
@@ -176,9 +203,31 @@ function jsonSeconds(value: unknown): number | undefined {
176
203
  return Number.isFinite(n) ? n : undefined;
177
204
  }
178
205
 
206
+ const PROSE_RETRY_PATTERNS = [
207
+ /try again in (\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)/i,
208
+ /retry after (\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)/i,
209
+ /resets? (?:in|after) (\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)/i,
210
+ /retry[- ]after[:\s]+(\d+(?:\.\d+)?)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?)?/i,
211
+ ];
212
+
213
+ function parseProseRetryMs(text: string): number | undefined {
214
+ for (const pattern of PROSE_RETRY_PATTERNS) {
215
+ const match = text.match(pattern);
216
+ if (!match?.[1]) continue;
217
+ const val = Number.parseFloat(match[1]);
218
+ if (!Number.isFinite(val) || val <= 0) continue;
219
+ const unit = (match[2] ?? "s").toLowerCase();
220
+ const multiplier = unit.startsWith("h") ? 3_600_000 : unit.startsWith("m") ? 60_000 : 1_000;
221
+ return Math.ceil(val * multiplier);
222
+ }
223
+ return undefined;
224
+ }
225
+
179
226
  /**
180
227
  * ChatGPT's codex backend puts the reset IN THE 429 BODY, not the headers:
181
228
  * `{"error":{"type":"usage_limit_reached","resets_at":1788801754,"resets_in_seconds":2501465}}`.
229
+ * Other providers and gateways (LiteLLM, Cloudflare, Ollama) report countdowns in prose:
230
+ * `Rate limit exceeded. Try again in 20s.`
182
231
  * The window name is inferred from the wait itself, and only past the floor
183
232
  * above — a sub-minute retry is a throttle, not a subscription window.
184
233
  */
@@ -187,33 +236,46 @@ export function parseUsageLimitBody(bodyText: string, now = Date.now()): RateLim
187
236
  try {
188
237
  body = JSON.parse(bodyText);
189
238
  } catch {
190
- return {};
239
+ body = undefined;
191
240
  }
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";
241
+
242
+ if (typeof body === "object" && body !== null) {
243
+ const source = (body as Record<string, unknown>).error;
244
+ const error =
245
+ typeof source === "object" && source !== null
246
+ ? (source as Record<string, unknown>)
247
+ : (body as Record<string, unknown>);
248
+
249
+ const inSeconds = jsonSeconds(error.resets_in_seconds);
250
+ const atSeconds = jsonSeconds(error.resets_at);
251
+ const waitMs =
252
+ inSeconds !== undefined && inSeconds >= 0
253
+ ? inSeconds * 1000
254
+ : atSeconds !== undefined && atSeconds > 0
255
+ ? Math.max(0, atSeconds * 1000 - now)
256
+ : undefined;
257
+ if (waitMs !== undefined) {
258
+ const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
259
+ if (waitMs > WINDOW_FLOOR_MS) {
260
+ result.window =
261
+ waitMs <= FIVE_HOUR_MAX_MS ? "5h" : waitMs <= WEEKLY_MAX_MS ? "weekly" : "monthly";
262
+ }
263
+ return result;
264
+ }
215
265
  }
216
- return result;
266
+
267
+ // If structured JSON did not yield a reset, check prose wording
268
+ const waitMs = parseProseRetryMs(bodyText);
269
+ if (waitMs !== undefined) {
270
+ const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
271
+ if (waitMs > WINDOW_FLOOR_MS) {
272
+ result.window =
273
+ waitMs <= FIVE_HOUR_MAX_MS ? "5h" : waitMs <= WEEKLY_MAX_MS ? "weekly" : "monthly";
274
+ }
275
+ return result;
276
+ }
277
+
278
+ return {};
217
279
  }
218
280
 
219
281
  /** Read both places an endpoint can report its reset. A short Retry-After is