@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.
- package/.env.example +21 -0
- package/LICENSE +21 -0
- package/README.md +293 -0
- package/dist/config.d.ts +77 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +246 -0
- package/dist/config.js.map +1 -0
- package/dist/database.d.ts +24 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +414 -0
- package/dist/database.js.map +1 -0
- package/dist/providers.d.ts +20 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +208 -0
- package/dist/providers.js.map +1 -0
- package/dist/proxy.d.ts +7 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +181 -0
- package/dist/proxy.js.map +1 -0
- package/dist/rate-limiter.d.ts +8 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +72 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/router.d.ts +33 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +186 -0
- package/dist/router.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +705 -0
- package/dist/server.js.map +1 -0
- package/dist/task-classifier.d.ts +4 -0
- package/dist/task-classifier.d.ts.map +1 -0
- package/dist/task-classifier.js +123 -0
- package/dist/task-classifier.js.map +1 -0
- package/dist/types.d.ts +205 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +46 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/encryption.d.ts +4 -0
- package/dist/utils/encryption.d.ts.map +1 -0
- package/dist/utils/encryption.js +40 -0
- package/dist/utils/encryption.js.map +1 -0
- package/package.json +63 -0
- package/src/config.ts +254 -0
- package/src/database.ts +496 -0
- package/src/providers.ts +315 -0
- package/src/proxy.ts +226 -0
- package/src/rate-limiter.ts +81 -0
- package/src/router.ts +228 -0
- package/src/server.ts +754 -0
- package/src/task-classifier.ts +134 -0
- package/src/types/sql.js.d.ts +27 -0
- package/src/types.ts +258 -0
- package/src/utils/encryption.ts +36 -0
- package/tests/config.test.ts +85 -0
- package/tests/database.test.ts +194 -0
- package/tests/encryption.test.ts +57 -0
- package/tests/rate-limiter.test.ts +83 -0
- package/tests/router.test.ts +182 -0
- package/tests/server.test.ts +253 -0
- package/tests/setup.ts +15 -0
- package/tests/task-classifier.test.ts +117 -0
- package/tsconfig.json +25 -0
- 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% — 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
|
+
}
|