@vymalo/opencode-ratelimit 0.12.0 → 0.14.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/dist/config.d.ts +14 -14
- package/dist/config.js +103 -108
- package/dist/config.js.map +1 -1
- package/dist/headers.d.ts +15 -15
- package/dist/headers.js +71 -70
- package/dist/headers.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib.js +1 -0
- package/dist/lib.js.map +1 -1
- package/dist/logging.d.ts +6 -6
- package/dist/logging.js +58 -62
- package/dist/logging.js.map +1 -1
- package/dist/opencode.d.ts +4 -4
- package/dist/opencode.js +51 -56
- package/dist/opencode.js.map +1 -1
- package/dist/plugin.d.ts +49 -49
- package/dist/plugin.js +442 -423
- package/dist/plugin.js.map +1 -1
- package/dist/types.d.ts +53 -53
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
package/dist/plugin.js
CHANGED
|
@@ -1,461 +1,480 @@
|
|
|
1
1
|
import { parseRateLimitOptions, selectTier } from "./config.js";
|
|
2
2
|
import { parseRateLimit } from "./headers.js";
|
|
3
3
|
/** Fallback backoff when a 429 carries no `x-ratelimit-reset` and no `Retry-After`. */
|
|
4
|
-
export const DEFAULT_BACKOFF_MS =
|
|
4
|
+
export const DEFAULT_BACKOFF_MS = 1e3;
|
|
5
5
|
export function createProviderState() {
|
|
6
|
-
|
|
6
|
+
return {
|
|
7
|
+
cooldownUntilMs: 0,
|
|
8
|
+
cooldownMaxWaitMs: 0
|
|
9
|
+
};
|
|
7
10
|
}
|
|
8
11
|
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
* Walk every provider in the assembled OpenCode config; for each one that has
|
|
13
|
+
* opted in via `options.meta.rateLimit`, wrap its `options.fetch` with a
|
|
14
|
+
* rate-limit-aware fetch. Synchronous — all real work happens lazily inside the
|
|
15
|
+
* wrapper at request time.
|
|
16
|
+
*/
|
|
14
17
|
export function installRateLimiter(input, deps) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
18
|
+
const providers = input.provider;
|
|
19
|
+
deps.logger.trace("ratelimit_install_start", { providerCount: providers ? Object.keys(providers).length : 0 });
|
|
20
|
+
if (!providers) {
|
|
21
|
+
deps.logger.info("ratelimit_plugin_initialized", { providerCount: 0 });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let enabledCount = 0;
|
|
25
|
+
for (const [providerId, providerConfig] of Object.entries(providers)) {
|
|
26
|
+
if (!providerConfig) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
deps.logger.trace("ratelimit_provider_optin_check", {
|
|
30
|
+
providerId,
|
|
31
|
+
hasOptions: Boolean(providerConfig.options)
|
|
32
|
+
});
|
|
33
|
+
const opts = parseRateLimitOptions(providerConfig.options);
|
|
34
|
+
if (!opts) {
|
|
35
|
+
deps.logger.debug("ratelimit_provider_skipped", {
|
|
36
|
+
providerId,
|
|
37
|
+
reason: "not_opted_in"
|
|
38
|
+
});
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
deps.logger.trace("ratelimit_provider_optin_resolved", {
|
|
42
|
+
providerId,
|
|
43
|
+
scope: opts.scope,
|
|
44
|
+
headerPrefix: opts.headerPrefix,
|
|
45
|
+
tiers: opts.tiers.length
|
|
46
|
+
});
|
|
47
|
+
const options = providerConfig.options ??= {};
|
|
48
|
+
// Compose with any fetch a prior plugin already installed (capture it now,
|
|
49
|
+
// not at call time, so we delegate to the original — not to ourselves).
|
|
50
|
+
const delegate = typeof options.fetch === "function" ? options.fetch : undefined;
|
|
51
|
+
deps.logger.trace("ratelimit_fetch_wrapped", {
|
|
52
|
+
providerId,
|
|
53
|
+
composedWithExistingFetch: Boolean(delegate)
|
|
54
|
+
});
|
|
55
|
+
const store = new Map();
|
|
56
|
+
options.fetch = makeRateLimitFetch(providerId, opts, store, deps, delegate);
|
|
57
|
+
enabledCount += 1;
|
|
58
|
+
deps.logger.info("ratelimit_provider_enabled", {
|
|
59
|
+
providerId,
|
|
60
|
+
scope: opts.scope,
|
|
61
|
+
tiers: opts.tiers.length,
|
|
62
|
+
headerPrefix: opts.headerPrefix
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
deps.logger.info("ratelimit_plugin_initialized", { providerCount: enabledCount });
|
|
62
66
|
}
|
|
63
67
|
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
* Build the fetch wrapper for one provider. The wrapper:
|
|
69
|
+
* 1. Resolves the bucket (provider, or `(provider, model)` under `scope: "model"`).
|
|
70
|
+
* 2. Pre-request gate: if that bucket's cooldown is armed, waits until it clears.
|
|
71
|
+
* 3. Sends the request via the underlying fetch.
|
|
72
|
+
* 4. Reads the rate-limit headers; selects the policy tier by reset magnitude.
|
|
73
|
+
* On `remaining: 0` it arms the gate for the next callers (only when the
|
|
74
|
+
* matched tier is `"wait"`).
|
|
75
|
+
* 5. On a 429: a `"wait"` tier waits the reset window and retries (up to the
|
|
76
|
+
* tier's `maxRetries`); an `"error"` tier surfaces the 429 immediately.
|
|
77
|
+
*
|
|
78
|
+
* The Response is returned untouched (no `.clone()`) — we only read `status`
|
|
79
|
+
* and `headers`, so the body stream is delivered to OpenCode intact.
|
|
80
|
+
*/
|
|
77
81
|
export function makeRateLimitFetch(providerId, opts, store, deps, delegate) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
82
|
+
const now = deps.now ?? Date.now;
|
|
83
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
84
|
+
const underlying = delegate ?? deps.fetchImpl ?? globalThis.fetch;
|
|
85
|
+
const { logger } = deps;
|
|
86
|
+
const isRequest = (value) => typeof Request !== "undefined" && value instanceof Request;
|
|
87
|
+
const wrapped = async (input, init) => {
|
|
88
|
+
// Honor an abort signal whether it rides on `init` or on a `Request` input.
|
|
89
|
+
const signal = init?.signal ?? (isRequest(input) ? input.signal : undefined) ?? undefined;
|
|
90
|
+
const model = opts.scope === "model" ? await modelFromRequest(input, init) : undefined;
|
|
91
|
+
if (opts.scope === "model") {
|
|
92
|
+
logger.trace("ratelimit_model_resolved", {
|
|
93
|
+
providerId,
|
|
94
|
+
model,
|
|
95
|
+
matched: model !== undefined
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const key = model ? `${providerId}\u0000${model}` : providerId;
|
|
99
|
+
let state = store.get(key);
|
|
100
|
+
const bucketExisted = state !== undefined;
|
|
101
|
+
if (!state) {
|
|
102
|
+
state = createProviderState();
|
|
103
|
+
store.set(key, state);
|
|
104
|
+
}
|
|
105
|
+
logger.trace("ratelimit_fetch_invoked", {
|
|
106
|
+
providerId,
|
|
107
|
+
scope: opts.scope,
|
|
108
|
+
model,
|
|
109
|
+
bucketKey: key,
|
|
110
|
+
bucketExisted,
|
|
111
|
+
cooldownUntilMs: state.cooldownUntilMs
|
|
112
|
+
});
|
|
113
|
+
// 2. Pre-request gate — wait out a cooldown a previous "wait" tier armed.
|
|
114
|
+
if (state.cooldownUntilMs > now()) {
|
|
115
|
+
const waitMs = clampWait(state.cooldownUntilMs - now(), state.cooldownMaxWaitMs);
|
|
116
|
+
logger.trace("ratelimit_cooldown_gate_armed", {
|
|
117
|
+
providerId,
|
|
118
|
+
model,
|
|
119
|
+
cooldownUntilMs: state.cooldownUntilMs,
|
|
120
|
+
remainingMs: state.cooldownUntilMs - now(),
|
|
121
|
+
clampedWaitMs: waitMs,
|
|
122
|
+
maxWaitMs: state.cooldownMaxWaitMs
|
|
123
|
+
});
|
|
124
|
+
if (waitMs > 0) {
|
|
125
|
+
logger.info("ratelimit_throttle_wait", {
|
|
126
|
+
providerId,
|
|
127
|
+
model,
|
|
128
|
+
waitMs
|
|
129
|
+
});
|
|
130
|
+
await waitGate(state, sleep, now, state.cooldownMaxWaitMs, signal, logger, providerId, model, "pre_request");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// 3-5. Attempt loop.
|
|
134
|
+
let attempt = 0;
|
|
135
|
+
for (;;) {
|
|
136
|
+
// A Request body is single-use; send a fresh clone each attempt so a
|
|
137
|
+
// wait-tier retry doesn't fail with "body already used". (The original is
|
|
138
|
+
// never sent directly, so each clone has an unconsumed body.)
|
|
139
|
+
const attemptInput = isRequest(input) ? input.clone() : input;
|
|
140
|
+
logger.trace("ratelimit_underlying_fetch", {
|
|
141
|
+
providerId,
|
|
142
|
+
model,
|
|
143
|
+
attempt
|
|
144
|
+
});
|
|
145
|
+
const response = await underlying(attemptInput, init);
|
|
146
|
+
const snapshot = readSnapshot(response, opts, now(), logger, providerId);
|
|
147
|
+
logger.trace("ratelimit_snapshot_parsed", {
|
|
148
|
+
providerId,
|
|
149
|
+
model,
|
|
150
|
+
status: response.status,
|
|
151
|
+
limit: snapshot.limit,
|
|
152
|
+
remaining: snapshot.remaining,
|
|
153
|
+
resetSeconds: snapshot.resetSeconds,
|
|
154
|
+
resetAtMs: snapshot.resetAtMs,
|
|
155
|
+
retryAfterMs: snapshot.retryAfterMs
|
|
156
|
+
});
|
|
157
|
+
logger.debug("ratelimit_quota", {
|
|
158
|
+
providerId,
|
|
159
|
+
model,
|
|
160
|
+
remaining: snapshot.remaining,
|
|
161
|
+
limit: snapshot.limit,
|
|
162
|
+
resetSeconds: snapshot.resetSeconds
|
|
163
|
+
});
|
|
164
|
+
if (response.status !== 429) {
|
|
165
|
+
// Arm the gate for the NEXT callers when the window is exhausted — but
|
|
166
|
+
// only under a "wait" tier. An "error" tier wants the next request to
|
|
167
|
+
// hit a real 429 and be surfaced, so we leave the gate unarmed.
|
|
168
|
+
if (snapshot.remaining !== undefined && snapshot.remaining <= 0 && snapshot.resetAtMs !== undefined) {
|
|
169
|
+
const resetForTier = effectiveResetSeconds(snapshot);
|
|
170
|
+
const tier = selectTier(opts.tiers, resetForTier);
|
|
171
|
+
logger.trace("ratelimit_exhausted_tier_selected", {
|
|
172
|
+
providerId,
|
|
173
|
+
model,
|
|
174
|
+
resetSeconds: resetForTier,
|
|
175
|
+
maxResetSeconds: tier.maxResetSeconds,
|
|
176
|
+
action: tier.action,
|
|
177
|
+
maxWaitMs: tier.maxWaitMs,
|
|
178
|
+
maxRetries: tier.maxRetries
|
|
179
|
+
});
|
|
180
|
+
if (tier.action === "wait") {
|
|
181
|
+
state.cooldownUntilMs = snapshot.resetAtMs;
|
|
182
|
+
state.cooldownMaxWaitMs = tier.maxWaitMs;
|
|
183
|
+
logger.trace("ratelimit_cooldown_set", {
|
|
184
|
+
providerId,
|
|
185
|
+
model,
|
|
186
|
+
cooldownUntilMs: state.cooldownUntilMs,
|
|
187
|
+
maxWaitMs: tier.maxWaitMs,
|
|
188
|
+
source: "exhausted"
|
|
189
|
+
});
|
|
190
|
+
} else {
|
|
191
|
+
// Error tier → don't arm, and drop any stale cooldown a prior
|
|
192
|
+
// "wait" window left so the next request hits a real 429 at once.
|
|
193
|
+
state.cooldownUntilMs = 0;
|
|
194
|
+
state.cooldownMaxWaitMs = 0;
|
|
195
|
+
logger.trace("ratelimit_cooldown_cleared", {
|
|
196
|
+
providerId,
|
|
197
|
+
model,
|
|
198
|
+
source: "exhausted_error_tier"
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return response;
|
|
203
|
+
}
|
|
204
|
+
const resetForTier = effectiveResetSeconds(snapshot);
|
|
205
|
+
const tier = selectTier(opts.tiers, resetForTier);
|
|
206
|
+
logger.trace("ratelimit_429_tier_selected", {
|
|
207
|
+
providerId,
|
|
208
|
+
model,
|
|
209
|
+
attempt,
|
|
210
|
+
resetSeconds: resetForTier,
|
|
211
|
+
maxResetSeconds: tier.maxResetSeconds,
|
|
212
|
+
action: tier.action,
|
|
213
|
+
maxWaitMs: tier.maxWaitMs,
|
|
214
|
+
maxRetries: tier.maxRetries
|
|
215
|
+
});
|
|
216
|
+
if (tier.action === "error") {
|
|
217
|
+
// Drop any cooldown a prior "wait" tier armed, so the fail-fast is
|
|
218
|
+
// actually fast — later requests must not sleep a stale window first.
|
|
219
|
+
state.cooldownUntilMs = 0;
|
|
220
|
+
state.cooldownMaxWaitMs = 0;
|
|
221
|
+
logger.trace("ratelimit_cooldown_cleared", {
|
|
222
|
+
providerId,
|
|
223
|
+
model,
|
|
224
|
+
source: "429_error_tier"
|
|
225
|
+
});
|
|
226
|
+
logger.warn("ratelimit_failfast", {
|
|
227
|
+
providerId,
|
|
228
|
+
model,
|
|
229
|
+
resetSeconds: snapshot.resetSeconds
|
|
230
|
+
});
|
|
231
|
+
return response;
|
|
232
|
+
}
|
|
233
|
+
if (attempt >= tier.maxRetries) {
|
|
234
|
+
logger.trace("ratelimit_retries_exhausted", {
|
|
235
|
+
providerId,
|
|
236
|
+
model,
|
|
237
|
+
attempt,
|
|
238
|
+
maxRetries: tier.maxRetries
|
|
239
|
+
});
|
|
240
|
+
logger.error("ratelimit_giveup", {
|
|
241
|
+
providerId,
|
|
242
|
+
model,
|
|
243
|
+
attempts: attempt
|
|
244
|
+
});
|
|
245
|
+
return response;
|
|
246
|
+
}
|
|
247
|
+
const backoffMs = computeBackoff(snapshot, now());
|
|
248
|
+
const waitMs = clampWait(backoffMs, tier.maxWaitMs);
|
|
249
|
+
logger.trace("ratelimit_429_backoff_computed", {
|
|
250
|
+
providerId,
|
|
251
|
+
model,
|
|
252
|
+
attempt: attempt + 1,
|
|
253
|
+
rawBackoffMs: backoffMs,
|
|
254
|
+
clampedWaitMs: waitMs,
|
|
255
|
+
maxWaitMs: tier.maxWaitMs
|
|
256
|
+
});
|
|
257
|
+
state.cooldownUntilMs = now() + waitMs;
|
|
258
|
+
state.cooldownMaxWaitMs = tier.maxWaitMs;
|
|
259
|
+
logger.trace("ratelimit_cooldown_set", {
|
|
260
|
+
providerId,
|
|
261
|
+
model,
|
|
262
|
+
cooldownUntilMs: state.cooldownUntilMs,
|
|
263
|
+
maxWaitMs: tier.maxWaitMs,
|
|
264
|
+
source: "429_backoff"
|
|
265
|
+
});
|
|
266
|
+
logger.warn("ratelimit_429_backoff", {
|
|
267
|
+
providerId,
|
|
268
|
+
model,
|
|
269
|
+
attempt: attempt + 1,
|
|
270
|
+
waitMs,
|
|
271
|
+
resetSeconds: snapshot.resetSeconds
|
|
272
|
+
});
|
|
273
|
+
await waitGate(state, sleep, now, tier.maxWaitMs, signal, logger, providerId, model, "backoff");
|
|
274
|
+
attempt += 1;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
return wrapped;
|
|
261
278
|
}
|
|
262
279
|
/**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
280
|
+
* Best-effort extraction of the `model` from an OpenAI-compatible request.
|
|
281
|
+
* The AI SDK calls `fetch(url, { body: "<json>" })`, so the common path reads a
|
|
282
|
+
* JSON string body synchronously (non-destructive). It also supports the
|
|
283
|
+
* `fetch(new Request(...))` shape by reading a clone of the Request body, so a
|
|
284
|
+
* Request-style caller doesn't silently collapse to the provider-wide bucket.
|
|
285
|
+
* Anything unparseable → `undefined` → caller falls back to the provider bucket.
|
|
286
|
+
*/
|
|
270
287
|
async function modelFromRequest(input, init) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
return undefined;
|
|
288
|
+
const fromInit = modelFromBody(init?.body);
|
|
289
|
+
if (fromInit !== undefined) {
|
|
290
|
+
return fromInit;
|
|
291
|
+
}
|
|
292
|
+
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
293
|
+
try {
|
|
294
|
+
return modelFromBody(await input.clone().text());
|
|
295
|
+
} catch {
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
284
300
|
}
|
|
285
301
|
function modelFromBody(body) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
302
|
+
if (typeof body !== "string") {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const parsed = JSON.parse(body);
|
|
307
|
+
return typeof parsed.model === "string" && parsed.model.length > 0 ? parsed.model : undefined;
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
296
311
|
}
|
|
297
312
|
/**
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
313
|
+
* Reset magnitude (seconds) used for tier selection: prefer `x-ratelimit-reset`,
|
|
314
|
+
* else derive from a `Retry-After` fallback — so a `Retry-After`-only 429 with a
|
|
315
|
+
* multi-day delay still lands in a long-reset (`error`) tier instead of the
|
|
316
|
+
* smallest band.
|
|
317
|
+
*/
|
|
303
318
|
function effectiveResetSeconds(snapshot) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
319
|
+
if (snapshot.resetSeconds !== undefined) {
|
|
320
|
+
return snapshot.resetSeconds;
|
|
321
|
+
}
|
|
322
|
+
if (snapshot.retryAfterMs !== undefined) {
|
|
323
|
+
return Math.ceil(snapshot.retryAfterMs / 1e3);
|
|
324
|
+
}
|
|
325
|
+
return undefined;
|
|
311
326
|
}
|
|
312
327
|
function readSnapshot(response, opts, nowMs, logger, providerId) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
328
|
+
try {
|
|
329
|
+
return parseRateLimit(response.headers, opts.headerPrefix, nowMs);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
logger.debug("ratelimit_header_parse_failed", {
|
|
332
|
+
providerId,
|
|
333
|
+
error: error instanceof Error ? error.message : String(error)
|
|
334
|
+
});
|
|
335
|
+
return {};
|
|
336
|
+
}
|
|
323
337
|
}
|
|
324
338
|
function computeBackoff(snapshot, nowMs) {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
339
|
+
if (snapshot.resetAtMs !== undefined) {
|
|
340
|
+
return Math.max(0, snapshot.resetAtMs - nowMs);
|
|
341
|
+
}
|
|
342
|
+
if (snapshot.retryAfterMs !== undefined) {
|
|
343
|
+
return snapshot.retryAfterMs;
|
|
344
|
+
}
|
|
345
|
+
return DEFAULT_BACKOFF_MS;
|
|
332
346
|
}
|
|
333
347
|
/** `maxWaitMs` of 0 means unlimited (wait the full reset window). */
|
|
334
348
|
function clampWait(ms, maxWaitMs) {
|
|
335
|
-
|
|
336
|
-
|
|
349
|
+
const nonNegative = Math.max(0, ms);
|
|
350
|
+
return maxWaitMs > 0 ? Math.min(nonNegative, maxWaitMs) : nonNegative;
|
|
337
351
|
}
|
|
338
352
|
/**
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
353
|
+
* Wait until `state.cooldownUntilMs` has elapsed. Two concurrency properties:
|
|
354
|
+
*
|
|
355
|
+
* - **One shared timer.** The first caller to hit the gate creates the timer on
|
|
356
|
+
* `state.cooldownPromise`; concurrent callers await that same timer rather
|
|
357
|
+
* than each starting their own, so a burst during cooldown produces ONE wait,
|
|
358
|
+
* not N. The shared timer is not tied to any single caller's `signal` — each
|
|
359
|
+
* caller races it against its own signal (see `raceWithAbort`), so one
|
|
360
|
+
* request's cancellation never aborts the others.
|
|
361
|
+
* - **Honors the longest window.** After the shared timer resolves we re-check
|
|
362
|
+
* `cooldownUntilMs`. If another caller extended the window (e.g. a 429 landed
|
|
363
|
+
* with a further-out reset) while we were waiting we wait again for the
|
|
364
|
+
* remainder instead of returning early and hammering the gateway.
|
|
365
|
+
*
|
|
366
|
+
* `maxWaitMs` (when > 0) bounds the TOTAL wait of a single call: once a caller
|
|
367
|
+
* has waited that long it proceeds even if the window hasn't fully elapsed. A
|
|
368
|
+
* caller that needs LESS than the in-flight shared timer (the window shrank, or
|
|
369
|
+
* the timer was created by a caller with a later deadline) falls back to its own
|
|
370
|
+
* private sleep so it is never held past its own required time.
|
|
371
|
+
*/
|
|
358
372
|
async function waitGate(state, sleep, now, maxWaitMs, signal, logger, providerId, model, phase) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
373
|
+
const deadlineMs = maxWaitMs > 0 ? now() + maxWaitMs : Number.POSITIVE_INFINITY;
|
|
374
|
+
logger.trace("ratelimit_wait_start", {
|
|
375
|
+
providerId,
|
|
376
|
+
model,
|
|
377
|
+
phase,
|
|
378
|
+
cooldownUntilMs: state.cooldownUntilMs,
|
|
379
|
+
maxWaitMs
|
|
380
|
+
});
|
|
381
|
+
for (;;) {
|
|
382
|
+
const target = Math.min(state.cooldownUntilMs, deadlineMs);
|
|
383
|
+
const remainingMs = target - now();
|
|
384
|
+
if (remainingMs <= 0) {
|
|
385
|
+
logger.trace("ratelimit_wait_end", {
|
|
386
|
+
providerId,
|
|
387
|
+
model,
|
|
388
|
+
phase
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
logger.trace("ratelimit_wait_tick", {
|
|
393
|
+
providerId,
|
|
394
|
+
model,
|
|
395
|
+
phase,
|
|
396
|
+
remainingMs,
|
|
397
|
+
sharedTimer: state.cooldownPromise !== undefined
|
|
398
|
+
});
|
|
399
|
+
// Reuse the shared timer only if it won't make us wait longer than we need.
|
|
400
|
+
let pending = state.cooldownPromise;
|
|
401
|
+
if (pending && state.cooldownPromiseUntilMs !== undefined && state.cooldownPromiseUntilMs - now() > remainingMs) {
|
|
402
|
+
pending = undefined;
|
|
403
|
+
}
|
|
404
|
+
if (!pending) {
|
|
405
|
+
const created = sleep(remainingMs).finally(() => {
|
|
406
|
+
if (state.cooldownPromise === created) {
|
|
407
|
+
state.cooldownPromise = undefined;
|
|
408
|
+
state.cooldownPromiseUntilMs = undefined;
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
// Publish as the shared timer only when there isn't already a (shorter)
|
|
412
|
+
// one in flight — never clobber it with our longer/private wait.
|
|
413
|
+
if (!state.cooldownPromise) {
|
|
414
|
+
state.cooldownPromise = created;
|
|
415
|
+
state.cooldownPromiseUntilMs = now() + remainingMs;
|
|
416
|
+
}
|
|
417
|
+
pending = created;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
await raceWithAbort(pending, signal);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
if (isAbortError(error)) {
|
|
423
|
+
logger.warn("ratelimit_wait_aborted", {
|
|
424
|
+
providerId,
|
|
425
|
+
model,
|
|
426
|
+
phase
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
414
432
|
}
|
|
415
433
|
function raceWithAbort(promise, signal) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
434
|
+
if (!signal) {
|
|
435
|
+
return promise;
|
|
436
|
+
}
|
|
437
|
+
if (signal.aborted) {
|
|
438
|
+
return Promise.reject(toAbortError(signal));
|
|
439
|
+
}
|
|
440
|
+
return new Promise((resolve, reject) => {
|
|
441
|
+
const onAbort = () => reject(toAbortError(signal));
|
|
442
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
443
|
+
promise.then(() => {
|
|
444
|
+
signal.removeEventListener("abort", onAbort);
|
|
445
|
+
resolve();
|
|
446
|
+
}, (error) => {
|
|
447
|
+
signal.removeEventListener("abort", onAbort);
|
|
448
|
+
reject(error);
|
|
449
|
+
});
|
|
450
|
+
});
|
|
433
451
|
}
|
|
434
452
|
function defaultSleep(ms, signal) {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
453
|
+
return new Promise((resolve, reject) => {
|
|
454
|
+
if (signal?.aborted) {
|
|
455
|
+
reject(toAbortError(signal));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const timer = setTimeout(() => {
|
|
459
|
+
signal?.removeEventListener("abort", onAbort);
|
|
460
|
+
resolve();
|
|
461
|
+
}, ms);
|
|
462
|
+
const onAbort = () => {
|
|
463
|
+
clearTimeout(timer);
|
|
464
|
+
reject(toAbortError(signal));
|
|
465
|
+
};
|
|
466
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
467
|
+
});
|
|
450
468
|
}
|
|
451
469
|
function toAbortError(signal) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
470
|
+
const reason = signal.reason;
|
|
471
|
+
if (reason instanceof Error) {
|
|
472
|
+
return reason;
|
|
473
|
+
}
|
|
474
|
+
return new DOMException("The operation was aborted", "AbortError");
|
|
457
475
|
}
|
|
458
476
|
function isAbortError(error) {
|
|
459
|
-
|
|
477
|
+
return error instanceof Error && error.name === "AbortError";
|
|
460
478
|
}
|
|
479
|
+
|
|
461
480
|
//# sourceMappingURL=plugin.js.map
|