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.
- package/AGENT_COUNCIL_FINDINGS.md +142 -0
- package/LAUNCH_CHECKLIST.md +141 -0
- package/README.md.bak +836 -0
- package/articles/CHINESE_SUBMISSIONS_READY.md +322 -0
- package/articles/DEVTO_READY.md +255 -0
- package/articles/HN_POST_READY.md +137 -0
- package/articles/INDIEHACKERS_READY.md +120 -0
- package/articles/NEWSLETTER_SEND_NOW.md +259 -0
- package/articles/PRODUCTHUNT_READY.md +106 -0
- package/articles/REDDIT_SUBMISSION_READY.md +348 -0
- package/articles/TWEET_STORM_READY.md +165 -0
- package/council-votes/architecture-vote.md +121 -0
- package/council-votes/coverage-vote.md +93 -0
- package/dist/cost/costTracker.d.ts +109 -44
- package/dist/cost/costTracker.js +321 -98
- package/dist/cost/costTracker.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/routing/advancedRouter.d.ts +38 -43
- package/dist/routing/advancedRouter.js +394 -408
- package/dist/routing/advancedRouter.js.map +1 -1
- package/dist/routing/providers/providerConfig.d.ts +49 -0
- package/dist/routing/providers/providerConfig.js +883 -0
- package/dist/routing/routing/advancedRouter.d.ts +62 -0
- package/dist/routing/routing/advancedRouter.js +447 -0
- package/dist/routing/utils/tokenUtils.d.ts +52 -0
- package/dist/routing/utils/tokenUtils.js +129 -0
- package/dist/server/proxyServer.d.ts +1 -1
- package/package.json +1 -1
- package/research-log.md +49 -0
- package/src/cost/costTracker.ts +576 -0
- package/src/routing/advancedRouter.ts +536 -0
- package/test-council/AGENT_COUNCIL_ARCHITECTURE.md +349 -0
- package/tests/security/guardrailEngine.test.ts +700 -0
- package/research/PUBLISH_LOG.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adaptive-memory-multi-model-router",
|
|
3
|
-
"version": "2.14.
|
|
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",
|
package/research-log.md
ADDED
|
@@ -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
|
+
}
|