@theihtisham/budget-llm 1.0.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.
Files changed (65) hide show
  1. package/.env.example +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +293 -0
  4. package/dist/config.d.ts +77 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/config.js +246 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/database.d.ts +24 -0
  9. package/dist/database.d.ts.map +1 -0
  10. package/dist/database.js +414 -0
  11. package/dist/database.js.map +1 -0
  12. package/dist/providers.d.ts +20 -0
  13. package/dist/providers.d.ts.map +1 -0
  14. package/dist/providers.js +208 -0
  15. package/dist/providers.js.map +1 -0
  16. package/dist/proxy.d.ts +7 -0
  17. package/dist/proxy.d.ts.map +1 -0
  18. package/dist/proxy.js +181 -0
  19. package/dist/proxy.js.map +1 -0
  20. package/dist/rate-limiter.d.ts +8 -0
  21. package/dist/rate-limiter.d.ts.map +1 -0
  22. package/dist/rate-limiter.js +72 -0
  23. package/dist/rate-limiter.js.map +1 -0
  24. package/dist/router.d.ts +33 -0
  25. package/dist/router.d.ts.map +1 -0
  26. package/dist/router.js +186 -0
  27. package/dist/router.js.map +1 -0
  28. package/dist/server.d.ts +3 -0
  29. package/dist/server.d.ts.map +1 -0
  30. package/dist/server.js +705 -0
  31. package/dist/server.js.map +1 -0
  32. package/dist/task-classifier.d.ts +4 -0
  33. package/dist/task-classifier.d.ts.map +1 -0
  34. package/dist/task-classifier.js +123 -0
  35. package/dist/task-classifier.js.map +1 -0
  36. package/dist/types.d.ts +205 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +46 -0
  39. package/dist/types.js.map +1 -0
  40. package/dist/utils/encryption.d.ts +4 -0
  41. package/dist/utils/encryption.d.ts.map +1 -0
  42. package/dist/utils/encryption.js +40 -0
  43. package/dist/utils/encryption.js.map +1 -0
  44. package/package.json +63 -0
  45. package/src/config.ts +254 -0
  46. package/src/database.ts +496 -0
  47. package/src/providers.ts +315 -0
  48. package/src/proxy.ts +226 -0
  49. package/src/rate-limiter.ts +81 -0
  50. package/src/router.ts +228 -0
  51. package/src/server.ts +754 -0
  52. package/src/task-classifier.ts +134 -0
  53. package/src/types/sql.js.d.ts +27 -0
  54. package/src/types.ts +258 -0
  55. package/src/utils/encryption.ts +36 -0
  56. package/tests/config.test.ts +85 -0
  57. package/tests/database.test.ts +194 -0
  58. package/tests/encryption.test.ts +57 -0
  59. package/tests/rate-limiter.test.ts +83 -0
  60. package/tests/router.test.ts +182 -0
  61. package/tests/server.test.ts +253 -0
  62. package/tests/setup.ts +15 -0
  63. package/tests/task-classifier.test.ts +117 -0
  64. package/tsconfig.json +25 -0
  65. package/vitest.config.ts +15 -0
