adaptive-memory-multi-model-router 2.14.16 → 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/.a3m-vault.json +23 -0
- package/.github/workflows/ci.yml +253 -5
- package/.publish-tick +1 -1
- package/AGENT_COUNCIL_FINDINGS.md +142 -0
- package/LAUNCH_CHECKLIST.md +141 -0
- package/README.md +15 -17
- 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/benchmark-results.json +45 -43
- 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/ensemble.d.ts +21 -0
- package/dist/ensemble.js +85 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +12 -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/dist/tui/dashboard.js +66 -2
- package/dist/tui/dashboard.js.map +1 -1
- package/dist/utils/tokenUtils.d.ts +48 -1
- package/dist/utils/tokenUtils.js +117 -4
- package/dist/utils/tokenUtils.js.map +1 -1
- package/docs/CITATIONS.md +2 -2
- package/docs/GEO_STATUS.md +43 -157
- package/docs/ai-plugin.json +4 -4
- package/docs/llms.txt +21 -27
- package/docs/sitemap.xml +14 -20
- package/package.json +2 -2
- package/research-log.md +49 -0
- package/sitemap.xml +57 -0
- package/src/cost/costTracker.ts +576 -0
- package/src/ensemble.ts +103 -0
- package/src/index.ts +13 -3
- package/src/routing/advancedRouter.ts +536 -0
- package/src/tui/dashboard.ts +76 -3
- package/src/utils/tokenUtils.ts +142 -4
- package/test-council/1-structure-tests.test.js +353 -0
- package/test-council/1-structure-tests.test.ts +353 -0
- package/test-council/2-edge-case-tests.test.ts +361 -0
- package/test-council/3-performance-tests.test.ts +669 -0
- package/test-council/4-integration-tests.test.ts +391 -0
- package/test-council/5-agent-council-eval.test.ts +413 -0
- package/test-council/AGENT_COUNCIL_ARCHITECTURE.md +349 -0
- package/test-council/TEST_COUNCIL_REPORT.md +201 -0
- package/test-council/agents/edge-case-agent.ts +363 -0
- package/test-council/agents/performance-agent.ts +426 -0
- package/test-council/agents/structure-agent.ts +227 -0
- package/test-council/council.md +183 -0
- package/tests/security/guardrailEngine.test.ts +700 -0
- package/docs/.well-known/ai-plugin.json +0 -16
- package/research/PUBLISH_LOG.md +0 -3
|
@@ -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
|
+
}
|
package/src/ensemble.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { A3MRouter, RouterDecision } from './index';
|
|
2
|
+
|
|
3
|
+
export type EnsembleStrategy = 'majority' | 'weighted' | 'conservative';
|
|
4
|
+
|
|
5
|
+
export interface EnsembleResponse {
|
|
6
|
+
finalAnswer: string;
|
|
7
|
+
confidence: number; // 0.0 to 1.0
|
|
8
|
+
isUncertain: boolean;
|
|
9
|
+
winner: string;
|
|
10
|
+
allResults: Record<string, { answer: string; score: number }>;
|
|
11
|
+
reasoning: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class EnsembleOrchestrator {
|
|
15
|
+
constructor(private router: A3MRouter) {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Executes a query across multiple providers in parallel and resolves the best answer.
|
|
19
|
+
*/
|
|
20
|
+
async executeEnsemble(
|
|
21
|
+
query: string,
|
|
22
|
+
providers: string[],
|
|
23
|
+
strategy: EnsembleStrategy = 'majority',
|
|
24
|
+
weights: Record<string, number> = {}
|
|
25
|
+
): Promise<EnsembleResponse> {
|
|
26
|
+
// 1. Parallel Execution
|
|
27
|
+
const results = await Promise.all(
|
|
28
|
+
providers.map(async (p) => {
|
|
29
|
+
try {
|
|
30
|
+
const res = await this.router.chat(query, { model: p });
|
|
31
|
+
return { provider: p, answer: res.choices[0].message.content, success: true };
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return { provider: p, answer: '', success: false };
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const successful = results.filter(r => r.success);
|
|
39
|
+
const answers = successful.map(r => r.answer.trim());
|
|
40
|
+
|
|
41
|
+
if (answers.length === 0) {
|
|
42
|
+
throw new Error('All ensemble providers failed.');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 2. Voting Logic
|
|
46
|
+
let winnerAnswer = '';
|
|
47
|
+
let winnerProvider = '';
|
|
48
|
+
let confidence = 0;
|
|
49
|
+
|
|
50
|
+
if (strategy === 'majority') {
|
|
51
|
+
const counts = {};
|
|
52
|
+
successful.forEach(r => counts[r.answer] = (counts[r.answer] || 0) + 1);
|
|
53
|
+
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
|
54
|
+
winnerAnswer = sorted[0][0];
|
|
55
|
+
confidence = sorted[0][1] / successful.length;
|
|
56
|
+
winnerProvider = successful.find(r => r.answer === winnerAnswer)?.provider || 'unknown';
|
|
57
|
+
}
|
|
58
|
+
else if (strategy === 'weighted') {
|
|
59
|
+
const weightedCounts = {};
|
|
60
|
+
successful.forEach(r => {
|
|
61
|
+
const weight = weights[r.provider] || 1.0;
|
|
62
|
+
weightedCounts[r.answer] = (weightedCounts[r.answer] || 0) + weight;
|
|
63
|
+
});
|
|
64
|
+
const sorted = Object.entries(weightedCounts).sort((a, b) => b[1] - a[1]);
|
|
65
|
+
winnerAnswer = sorted[0][0];
|
|
66
|
+
confidence = sorted[0][1] / (successful.length || 1); // Simplified
|
|
67
|
+
winnerProvider = successful.find(r => r.answer === winnerAnswer)?.provider || 'unknown';
|
|
68
|
+
}
|
|
69
|
+
else if (strategy === 'conservative') {
|
|
70
|
+
const counts = {};
|
|
71
|
+
successful.forEach(r => counts[r.answer] = (counts[r.answer] || 0) + 1);
|
|
72
|
+
const best = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
|
73
|
+
|
|
74
|
+
if (best && best[1] >= 2) {
|
|
75
|
+
winnerAnswer = best[0];
|
|
76
|
+
confidence = best[1] / successful.length;
|
|
77
|
+
winnerProvider = successful.find(r => r.answer === winnerAnswer)?.provider || 'unknown';
|
|
78
|
+
} else {
|
|
79
|
+
winnerAnswer = 'UNCERTAIN';
|
|
80
|
+
confidence = 0;
|
|
81
|
+
winnerProvider = 'none';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 3. Final Assembly
|
|
86
|
+
const allResults = {};
|
|
87
|
+
successful.forEach(r => {
|
|
88
|
+
allResults[r.provider] = {
|
|
89
|
+
answer: r.answer,
|
|
90
|
+
score: r.answer === winnerAnswer ? 1.0 : 0.0
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
finalAnswer: winnerAnswer,
|
|
96
|
+
confidence: confidence,
|
|
97
|
+
isUncertain: confidence < 0.6 || winnerAnswer === 'UNCERTAIN',
|
|
98
|
+
winner: winnerProvider,
|
|
99
|
+
allResults,
|
|
100
|
+
reasoning: `Ensemble of ${successful.length} models. ${Math.round(confidence * 100)}% agreement.`
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|