edgeinfer 0.0.0-stage → 2.0.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.
@@ -0,0 +1,338 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024-2026 Soumya Debnath
3
+
4
+ import * as ort from 'onnxruntime-web';
5
+
6
+ export interface VRAMProbeResult {
7
+ available: boolean;
8
+ /** Largest single storage buffer the device actually allocated. */
9
+ maxAllocationBytes: number;
10
+ testedAllocations: number[];
11
+ failedAllocationBytes: number | null;
12
+ adapterLimits: {
13
+ maxBufferSize: number;
14
+ maxStorageBufferBindingSize: number;
15
+ };
16
+ deviceCreated: boolean;
17
+ probe: 'empirical' | 'limits' | 'unavailable';
18
+ }
19
+
20
+ export interface VRAMProbeOptions {
21
+ startBytes?: number;
22
+ maxBytes?: number;
23
+ stepBytes?: number;
24
+ }
25
+
26
+ export interface GPUCapabilities {
27
+ provider: string;
28
+ hasWebGPU: boolean;
29
+ hasWebNN: boolean;
30
+ /**
31
+ * NOT an amount of video memory. This is the largest single GPU storage
32
+ * buffer allocated by the empirical probe, bounded by the adapter's
33
+ * `maxBufferSize`. Browser baselines are commonly 256 MiB regardless of the
34
+ * physical card, so treat this as a maximum-tensor-size budget, not as VRAM.
35
+ */
36
+ estimatedVRAM: number;
37
+ /** Raw adapter limits, kept separate from the empirical probe. */
38
+ adapterLimits: {
39
+ maxBufferSize: number;
40
+ maxStorageBufferBindingSize: number;
41
+ } | null;
42
+ /** Evidence for how `estimatedVRAM` was obtained. */
43
+ vramProbe: VRAMProbeResult | null;
44
+ recommendedQuantization: 'fp32' | 'fp16' | 'int8' | 'int4';
45
+ }
46
+
47
+ /** Max time to wait for navigator.gpu.requestAdapter() before giving up. */
48
+ const ADAPTER_TIMEOUT_MS = 5000;
49
+
50
+ /**
51
+ * RuntimeManager handles execution provider detection and ONNX session creation.
52
+ * Implements a smart fallback chain: WebGPU → WebNN → WASM
53
+ * with automatic quantization level recommendation based on available GPU memory.
54
+ */
55
+ export class RuntimeManager {
56
+ private static cachedCapabilities: GPUCapabilities | null = null;
57
+
58
+ /**
59
+ * Detect GPU capabilities and recommend the best execution configuration.
60
+ */
61
+ static async detectCapabilities(): Promise<GPUCapabilities> {
62
+ if (this.cachedCapabilities) return this.cachedCapabilities;
63
+
64
+ const capabilities: GPUCapabilities = {
65
+ provider: 'wasm',
66
+ hasWebGPU: false,
67
+ hasWebNN: false,
68
+ estimatedVRAM: 0,
69
+ adapterLimits: null,
70
+ vramProbe: null,
71
+ recommendedQuantization: 'int8',
72
+ };
73
+
74
+ // Check WebGPU availability.
75
+ // NOTE: `'gpu' in navigator` is NOT sufficient to conclude WebGPU works.
76
+ // A browser can expose navigator.gpu and still return null from
77
+ // requestAdapter() (no GPU, driver blocklisted, headless). We therefore
78
+ // always request an adapter, and we bound the wait: requestAdapter() is
79
+ // specified to settle, but a hung driver must not stall the whole SDK.
80
+ if (typeof navigator !== 'undefined' && 'gpu' in navigator) {
81
+ try {
82
+ const gpu = (navigator as any).gpu;
83
+ const adapter = await RuntimeManager.withTimeout(
84
+ Promise.resolve(gpu.requestAdapter()),
85
+ ADAPTER_TIMEOUT_MS
86
+ );
87
+ if (adapter) {
88
+ capabilities.hasWebGPU = true;
89
+ capabilities.provider = 'webgpu';
90
+
91
+ const maxBufferSize = adapter.limits?.maxBufferSize || 0;
92
+ const maxStorageSize = adapter.limits?.maxStorageBufferBindingSize || 0;
93
+ capabilities.adapterLimits = {
94
+ maxBufferSize,
95
+ maxStorageBufferBindingSize: maxStorageSize,
96
+ };
97
+
98
+ // Probe what the device actually allocates instead of treating the
99
+ // adapter's baseline limits as physical VRAM.
100
+ const probe = await RuntimeManager.probeVRAM(adapter);
101
+ capabilities.vramProbe = probe;
102
+ capabilities.estimatedVRAM = probe.available
103
+ ? probe.maxAllocationBytes
104
+ : Math.max(maxBufferSize, maxStorageSize);
105
+
106
+ const vramGB = capabilities.estimatedVRAM / (1024 * 1024 * 1024);
107
+ if (vramGB >= 4) {
108
+ capabilities.recommendedQuantization = 'fp16';
109
+ } else if (vramGB >= 1) {
110
+ capabilities.recommendedQuantization = 'int8';
111
+ } else {
112
+ capabilities.recommendedQuantization = 'int4';
113
+ }
114
+ }
115
+ } catch {
116
+ // WebGPU not available or adapter request failed
117
+ }
118
+ }
119
+
120
+ // Check WebNN availability
121
+ if (typeof navigator !== 'undefined' && 'ml' in navigator) {
122
+ try {
123
+ const ml = (navigator as any).ml;
124
+ if (ml) {
125
+ capabilities.hasWebNN = true;
126
+ if (!capabilities.hasWebGPU) {
127
+ capabilities.provider = 'webnn';
128
+ }
129
+ }
130
+ } catch {
131
+ // WebNN not available
132
+ }
133
+ }
134
+
135
+ this.cachedCapabilities = capabilities;
136
+ return capabilities;
137
+ }
138
+
139
+ /**
140
+ * Get the best available execution provider with smart fallback.
141
+ * Priority: WebGPU → WebNN → WASM
142
+ */
143
+ static async getBestExecutionProvider(): Promise<string> {
144
+ const caps = await this.detectCapabilities();
145
+ return caps.provider;
146
+ }
147
+
148
+ /**
149
+ * Create an ONNX inference session with automatic provider selection.
150
+ * Falls back gracefully if the preferred provider fails.
151
+ *
152
+ * Returns BOTH the session and the provider that actually served it. The
153
+ * detected capability (`detectCapabilities().provider`) is only a preference:
154
+ * a provider can be detected as available and still fail to create a session
155
+ * (for example onnxruntime-web only registers the WebGPU execution provider
156
+ * in some builds/versions). Callers must report the returned `provider`, not
157
+ * the detected one, or `EdgeInfer.executionProvider` will lie.
158
+ */
159
+ static async createSession(
160
+ modelBuffer: ArrayBuffer,
161
+ providers?: string[]
162
+ ): Promise<{ session: ort.InferenceSession; provider: string }> {
163
+ const requestedProviders = providers || [await this.getBestExecutionProvider()];
164
+
165
+ // Try each provider in order, falling back on failure
166
+ for (const provider of requestedProviders) {
167
+ try {
168
+ const session = await ort.InferenceSession.create(
169
+ modelBuffer,
170
+ {
171
+ executionProviders: [provider],
172
+ graphOptimizationLevel: 'all',
173
+ }
174
+ );
175
+ return { session, provider };
176
+ } catch (err) {
177
+ console.warn(`EdgeInfer: Failed to create session with provider "${provider}", trying next...`, err);
178
+ }
179
+ }
180
+
181
+ // Final fallback: WASM (should always work)
182
+ console.warn('EdgeInfer: All preferred providers failed, falling back to WASM');
183
+ const session = await ort.InferenceSession.create(modelBuffer, {
184
+ executionProviders: ['wasm'],
185
+ graphOptimizationLevel: 'all',
186
+ });
187
+ return { session, provider: 'wasm' };
188
+ }
189
+
190
+ /**
191
+ * Empirically probe the largest single storage buffer the adapter's device
192
+ * will allocate. Allocates and destroys one buffer at a time; never relies on
193
+ * `maxBufferSize` as if it were physical VRAM.
194
+ */
195
+ static async probeVRAM(
196
+ adapter: any,
197
+ options: VRAMProbeOptions = {}
198
+ ): Promise<VRAMProbeResult> {
199
+ const limits = {
200
+ maxBufferSize: Number(adapter?.limits?.maxBufferSize) || 0,
201
+ maxStorageBufferBindingSize:
202
+ Number(adapter?.limits?.maxStorageBufferBindingSize) || 0,
203
+ };
204
+ const unavailable: VRAMProbeResult = {
205
+ available: false,
206
+ maxAllocationBytes: 0,
207
+ testedAllocations: [],
208
+ failedAllocationBytes: null,
209
+ adapterLimits: limits,
210
+ deviceCreated: false,
211
+ probe: 'unavailable',
212
+ };
213
+
214
+ if (!adapter || typeof adapter.requestDevice !== 'function') return unavailable;
215
+
216
+ let device: any;
217
+ try {
218
+ device = await adapter.requestDevice();
219
+ } catch {
220
+ return unavailable;
221
+ }
222
+ if (!device || typeof device.createBuffer !== 'function') {
223
+ return { ...unavailable, deviceCreated: Boolean(device) };
224
+ }
225
+
226
+ const MIB = 1024 * 1024;
227
+ const start = Math.max(1, options.startBytes ?? 64 * MIB);
228
+ const configuredMax = Math.max(start, options.maxBytes ?? 4 * 1024 * MIB);
229
+ const max = limits.maxBufferSize > 0
230
+ ? Math.min(configuredMax, limits.maxBufferSize)
231
+ : configuredMax;
232
+ const step = Math.max(1, options.stepBytes ?? 32 * MIB);
233
+ const tested: number[] = [];
234
+ const STORAGE = 0x80;
235
+ const COPY_DST = 0x8;
236
+
237
+ const attempt = (size: number): boolean => {
238
+ tested.push(size);
239
+ try {
240
+ const buffer = device.createBuffer({ size, usage: STORAGE | COPY_DST });
241
+ buffer?.destroy?.();
242
+ return true;
243
+ } catch {
244
+ return false;
245
+ }
246
+ };
247
+
248
+ let low = 0;
249
+ let high = 0;
250
+ let size = Math.min(start, max);
251
+ while (size >= 1) {
252
+ if (attempt(size)) {
253
+ low = size;
254
+ break;
255
+ }
256
+ high = size;
257
+ if (size === 1) break;
258
+ size = Math.max(1, Math.floor(size / 2));
259
+ }
260
+
261
+ if (low === 0) {
262
+ device.destroy?.();
263
+ return {
264
+ ...unavailable,
265
+ testedAllocations: tested,
266
+ failedAllocationBytes: high || null,
267
+ deviceCreated: true,
268
+ probe: 'limits',
269
+ };
270
+ }
271
+
272
+ let next = Math.min(max, low * 2);
273
+ while (next > low && tested.length < 16) {
274
+ if (attempt(next)) {
275
+ low = next;
276
+ next = Math.min(max, low * 2);
277
+ if (low === max) break;
278
+ } else {
279
+ high = next;
280
+ break;
281
+ }
282
+ }
283
+ if (high === 0) high = Math.min(max, low * 2);
284
+
285
+ while (high - low > step && tested.length < 24) {
286
+ const mid = Math.floor((low + high) / 2);
287
+ if (mid <= low || mid >= high) break;
288
+ if (attempt(mid)) low = mid;
289
+ else high = mid;
290
+ }
291
+
292
+ device.destroy?.();
293
+ return {
294
+ available: true,
295
+ maxAllocationBytes: low,
296
+ testedAllocations: tested,
297
+ failedAllocationBytes: high > low ? high : null,
298
+ adapterLimits: limits,
299
+ deviceCreated: true,
300
+ probe: 'empirical',
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Resolve `promise`, or resolve to `null` if it has not settled within `ms`.
306
+ * Used to stop a hung GPU driver from blocking model loading forever.
307
+ */
308
+ private static withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
309
+ let timer: any;
310
+ return Promise.race([
311
+ promise.then((v) => { clearTimeout(timer); return v; }),
312
+ new Promise<null>((resolve) => {
313
+ timer = setTimeout(() => {
314
+ console.warn(`EdgeInfer: navigator.gpu.requestAdapter() did not settle within ${ms}ms; falling back to WASM.`);
315
+ resolve(null);
316
+ }, ms);
317
+ if (typeof timer?.unref === 'function') timer.unref();
318
+ }),
319
+ ]);
320
+ }
321
+
322
+ /**
323
+ * Estimate if a model of given size (bytes) can fit in available VRAM.
324
+ */
325
+ static async canFitModel(modelSizeBytes: number): Promise<boolean> {
326
+ const caps = await this.detectCapabilities();
327
+ if (!caps.hasWebGPU) return true; // WASM uses system RAM, not VRAM
328
+ // Model typically needs 1.5x its size in VRAM (weights + activations)
329
+ return caps.estimatedVRAM >= modelSizeBytes * 1.5;
330
+ }
331
+
332
+ /**
333
+ * Reset cached capabilities (useful for testing or re-detection).
334
+ */
335
+ static resetCache(): void {
336
+ this.cachedCapabilities = null;
337
+ }
338
+ }
@@ -0,0 +1,294 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024-2026 Soumya Debnath
3
+ //
4
+ // Speculative decoding with a small draft model and a larger target model.
5
+ // The target verifies gamma draft tokens in one forward pass. Accepted tokens
6
+ // keep their draft distribution; a rejection resamples from the residual
7
+ // max(0, p_target - p_draft) and truncates both KV-cache states.
8
+
9
+ export interface ModelState {
10
+ tokens: number[];
11
+ /** Opaque KV-cache handle for cache-aware adapters. */
12
+ cache?: unknown;
13
+ }
14
+
15
+ export interface DecodeResult {
16
+ state: ModelState;
17
+ /**
18
+ * One next-token distribution for each input position, plus one distribution
19
+ * after the last input token. Length = tokens.length + 1. Calling decode()
20
+ * with an empty token array returns the distribution after the current state.
21
+ */
22
+ logits: Float32Array[];
23
+ }
24
+
25
+ export interface LanguageModel {
26
+ readonly vocabSize: number;
27
+ prefill(tokens: number[]): Promise<ModelState>;
28
+ decode(state: ModelState, tokens: number[]): Promise<DecodeResult>;
29
+ /**
30
+ * Drop cache entries after `length` total tokens. Cache-aware adapters must
31
+ * implement this; stateless models can just slice the token list.
32
+ */
33
+ truncate(state: ModelState, length: number): ModelState;
34
+ }
35
+
36
+ export interface SpeculativeOptions {
37
+ draft: LanguageModel;
38
+ target: LanguageModel;
39
+ gamma?: number;
40
+ rng?: () => number;
41
+ eosTokenId?: number;
42
+ }
43
+
44
+ export interface SpeculativeStats {
45
+ outputTokens: number;
46
+ draftTokens: number;
47
+ acceptedTokens: number;
48
+ bonusTokens: number;
49
+ rejectedTokens: number;
50
+ targetCalls: number;
51
+ draftCalls: number;
52
+ acceptanceRate: number;
53
+ targetCallsPerToken: number;
54
+ elapsedMs: number;
55
+ tokensPerSecond: number;
56
+ }
57
+
58
+ export interface SpeculativeGenerationResult {
59
+ tokens: number[];
60
+ stats: SpeculativeStats;
61
+ }
62
+
63
+ export class SpeculativeDecoder {
64
+ readonly draft: LanguageModel;
65
+ readonly target: LanguageModel;
66
+ readonly gamma: number;
67
+ private readonly rng: () => number;
68
+ private readonly eosTokenId?: number;
69
+
70
+ constructor(options: SpeculativeOptions) {
71
+ if (options.draft.vocabSize !== options.target.vocabSize) {
72
+ throw new Error("Draft and target models must share a vocabulary size");
73
+ }
74
+ const gamma = options.gamma ?? 5;
75
+ if (!Number.isInteger(gamma) || gamma < 1) {
76
+ throw new Error("gamma must be a positive integer");
77
+ }
78
+ this.draft = options.draft;
79
+ this.target = options.target;
80
+ this.gamma = gamma;
81
+ this.rng = options.rng ?? Math.random;
82
+ this.eosTokenId = options.eosTokenId;
83
+ }
84
+
85
+ async generate(
86
+ prompt: number[],
87
+ maxNewTokens = 64,
88
+ { temperature = 1 }: { temperature?: number } = {}
89
+ ): Promise<SpeculativeGenerationResult> {
90
+ if (prompt.length === 0) throw new Error("prompt must not be empty");
91
+ if (maxNewTokens < 1) throw new Error("maxNewTokens must be positive");
92
+ if (temperature <= 0) throw new Error("temperature must be positive");
93
+
94
+ const started = performance.now();
95
+ let draftState = await this.draft.prefill(prompt);
96
+ let targetState = await this.target.prefill(prompt);
97
+
98
+ const output: number[] = [];
99
+ let draftTokens = 0;
100
+ let acceptedTokens = 0;
101
+ let bonusTokens = 0;
102
+ let rejectedTokens = 0;
103
+ let targetCalls = 0;
104
+ let draftCalls = 0;
105
+
106
+ while (output.length < maxNewTokens) {
107
+ const gamma = Math.min(this.gamma, maxNewTokens - output.length);
108
+ const draft = await this.draftBatch(draftState, gamma, temperature, draftCalls);
109
+ draftState = draft.state;
110
+ draftCalls = draft.calls;
111
+ draftTokens += draft.tokens.length;
112
+
113
+ const prefixLength = targetState.tokens.length;
114
+ const verification = await this.target.decode(targetState, draft.tokens);
115
+ targetCalls++;
116
+ targetState = verification.state;
117
+
118
+ let rejected = false;
119
+ for (let i = 0; i < draft.tokens.length; i++) {
120
+ const token = draft.tokens[i];
121
+ const q = draft.probabilities[i][token];
122
+ const p = probability(verification.logits[i], token, temperature);
123
+ if (this.rng() < Math.min(1, p / q)) {
124
+ output.push(token);
125
+ acceptedTokens++;
126
+ if (token === this.eosTokenId || output.length >= maxNewTokens) break;
127
+ continue;
128
+ }
129
+
130
+ const residual = residualDistribution(
131
+ verification.logits[i],
132
+ draft.probabilities[i],
133
+ temperature
134
+ );
135
+ const replacement = sample(residual, this.rng());
136
+ output.push(replacement);
137
+ rejectedTokens++;
138
+ rejected = true;
139
+
140
+ const keep = prefixLength + i;
141
+ targetState = this.target.truncate(targetState, keep);
142
+ draftState = this.draft.truncate(draftState, keep);
143
+ const targetAdvance = await this.target.decode(targetState, [replacement]);
144
+ targetCalls++;
145
+ targetState = targetAdvance.state;
146
+ const draftAdvance = await this.draft.decode(draftState, [replacement]);
147
+ draftCalls++;
148
+ draftState = draftAdvance.state;
149
+ break;
150
+ }
151
+
152
+ if (rejected) continue;
153
+ if (output.length >= maxNewTokens || output[output.length - 1] === this.eosTokenId) break;
154
+
155
+ const bonusDistribution = softmax(
156
+ verification.logits[draft.tokens.length],
157
+ temperature
158
+ );
159
+ const bonus = sample(bonusDistribution, this.rng());
160
+ output.push(bonus);
161
+ bonusTokens++;
162
+ const targetAdvance = await this.target.decode(targetState, [bonus]);
163
+ targetCalls++;
164
+ targetState = targetAdvance.state;
165
+ const draftAdvance = await this.draft.decode(draftState, [bonus]);
166
+ draftCalls++;
167
+ draftState = draftAdvance.state;
168
+ if (bonus === this.eosTokenId) break;
169
+ }
170
+
171
+ const elapsedMs = performance.now() - started;
172
+ const stats: SpeculativeStats = {
173
+ outputTokens: output.length,
174
+ draftTokens,
175
+ acceptedTokens,
176
+ bonusTokens,
177
+ rejectedTokens,
178
+ targetCalls,
179
+ draftCalls,
180
+ acceptanceRate: draftTokens === 0 ? 0 : acceptedTokens / draftTokens,
181
+ targetCallsPerToken: output.length === 0 ? 0 : targetCalls / output.length,
182
+ elapsedMs,
183
+ tokensPerSecond: elapsedMs === 0 ? 0 : (output.length / elapsedMs) * 1000,
184
+ };
185
+ return { tokens: output, stats };
186
+ }
187
+
188
+ private async draftBatch(
189
+ state: ModelState,
190
+ gamma: number,
191
+ temperature: number,
192
+ calls: number
193
+ ): Promise<{ state: ModelState; tokens: number[]; probabilities: Float32Array[]; calls: number }> {
194
+ let current = state;
195
+ const tokens: number[] = [];
196
+ const probabilities: Float32Array[] = [];
197
+
198
+ const first = await this.draft.decode(current, []);
199
+ calls++;
200
+ let nextDistribution = first.logits[0];
201
+ for (let i = 0; i < gamma; i++) {
202
+ const distribution = softmax(nextDistribution, temperature);
203
+ const token = sample(distribution, this.rng());
204
+ tokens.push(token);
205
+ probabilities.push(distribution);
206
+ const advanced = await this.draft.decode(current, [token]);
207
+ calls++;
208
+ current = advanced.state;
209
+ nextDistribution = advanced.logits[advanced.logits.length - 1];
210
+ }
211
+ return { state: current, tokens, probabilities, calls };
212
+ }
213
+ }
214
+
215
+ export function softmax(logits: Float32Array, temperature = 1): Float32Array {
216
+ let max = -Infinity;
217
+ for (let i = 0; i < logits.length; i++) max = Math.max(max, logits[i]);
218
+ const output = new Float32Array(logits.length);
219
+ let sum = 0;
220
+ for (let i = 0; i < logits.length; i++) {
221
+ const value = Math.exp((logits[i] - max) / temperature);
222
+ output[i] = value;
223
+ sum += value;
224
+ }
225
+ if (sum === 0) return output;
226
+ for (let i = 0; i < output.length; i++) output[i] /= sum;
227
+ return output;
228
+ }
229
+
230
+ export function probability(logits: Float32Array, token: number, temperature = 1): number {
231
+ return softmax(logits, temperature)[token] ?? 0;
232
+ }
233
+
234
+ export function sample(distribution: Float32Array, random: number): number {
235
+ let cumulative = 0;
236
+ for (let i = 0; i < distribution.length; i++) {
237
+ cumulative += distribution[i];
238
+ if (random <= cumulative) return i;
239
+ }
240
+ return distribution.length - 1;
241
+ }
242
+
243
+ export function residualDistribution(
244
+ targetLogits: Float32Array,
245
+ draftProbabilities: Float32Array,
246
+ temperature = 1
247
+ ): Float32Array {
248
+ const p = softmax(targetLogits, temperature);
249
+ const q = draftProbabilities;
250
+ const residual = new Float32Array(p.length);
251
+ let sum = 0;
252
+ for (let i = 0; i < p.length; i++) {
253
+ const value = Math.max(0, p[i] - q[i]);
254
+ residual[i] = value;
255
+ sum += value;
256
+ }
257
+ if (sum === 0) return p;
258
+ for (let i = 0; i < residual.length; i++) residual[i] /= sum;
259
+ return residual;
260
+ }
261
+
262
+ export type LogitPolicy = (tokens: number[]) => Float32Array;
263
+
264
+ /** Deterministic mock used by tests and the throughput benchmark. */
265
+ export class MockLanguageModel implements LanguageModel {
266
+ constructor(
267
+ private readonly policy: LogitPolicy,
268
+ readonly vocabSize: number,
269
+ private readonly latencyMs = 0
270
+ ) {}
271
+
272
+ async prefill(tokens: number[]): Promise<ModelState> {
273
+ await delay(this.latencyMs);
274
+ return { tokens: tokens.slice() };
275
+ }
276
+
277
+ async decode(state: ModelState, tokens: number[]): Promise<DecodeResult> {
278
+ await delay(this.latencyMs);
279
+ const logits: Float32Array[] = [];
280
+ for (let i = 0; i <= tokens.length; i++) {
281
+ logits.push(this.policy([...state.tokens, ...tokens.slice(0, i)]));
282
+ }
283
+ return { state: { tokens: [...state.tokens, ...tokens] }, logits };
284
+ }
285
+
286
+ truncate(state: ModelState, length: number): ModelState {
287
+ return { ...state, tokens: state.tokens.slice(0, length), cache: undefined };
288
+ }
289
+ }
290
+
291
+ function delay(ms: number): Promise<void> {
292
+ if (ms <= 0) return Promise.resolve();
293
+ return new Promise((resolve) => setTimeout(resolve, ms));
294
+ }