@robota-sdk/agent-plugin 3.0.0-beta.64
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/LICENSE +21 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.ts +1724 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +48 -0
- package/src/conversation-history/__tests__/conversation-history-plugin.test.ts +221 -0
- package/src/conversation-history/__tests__/history-storages.test.ts +115 -0
- package/src/conversation-history/conversation-history-helpers.ts +120 -0
- package/src/conversation-history/conversation-history-plugin.ts +294 -0
- package/src/conversation-history/index.ts +11 -0
- package/src/conversation-history/storages/database-storage.ts +96 -0
- package/src/conversation-history/storages/file-storage.ts +95 -0
- package/src/conversation-history/storages/index.ts +3 -0
- package/src/conversation-history/storages/memory-storage.ts +44 -0
- package/src/conversation-history/types.ts +64 -0
- package/src/error-handling/__tests__/error-handling-plugin.test.ts +201 -0
- package/src/error-handling/context-adapter.ts +48 -0
- package/src/error-handling/error-handling-helpers.ts +53 -0
- package/src/error-handling/error-handling-plugin.ts +293 -0
- package/src/error-handling/index.ts +9 -0
- package/src/error-handling/types.ts +82 -0
- package/src/execution-analytics/__tests__/execution-analytics-plugin.test.ts +224 -0
- package/src/execution-analytics/analytics-aggregation.ts +88 -0
- package/src/execution-analytics/execution-analytics-helpers.ts +83 -0
- package/src/execution-analytics/execution-analytics-plugin.ts +315 -0
- package/src/execution-analytics/index.ts +9 -0
- package/src/execution-analytics/types.ts +97 -0
- package/src/index.ts +8 -0
- package/src/limits/__tests__/limits-plugin.test.ts +712 -0
- package/src/limits/index.ts +9 -0
- package/src/limits/limits-helpers.ts +185 -0
- package/src/limits/limits-plugin.ts +196 -0
- package/src/limits/types.ts +73 -0
- package/src/limits/validation.ts +81 -0
- package/src/logging/__tests__/formatters.test.ts +48 -0
- package/src/logging/__tests__/logging-plugin.test.ts +464 -0
- package/src/logging/__tests__/logging-storages.test.ts +95 -0
- package/src/logging/formatters.ts +28 -0
- package/src/logging/index.ts +15 -0
- package/src/logging/logging-helpers.ts +223 -0
- package/src/logging/logging-plugin.ts +288 -0
- package/src/logging/storages/console-storage.ts +44 -0
- package/src/logging/storages/file-storage.ts +44 -0
- package/src/logging/storages/index.ts +4 -0
- package/src/logging/storages/remote-storage.ts +78 -0
- package/src/logging/storages/silent-storage.ts +18 -0
- package/src/logging/types.ts +106 -0
- package/src/performance/__tests__/memory-storage.test.ts +86 -0
- package/src/performance/__tests__/performance-plugin.test.ts +208 -0
- package/src/performance/__tests__/system-metrics-collector.test.ts +33 -0
- package/src/performance/collectors/system-metrics-collector.ts +69 -0
- package/src/performance/index.ts +12 -0
- package/src/performance/performance-helpers.ts +86 -0
- package/src/performance/performance-plugin.ts +274 -0
- package/src/performance/storages/index.ts +1 -0
- package/src/performance/storages/memory-storage.ts +88 -0
- package/src/performance/types.ts +160 -0
- package/src/usage/__tests__/aggregate-usage-stats.test.ts +136 -0
- package/src/usage/__tests__/memory-storage.test.ts +83 -0
- package/src/usage/__tests__/silent-storage.test.ts +44 -0
- package/src/usage/__tests__/usage-plugin-helpers.test.ts +155 -0
- package/src/usage/__tests__/usage-plugin.test.ts +358 -0
- package/src/usage/aggregate-usage-stats.ts +142 -0
- package/src/usage/index.ts +14 -0
- package/src/usage/storages/file-storage.ts +115 -0
- package/src/usage/storages/index.ts +4 -0
- package/src/usage/storages/memory-storage.ts +61 -0
- package/src/usage/storages/remote-storage.ts +143 -0
- package/src/usage/storages/silent-storage.ts +38 -0
- package/src/usage/types.ts +132 -0
- package/src/usage/usage-plugin-helpers.ts +116 -0
- package/src/usage/usage-plugin.ts +296 -0
- package/src/webhook/__tests__/webhook-plugin.test.ts +560 -0
- package/src/webhook/http-client.ts +141 -0
- package/src/webhook/index.ts +9 -0
- package/src/webhook/transformer.ts +209 -0
- package/src/webhook/types.ts +201 -0
- package/src/webhook/webhook-helpers.ts +60 -0
- package/src/webhook/webhook-plugin.ts +298 -0
- package/src/webhook/webhook-queue.ts +148 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import type { IPluginExecutionContext, IPluginExecutionResult } from '@robota-sdk/agent-core';
|
|
3
|
+
import { PluginError } from '@robota-sdk/agent-core';
|
|
4
|
+
|
|
5
|
+
// Mock logger before importing LimitsPlugin
|
|
6
|
+
vi.mock('@robota-sdk/agent-core', async (importOriginal) => {
|
|
7
|
+
const actual = await importOriginal<typeof import('@robota-sdk/agent-core')>();
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
createLogger: vi.fn().mockReturnValue({
|
|
11
|
+
debug: vi.fn(),
|
|
12
|
+
info: vi.fn(),
|
|
13
|
+
warn: vi.fn(),
|
|
14
|
+
error: vi.fn(),
|
|
15
|
+
isDebugEnabled: vi.fn().mockReturnValue(false),
|
|
16
|
+
setLevel: vi.fn(),
|
|
17
|
+
getLevel: vi.fn().mockReturnValue('warn'),
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
import { LimitsPlugin } from '../limits-plugin';
|
|
23
|
+
import type { ILimitsPluginOptions } from '../types';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Helper to build a minimal IPluginExecutionContext for testing.
|
|
27
|
+
*/
|
|
28
|
+
function createContext(overrides: Partial<IPluginExecutionContext> = {}): IPluginExecutionContext {
|
|
29
|
+
return {
|
|
30
|
+
executionId: 'exec_1',
|
|
31
|
+
sessionId: 'session_1',
|
|
32
|
+
userId: 'user_1',
|
|
33
|
+
messages: [
|
|
34
|
+
{
|
|
35
|
+
id: 'msg-1',
|
|
36
|
+
role: 'user',
|
|
37
|
+
content: 'hello',
|
|
38
|
+
state: 'complete' as const,
|
|
39
|
+
timestamp: new Date(),
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
config: { model: 'gpt-3.5-turbo' },
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Helper to build a minimal IPluginExecutionResult for testing.
|
|
49
|
+
*/
|
|
50
|
+
function createResult(overrides: Partial<IPluginExecutionResult> = {}): IPluginExecutionResult {
|
|
51
|
+
return {
|
|
52
|
+
success: true,
|
|
53
|
+
tokensUsed: 100,
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('LimitsPlugin', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.useFakeTimers();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
vi.useRealTimers();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ----------------------------------------------------------------
|
|
68
|
+
// Construction
|
|
69
|
+
// ----------------------------------------------------------------
|
|
70
|
+
describe('construction', () => {
|
|
71
|
+
it('should construct with "none" strategy', () => {
|
|
72
|
+
const plugin = new LimitsPlugin({ strategy: 'none' });
|
|
73
|
+
expect(plugin.name).toBe('LimitsPlugin');
|
|
74
|
+
expect(plugin.version).toBe('1.0.0');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should construct with "token-bucket" strategy', () => {
|
|
78
|
+
const plugin = new LimitsPlugin({
|
|
79
|
+
strategy: 'token-bucket',
|
|
80
|
+
bucketSize: 5000,
|
|
81
|
+
refillRate: 50,
|
|
82
|
+
});
|
|
83
|
+
expect(plugin.name).toBe('LimitsPlugin');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should construct with "sliding-window" strategy', () => {
|
|
87
|
+
const plugin = new LimitsPlugin({
|
|
88
|
+
strategy: 'sliding-window',
|
|
89
|
+
timeWindow: 60000,
|
|
90
|
+
maxTokens: 50000,
|
|
91
|
+
maxRequests: 100,
|
|
92
|
+
});
|
|
93
|
+
expect(plugin.name).toBe('LimitsPlugin');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should construct with "fixed-window" strategy', () => {
|
|
97
|
+
const plugin = new LimitsPlugin({
|
|
98
|
+
strategy: 'fixed-window',
|
|
99
|
+
timeWindow: 60000,
|
|
100
|
+
maxTokens: 50000,
|
|
101
|
+
maxRequests: 100,
|
|
102
|
+
});
|
|
103
|
+
expect(plugin.name).toBe('LimitsPlugin');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ----------------------------------------------------------------
|
|
108
|
+
// Construction validation
|
|
109
|
+
// ----------------------------------------------------------------
|
|
110
|
+
describe('construction validation', () => {
|
|
111
|
+
it('should throw when strategy is missing', () => {
|
|
112
|
+
expect(() => new LimitsPlugin({ strategy: '' } as unknown as ILimitsPluginOptions)).toThrow(
|
|
113
|
+
PluginError,
|
|
114
|
+
);
|
|
115
|
+
expect(() => new LimitsPlugin({ strategy: '' } as unknown as ILimitsPluginOptions)).toThrow(
|
|
116
|
+
'Strategy must be specified',
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should throw when strategy is invalid', () => {
|
|
121
|
+
expect(
|
|
122
|
+
() => new LimitsPlugin({ strategy: 'invalid' } as unknown as ILimitsPluginOptions),
|
|
123
|
+
).toThrow(PluginError);
|
|
124
|
+
expect(
|
|
125
|
+
() => new LimitsPlugin({ strategy: 'invalid' } as unknown as ILimitsPluginOptions),
|
|
126
|
+
).toThrow('Invalid strategy');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should throw when bucketSize is negative for token-bucket', () => {
|
|
130
|
+
expect(() => new LimitsPlugin({ strategy: 'token-bucket', bucketSize: -1 })).toThrow(
|
|
131
|
+
'Bucket size must be positive',
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should throw when bucketSize is zero for token-bucket', () => {
|
|
136
|
+
expect(() => new LimitsPlugin({ strategy: 'token-bucket', bucketSize: 0 })).toThrow(
|
|
137
|
+
'Bucket size must be positive',
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should throw when refillRate is negative for token-bucket', () => {
|
|
142
|
+
expect(() => new LimitsPlugin({ strategy: 'token-bucket', refillRate: -1 })).toThrow(
|
|
143
|
+
'Refill rate must be non-negative',
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should throw when maxRequests is negative', () => {
|
|
148
|
+
expect(() => new LimitsPlugin({ strategy: 'fixed-window', maxRequests: -1 })).toThrow(
|
|
149
|
+
'Max requests must be non-negative',
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should throw when maxTokens is negative', () => {
|
|
154
|
+
expect(() => new LimitsPlugin({ strategy: 'fixed-window', maxTokens: -1 })).toThrow(
|
|
155
|
+
'Max tokens must be non-negative',
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should throw when maxCost is negative', () => {
|
|
160
|
+
expect(() => new LimitsPlugin({ strategy: 'fixed-window', maxCost: -1 })).toThrow(
|
|
161
|
+
'Max cost must be non-negative',
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should throw when tokenCostPer1000 is negative', () => {
|
|
166
|
+
expect(() => new LimitsPlugin({ strategy: 'fixed-window', tokenCostPer1000: -1 })).toThrow(
|
|
167
|
+
'Token cost per 1000 must be non-negative',
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should throw when timeWindow is non-positive for sliding-window', () => {
|
|
172
|
+
expect(() => new LimitsPlugin({ strategy: 'sliding-window', timeWindow: 0 })).toThrow(
|
|
173
|
+
'Time window must be positive',
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should throw when timeWindow is non-positive for fixed-window', () => {
|
|
178
|
+
expect(() => new LimitsPlugin({ strategy: 'fixed-window', timeWindow: -100 })).toThrow(
|
|
179
|
+
'Time window must be positive',
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ----------------------------------------------------------------
|
|
185
|
+
// "none" strategy
|
|
186
|
+
// ----------------------------------------------------------------
|
|
187
|
+
describe('"none" strategy', () => {
|
|
188
|
+
it('beforeExecution should be a no-op', async () => {
|
|
189
|
+
const plugin = new LimitsPlugin({ strategy: 'none' });
|
|
190
|
+
// Should not throw
|
|
191
|
+
await plugin.beforeExecution(createContext());
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('afterExecution should be a no-op', async () => {
|
|
195
|
+
const plugin = new LimitsPlugin({ strategy: 'none' });
|
|
196
|
+
await plugin.afterExecution(createContext(), createResult());
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ----------------------------------------------------------------
|
|
201
|
+
// "fixed-window" strategy
|
|
202
|
+
// ----------------------------------------------------------------
|
|
203
|
+
describe('"fixed-window" strategy', () => {
|
|
204
|
+
it('should allow requests within limits', async () => {
|
|
205
|
+
const plugin = new LimitsPlugin({
|
|
206
|
+
strategy: 'fixed-window',
|
|
207
|
+
maxTokens: 100000,
|
|
208
|
+
maxRequests: 10,
|
|
209
|
+
maxCost: 10,
|
|
210
|
+
timeWindow: 60000,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await expect(plugin.beforeExecution(createContext())).resolves.toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should throw PluginError when token limit is exceeded', async () => {
|
|
217
|
+
const plugin = new LimitsPlugin({
|
|
218
|
+
strategy: 'fixed-window',
|
|
219
|
+
maxTokens: 50, // Very low limit
|
|
220
|
+
maxRequests: 1000,
|
|
221
|
+
maxCost: 100,
|
|
222
|
+
timeWindow: 60000,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// The first call estimates tokens from message content ("hello" = 5 chars / 4 ~ 2 + 100 buffer = 102)
|
|
226
|
+
// which exceeds maxTokens of 50
|
|
227
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(PluginError);
|
|
228
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow('Token limit exceeded');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should throw PluginError when request limit is exceeded', async () => {
|
|
232
|
+
const plugin = new LimitsPlugin({
|
|
233
|
+
strategy: 'fixed-window',
|
|
234
|
+
maxTokens: 1000000,
|
|
235
|
+
maxRequests: 2,
|
|
236
|
+
maxCost: 100,
|
|
237
|
+
timeWindow: 60000,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Use up the request limit
|
|
241
|
+
await plugin.beforeExecution(createContext());
|
|
242
|
+
await plugin.beforeExecution(createContext());
|
|
243
|
+
|
|
244
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(PluginError);
|
|
245
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(
|
|
246
|
+
'Request limit exceeded',
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should throw PluginError when cost limit is exceeded', async () => {
|
|
251
|
+
const plugin = new LimitsPlugin({
|
|
252
|
+
strategy: 'fixed-window',
|
|
253
|
+
maxTokens: 1000000,
|
|
254
|
+
maxRequests: 1000,
|
|
255
|
+
maxCost: 0.0001, // Very low cost limit
|
|
256
|
+
timeWindow: 60000,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(PluginError);
|
|
260
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow('Cost limit exceeded');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should reset counters after window expires', async () => {
|
|
264
|
+
const plugin = new LimitsPlugin({
|
|
265
|
+
strategy: 'fixed-window',
|
|
266
|
+
maxTokens: 1000000,
|
|
267
|
+
maxRequests: 2,
|
|
268
|
+
maxCost: 100,
|
|
269
|
+
timeWindow: 60000,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Use up the request limit
|
|
273
|
+
await plugin.beforeExecution(createContext());
|
|
274
|
+
await plugin.beforeExecution(createContext());
|
|
275
|
+
|
|
276
|
+
// Should be blocked
|
|
277
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(
|
|
278
|
+
'Request limit exceeded',
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// Advance time past the window
|
|
282
|
+
vi.advanceTimersByTime(60001);
|
|
283
|
+
|
|
284
|
+
// Should be allowed again after window reset
|
|
285
|
+
await expect(plugin.beforeExecution(createContext())).resolves.toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ----------------------------------------------------------------
|
|
290
|
+
// "token-bucket" strategy
|
|
291
|
+
// ----------------------------------------------------------------
|
|
292
|
+
describe('"token-bucket" strategy', () => {
|
|
293
|
+
it('should allow requests when tokens are available', async () => {
|
|
294
|
+
const plugin = new LimitsPlugin({
|
|
295
|
+
strategy: 'token-bucket',
|
|
296
|
+
bucketSize: 10000,
|
|
297
|
+
refillRate: 100,
|
|
298
|
+
maxRequests: 1000,
|
|
299
|
+
maxCost: 100,
|
|
300
|
+
timeWindow: 60000,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await expect(plugin.beforeExecution(createContext())).resolves.toBeUndefined();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should throw when bucket is depleted', async () => {
|
|
307
|
+
const plugin = new LimitsPlugin({
|
|
308
|
+
strategy: 'token-bucket',
|
|
309
|
+
bucketSize: 50, // Very small bucket (estimated tokens for "hello" is ~102)
|
|
310
|
+
refillRate: 0, // No refill
|
|
311
|
+
maxRequests: 1000,
|
|
312
|
+
maxCost: 100,
|
|
313
|
+
timeWindow: 60000,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(PluginError);
|
|
317
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(
|
|
318
|
+
'Token bucket depleted',
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should refill tokens over time', async () => {
|
|
323
|
+
const plugin = new LimitsPlugin({
|
|
324
|
+
strategy: 'token-bucket',
|
|
325
|
+
bucketSize: 200,
|
|
326
|
+
refillRate: 200, // 200 tokens per second
|
|
327
|
+
maxRequests: 1000,
|
|
328
|
+
maxCost: 100,
|
|
329
|
+
timeWindow: 3600000,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// First call consumes tokens (~102 estimated)
|
|
333
|
+
await plugin.beforeExecution(createContext());
|
|
334
|
+
|
|
335
|
+
// Second call may deplete remaining tokens
|
|
336
|
+
// Advance time to allow refill
|
|
337
|
+
vi.advanceTimersByTime(2000); // 2 seconds => 400 tokens refilled, capped at bucketSize 200
|
|
338
|
+
|
|
339
|
+
// Should succeed after refill
|
|
340
|
+
await expect(plugin.beforeExecution(createContext())).resolves.toBeUndefined();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should throw when request limit is exceeded', async () => {
|
|
344
|
+
const plugin = new LimitsPlugin({
|
|
345
|
+
strategy: 'token-bucket',
|
|
346
|
+
bucketSize: 1000000,
|
|
347
|
+
refillRate: 100000,
|
|
348
|
+
maxRequests: 2,
|
|
349
|
+
maxCost: 100,
|
|
350
|
+
timeWindow: 60000,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await plugin.beforeExecution(createContext());
|
|
354
|
+
await plugin.beforeExecution(createContext());
|
|
355
|
+
|
|
356
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(PluginError);
|
|
357
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(
|
|
358
|
+
'Request limit exceeded',
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should throw when cost limit is exceeded', async () => {
|
|
363
|
+
const plugin = new LimitsPlugin({
|
|
364
|
+
strategy: 'token-bucket',
|
|
365
|
+
bucketSize: 1000000,
|
|
366
|
+
refillRate: 100000,
|
|
367
|
+
maxRequests: 1000,
|
|
368
|
+
maxCost: 0.0001, // Very low cost limit
|
|
369
|
+
timeWindow: 60000,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(PluginError);
|
|
373
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow('Cost limit exceeded');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should reset request/cost counters after window expires', async () => {
|
|
377
|
+
const plugin = new LimitsPlugin({
|
|
378
|
+
strategy: 'token-bucket',
|
|
379
|
+
bucketSize: 1000000,
|
|
380
|
+
refillRate: 100000,
|
|
381
|
+
maxRequests: 2,
|
|
382
|
+
maxCost: 100,
|
|
383
|
+
timeWindow: 60000,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
await plugin.beforeExecution(createContext());
|
|
387
|
+
await plugin.beforeExecution(createContext());
|
|
388
|
+
|
|
389
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(
|
|
390
|
+
'Request limit exceeded',
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Advance past window
|
|
394
|
+
vi.advanceTimersByTime(60001);
|
|
395
|
+
|
|
396
|
+
await expect(plugin.beforeExecution(createContext())).resolves.toBeUndefined();
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ----------------------------------------------------------------
|
|
401
|
+
// "sliding-window" strategy
|
|
402
|
+
// ----------------------------------------------------------------
|
|
403
|
+
describe('"sliding-window" strategy', () => {
|
|
404
|
+
it('should allow requests within limits', async () => {
|
|
405
|
+
const plugin = new LimitsPlugin({
|
|
406
|
+
strategy: 'sliding-window',
|
|
407
|
+
maxTokens: 100000,
|
|
408
|
+
maxRequests: 10,
|
|
409
|
+
maxCost: 10,
|
|
410
|
+
timeWindow: 60000,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
await expect(plugin.beforeExecution(createContext())).resolves.toBeUndefined();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('should throw when token limit is exceeded', async () => {
|
|
417
|
+
const plugin = new LimitsPlugin({
|
|
418
|
+
strategy: 'sliding-window',
|
|
419
|
+
maxTokens: 50, // Very low limit
|
|
420
|
+
maxRequests: 1000,
|
|
421
|
+
maxCost: 100,
|
|
422
|
+
timeWindow: 60000,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(PluginError);
|
|
426
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow('Token limit exceeded');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should throw when request limit is exceeded', async () => {
|
|
430
|
+
const plugin = new LimitsPlugin({
|
|
431
|
+
strategy: 'sliding-window',
|
|
432
|
+
maxTokens: 1000000,
|
|
433
|
+
maxRequests: 2,
|
|
434
|
+
maxCost: 100,
|
|
435
|
+
timeWindow: 60000,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await plugin.beforeExecution(createContext());
|
|
439
|
+
await plugin.beforeExecution(createContext());
|
|
440
|
+
|
|
441
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(PluginError);
|
|
442
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(
|
|
443
|
+
'Request limit exceeded',
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should throw when cost limit is exceeded', async () => {
|
|
448
|
+
const plugin = new LimitsPlugin({
|
|
449
|
+
strategy: 'sliding-window',
|
|
450
|
+
maxTokens: 1000000,
|
|
451
|
+
maxRequests: 1000,
|
|
452
|
+
maxCost: 0.0001,
|
|
453
|
+
timeWindow: 60000,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(PluginError);
|
|
457
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow('Cost limit exceeded');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should reset counters after window expires', async () => {
|
|
461
|
+
const plugin = new LimitsPlugin({
|
|
462
|
+
strategy: 'sliding-window',
|
|
463
|
+
maxTokens: 1000000,
|
|
464
|
+
maxRequests: 2,
|
|
465
|
+
maxCost: 100,
|
|
466
|
+
timeWindow: 60000,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
await plugin.beforeExecution(createContext());
|
|
470
|
+
await plugin.beforeExecution(createContext());
|
|
471
|
+
|
|
472
|
+
await expect(plugin.beforeExecution(createContext())).rejects.toThrow(
|
|
473
|
+
'Request limit exceeded',
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// Advance past window
|
|
477
|
+
vi.advanceTimersByTime(60001);
|
|
478
|
+
|
|
479
|
+
await expect(plugin.beforeExecution(createContext())).resolves.toBeUndefined();
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// ----------------------------------------------------------------
|
|
484
|
+
// getLimitsStatus
|
|
485
|
+
// ----------------------------------------------------------------
|
|
486
|
+
describe('getLimitsStatus', () => {
|
|
487
|
+
it('should return summary status when no key is provided', () => {
|
|
488
|
+
const plugin = new LimitsPlugin({ strategy: 'fixed-window' });
|
|
489
|
+
const status = plugin.getLimitsStatus();
|
|
490
|
+
|
|
491
|
+
expect(status).toHaveProperty('strategy', 'fixed-window');
|
|
492
|
+
expect(status).toHaveProperty('totalKeys');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('should return key-specific status with bucket data for token-bucket strategy', async () => {
|
|
496
|
+
const plugin = new LimitsPlugin({
|
|
497
|
+
strategy: 'token-bucket',
|
|
498
|
+
bucketSize: 10000,
|
|
499
|
+
refillRate: 100,
|
|
500
|
+
maxRequests: 1000,
|
|
501
|
+
maxCost: 100,
|
|
502
|
+
timeWindow: 60000,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const ctx = createContext({ userId: 'user_test' });
|
|
506
|
+
await plugin.beforeExecution(ctx);
|
|
507
|
+
|
|
508
|
+
const status = plugin.getLimitsStatus('user_test');
|
|
509
|
+
expect(status).toHaveProperty('strategy', 'token-bucket');
|
|
510
|
+
expect(status).toHaveProperty('key', 'user_test');
|
|
511
|
+
expect(status).toHaveProperty('bucket');
|
|
512
|
+
expect(status.bucket).not.toBeNull();
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('should return key-specific status with window data for fixed-window strategy', async () => {
|
|
516
|
+
const plugin = new LimitsPlugin({
|
|
517
|
+
strategy: 'fixed-window',
|
|
518
|
+
maxTokens: 100000,
|
|
519
|
+
maxRequests: 1000,
|
|
520
|
+
maxCost: 100,
|
|
521
|
+
timeWindow: 60000,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const ctx = createContext({ userId: 'user_test' });
|
|
525
|
+
await plugin.beforeExecution(ctx);
|
|
526
|
+
|
|
527
|
+
const status = plugin.getLimitsStatus('user_test');
|
|
528
|
+
expect(status).toHaveProperty('strategy', 'fixed-window');
|
|
529
|
+
expect(status).toHaveProperty('key', 'user_test');
|
|
530
|
+
expect(status).toHaveProperty('window');
|
|
531
|
+
expect(status.window).not.toBeNull();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('should return null bucket/window for unknown key', () => {
|
|
535
|
+
const plugin = new LimitsPlugin({ strategy: 'fixed-window' });
|
|
536
|
+
const status = plugin.getLimitsStatus('unknown_key');
|
|
537
|
+
expect(status.bucket).toBeNull();
|
|
538
|
+
expect(status.window).toBeNull();
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ----------------------------------------------------------------
|
|
543
|
+
// resetLimits
|
|
544
|
+
// ----------------------------------------------------------------
|
|
545
|
+
describe('resetLimits', () => {
|
|
546
|
+
it('should reset a specific key', async () => {
|
|
547
|
+
const plugin = new LimitsPlugin({
|
|
548
|
+
strategy: 'fixed-window',
|
|
549
|
+
maxTokens: 100000,
|
|
550
|
+
maxRequests: 1000,
|
|
551
|
+
maxCost: 100,
|
|
552
|
+
timeWindow: 60000,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const ctx = createContext({ userId: 'user_reset' });
|
|
556
|
+
await plugin.beforeExecution(ctx);
|
|
557
|
+
|
|
558
|
+
// Verify data exists
|
|
559
|
+
const statusBefore = plugin.getLimitsStatus('user_reset');
|
|
560
|
+
expect(statusBefore.window).not.toBeNull();
|
|
561
|
+
|
|
562
|
+
// Reset
|
|
563
|
+
plugin.resetLimits('user_reset');
|
|
564
|
+
|
|
565
|
+
// Verify data cleared
|
|
566
|
+
const statusAfter = plugin.getLimitsStatus('user_reset');
|
|
567
|
+
expect(statusAfter.window).toBeNull();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('should reset all keys when no key is provided', async () => {
|
|
571
|
+
const plugin = new LimitsPlugin({
|
|
572
|
+
strategy: 'fixed-window',
|
|
573
|
+
maxTokens: 100000,
|
|
574
|
+
maxRequests: 1000,
|
|
575
|
+
maxCost: 100,
|
|
576
|
+
timeWindow: 60000,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
await plugin.beforeExecution(createContext({ userId: 'user_a' }));
|
|
580
|
+
await plugin.beforeExecution(createContext({ userId: 'user_b' }));
|
|
581
|
+
|
|
582
|
+
// Both should have data
|
|
583
|
+
const summaryBefore = plugin.getLimitsStatus();
|
|
584
|
+
expect(summaryBefore.totalKeys).toBeGreaterThan(0);
|
|
585
|
+
|
|
586
|
+
// Reset all
|
|
587
|
+
plugin.resetLimits();
|
|
588
|
+
|
|
589
|
+
const summaryAfter = plugin.getLimitsStatus();
|
|
590
|
+
expect(summaryAfter.totalKeys).toBe(0);
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ----------------------------------------------------------------
|
|
595
|
+
// afterExecution
|
|
596
|
+
// ----------------------------------------------------------------
|
|
597
|
+
describe('afterExecution', () => {
|
|
598
|
+
it('should update token and cost tracking for fixed-window', async () => {
|
|
599
|
+
const plugin = new LimitsPlugin({
|
|
600
|
+
strategy: 'fixed-window',
|
|
601
|
+
maxTokens: 100000,
|
|
602
|
+
maxRequests: 1000,
|
|
603
|
+
maxCost: 100,
|
|
604
|
+
timeWindow: 60000,
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const ctx = createContext({ userId: 'user_after' });
|
|
608
|
+
await plugin.beforeExecution(ctx);
|
|
609
|
+
await plugin.afterExecution(ctx, createResult({ tokensUsed: 500 }));
|
|
610
|
+
|
|
611
|
+
const status = plugin.getLimitsStatus('user_after');
|
|
612
|
+
expect(status.window).not.toBeNull();
|
|
613
|
+
|
|
614
|
+
// Window tokens should reflect the tokensUsed from afterExecution
|
|
615
|
+
const window = status.window as Record<string, string | number | boolean>;
|
|
616
|
+
expect(window.tokens).toBe(500);
|
|
617
|
+
expect(typeof window.cost).toBe('number');
|
|
618
|
+
expect(window.cost as number).toBeGreaterThan(0);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('should update cost tracking for token-bucket', async () => {
|
|
622
|
+
const plugin = new LimitsPlugin({
|
|
623
|
+
strategy: 'token-bucket',
|
|
624
|
+
bucketSize: 100000,
|
|
625
|
+
refillRate: 100,
|
|
626
|
+
maxRequests: 1000,
|
|
627
|
+
maxCost: 100,
|
|
628
|
+
timeWindow: 60000,
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
const ctx = createContext({ userId: 'user_bucket_after' });
|
|
632
|
+
await plugin.beforeExecution(ctx);
|
|
633
|
+
await plugin.afterExecution(ctx, createResult({ tokensUsed: 500 }));
|
|
634
|
+
|
|
635
|
+
const status = plugin.getLimitsStatus('user_bucket_after');
|
|
636
|
+
expect(status.bucket).not.toBeNull();
|
|
637
|
+
|
|
638
|
+
const bucket = status.bucket as Record<string, string | number | boolean>;
|
|
639
|
+
expect(typeof bucket.cost).toBe('number');
|
|
640
|
+
expect(bucket.cost as number).toBeGreaterThan(0);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// ----------------------------------------------------------------
|
|
645
|
+
// Key derivation
|
|
646
|
+
// ----------------------------------------------------------------
|
|
647
|
+
describe('key derivation', () => {
|
|
648
|
+
it('should use userId as key when present', async () => {
|
|
649
|
+
const plugin = new LimitsPlugin({
|
|
650
|
+
strategy: 'fixed-window',
|
|
651
|
+
maxTokens: 100000,
|
|
652
|
+
maxRequests: 1000,
|
|
653
|
+
maxCost: 100,
|
|
654
|
+
timeWindow: 60000,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
await plugin.beforeExecution(createContext({ userId: 'uid_1' }));
|
|
658
|
+
|
|
659
|
+
const status = plugin.getLimitsStatus('uid_1');
|
|
660
|
+
expect(status.window).not.toBeNull();
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('should fall back to sessionId when userId is absent', async () => {
|
|
664
|
+
const plugin = new LimitsPlugin({
|
|
665
|
+
strategy: 'fixed-window',
|
|
666
|
+
maxTokens: 100000,
|
|
667
|
+
maxRequests: 1000,
|
|
668
|
+
maxCost: 100,
|
|
669
|
+
timeWindow: 60000,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
await plugin.beforeExecution(createContext({ userId: undefined, sessionId: 'sid_1' }));
|
|
673
|
+
|
|
674
|
+
const status = plugin.getLimitsStatus('sid_1');
|
|
675
|
+
expect(status.window).not.toBeNull();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('should fall back to executionId when userId and sessionId are absent', async () => {
|
|
679
|
+
const plugin = new LimitsPlugin({
|
|
680
|
+
strategy: 'fixed-window',
|
|
681
|
+
maxTokens: 100000,
|
|
682
|
+
maxRequests: 1000,
|
|
683
|
+
maxCost: 100,
|
|
684
|
+
timeWindow: 60000,
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
await plugin.beforeExecution(
|
|
688
|
+
createContext({ userId: undefined, sessionId: undefined, executionId: 'eid_1' }),
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
const status = plugin.getLimitsStatus('eid_1');
|
|
692
|
+
expect(status.window).not.toBeNull();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('should use "default" when no identifiers are present', async () => {
|
|
696
|
+
const plugin = new LimitsPlugin({
|
|
697
|
+
strategy: 'fixed-window',
|
|
698
|
+
maxTokens: 100000,
|
|
699
|
+
maxRequests: 1000,
|
|
700
|
+
maxCost: 100,
|
|
701
|
+
timeWindow: 60000,
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
await plugin.beforeExecution(
|
|
705
|
+
createContext({ userId: undefined, sessionId: undefined, executionId: undefined }),
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
const status = plugin.getLimitsStatus('default');
|
|
709
|
+
expect(status.window).not.toBeNull();
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
});
|