adaptive-memory-multi-model-router 2.14.17 → 2.14.18

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.
Files changed (34) hide show
  1. package/AGENT_COUNCIL_FINDINGS.md +142 -0
  2. package/LAUNCH_CHECKLIST.md +141 -0
  3. package/README.md.bak +836 -0
  4. package/articles/CHINESE_SUBMISSIONS_READY.md +322 -0
  5. package/articles/DEVTO_READY.md +255 -0
  6. package/articles/HN_POST_READY.md +137 -0
  7. package/articles/INDIEHACKERS_READY.md +120 -0
  8. package/articles/NEWSLETTER_SEND_NOW.md +259 -0
  9. package/articles/PRODUCTHUNT_READY.md +106 -0
  10. package/articles/REDDIT_SUBMISSION_READY.md +348 -0
  11. package/articles/TWEET_STORM_READY.md +165 -0
  12. package/council-votes/architecture-vote.md +121 -0
  13. package/council-votes/coverage-vote.md +93 -0
  14. package/dist/cost/costTracker.d.ts +109 -44
  15. package/dist/cost/costTracker.js +321 -98
  16. package/dist/cost/costTracker.js.map +1 -1
  17. package/dist/index.d.ts +6 -4
  18. package/dist/routing/advancedRouter.d.ts +38 -43
  19. package/dist/routing/advancedRouter.js +394 -408
  20. package/dist/routing/advancedRouter.js.map +1 -1
  21. package/dist/routing/providers/providerConfig.d.ts +49 -0
  22. package/dist/routing/providers/providerConfig.js +883 -0
  23. package/dist/routing/routing/advancedRouter.d.ts +62 -0
  24. package/dist/routing/routing/advancedRouter.js +447 -0
  25. package/dist/routing/utils/tokenUtils.d.ts +52 -0
  26. package/dist/routing/utils/tokenUtils.js +129 -0
  27. package/dist/server/proxyServer.d.ts +1 -1
  28. package/package.json +1 -1
  29. package/research-log.md +49 -0
  30. package/src/cost/costTracker.ts +576 -0
  31. package/src/routing/advancedRouter.ts +536 -0
  32. package/test-council/AGENT_COUNCIL_ARCHITECTURE.md +349 -0
  33. package/tests/security/guardrailEngine.test.ts +700 -0
  34. package/research/PUBLISH_LOG.md +0 -3
@@ -29,7 +29,7 @@ interface RequestLog {
29
29
  timestamp: number;
30
30
  }
31
31
  declare const requestLogs: RequestLog[];
