@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
package/src/server.ts ADDED
@@ -0,0 +1,754 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import helmet from 'helmet';
4
+ import compression from 'compression';
5
+ import { env, log } from './config';
6
+ import { handleProxyRequest } from './proxy';
7
+ import {
8
+ getCostSummary,
9
+ getDashboardData,
10
+ getBudgetStatus,
11
+ getBudgetConfig,
12
+ setBudgetConfig,
13
+ clearCache,
14
+ initDb,
15
+ closeDb,
16
+ } from './database';
17
+ import { checkRateLimit } from './rate-limiter';
18
+ import {
19
+ BudgetExceededError,
20
+ RateLimitError,
21
+ NoProviderAvailableError,
22
+ } from './types';
23
+ import type { ProxyRequest, BudgetConfig } from './types';
24
+
25
+ const app = express();
26
+
27
+ // ---- Middleware ----
28
+
29
+ app.use(helmet({ contentSecurityPolicy: false }));
30
+ app.use(compression());
31
+ app.use(cors());
32
+ app.use(express.json({ limit: '10mb' }));
33
+
34
+ // Strip prompt content from logs for security
35
+ app.use((req, _res, next) => {
36
+ // Only log metadata, never the prompt body
37
+ log.info(`${req.method} ${req.path} from ${req.ip}`);
38
+ next();
39
+ });
40
+
41
+ // ---- Health Check ----
42
+
43
+ app.get('/health', (_req, res) => {
44
+ res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() });
45
+ });
46
+
47
+ // ---- OpenAI-Compatible Chat Completions Endpoint ----
48
+
49
+ app.post('/v1/chat/completions', async (req, res) => {
50
+ try {
51
+ const body = req.body as ProxyRequest;
52
+
53
+ // Validate required fields
54
+ if (!body.messages || !Array.isArray(body.messages)) {
55
+ res.status(400).json({
56
+ error: {
57
+ message: 'messages is required and must be an array',
58
+ type: 'invalid_request_error',
59
+ },
60
+ });
61
+ return;
62
+ }
63
+
64
+ const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
65
+ const response = await handleProxyRequest(body, clientIp);
66
+
67
+ res.json(response);
68
+ } catch (err) {
69
+ handleProxyError(err, res);
70
+ }
71
+ });
72
+
73
+ // Also support without /v1 prefix for convenience
74
+ app.post('/chat/completions', async (req, res) => {
75
+ try {
76
+ const body = req.body as ProxyRequest;
77
+
78
+ if (!body.messages || !Array.isArray(body.messages)) {
79
+ res.status(400).json({
80
+ error: {
81
+ message: 'messages is required and must be an array',
82
+ type: 'invalid_request_error',
83
+ },
84
+ });
85
+ return;
86
+ }
87
+
88
+ const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
89
+ const response = await handleProxyRequest(body, clientIp);
90
+
91
+ res.json(response);
92
+ } catch (err) {
93
+ handleProxyError(err, res);
94
+ }
95
+ });
96
+
97
+ // ---- Models Endpoint (OpenAI-compatible) ----
98
+
99
+ app.get('/v1/models', (_req, res) => {
100
+ const { MODEL_CATALOG } = require('./config');
101
+ const models = MODEL_CATALOG.map((m: { id: string; displayName: string; provider: string; capabilities: string[]; contextWindow: number }) => ({
102
+ id: m.id,
103
+ object: 'model',
104
+ created: 1700000000,
105
+ owned_by: m.provider,
106
+ display_name: m.displayName,
107
+ capabilities: m.capabilities,
108
+ context_window: m.contextWindow,
109
+ }));
110
+
111
+ res.json({
112
+ object: 'list',
113
+ data: models,
114
+ });
115
+ });
116
+
117
+ app.get('/models', (_req, res) => {
118
+ const { MODEL_CATALOG } = require('./config');
119
+ const models = MODEL_CATALOG.map((m: { id: string; displayName: string; provider: string; capabilities: string[]; contextWindow: number }) => ({
120
+ id: m.id,
121
+ object: 'model',
122
+ created: 1700000000,
123
+ owned_by: m.provider,
124
+ display_name: m.displayName,
125
+ capabilities: m.capabilities,
126
+ context_window: m.contextWindow,
127
+ }));
128
+
129
+ res.json({
130
+ object: 'list',
131
+ data: models,
132
+ });
133
+ });
134
+
135
+ // ---- Dashboard API ----
136
+
137
+ app.get('/api/dashboard', (_req, res) => {
138
+ try {
139
+ const data = getDashboardData();
140
+ res.json(data);
141
+ } catch (err) {
142
+ res.status(500).json({ error: 'Failed to load dashboard data' });
143
+ }
144
+ });
145
+
146
+ app.get('/api/costs', (req, res) => {
147
+ try {
148
+ const days = parseInt(req.query.days as string) || 30;
149
+ const summary = getCostSummary(Math.min(days, 365));
150
+ res.json(summary);
151
+ } catch (err) {
152
+ res.status(500).json({ error: 'Failed to load cost data' });
153
+ }
154
+ });
155
+
156
+ app.get('/api/budget', (_req, res) => {
157
+ try {
158
+ const config = getBudgetConfig();
159
+ const status = getBudgetStatus(config);
160
+ res.json({ config, status });
161
+ } catch (err) {
162
+ res.status(500).json({ error: 'Failed to load budget data' });
163
+ }
164
+ });
165
+
166
+ app.put('/api/budget', (req, res) => {
167
+ try {
168
+ const updates = req.body as Partial<BudgetConfig>;
169
+
170
+ // Validate inputs
171
+ if (updates.dailyBudget !== undefined && (typeof updates.dailyBudget !== 'number' || updates.dailyBudget <= 0)) {
172
+ res.status(400).json({ error: 'dailyBudget must be a positive number' });
173
+ return;
174
+ }
175
+ if (updates.monthlyBudget !== undefined && (typeof updates.monthlyBudget !== 'number' || updates.monthlyBudget <= 0)) {
176
+ res.status(400).json({ error: 'monthlyBudget must be a positive number' });
177
+ return;
178
+ }
179
+ if (updates.perRequestCap !== undefined && (typeof updates.perRequestCap !== 'number' || updates.perRequestCap <= 0)) {
180
+ res.status(400).json({ error: 'perRequestCap must be a positive number' });
181
+ return;
182
+ }
183
+
184
+ setBudgetConfig(updates);
185
+ const config = getBudgetConfig();
186
+ const status = getBudgetStatus(config);
187
+ res.json({ config, status });
188
+ } catch (err) {
189
+ res.status(500).json({ error: 'Failed to update budget' });
190
+ }
191
+ });
192
+
193
+ app.get('/api/rate-limit', (req, res) => {
194
+ const ip = req.ip || req.socket.remoteAddress || 'unknown';
195
+ const status = checkRateLimit(ip);
196
+ res.json({ ip, ...status });
197
+ });
198
+
199
+ app.delete('/api/cache', (_req, res) => {
200
+ try {
201
+ const cleared = clearCache();
202
+ res.json({ cleared, message: `Cleared ${cleared} cache entries` });
203
+ } catch (err) {
204
+ res.status(500).json({ error: 'Failed to clear cache' });
205
+ }
206
+ });
207
+
208
+ // ---- Dashboard HTML ----
209
+
210
+ app.get('/dashboard', (_req, res) => {
211
+ res.setHeader('Content-Type', 'text/html');
212
+ res.send(getDashboardHtml());
213
+ });
214
+
215
+ app.get('/', (_req, res) => {
216
+ res.setHeader('Content-Type', 'text/html');
217
+ res.send(getLandingHtml());
218
+ });
219
+
220
+ // ---- Error Handler ----
221
+
222
+ function handleProxyError(err: unknown, res: express.Response): void {
223
+ if (err instanceof BudgetExceededError) {
224
+ res.status(429).json({
225
+ error: {
226
+ message: err.message,
227
+ type: 'budget_exceeded',
228
+ budget_type: err.type,
229
+ spent: err.spent,
230
+ limit: err.limit,
231
+ },
232
+ });
233
+ return;
234
+ }
235
+
236
+ if (err instanceof RateLimitError) {
237
+ res.status(429).json({
238
+ error: {
239
+ message: err.message,
240
+ type: 'rate_limit_exceeded',
241
+ retry_after_ms: err.resetMs,
242
+ },
243
+ });
244
+ return;
245
+ }
246
+
247
+ if (err instanceof NoProviderAvailableError) {
248
+ res.status(503).json({
249
+ error: {
250
+ message: err.message,
251
+ type: 'no_provider_available',
252
+ },
253
+ });
254
+ return;
255
+ }
256
+
257
+ if (err instanceof Error) {
258
+ // Don't leak internal error details in production
259
+ const message =
260
+ env.NODE_ENV === 'production'
261
+ ? 'Internal server error'
262
+ : err.message;
263
+ res.status(500).json({
264
+ error: {
265
+ message,
266
+ type: 'internal_error',
267
+ },
268
+ });
269
+ return;
270
+ }
271
+
272
+ res.status(500).json({
273
+ error: { message: 'Unknown error', type: 'internal_error' },
274
+ });
275
+ }
276
+
277
+ // ---- Dashboard HTML ----
278
+
279
+ function getLandingHtml(): string {
280
+ return `<!DOCTYPE html>
281
+ <html lang="en">
282
+ <head>
283
+ <meta charset="UTF-8">
284
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
285
+ <title>BudgetLLM - Smart LLM Cost Optimizer</title>
286
+ <style>
287
+ * { margin: 0; padding: 0; box-sizing: border-box; }
288
+ body {
289
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
290
+ background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
291
+ color: #e2e8f0;
292
+ min-height: 100vh;
293
+ display: flex;
294
+ align-items: center;
295
+ justify-content: center;
296
+ }
297
+ .container {
298
+ max-width: 700px;
299
+ text-align: center;
300
+ padding: 2rem;
301
+ }
302
+ h1 {
303
+ font-size: 3rem;
304
+ font-weight: 800;
305
+ background: linear-gradient(135deg, #38bdf8, #818cf8, #c084fc);
306
+ -webkit-background-clip: text;
307
+ -webkit-text-fill-color: transparent;
308
+ margin-bottom: 1rem;
309
+ }
310
+ .tagline {
311
+ font-size: 1.2rem;
312
+ color: #94a3b8;
313
+ margin-bottom: 2.5rem;
314
+ line-height: 1.6;
315
+ }
316
+ .endpoint {
317
+ background: #1e293b;
318
+ border: 1px solid #334155;
319
+ border-radius: 12px;
320
+ padding: 1.5rem;
321
+ margin-bottom: 1.5rem;
322
+ text-align: left;
323
+ }
324
+ .endpoint h3 { color: #38bdf8; margin-bottom: 0.5rem; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.05em; }
325
+ .endpoint code {
326
+ display: block;
327
+ background: #0f172a;
328
+ padding: 1rem;
329
+ border-radius: 8px;
330
+ font-family: 'Fira Code', 'Cascadia Code', monospace;
331
+ font-size: 0.85rem;
332
+ color: #a5f3fc;
333
+ overflow-x: auto;
334
+ white-space: pre-wrap;
335
+ }
336
+ .links {
337
+ display: flex;
338
+ gap: 1rem;
339
+ justify-content: center;
340
+ margin-top: 2rem;
341
+ }
342
+ .links a {
343
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
344
+ color: white;
345
+ padding: 0.75rem 2rem;
346
+ border-radius: 10px;
347
+ text-decoration: none;
348
+ font-weight: 600;
349
+ transition: transform 0.2s, box-shadow 0.2s;
350
+ }
351
+ .links a:hover {
352
+ transform: translateY(-2px);
353
+ box-shadow: 0 8px 25px rgba(99, 102, 241, 0.4);
354
+ }
355
+ .links a.secondary {
356
+ background: #334155;
357
+ }
358
+ .links a.secondary:hover {
359
+ box-shadow: 0 8px 25px rgba(51, 65, 85, 0.6);
360
+ }
361
+ </style>
362
+ </head>
363
+ <body>
364
+ <div class="container">
365
+ <h1>BudgetLLM</h1>
366
+ <p class="tagline">Cut your AI costs by 60% &mdash; one API endpoint that<br>automatically picks the cheapest model for every request.</p>
367
+ <div class="endpoint">
368
+ <h3>OpenAI-Compatible Endpoint</h3>
369
+ <code>POST http://localhost:${env.PORT}/v1/chat/completions
370
+
371
+ {
372
+ "messages": [
373
+ {"role": "user", "content": "Hello!"}
374
+ ]
375
+ }</code>
376
+ </div>
377
+ <div class="endpoint">
378
+ <h3>With Task Hint</h3>
379
+ <code>{
380
+ "messages": [
381
+ {"role": "user", "content": "Write a function to sort an array"}
382
+ ],
383
+ "task_type": "code"
384
+ }</code>
385
+ </div>
386
+ <div class="links">
387
+ <a href="/dashboard">Dashboard</a>
388
+ <a href="/v1/models" class="secondary">Models</a>
389
+ <a href="/api/costs" class="secondary">Costs API</a>
390
+ </div>
391
+ </div>
392
+ </body>
393
+ </html>`;
394
+ }
395
+
396
+ function getDashboardHtml(): string {
397
+ return `<!DOCTYPE html>
398
+ <html lang="en">
399
+ <head>
400
+ <meta charset="UTF-8">
401
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
402
+ <title>BudgetLLM Dashboard</title>
403
+ <style>
404
+ * { margin: 0; padding: 0; box-sizing: border-box; }
405
+ body {
406
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
407
+ background: #0f172a;
408
+ color: #e2e8f0;
409
+ min-height: 100vh;
410
+ }
411
+ .header {
412
+ background: linear-gradient(135deg, #1e293b, #0f172a);
413
+ border-bottom: 1px solid #334155;
414
+ padding: 1.5rem 2rem;
415
+ display: flex;
416
+ justify-content: space-between;
417
+ align-items: center;
418
+ }
419
+ .header h1 {
420
+ font-size: 1.5rem;
421
+ background: linear-gradient(135deg, #38bdf8, #818cf8);
422
+ -webkit-background-clip: text;
423
+ -webkit-text-fill-color: transparent;
424
+ }
425
+ .header .links { display: flex; gap: 1rem; }
426
+ .header a { color: #94a3b8; text-decoration: none; font-size: 0.9rem; }
427
+ .header a:hover { color: #38bdf8; }
428
+ .grid {
429
+ display: grid;
430
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
431
+ gap: 1rem;
432
+ padding: 1.5rem 2rem;
433
+ }
434
+ .card {
435
+ background: #1e293b;
436
+ border: 1px solid #334155;
437
+ border-radius: 12px;
438
+ padding: 1.25rem;
439
+ }
440
+ .card h3 {
441
+ font-size: 0.8rem;
442
+ text-transform: uppercase;
443
+ letter-spacing: 0.05em;
444
+ color: #64748b;
445
+ margin-bottom: 0.5rem;
446
+ }
447
+ .card .value {
448
+ font-size: 1.8rem;
449
+ font-weight: 700;
450
+ }
451
+ .card .value.green { color: #4ade80; }
452
+ .card .value.blue { color: #38bdf8; }
453
+ .card .value.purple { color: #a78bfa; }
454
+ .card .value.yellow { color: #facc15; }
455
+ .budget-bar {
456
+ width: 100%;
457
+ height: 8px;
458
+ background: #334155;
459
+ border-radius: 4px;
460
+ margin-top: 0.5rem;
461
+ overflow: hidden;
462
+ }
463
+ .budget-bar .fill {
464
+ height: 100%;
465
+ border-radius: 4px;
466
+ transition: width 0.5s;
467
+ }
468
+ .fill.green { background: #4ade80; }
469
+ .fill.yellow { background: #facc15; }
470
+ .fill.red { background: #f87171; }
471
+ .section {
472
+ padding: 0 2rem 2rem;
473
+ }
474
+ .section h2 {
475
+ font-size: 1.1rem;
476
+ margin-bottom: 1rem;
477
+ color: #94a3b8;
478
+ }
479
+ table {
480
+ width: 100%;
481
+ border-collapse: collapse;
482
+ background: #1e293b;
483
+ border-radius: 12px;
484
+ overflow: hidden;
485
+ border: 1px solid #334155;
486
+ }
487
+ th {
488
+ text-align: left;
489
+ padding: 0.75rem 1rem;
490
+ font-size: 0.8rem;
491
+ text-transform: uppercase;
492
+ letter-spacing: 0.05em;
493
+ color: #64748b;
494
+ border-bottom: 1px solid #334155;
495
+ }
496
+ td {
497
+ padding: 0.75rem 1rem;
498
+ font-size: 0.9rem;
499
+ border-bottom: 1px solid #1e293b;
500
+ }
501
+ tr:last-child td { border-bottom: none; }
502
+ .badge {
503
+ display: inline-block;
504
+ padding: 2px 8px;
505
+ border-radius: 6px;
506
+ font-size: 0.75rem;
507
+ font-weight: 600;
508
+ }
509
+ .badge.cached { background: #065f46; color: #6ee7b7; }
510
+ .badge.live { background: #1e3a5f; color: #7dd3fc; }
511
+ .loading {
512
+ text-align: center;
513
+ padding: 3rem;
514
+ color: #64748b;
515
+ }
516
+ .chart-container {
517
+ background: #1e293b;
518
+ border: 1px solid #334155;
519
+ border-radius: 12px;
520
+ padding: 1.5rem;
521
+ margin-bottom: 1.5rem;
522
+ }
523
+ .bar-chart { display: flex; align-items: flex-end; gap: 4px; height: 120px; }
524
+ .bar {
525
+ flex: 1;
526
+ background: linear-gradient(to top, #6366f1, #818cf8);
527
+ border-radius: 4px 4px 0 0;
528
+ min-width: 8px;
529
+ position: relative;
530
+ transition: height 0.5s;
531
+ }
532
+ .bar:hover { filter: brightness(1.2); }
533
+ .bar-label {
534
+ position: absolute;
535
+ bottom: -20px;
536
+ left: 50%;
537
+ transform: translateX(-50%);
538
+ font-size: 0.65rem;
539
+ color: #64748b;
540
+ white-space: nowrap;
541
+ }
542
+ .refresh-btn {
543
+ background: #334155;
544
+ border: none;
545
+ color: #e2e8f0;
546
+ padding: 0.5rem 1rem;
547
+ border-radius: 8px;
548
+ cursor: pointer;
549
+ font-size: 0.85rem;
550
+ }
551
+ .refresh-btn:hover { background: #475569; }
552
+ </style>
553
+ </head>
554
+ <body>
555
+ <div class="header">
556
+ <h1>BudgetLLM Dashboard</h1>
557
+ <div>
558
+ <button class="refresh-btn" onclick="loadData()">Refresh</button>
559
+ <a href="/" style="color:#94a3b8;text-decoration:none;margin-left:1rem;font-size:0.9rem">Home</a>
560
+ </div>
561
+ </div>
562
+
563
+ <div class="grid" id="stats">
564
+ <div class="card">
565
+ <h3>Total Spent</h3>
566
+ <div class="value blue" id="total-spent">$0.00</div>
567
+ </div>
568
+ <div class="card">
569
+ <h3>Total Saved vs GPT-4</h3>
570
+ <div class="value green" id="total-saved">$0.00</div>
571
+ </div>
572
+ <div class="card">
573
+ <h3>Total Requests</h3>
574
+ <div class="value purple" id="total-requests">0</div>
575
+ </div>
576
+ <div class="card">
577
+ <h3>Cache Hit Rate</h3>
578
+ <div class="value yellow" id="cache-rate">0%</div>
579
+ </div>
580
+ </div>
581
+
582
+ <div class="grid">
583
+ <div class="card">
584
+ <h3>Daily Budget</h3>
585
+ <div class="value blue" id="daily-spent">$0.00 / $10.00</div>
586
+ <div class="budget-bar"><div class="fill green" id="daily-bar" style="width:0%"></div></div>
587
+ </div>
588
+ <div class="card">
589
+ <h3>Monthly Budget</h3>
590
+ <div class="value blue" id="monthly-spent">$0.00 / $200.00</div>
591
+ <div class="budget-bar"><div class="fill green" id="monthly-bar" style="width:0%"></div></div>
592
+ </div>
593
+ </div>
594
+
595
+ <div class="section">
596
+ <h2>Cost by Day (Last 14 Days)</h2>
597
+ <div class="chart-container">
598
+ <div class="bar-chart" id="daily-chart">
599
+ <div class="loading">Loading...</div>
600
+ </div>
601
+ </div>
602
+ </div>
603
+
604
+ <div class="section">
605
+ <h2>Recent Requests</h2>
606
+ <table>
607
+ <thead>
608
+ <tr>
609
+ <th>Time</th>
610
+ <th>Model</th>
611
+ <th>Provider</th>
612
+ <th>Task</th>
613
+ <th>Cost</th>
614
+ <th>Tokens</th>
615
+ <th>Status</th>
616
+ </tr>
617
+ </thead>
618
+ <tbody id="requests-table">
619
+ <tr><td colspan="7" class="loading">Loading...</td></tr>
620
+ </tbody>
621
+ </table>
622
+ </div>
623
+
624
+ <div class="section">
625
+ <h2>Model Distribution</h2>
626
+ <table>
627
+ <thead>
628
+ <tr>
629
+ <th>Model</th>
630
+ <th>Requests</th>
631
+ <th>Cost</th>
632
+ </tr>
633
+ </thead>
634
+ <tbody id="model-table">
635
+ <tr><td colspan="3" class="loading">Loading...</td></tr>
636
+ </tbody>
637
+ </table>
638
+ </div>
639
+
640
+ <script>
641
+ async function loadData() {
642
+ try {
643
+ const resp = await fetch('/api/dashboard');
644
+ const data = await resp.json();
645
+
646
+ document.getElementById('total-spent').textContent = '$' + data.overview.totalSpent.toFixed(4);
647
+ document.getElementById('total-saved').textContent = '$' + data.overview.totalSaved.toFixed(2);
648
+ document.getElementById('total-requests').textContent = data.overview.totalRequests.toLocaleString();
649
+ document.getElementById('cache-rate').textContent = (data.overview.cacheHitRate * 100).toFixed(1) + '%';
650
+
651
+ // Budget
652
+ document.getElementById('daily-spent').textContent =
653
+ '$' + data.budget.daily.spent.toFixed(2) + ' / $' + data.budget.daily.limit.toFixed(2);
654
+ document.getElementById('monthly-spent').textContent =
655
+ '$' + data.budget.monthly.spent.toFixed(2) + ' / $' + data.budget.monthly.limit.toFixed(2);
656
+
657
+ const dailyPct = Math.min(100, data.budget.daily.percentUsed);
658
+ const monthlyPct = Math.min(100, data.budget.monthly.percentUsed);
659
+
660
+ const dailyBar = document.getElementById('daily-bar');
661
+ dailyBar.style.width = dailyPct + '%';
662
+ dailyBar.className = 'fill ' + (dailyPct > 90 ? 'red' : dailyPct > 70 ? 'yellow' : 'green');
663
+
664
+ const monthlyBar = document.getElementById('monthly-bar');
665
+ monthlyBar.style.width = monthlyPct + '%';
666
+ monthlyBar.className = 'fill ' + (monthlyPct > 90 ? 'red' : monthlyPct > 70 ? 'yellow' : 'green');
667
+
668
+ // Daily chart
669
+ const chartEl = document.getElementById('daily-chart');
670
+ const days = data.costByDay.slice(0, 14).reverse();
671
+ if (days.length > 0) {
672
+ const maxCost = Math.max(...days.map(d => d.cost), 0.01);
673
+ chartEl.innerHTML = days.map(d => {
674
+ const height = Math.max(2, (d.cost / maxCost) * 100);
675
+ return '<div class="bar" style="height:' + height + '%" title="$' + d.cost.toFixed(4) + ' (' + d.requests + ' reqs)"><span class="bar-label">' + d.date.slice(5) + '</span></div>';
676
+ }).join('');
677
+ } else {
678
+ chartEl.innerHTML = '<div style="color:#64748b;text-align:center;width:100%">No data yet</div>';
679
+ }
680
+
681
+ // Recent requests
682
+ const reqBody = document.getElementById('requests-table');
683
+ if (data.recentRequests.length > 0) {
684
+ reqBody.innerHTML = data.recentRequests.slice(0, 20).map(r =>
685
+ '<tr>' +
686
+ '<td>' + new Date(r.time).toLocaleString() + '</td>' +
687
+ '<td>' + r.model + '</td>' +
688
+ '<td>' + r.provider + '</td>' +
689
+ '<td>' + r.taskType + '</td>' +
690
+ '<td>$' + r.cost.toFixed(4) + '</td>' +
691
+ '<td>' + r.tokens.toLocaleString() + '</td>' +
692
+ '<td><span class="badge ' + (r.cached ? 'cached">Cached' : 'live">Live') + '</span></td>' +
693
+ '</tr>'
694
+ ).join('');
695
+ } else {
696
+ reqBody.innerHTML = '<tr><td colspan="7" class="loading">No requests yet. Send a request to POST /v1/chat/completions</td></tr>';
697
+ }
698
+
699
+ // Model distribution
700
+ const modelBody = document.getElementById('model-table');
701
+ if (data.modelDistribution.length > 0) {
702
+ modelBody.innerHTML = data.modelDistribution.map(m =>
703
+ '<tr><td>' + m.model + '</td><td>' + m.count.toLocaleString() + '</td><td>$' + m.cost.toFixed(4) + '</td></tr>'
704
+ ).join('');
705
+ } else {
706
+ modelBody.innerHTML = '<tr><td colspan="3" class="loading">No data yet</td></tr>';
707
+ }
708
+ } catch (err) {
709
+ console.error('Dashboard load error:', err);
710
+ }
711
+ }
712
+
713
+ loadData();
714
+ setInterval(loadData, 30000);
715
+ </script>
716
+ </body>
717
+ </html>`;
718
+ }
719
+
720
+ // ---- Start Server ----
721
+
722
+ export async function startServer(): Promise<express.Application> {
723
+ // Initialize database (async for sql.js)
724
+ await initDb();
725
+
726
+ app.listen(env.PORT, () => {
727
+ log.info(`BudgetLLM proxy running on http://localhost:${env.PORT}`);
728
+ log.info(`Dashboard: http://localhost:${env.PORT}/dashboard`);
729
+ log.info(`Endpoint: POST http://localhost:${env.PORT}/v1/chat/completions`);
730
+ });
731
+
732
+ // Graceful shutdown
733
+ process.on('SIGTERM', () => {
734
+ log.info('SIGTERM received, shutting down');
735
+ closeDb();
736
+ process.exit(0);
737
+ });
738
+
739
+ process.on('SIGINT', () => {
740
+ log.info('SIGINT received, shutting down');
741
+ closeDb();
742
+ process.exit(0);
743
+ });
744
+
745
+ return app;
746
+ }
747
+
748
+ // Start if run directly
749
+ if (require.main === module) {
750
+ startServer().catch((err) => {
751
+ console.error('Failed to start server:', err);
752
+ process.exit(1);
753
+ });
754
+ }