@@ -0,0 +1,496 @@
1
+ import initSqlJs, { Database as SqlJsDatabase } from 'sql.js';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { getDbPath, log } from './config';
5
+ import type { CostRecord, CostSummary, ProviderSummary, ModelSummary, BudgetStatus, BudgetConfig, CacheEntry, DashboardData, ProviderId } from './types';
6
+
7
+ let db: SqlJsDatabase | null = null;
8
+ let dbPath: string = '';
9
+ let saveTimer: ReturnType<typeof setTimeout> | null = null;
10
+
11
+ /**
12
+ * Get or initialize the database. sql.js is async so we init once.
13
+ */
14
+ export async function initDb(): Promise<SqlJsDatabase> {
15
+ if (db) return db;
16
+
17
+ dbPath = getDbPath();
18
+ const dir = path.dirname(dbPath);
19
+
20
+ if (!fs.existsSync(dir)) {
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ }
23
+
24
+ const SQL = await initSqlJs();
25
+
26
+ // Load existing database if it exists
27
+ if (fs.existsSync(dbPath)) {
28
+ const fileBuffer = fs.readFileSync(dbPath);
29
+ db = new SQL.Database(fileBuffer);
30
+ } else {
31
+ db = new SQL.Database();
32
+ }
33
+
34
+ migrate(db);
35
+ log.info('Database initialized and migrated');
36
+
37
+ // Auto-save every 5 seconds if dirty
38
+ saveTimer = setInterval(() => {
39
+ saveDb();
40
+ }, 5000).unref();
41
+
42
+ return db;
43
+ }
44
+
45
+ /**
46
+ * Synchronous access — throws if not initialized yet.
47
+ * Call initDb() during server startup before using this.
48
+ */
49
+ export function getDb(): SqlJsDatabase {
50
+ if (!db) {
51
+ throw new Error('Database not initialized. Call initDb() first.');
52
+ }
53
+ return db;
54
+ }
55
+
56
+ function migrate(database: SqlJsDatabase): void {
57
+ database.run(`
58
+ CREATE TABLE IF NOT EXISTS cost_records (
59
+ id TEXT PRIMARY KEY,
60
+ request_id TEXT NOT NULL,
61
+ timestamp TEXT NOT NULL,
62
+ provider TEXT NOT NULL,
63
+ model TEXT NOT NULL,
64
+ task_type TEXT NOT NULL,
65
+ input_tokens INTEGER NOT NULL,
66
+ output_tokens INTEGER NOT NULL,
67
+ input_cost REAL NOT NULL,
68
+ output_cost REAL NOT NULL,
69
+ total_cost REAL NOT NULL,
70
+ latency_ms INTEGER NOT NULL,
71
+ cached INTEGER NOT NULL DEFAULT 0
72
+ );
73
+ `);
74
+ database.run(`CREATE INDEX IF NOT EXISTS idx_cost_timestamp ON cost_records(timestamp);`);
75
+ database.run(`CREATE INDEX IF NOT EXISTS idx_cost_provider ON cost_records(provider);`);
76
+ database.run(`CREATE INDEX IF NOT EXISTS idx_cost_model ON cost_records(model);`);
77
+
78
+ database.run(`
79
+ CREATE TABLE IF NOT EXISTS cache_entries (
80
+ prompt_hash TEXT PRIMARY KEY,
81
+ model TEXT NOT NULL,
82
+ provider TEXT NOT NULL,
83
+ response TEXT NOT NULL,
84
+ input_tokens INTEGER NOT NULL,
85
+ output_tokens INTEGER NOT NULL,
86
+ cost REAL NOT NULL,
87
+ created_at INTEGER NOT NULL,
88
+ expires_at INTEGER NOT NULL,
89
+ hit_count INTEGER NOT NULL DEFAULT 0
90
+ );
91
+ `);
92
+ database.run(`CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache_entries(expires_at);`);
93
+
94
+ database.run(`
95
+ CREATE TABLE IF NOT EXISTS budget_config (
96
+ key TEXT PRIMARY KEY,
97
+ value TEXT NOT NULL
98
+ );
99
+ `);
100
+
101
+ database.run(`
102
+ CREATE TABLE IF NOT EXISTS api_keys (
103
+ provider TEXT PRIMARY KEY,
104
+ encrypted_key TEXT NOT NULL,
105
+ updated_at TEXT NOT NULL
106
+ );
107
+ `);
108
+
109
+ log.info('Database initialized and migrated');
110
+ }
111
+
112
+ function saveDb(): void {
113
+ if (!db || !dbPath) return;
114
+ try {
115
+ const data = db.export();
116
+ const buffer = Buffer.from(data);
117
+ fs.writeFileSync(dbPath, buffer);
118
+ } catch (err) {
119
+ log.error('Failed to save database', err);
120
+ }
121
+ }
122
+
123
+ // ---- Helper to query single row ----
124
+
125
+ function queryOne<T>(sql: string, params: (string | number | null)[] = []): T | null {
126
+ const database = getDb();
127
+ const stmt = database.prepare(sql);
128
+ stmt.bind(params);
129
+ try {
130
+ if (stmt.step()) {
131
+ const row = stmt.getAsObject() as T;
132
+ return row;
133
+ }
134
+ return null;
135
+ } finally {
136
+ stmt.free();
137
+ }
138
+ }
139
+
140
+ function queryAll<T>(sql: string, params: (string | number | null)[] = []): T[] {
141
+ const database = getDb();
142
+ const stmt = database.prepare(sql);
143
+ stmt.bind(params);
144
+ const results: T[] = [];
145
+ try {
146
+ while (stmt.step()) {
147
+ results.push(stmt.getAsObject() as T);
148
+ }
149
+ return results;
150
+ } finally {
151
+ stmt.free();
152
+ }
153
+ }
154
+
155
+ // ---- Cost Record Operations ----
156
+
157
+ export function insertCostRecord(record: CostRecord): void {
158
+ const database = getDb();
159
+ database.run(
160
+ `INSERT INTO cost_records (id, request_id, timestamp, provider, model, task_type,
161
+ input_tokens, output_tokens, input_cost, output_cost, total_cost, latency_ms, cached)
162
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
163
+ [
164
+ record.id,
165
+ record.requestId,
166
+ record.timestamp,
167
+ record.provider,
168
+ record.model,
169
+ record.taskType,
170
+ record.inputTokens,
171
+ record.outputTokens,
172
+ record.inputCost,
173
+ record.outputCost,
174
+ record.totalCost,
175
+ record.latencyMs,
176
+ record.cached ? 1 : 0,
177
+ ]
178
+ );
179
+ saveDb();
180
+ }
181
+
182
+ export function getDailySpend(): number {
183
+ const todayStr = new Date().toISOString().slice(0, 10);
184
+ const row = queryOne<{ spent: number }>(
185
+ `SELECT COALESCE(SUM(total_cost), 0) as spent FROM cost_records WHERE timestamp >= ?`,
186
+ [todayStr]
187
+ );
188
+ return row?.spent ?? 0;
189
+ }
190
+
191
+ export function getMonthlySpend(): number {
192
+ const now = new Date();
193
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
194
+ const row = queryOne<{ spent: number }>(
195
+ `SELECT COALESCE(SUM(total_cost), 0) as spent FROM cost_records WHERE timestamp >= ?`,
196
+ [monthStart]
197
+ );
198
+ return row?.spent ?? 0;
199
+ }
200
+
201
+ export function getBudgetStatus(config: BudgetConfig): BudgetStatus {
202
+ const dailySpent = getDailySpend();
203
+ const monthlySpent = getMonthlySpend();
204
+ return {
205
+ daily: {
206
+ spent: dailySpent,
207
+ limit: config.dailyBudget,
208
+ remaining: Math.max(0, config.dailyBudget - dailySpent),
209
+ percentUsed: config.dailyBudget > 0 ? (dailySpent / config.dailyBudget) * 100 : 0,
210
+ },
211
+ monthly: {
212
+ spent: monthlySpent,
213
+ limit: config.monthlyBudget,
214
+ remaining: Math.max(0, config.monthlyBudget - monthlySpent),
215
+ percentUsed: config.monthlyBudget > 0 ? (monthlySpent / config.monthlyBudget) * 100 : 0,
216
+ },
217
+ };
218
+ }
219
+
220
+ export function getCostSummary(days = 30): CostSummary {
221
+ const since = new Date(Date.now() - days * 86400000).toISOString();
222
+
223
+ const total = queryOne<{
224
+ totalSpent: number;
225
+ totalRequests: number;
226
+ totalInputTokens: number;
227
+ totalOutputTokens: number;
228
+ cacheHits: number;
229
+ averageLatencyMs: number;
230
+ }>(
231
+ `SELECT
232
+ COALESCE(SUM(total_cost), 0) as totalSpent,
233
+ COUNT(*) as totalRequests,
234
+ COALESCE(SUM(input_tokens), 0) as totalInputTokens,
235
+ COALESCE(SUM(output_tokens), 0) as totalOutputTokens,
236
+ COALESCE(SUM(CASE WHEN cached = 1 THEN 1 ELSE 0 END), 0) as cacheHits,
237
+ COALESCE(AVG(latency_ms), 0) as averageLatencyMs
238
+ FROM cost_records
239
+ WHERE timestamp >= ?`,
240
+ [since]
241
+ );
242
+
243
+ const providerRows = queryAll<{
244
+ provider: string;
245
+ totalSpent: number;
246
+ requestCount: number;
247
+ inputTokens: number;
248
+ outputTokens: number;
249
+ }>(
250
+ `SELECT provider,
251
+ COALESCE(SUM(total_cost), 0) as totalSpent,
252
+ COUNT(*) as requestCount,
253
+ COALESCE(SUM(input_tokens), 0) as inputTokens,
254
+ COALESCE(SUM(output_tokens), 0) as outputTokens
255
+ FROM cost_records
256
+ WHERE timestamp >= ?
257
+ GROUP BY provider`,
258
+ [since]
259
+ );
260
+
261
+ const modelRows = queryAll<{
262
+ model: string;
263
+ totalSpent: number;
264
+ requestCount: number;
265
+ inputTokens: number;
266
+ outputTokens: number;
267
+ averageLatencyMs: number;
268
+ }>(
269
+ `SELECT model,
270
+ COALESCE(SUM(total_cost), 0) as totalSpent,
271
+ COUNT(*) as requestCount,
272
+ COALESCE(SUM(input_tokens), 0) as inputTokens,
273
+ COALESCE(SUM(output_tokens), 0) as outputTokens,
274
+ COALESCE(AVG(latency_ms), 0) as averageLatencyMs
275
+ FROM cost_records
276
+ WHERE timestamp >= ?
277
+ GROUP BY model`,
278
+ [since]
279
+ );
280
+
281
+ const totalSpent = total?.totalSpent ?? 0;
282
+ const totalRequests = total?.totalRequests ?? 0;
283
+ const totalInputTokens = total?.totalInputTokens ?? 0;
284
+ const totalOutputTokens = total?.totalOutputTokens ?? 0;
285
+ const cacheHits = total?.cacheHits ?? 0;
286
+ const averageLatencyMs = total?.averageLatencyMs ?? 0;
287
+
288
+ const gpt4OutputCost = (totalOutputTokens / 1000000) * 30;
289
+ const gpt4InputCost = (totalInputTokens / 1000000) * 10;
290
+ const savingsVsGpt4 = Math.max(0, (gpt4InputCost + gpt4OutputCost) - totalSpent);
291
+
292
+ const byProvider: Record<string, ProviderSummary> = {};
293
+ for (const row of providerRows) {
294
+ byProvider[row.provider] = {
295
+ totalSpent: row.totalSpent,
296
+ requestCount: row.requestCount,
297
+ inputTokens: row.inputTokens,
298
+ outputTokens: row.outputTokens,
299
+ };
300
+ }
301
+
302
+ const byModel: Record<string, ModelSummary> = {};
303
+ for (const row of modelRows) {
304
+ byModel[row.model] = {
305
+ totalSpent: row.totalSpent,
306
+ requestCount: row.requestCount,
307
+ inputTokens: row.inputTokens,
308
+ outputTokens: row.outputTokens,
309
+ averageLatencyMs: row.averageLatencyMs,
310
+ };
311
+ }
312
+
313
+ return {
314
+ totalSpent,
315
+ totalRequests,
316
+ totalInputTokens,
317
+ totalOutputTokens,
318
+ cacheHitRate: totalRequests > 0 ? cacheHits / totalRequests : 0,
319
+ averageLatencyMs,
320
+ byProvider: byProvider as CostSummary['byProvider'],
321
+ byModel,
322
+ savingsVsGpt4,
323
+ };
324
+ }
325
+
326
+ // ---- Cache Operations ----
327
+
328
+ export function getCacheEntry(promptHash: string): CacheEntry | null {
329
+ const database = getDb();
330
+ const now = Date.now();
331
+
332
+ // Clean expired entries
333
+ database.run('DELETE FROM cache_entries WHERE expires_at < ?', [now]);
334
+
335
+ const row = queryOne<Record<string, unknown>>(
336
+ `SELECT prompt_hash, model, provider, response, input_tokens, output_tokens,
337
+ cost, created_at, expires_at, hit_count
338
+ FROM cache_entries WHERE prompt_hash = ? AND expires_at > ?`,
339
+ [promptHash, now]
340
+ );
341
+
342
+ if (row) {
343
+ database.run(
344
+ 'UPDATE cache_entries SET hit_count = hit_count + 1 WHERE prompt_hash = ?',
345
+ [promptHash]
346
+ );
347
+ const entry: CacheEntry = {
348
+ promptHash: row['prompt_hash'] as string,
349
+ model: row['model'] as string,
350
+ provider: row['provider'] as ProviderId,
351
+ response: row['response'] as string,
352
+ inputTokens: row['input_tokens'] as number,
353
+ outputTokens: row['output_tokens'] as number,
354
+ cost: row['cost'] as number,
355
+ createdAt: row['created_at'] as number,
356
+ expiresAt: row['expires_at'] as number,
357
+ hitCount: ((row['hit_count'] as number) ?? 0) + 1,
358
+ };
359
+ return entry;
360
+ }
361
+
362
+ return null;
363
+ }
364
+
365
+ export function setCacheEntry(entry: CacheEntry): void {
366
+ const database = getDb();
367
+ database.run(
368
+ `INSERT OR REPLACE INTO cache_entries
369
+ (prompt_hash, model, provider, response, input_tokens, output_tokens, cost, created_at, expires_at, hit_count)
370
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
371
+ [
372
+ entry.promptHash,
373
+ entry.model,
374
+ entry.provider,
375
+ entry.response,
376
+ entry.inputTokens,
377
+ entry.outputTokens,
378
+ entry.cost,
379
+ entry.createdAt,
380
+ entry.expiresAt,
381
+ entry.hitCount,
382
+ ]
383
+ );
384
+ saveDb();
385
+ }
386
+
387
+ export function clearCache(): number {
388
+ const database = getDb();
389
+ // Get count before clearing
390
+ const countRow = queryOne<{ cnt: number }>('SELECT COUNT(*) as cnt FROM cache_entries');
391
+ database.run('DELETE FROM cache_entries');
392
+ saveDb();
393
+ return countRow?.cnt ?? 0;
394
+ }
395
+
396
+ // ---- Budget Config Operations ----
397
+
398
+ export function getBudgetConfig(): BudgetConfig {
399
+ const dailyRow = queryOne<{ value: string }>("SELECT value FROM budget_config WHERE key = 'dailyBudget'");
400
+ const monthlyRow = queryOne<{ value: string }>("SELECT value FROM budget_config WHERE key = 'monthlyBudget'");
401
+ const capRow = queryOne<{ value: string }>("SELECT value FROM budget_config WHERE key = 'perRequestCap'");
402
+
403
+ return {
404
+ dailyBudget: dailyRow ? parseFloat(dailyRow.value) : 10,
405
+ monthlyBudget: monthlyRow ? parseFloat(monthlyRow.value) : 200,
406
+ perRequestCap: capRow ? parseFloat(capRow.value) : 1,
407
+ };
408
+ }
409
+
410
+ export function setBudgetConfig(config: Partial<BudgetConfig>): void {
411
+ const database = getDb();
412
+ if (config.dailyBudget !== undefined) {
413
+ database.run("INSERT OR REPLACE INTO budget_config (key, value) VALUES ('dailyBudget', ?)", [
414
+ config.dailyBudget.toString(),
415
+ ]);
416
+ }
417
+ if (config.monthlyBudget !== undefined) {
418
+ database.run("INSERT OR REPLACE INTO budget_config (key, value) VALUES ('monthlyBudget', ?)", [
419
+ config.monthlyBudget.toString(),
420
+ ]);
421
+ }
422
+ if (config.perRequestCap !== undefined) {
423
+ database.run("INSERT OR REPLACE INTO budget_config (key, value) VALUES ('perRequestCap', ?)", [
424
+ config.perRequestCap.toString(),
425
+ ]);
426
+ }
427
+ saveDb();
428
+ }
429
+
430
+ // ---- Dashboard Data ----
431
+
432
+ export function getDashboardData(): DashboardData {
433
+ const summary = getCostSummary(30);
434
+ const budget = getBudgetStatus(getBudgetConfig());
435
+
436
+ const recentReqRows = queryAll<Record<string, unknown>>(
437
+ `SELECT timestamp as time, model, provider, task_type as taskType,
438
+ total_cost as cost, (input_tokens + output_tokens) as tokens, cached
439
+ FROM cost_records
440
+ ORDER BY timestamp DESC
441
+ LIMIT 50`
442
+ );
443
+
444
+ const recentReqs = recentReqRows.map((r) => ({
445
+ time: r['time'] as string,
446
+ model: r['model'] as string,
447
+ provider: r['provider'] as string,
448
+ taskType: r['taskType'] as string,
449
+ cost: r['cost'] as number,
450
+ tokens: r['tokens'] as number,
451
+ cached: Boolean(r['cached']),
452
+ }));
453
+
454
+ const costByDay = queryAll<{ date: string; cost: number; requests: number }>(
455
+ `SELECT DATE(timestamp) as date, SUM(total_cost) as cost, COUNT(*) as requests
456
+ FROM cost_records
457
+ WHERE timestamp >= datetime('now', '-30 days')
458
+ GROUP BY DATE(timestamp)
459
+ ORDER BY date DESC`
460
+ );
461
+
462
+ const modelDist = queryAll<{ model: string; count: number; cost: number }>(
463
+ `SELECT model, COUNT(*) as count, SUM(total_cost) as cost
464
+ FROM cost_records
465
+ WHERE timestamp >= datetime('now', '-30 days')
466
+ GROUP BY model
467
+ ORDER BY count DESC`
468
+ );
469
+
470
+ return {
471
+ overview: {
472
+ totalSpent: summary.totalSpent,
473
+ totalRequests: summary.totalRequests,
474
+ totalSaved: summary.savingsVsGpt4,
475
+ cacheHitRate: summary.cacheHitRate,
476
+ },
477
+ budget,
478
+ recentRequests: recentReqs,
479
+ costByDay,
480
+ modelDistribution: modelDist,
481
+ };
482
+ }
483
+
484
+ // ---- Cleanup ----
485
+
486
+ export function closeDb(): void {
487
+ if (saveTimer) {
488
+ clearInterval(saveTimer);
489
+ saveTimer = null;
490
+ }
491
+ if (db) {
492
+ saveDb();
493
+ db.close();
494
+ db = null;
495
+ }
496
+ }