32
- declare const costTracker: any;
32
+ declare const costTracker: CostTracker;
33
33
  /**
34
34
  * Create and start the proxy server.
35
35
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adaptive-memory-multi-model-router",
3
- "version": "2.14.17",
3
+ "version": "2.14.18",
4
4
  "shortName": "A3M Router",
5
5
  "displayName": "A3M Router - Adaptive Memory Multi-Model Router",
6
6
  "description": "🥇 Cheapest LLM router on RouterArena ($0.05/1K) · 15K+ downloads in 2 weeks · Open-source AI gateway with parallel multi-LLM execution across 47+ providers, ensemble voting, semantic cache, and budget enforcement",
@@ -0,0 +1,49 @@
1
+ # A3M Router Research Log
2
+
3
+ ## 2026-06-03 - Test Coverage Analysis
4
+
5
+ ### Research State
6
+ ```
7
+ Project: A3M Router Test Coverage Analysis
8
+ Date: 2026-06-03
9
+ Agents: 3 (Architecture, Performance, Test Coverage)
10
+ Goal: Identify top 3 improvements via council vote
11
+ ```
12
+
13
+ ### Scope Explored
14
+ - `test/` - 7 legacy JS test files (budgetEnforcer, observability, providerHealth, providerRetry, semanticCache)
15
+ - `tests/` - Vitest test suite (routing/ensembleVoting, routing/providerRetry, routing/queryTypePresets, memory/episodicMemory)
16
+ - `test-council/` - 5 test files (structure, edge-case, performance, integration, agent-council-eval)
17
+
18
+ ### Key Source Files Analyzed
19
+ - `src/ensemble.ts` - EnsembleOrchestrator (no tests)
20
+ - `src/sdk.ts` - A3MRouter SDK (structure only)
21
+ - `src/cost/budgetEnforcer.ts` - Budget enforcement (legacy test)
22
+ - `src/analytics/costAnalytics.ts` - Cost analytics (no tests)
23
+ - `src/security/guardrails.ts` - GuardrailEngine (NO TESTS - CRITICAL)
24
+ - `src/observability/middleware.ts` - Express middleware (not tested)
25
+ - `src/routing/crossModelValidation.ts` - Cross-model validation (not tested)
26
+ - `src/observability/fatigueDetector.ts` - Fatigue detection (not tested)
27
+
28
+ ### Coverage Summary
29
+ | Module | Coverage | Status |
30
+ |--------|----------|--------|
31
+ | Routing | Partial | ensembleVoting, providerRetry, queryTypePresets |
32
+ | Memory | Good | episodicMemory well tested |
33
+ | Observability | Partial | Tracer, MetricsCollector tested; middleware not |
34
+ | Security | NONE | GuardrailEngine untested |
35
+ | Cost | Partial | budgetEnforcer legacy test; costAnalytics untested |
36
+ | SDK | Structure only | No behavioral tests |
37
+
38
+ ### Critical Gaps Identified
39
+ 1. **GuardrailEngine** - Zero tests for security-critical code
40
+ 2. **EnsembleOrchestrator** - Core P0 feature lacks integration tests
41
+ 3. **CostAnalytics** - No tests for savings calculation accuracy
42
+ 4. **SDK Class** - Only type checking, no behavioral tests
43
+ 5. **Middleware** - Not tested
44
+
45
+ ### Output
46
+ Created: `council-votes/coverage-vote.md`
47
+ Vote: Finding #1 (GuardrailEngine) as highest priority
48
+
49
+ ---
@@ -0,0 +1,576 @@
1
+ /**
2
+ * A3M Router - Budget Enforcer + Cost Tracker
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
+ * Plus legacy CostTracker for backward compatibility.
12
+ */
13
+
14
+ import { EventEmitter } from 'events';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface BudgetConfig {
21
+ apiKey: string;
22
+ monthlyLimit: number;
23
+ alertThresholds?: number[];
24
+ hardCap?: boolean;
25
+ }
26
+
27
+ export interface SpendRecord {
28
+ apiKey: string;
29
+ spent: number;
30
+ budget: number;
31
+ remaining: number;
32
+ resetDate: Date;
33
+ alertEmitted: number[] | Set<number>;
34
+ }
35
+
36
+ export interface BudgetCheckResult {
37
+ allowed: boolean;
38
+ reason?: string;
39
+ remaining: number;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // BudgetEnforcer
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export class BudgetEnforcer extends EventEmitter {
47
+ budgets = new Map<string, BudgetConfig>();
48
+ spend = new Map<string, SpendRecord>();
49
+
50
+ // Default alert thresholds: 50%, 80%, 100%
51
+ static DEFAULT_THRESHOLDS = [0.5, 0.8, 1.0];
52
+
53
+ constructor() {
54
+ super();
55
+ }
56
+
57
+ // ---- Configuration ----
58
+
59
+ /**
60
+ * Set budget for an API key
61
+ */
62
+ setBudget(apiKey: string, monthlyLimit: number, options?: { alertThresholds?: number[]; hardCap?: boolean }): void {
63
+ const config: BudgetConfig = {
64
+ apiKey,
65
+ monthlyLimit,
66
+ alertThresholds: options?.alertThresholds ?? BudgetEnforcer.DEFAULT_THRESHOLDS,
67
+ hardCap: options?.hardCap ?? false,
68
+ };
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
+ */
105
+ checkBudget(apiKey: string, additionalCost: number): BudgetCheckResult {
106
+ // Auto-initialize if no budget set (permissive default)
107
+ if (!this.budgets.has(apiKey)) {
108
+ return {
109
+ allowed: true,
110
+ remaining: Infinity,
111
+ };
112
+ }
113
+
114
+ // Check for reset
115
+ this.checkAndReset(apiKey);
116
+
117
+ const record = this.spend.get(apiKey)!;
118
+ const config = this.budgets.get(apiKey)!;
119
+
120
+ const projectedSpend = record.spent + additionalCost;
121
+
122
+ // Check if would exceed budget
123
+ if (projectedSpend > record.budget) {
124
+ if (config.hardCap) {
125
+ return {
126
+ allowed: false,
127
+ reason: `Budget exceeded. Spent: ${record.spent} cents, Budget: ${record.budget} cents, Additional: ${additionalCost} cents`,
128
+ remaining: record.remaining,
129
+ };
130
+ }
131
+
132
+ // Soft cap: allow but warn
133
+ this.emit('budget:warning', {
134
+ apiKey,
135
+ threshold: 1.0,
136
+ spent: record.spent,
137
+ budget: record.budget,
138
+ remaining: record.remaining,
139
+ message: `Soft cap: Budget would be exceeded by ${projectedSpend - record.budget} cents`,
140
+ });
141
+ }
142
+
143
+ return {
144
+ allowed: true,
145
+ remaining: record.remaining - additionalCost,
146
+ };
147
+ }
148
+
149
+ // ---- Spend Recording ----
150
+
151
+ /**
152
+ * Record spend for an API key
153
+ */
154
+ recordSpend(apiKey: string, cost: number): void {
155
+ // Auto-initialize if no budget set
156
+ if (!this.budgets.has(apiKey)) {
157
+ return; // No budget to track
158
+ }
159
+
160
+ // Check for reset
161
+ this.checkAndReset(apiKey);
162
+
163
+ const record = this.spend.get(apiKey)!;
164
+ const config = this.budgets.get(apiKey)!;
165
+ const previousSpent = record.spent;
166
+
167
+ record.spent += cost;
168
+ record.remaining = record.budget - record.spent;
169
+
170
+ // Check thresholds
171
+ const utilization = record.spent / record.budget;
172
+ for (const threshold of config.alertThresholds ?? BudgetEnforcer.DEFAULT_THRESHOLDS) {
173
+ const thresholdKey = Math.round(threshold * 1000);
174
+ if (!(record.alertEmitted as Set<number>).has(thresholdKey)) {
175
+ const previousUtilization = previousSpent / record.budget;
176
+ if (utilization >= threshold && previousUtilization < threshold) {
177
+ (record.alertEmitted as Set<number>).add(thresholdKey);
178
+ this.emit('budget:warning', {
179
+ apiKey,
180
+ threshold,
181
+ spent: record.spent,
182
+ budget: record.budget,
183
+ remaining: record.remaining,
184
+ message: `Budget ${Math.round(threshold * 100)}% threshold reached`,
185
+ });
186
+ }
187
+ }
188
+ }
189
+
190
+ // Fire 100% event if budget exceeded (for hard cap tracking)
191
+ if (record.spent >= record.budget && previousSpent < record.budget) {
192
+ this.emit('budget:exceeded', {
193
+ apiKey,
194
+ spent: record.spent,
195
+ budget: record.budget,
196
+ remaining: 0,
197
+ });
198
+ }
199
+ }
200
+
201
+ // ---- Spend Queries ----
202
+
203
+ /**
204
+ * Get current spend record for an API key
205
+ */
206
+ getSpend(apiKey: string): SpendRecord | undefined {
207
+ if (!this.spend.has(apiKey)) {
208
+ return undefined;
209
+ }
210
+ const record = { ...this.spend.get(apiKey)! };
211
+ // Convert Set to Array for serialization
212
+ record.alertEmitted = Array.from(record.alertEmitted as Set<number>);
213
+ return record;
214
+ }
215
+
216
+ /**
217
+ * Get all spend records
218
+ */
219
+ getAllSpend(): SpendRecord[] {
220
+ return Array.from(this.spend.values()).map((record) => {
221
+ const copy = { ...record };
222
+ copy.alertEmitted = Array.from(record.alertEmitted as Set<number>);
223
+ return copy;
224
+ });
225
+ }
226
+
227
+ // ---- Budget Management ----
228
+
229
+ /**
230
+ * Reset budget for an API key (manual reset)
231
+ */
232
+ resetBudget(apiKey: string): void {
233
+ if (!this.spend.has(apiKey)) {
234
+ return;
235
+ }
236
+ const record = this.spend.get(apiKey)!;
237
+ record.spent = 0;
238
+ record.remaining = record.budget;
239
+ record.resetDate = this.getNextResetDate();
240
+ (record.alertEmitted as Set<number>).clear();
241
+ }
242
+
243
+ /**
244
+ * Reset all budgets
245
+ */
246
+ resetAll(): void {
247
+ for (const record of this.spend.values()) {
248
+ record.spent = 0;
249
+ record.remaining = record.budget;
250
+ record.resetDate = this.getNextResetDate();
251
+ (record.alertEmitted as Set<number>).clear();
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Update monthly limit for an API key
257
+ */
258
+ updateLimit(apiKey: string, monthlyLimit: number): void {
259
+ if (!this.budgets.has(apiKey)) {
260
+ this.setBudget(apiKey, monthlyLimit);
261
+ return;
262
+ }
263
+
264
+ const config = this.budgets.get(apiKey)!;
265
+ config.monthlyLimit = monthlyLimit;
266
+
267
+ const record = this.spend.get(apiKey)!;
268
+ record.budget = monthlyLimit;
269
+ record.remaining = monthlyLimit - record.spent;
270
+
271
+ // Re-check if any thresholds are now exceeded
272
+ const utilization = record.spent / record.budget;
273
+ for (const threshold of config.alertThresholds ?? BudgetEnforcer.DEFAULT_THRESHOLDS) {
274
+ const thresholdKey = Math.round(threshold * 1000);
275
+ if (utilization >= threshold) {
276
+ (record.alertEmitted as Set<number>).add(thresholdKey);
277
+ } else {
278
+ (record.alertEmitted as Set<number>).delete(thresholdKey);
279
+ }
280
+ }
281
+ }
282
+
283
+ // ---- Internal Helpers ----
284
+
285
+ /**
286
+ * Check if reset is needed and perform it
287
+ */
288
+ checkAndReset(apiKey: string): void {
289
+ const record = this.spend.get(apiKey)!;
290
+ if (this.isResetDue(record.resetDate)) {
291
+ record.spent = 0;
292
+ record.remaining = record.budget;
293
+ record.resetDate = this.getNextResetDate();
294
+ (record.alertEmitted as Set<number>).clear();
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Check if monthly reset is due
300
+ */
301
+ isResetDue(resetDate: Date): boolean {
302
+ const now = new Date();
303
+ return now >= resetDate;
304
+ }
305
+
306
+ /**
307
+ * Get next monthly reset date
308
+ */
309
+ getNextResetDate(): Date {
310
+ const now = new Date();
311
+ return new Date(now.getFullYear(), now.getMonth() + 1, 1);
312
+ }
313
+
314
+ /**
315
+ * Get days until next reset
316
+ */
317
+ getDaysUntilReset(apiKey: string): number | undefined {
318
+ const record = this.spend.get(apiKey);
319
+ if (!record) return undefined;
320
+
321
+ const now = Date.now();
322
+ const resetMs = record.resetDate.getTime();
323
+ return Math.max(0, Math.ceil((resetMs - now) / (1000 * 60 * 60 * 24)));
324
+ }
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Convenience Factory
329
+ // ---------------------------------------------------------------------------
330
+
331
+ export function createBudgetEnforcer(): BudgetEnforcer {
332
+ return new BudgetEnforcer();
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Error Class for Hard Cap Violations
337
+ // ---------------------------------------------------------------------------
338
+
339
+ export class BudgetExceededError extends Error {
340
+ apiKey: string;
341
+ spent: number;
342
+ budget: number;
343
+ remaining: number;
344
+
345
+ constructor(apiKey: string, spent: number, budget: number) {
346
+ const message = `Budget exceeded for API key ${apiKey}. Spent: ${spent} cents, Budget: ${budget} cents`;
347
+ super(message);
348
+ this.name = 'BudgetExceededError';
349
+ this.apiKey = apiKey;
350
+ this.spent = spent;
351
+ this.budget = budget;
352
+ this.remaining = budget - spent;
353
+ }
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // CostTracker (legacy wrapper for backward compatibility)
358
+ // ---------------------------------------------------------------------------
359
+
360
+ interface CostSnapshot {
361
+ provider: string;
362
+ model: string;
363
+ input_tokens: number;
364
+ output_tokens: number;
365
+ input_cost: number;
366
+ output_cost: number;
367
+ total_cost: number;
368
+ timestamp: number;
369
+ }
370
+
371
+ interface CostSummary {
372
+ total_cost: number;
373
+ by_provider: Record<string, number>;
374
+ by_model: Record<string, number>;
375
+ daily_costs: Record<string, number>;
376
+ monthly_costs: Record<string, number>;
377
+ request_count: number;
378
+ token_count: { input: number; output: number };
379
+ average_cost_per_request: number;
380
+ }
381
+
382
+ const LEGACY_MODEL_COSTS: Record<string, { input: number; output: number }> = {
383
+ 'claude-3-5-sonnet-20241022': { input: 3.0, output: 15.0 },
384
+ 'claude-3-opus-20240229': { input: 15.0, output: 75.0 },
385
+ 'claude-3-sonnet-20240229': { input: 3.0, output: 15.0 },
386
+ 'claude-3-haiku-20240307': { input: 0.25, output: 1.25 },
387
+ 'gpt-4o': { input: 2.5, output: 10.0 },
388
+ 'gpt-4-turbo': { input: 10.0, output: 30.0 },
389
+ 'gpt-4': { input: 30.0, output: 60.0 },
390
+ 'gpt-3.5-turbo': { input: 0.5, output: 1.5 },
391
+ 'gemini-1.5-pro': { input: 1.25, output: 5.0 },
392
+ 'gemini-1.5-flash': { input: 0.075, output: 0.3 },
393
+ 'llama-3.3-70b-versatile': { input: 0.59, output: 0.79 },
394
+ 'llama-3.1-8b-instant': { input: 0.05, output: 0.08 },
395
+ 'llama-3.3-70b': { input: 0.1, output: 0.1 },
396
+ 'mistral-large-latest': { input: 2.0, output: 6.0 },
397
+ 'mistral-small-latest': { input: 0.2, output: 0.6 },
398
+ 'grok-2': { input: 2.0, output: 8.0 },
399
+ 'grok-2-mini': { input: 0.2, output: 0.8 },
400
+ 'openai/gpt-4o': { input: 2.5, output: 10.0 },
401
+ 'anthropic/claude-3.5-sonnet': { input: 3.0, output: 15.0 },
402
+ 'glm-5': { input: 0.1, output: 0.3 },
403
+ 'glm-4': { input: 0.1, output: 0.3 },
404
+ };
405
+
406
+ export class CostTracker {
407
+ private history: CostSnapshot[] = [];
408
+ private budgets: { daily_limit?: number; monthly_limit?: number; per_model_limits?: Record<string, number> };
409
+ private alerts: any[] = [];
410
+ private alerts_callback: ((alert: any) => void) | null = null;
411
+ private daily_reset: number;
412
+ private monthly_reset: number;
413
+
414
+ constructor(budgets: { daily_limit?: number; monthly_limit?: number; per_model_limits?: Record<string, number> } = {}) {
415
+ this.budgets = budgets;
416
+ const now = new Date();
417
+ this.daily_reset = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime();
418
+ this.monthly_reset = new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime();
419
+ }
420
+
421
+ calculateCost(model: string, input_tokens: number, output_tokens: number): { input: number; output: number; total: number } {
422
+ const model_key = model.split('/').pop() || model;
423
+ const rates = LEGACY_MODEL_COSTS[model_key] || { input: 1.0, output: 5.0 };
424
+ const input_cost = (input_tokens / 1_000_000) * rates.input;
425
+ const output_cost = (output_tokens / 1_000_000) * rates.output;
426
+ return {
427
+ input: Math.round(input_cost * 1000000) / 1000000,
428
+ output: Math.round(output_cost * 1000000) / 1000000,
429
+ total: Math.round((input_cost + output_cost) * 1000000) / 1000000,
430
+ };
431
+ }
432
+
433
+ record(provider: string, model: string, input_tokens: number, output_tokens: number): CostSnapshot {
434
+ const costs = this.calculateCost(model, input_tokens, output_tokens);
435
+ const snapshot: CostSnapshot = {
436
+ provider,
437
+ model,
438
+ input_tokens,
439
+ output_tokens,
440
+ input_cost: costs.input,
441
+ output_cost: costs.output,
442
+ total_cost: costs.total,
443
+ timestamp: Date.now(),
444
+ };
445
+ this.history.push(snapshot);
446
+ this.checkBudgets(snapshot);
447
+ return snapshot;
448
+ }
449
+
450
+ private checkBudgets(snapshot: CostSnapshot): void {
451
+ const summary = this.getSummary();
452
+ const today = new Date().toISOString().split('T')[0];
453
+ const month = today.substring(0, 7);
454
+
455
+ if (this.budgets.daily_limit) {
456
+ const daily_cost = summary.daily_costs[today] || 0;
457
+ if (daily_cost >= this.budgets.daily_limit * 0.9) {
458
+ this.emitAlert({ type: 'daily', threshold: this.budgets.daily_limit, current: daily_cost });
459
+ }
460
+ }
461
+
462
+ if (this.budgets.monthly_limit) {
463
+ const monthly_cost = summary.monthly_costs[month] || 0;
464
+ if (monthly_cost >= this.budgets.monthly_limit * 0.9) {
465
+ this.emitAlert({ type: 'monthly', threshold: this.budgets.monthly_limit, current: monthly_cost });
466
+ }
467
+ }
468
+
469
+ if (this.budgets.per_model_limits) {
470
+ const model_limit = this.budgets.per_model_limits[snapshot.model];
471
+ if (model_limit) {
472
+ const model_cost = summary.by_model[snapshot.model] || 0;
473
+ if (model_cost >= model_limit * 0.9) {
474
+ this.emitAlert({ type: 'model', threshold: model_limit, current: model_cost, model: snapshot.model });
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ private emitAlert(alert: any): void {
481
+ const recent = this.alerts.find((a) => a.type === alert.type &&
482
+ a.threshold === alert.threshold &&
483
+ Date.now() - a._emitted_at < 3600000);
484
+ if (recent) return;
485
+ alert._emitted_at = Date.now();
486
+ this.alerts.push(alert);
487
+ if (this.alerts_callback) {
488
+ this.alerts_callback(alert);
489
+ }
490
+ }
491
+
492
+ onAlert(callback: (alert: any) => void): void {
493
+ this.alerts_callback = callback;
494
+ }
495
+
496
+ getSummary(): CostSummary {
497
+ const nowMs = Date.now();
498
+ const nowDate = new Date(nowMs);
499
+ if (nowMs >= this.daily_reset) {
500
+ this.daily_reset = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate() + 1).getTime();
501
+ }
502
+ if (nowMs >= this.monthly_reset) {
503
+ this.monthly_reset = new Date(nowDate.getFullYear(), nowDate.getMonth() + 1, 1).getTime();
504
+ }
505
+
506
+ const by_provider: Record<string, number> = {};
507
+ const by_model: Record<string, number> = {};
508
+ const daily_costs: Record<string, number> = {};
509
+ const monthly_costs: Record<string, number> = {};
510
+ let total_cost = 0;
511
+ let total_input_tokens = 0;
512
+ let total_output_tokens = 0;
513
+
514
+ for (const entry of this.history) {
515
+ total_cost += entry.total_cost;
516
+ total_input_tokens += entry.input_tokens;
517
+ total_output_tokens += entry.output_tokens;
518
+ by_provider[entry.provider] = (by_provider[entry.provider] || 0) + entry.total_cost;
519
+ by_model[entry.model] = (by_model[entry.model] || 0) + entry.total_cost;
520
+ const entry_date = new Date(entry.timestamp).toISOString().split('T')[0];
521
+ const entry_month = entry_date.substring(0, 7);
522
+ daily_costs[entry_date] = (daily_costs[entry_date] || 0) + entry.total_cost;
523
+ monthly_costs[entry_month] = (monthly_costs[entry_month] || 0) + entry.total_cost;
524
+ }
525
+
526
+ return {
527
+ total_cost: Math.round(total_cost * 1000000) / 1000000,
528
+ by_provider,
529
+ by_model,
530
+ daily_costs,
531
+ monthly_costs,
532
+ request_count: this.history.length,
533
+ token_count: { input: total_input_tokens, output: total_output_tokens },
534
+ average_cost_per_request: this.history.length > 0
535
+ ? Math.round((total_cost / this.history.length) * 1000000) / 1000000
536
+ : 0,
537
+ };
538
+ }
539
+
540
+ getRemainingBudget(): { daily: number | null; monthly: number | null; per_model: Record<string, number> } {
541
+ const summary = this.getSummary();
542
+ const today = new Date().toISOString().split('T')[0];
543
+ const month = today.substring(0, 7);
544
+
545
+ return {
546
+ daily: this.budgets.daily_limit
547
+ ? Math.max(0, this.budgets.daily_limit - (summary.daily_costs[today] || 0))
548
+ : null,
549
+ monthly: this.budgets.monthly_limit
550
+ ? Math.max(0, this.budgets.monthly_limit - (summary.monthly_costs[month] || 0))
551
+ : null,
552
+ per_model: this.budgets.per_model_limits
553
+ ? Object.fromEntries(
554
+ Object.entries(this.budgets.per_model_limits).map(([model, limit]) => [
555
+ model,
556
+ Math.max(0, limit - (summary.by_model[model] || 0)),
557
+ ])
558
+ )
559
+ : {},
560
+ };
561
+ }
562
+
563
+ reset(): void {
564
+ this.history = [];
565
+ this.alerts = [];
566
+ }
567
+
568
+ export(): CostSnapshot[] {
569
+ return [...this.history];
570
+ }
571
+
572
+ // Alias for getSummary (backward compat)
573
+ getStatus(): CostSummary {
574
+ return this.getSummary();
575
+ }
576
+ }