adaptive-memory-multi-model-router 2.6.0 → 2.7.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,358 @@
1
+ /**
2
+ * A3M Router - Budget Enforcer
3
+ *
4
+ * Hard budget enforcement for API key spend management:
5
+ * - Track spend per API key with monthly reset
6
+ * - Check budget before each request
7
+ * - Emit alerts at configurable thresholds (50%, 80%, 100%)
8
+ * - Support hard cap (reject requests) or soft cap (warn only)
9
+ * - In-memory storage (Redis backup can be added later)
10
+ */
11
+
12
+ import { EventEmitter } from 'events';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface BudgetConfig {
19
+ apiKey: string;
20
+ monthlyLimit: number; // in cents
21
+ alertThresholds?: number[]; // e.g., [0.5, 0.8, 1.0] for 50%, 80%, 100%
22
+ hardCap?: boolean; // reject requests when exceeded (default: false)
23
+ }
24
+
25
+ export interface SpendRecord {
26
+ apiKey: string;
27
+ spent: number; // cents
28
+ budget: number; // cents
29
+ remaining: number; // cents
30
+ resetDate: Date;
31
+ alertEmitted: Set<number>; // track which thresholds have fired
32
+ }
33
+
34
+ export interface BudgetCheckResult {
35
+ allowed: boolean;
36
+ reason?: string;
37
+ remaining: number;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // BudgetEnforcer
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export class BudgetEnforcer extends EventEmitter {
45
+ private budgets: Map<string, BudgetConfig> = new Map();
46
+ private spend: Map<string, SpendRecord> = new Map();
47
+
48
+ // Default alert thresholds: 50%, 80%, 100%
49
+ private static readonly DEFAULT_THRESHOLDS = [0.5, 0.8, 1.0];
50
+
51
+ constructor() {
52
+ super();
53
+ }
54
+
55
+ // ---- Configuration ----
56
+
57
+ /**
58
+ * Set budget for an API key
59
+ */
60
+ setBudget(apiKey: string, monthlyLimit: number, options?: {
61
+ alertThresholds?: number[];
62
+ hardCap?: boolean;
63
+ }): void {
64
+ const config: BudgetConfig = {
65
+ apiKey,
66
+ monthlyLimit,
67
+ alertThresholds: options?.alertThresholds ?? BudgetEnforcer.DEFAULT_THRESHOLDS,
68
+ hardCap: options?.hardCap ?? false,
69
+ };
70
+ this.budgets.set(apiKey, config);
71
+
72
+ // Initialize spend record if not exists
73
+ if (!this.spend.has(apiKey)) {
74
+ this.spend.set(apiKey, {
75
+ apiKey,
76
+ spent: 0,
77
+ budget: monthlyLimit,
78
+ remaining: monthlyLimit,
79
+ resetDate: this.getNextResetDate(),
80
+ alertEmitted: new Set(),
81
+ });
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Get current budget config for an API key
87
+ */
88
+ getBudgetConfig(apiKey: string): BudgetConfig | undefined {
89
+ return this.budgets.get(apiKey);
90
+ }
91
+
92
+ /**
93
+ * Remove budget config (stops tracking)
94
+ */
95
+ removeBudget(apiKey: string): void {
96
+ this.budgets.delete(apiKey);
97
+ this.spend.delete(apiKey);
98
+ }
99
+
100
+ // ---- Budget Checking ----
101
+
102
+ /**
103
+ * Check if a request is allowed within budget
104
+ * @param apiKey - The API key making the request
105
+ * @param additionalCost - The cost of the request in cents
106
+ * @returns BudgetCheckResult with allowed status and remaining budget
107
+ */
108
+ checkBudget(apiKey: string, additionalCost: number): BudgetCheckResult {
109
+ // Auto-initialize if no budget set (permissive default)
110
+ if (!this.budgets.has(apiKey)) {
111
+ return {
112
+ allowed: true,
113
+ remaining: Infinity,
114
+ };
115
+ }
116
+
117
+ // Check for reset
118
+ this.checkAndReset(apiKey);
119
+
120
+ const record = this.spend.get(apiKey)!;
121
+ const config = this.budgets.get(apiKey)!;
122
+ const projectedSpend = record.spent + additionalCost;
123
+
124
+ // Check if would exceed budget
125
+ if (projectedSpend > record.budget) {
126
+ if (config.hardCap) {
127
+ return {
128
+ allowed: false,
129
+ reason: `Budget exceeded. Spent: ${record.spent} cents, Budget: ${record.budget} cents, Additional: ${additionalCost} cents`,
130
+ remaining: record.remaining,
131
+ };
132
+ }
133
+ // Soft cap: allow but warn
134
+ this.emit('budget:warning', {
135
+ apiKey,
136
+ threshold: 1.0,
137
+ spent: record.spent,
138
+ budget: record.budget,
139
+ remaining: record.remaining,
140
+ message: `Soft cap: Budget would be exceeded by ${projectedSpend - record.budget} cents`,
141
+ });
142
+ }
143
+
144
+ return {
145
+ allowed: true,
146
+ remaining: record.remaining - additionalCost,
147
+ };
148
+ }
149
+
150
+ // ---- Spend Recording ----
151
+
152
+ /**
153
+ * Record spend for an API key
154
+ * @param apiKey - The API key that incurred the cost
155
+ * @param cost - The cost in cents
156
+ */
157
+ recordSpend(apiKey: string, cost: number): void {
158
+ // Auto-initialize if no budget set
159
+ if (!this.budgets.has(apiKey)) {
160
+ return; // No budget to track
161
+ }
162
+
163
+ // Check for reset
164
+ this.checkAndReset(apiKey);
165
+
166
+ const record = this.spend.get(apiKey)!;
167
+ const config = this.budgets.get(apiKey)!;
168
+
169
+ const previousSpent = record.spent;
170
+ record.spent += cost;
171
+ record.remaining = record.budget - record.spent;
172
+
173
+ // Check thresholds
174
+ const utilization = record.spent / record.budget;
175
+ for (const threshold of config.alertThresholds ?? BudgetEnforcer.DEFAULT_THRESHOLDS) {
176
+ const thresholdKey = Math.round(threshold * 1000);
177
+ if (!record.alertEmitted.has(thresholdKey)) {
178
+ const previousUtilization = previousSpent / record.budget;
179
+ if (utilization >= threshold && previousUtilization < threshold) {
180
+ record.alertEmitted.add(thresholdKey);
181
+ this.emit('budget:warning', {
182
+ apiKey,
183
+ threshold,
184
+ spent: record.spent,
185
+ budget: record.budget,
186
+ remaining: record.remaining,
187
+ message: `Budget ${Math.round(threshold * 100)}% threshold reached`,
188
+ });
189
+ }
190
+ }
191
+ }
192
+
193
+ // Fire 100% event if budget exceeded (for hard cap tracking)
194
+ if (record.spent >= record.budget && previousSpent < record.budget) {
195
+ this.emit('budget:exceeded', {
196
+ apiKey,
197
+ spent: record.spent,
198
+ budget: record.budget,
199
+ remaining: 0,
200
+ });
201
+ }
202
+ }
203
+
204
+ // ---- Spend Queries ----
205
+
206
+ /**
207
+ * Get current spend record for an API key
208
+ */
209
+ getSpend(apiKey: string): SpendRecord | undefined {
210
+ if (!this.spend.has(apiKey)) {
211
+ return undefined;
212
+ }
213
+ const record = { ...this.spend.get(apiKey)! };
214
+ // Convert Set to Array for serialization
215
+ (record as any).alertEmitted = Array.from(record.alertEmitted);
216
+ return record;
217
+ }
218
+
219
+ /**
220
+ * Get all spend records
221
+ */
222
+ getAllSpend(): SpendRecord[] {
223
+ return Array.from(this.spend.values()).map((record) => {
224
+ const copy = { ...record };
225
+ (copy as any).alertEmitted = Array.from(record.alertEmitted);
226
+ return copy;
227
+ });
228
+ }
229
+
230
+ // ---- Budget Management ----
231
+
232
+ /**
233
+ * Reset budget for an API key (manual reset)
234
+ */
235
+ resetBudget(apiKey: string): void {
236
+ if (!this.spend.has(apiKey)) {
237
+ return;
238
+ }
239
+ const record = this.spend.get(apiKey)!;
240
+ record.spent = 0;
241
+ record.remaining = record.budget;
242
+ record.resetDate = this.getNextResetDate();
243
+ record.alertEmitted.clear();
244
+ }
245
+
246
+ /**
247
+ * Reset all budgets
248
+ */
249
+ resetAll(): void {
250
+ for (const record of this.spend.values()) {
251
+ record.spent = 0;
252
+ record.remaining = record.budget;
253
+ record.resetDate = this.getNextResetDate();
254
+ record.alertEmitted.clear();
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Update monthly limit for an API key
260
+ */
261
+ updateLimit(apiKey: string, monthlyLimit: number): void {
262
+ if (!this.budgets.has(apiKey)) {
263
+ this.setBudget(apiKey, monthlyLimit);
264
+ return;
265
+ }
266
+
267
+ const config = this.budgets.get(apiKey)!;
268
+ config.monthlyLimit = monthlyLimit;
269
+
270
+ const record = this.spend.get(apiKey)!;
271
+ const oldBudget = record.budget;
272
+ record.budget = monthlyLimit;
273
+ record.remaining = monthlyLimit - record.spent;
274
+
275
+ // Re-check if any thresholds are now exceeded
276
+ const utilization = record.spent / record.budget;
277
+ for (const threshold of config.alertThresholds ?? BudgetEnforcer.DEFAULT_THRESHOLDS) {
278
+ const thresholdKey = Math.round(threshold * 1000);
279
+ if (utilization >= threshold) {
280
+ record.alertEmitted.add(thresholdKey);
281
+ } else {
282
+ record.alertEmitted.delete(thresholdKey);
283
+ }
284
+ }
285
+ }
286
+
287
+ // ---- Internal Helpers ----
288
+
289
+ /**
290
+ * Check if reset is needed and perform it
291
+ */
292
+ private checkAndReset(apiKey: string): void {
293
+ const record = this.spend.get(apiKey)!;
294
+ if (this.isResetDue(record.resetDate)) {
295
+ record.spent = 0;
296
+ record.remaining = record.budget;
297
+ record.resetDate = this.getNextResetDate();
298
+ record.alertEmitted.clear();
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Check if monthly reset is due
304
+ */
305
+ private isResetDue(resetDate: Date): boolean {
306
+ const now = new Date();
307
+ return now >= resetDate;
308
+ }
309
+
310
+ /**
311
+ * Get next monthly reset date
312
+ */
313
+ private getNextResetDate(): Date {
314
+ const now = new Date();
315
+ const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
316
+ return nextMonth;
317
+ }
318
+
319
+ /**
320
+ * Get days until next reset
321
+ */
322
+ getDaysUntilReset(apiKey: string): number | undefined {
323
+ const record = this.spend.get(apiKey);
324
+ if (!record) return undefined;
325
+ const now = Date.now();
326
+ const resetMs = record.resetDate.getTime();
327
+ return Math.max(0, Math.ceil((resetMs - now) / (1000 * 60 * 60 * 24)));
328
+ }
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Convenience Factory
333
+ // ---------------------------------------------------------------------------
334
+
335
+ export function createBudgetEnforcer(): BudgetEnforcer {
336
+ return new BudgetEnforcer();
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Error Class for Hard Cap Violations
341
+ // ---------------------------------------------------------------------------
342
+
343
+ export class BudgetExceededError extends Error {
344
+ public readonly apiKey: string;
345
+ public readonly spent: number;
346
+ public readonly budget: number;
347
+ public readonly remaining: number;
348
+
349
+ constructor(apiKey: string, spent: number, budget: number) {
350
+ const message = `Budget exceeded for API key ${apiKey}. Spent: ${spent} cents, Budget: ${budget} cents`;
351
+ super(message);
352
+ this.name = 'BudgetExceededError';
353
+ this.apiKey = apiKey;
354
+ this.spent = spent;
355
+ this.budget = budget;
356
+ this.remaining = budget - spent;
357
+ }
358
+ }
package/src/index.ts CHANGED
@@ -43,6 +43,8 @@ export type {
43
43
  // COST TRACKING
44
44
  // ============================================================
45
45
  export { CostTracker } from './cost/costTracker';
46
+ export { BudgetEnforcer, BudgetExceededError, createBudgetEnforcer } from './cost/budgetEnforcer';
47
+ export type { BudgetConfig, SpendRecord, BudgetCheckResult } from './cost/budgetEnforcer';
46
48
 
47
49
  // ============================================================
48
50
  // MEMORY