@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,253 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import './setup';
3
+ import express from 'express';
4
+
5
+ describe('Server API Endpoints', () => {
6
+ let app: express.Express;
7
+
8
+ beforeAll(async () => {
9
+ // Create a test app with the same routes
10
+ app = express();
11
+ app.use(express.json());
12
+
13
+ // Import after env is set
14
+ const database = await import('../src/database');
15
+ const { MODEL_CATALOG } = await import('../src/config');
16
+ const { checkRateLimit } = await import('../src/rate-limiter');
17
+
18
+ // Initialize DB first
19
+ await database.initDb();
20
+
21
+ // Health
22
+ app.get('/health', (_req, res) => {
23
+ res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() });
24
+ });
25
+
26
+ // Models
27
+ app.get('/v1/models', (_req, res) => {
28
+ res.json({
29
+ object: 'list',
30
+ data: MODEL_CATALOG.map((m: { id: string; displayName: string; provider: string; capabilities: string[]; contextWindow: number }) => ({
31
+ id: m.id,
32
+ object: 'model',
33
+ owned_by: m.provider,
34
+ display_name: m.displayName,
35
+ })),
36
+ });
37
+ });
38
+
39
+ // Dashboard
40
+ app.get('/api/dashboard', (_req, res) => {
41
+ res.json(database.getDashboardData());
42
+ });
43
+
44
+ // Costs
45
+ app.get('/api/costs', (req, res) => {
46
+ const days = parseInt(req.query.days as string) || 30;
47
+ res.json(database.getCostSummary(Math.min(days, 365)));
48
+ });
49
+
50
+ // Budget
51
+ app.get('/api/budget', (_req, res) => {
52
+ const config = database.getBudgetConfig();
53
+ const status = database.getBudgetStatus(config);
54
+ res.json({ config, status });
55
+ });
56
+
57
+ app.put('/api/budget', (req, res) => {
58
+ const { dailyBudget, monthlyBudget, perRequestCap } = req.body;
59
+ const updates: Record<string, number> = {};
60
+ if (dailyBudget !== undefined) updates.dailyBudget = dailyBudget;
61
+ if (monthlyBudget !== undefined) updates.monthlyBudget = monthlyBudget;
62
+ if (perRequestCap !== undefined) updates.perRequestCap = perRequestCap;
63
+ database.setBudgetConfig(updates);
64
+ const config = database.getBudgetConfig();
65
+ const status = database.getBudgetStatus(config);
66
+ res.json({ config, status });
67
+ });
68
+
69
+ // Cache clear
70
+ app.delete('/api/cache', (_req, res) => {
71
+ const cleared = database.clearCache();
72
+ res.json({ cleared });
73
+ });
74
+
75
+ // Rate limit
76
+ app.get('/api/rate-limit', (req, res) => {
77
+ const ip = req.ip || '127.0.0.1';
78
+ res.json({ ip, ...checkRateLimit(ip) });
79
+ });
80
+
81
+ // Chat completions with validation
82
+ app.post('/v1/chat/completions', (req, res) => {
83
+ if (!req.body.messages || !Array.isArray(req.body.messages)) {
84
+ return res.status(400).json({
85
+ error: { message: 'messages is required and must be an array', type: 'invalid_request_error' },
86
+ });
87
+ }
88
+ // In test mode, we don't actually call providers
89
+ res.json({
90
+ id: 'test-response',
91
+ object: 'chat.completion',
92
+ created: Math.floor(Date.now() / 1000),
93
+ model: req.body.model || 'gpt-4o-mini',
94
+ choices: [{
95
+ index: 0,
96
+ message: { role: 'assistant', content: 'Test response' },
97
+ finish_reason: 'stop',
98
+ }],
99
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
100
+ cost: { inputCost: 0, outputCost: 0, totalCost: 0, currency: 'USD', model: 'test', provider: 'openai', savingsVsGpt4: 0 },
101
+ });
102
+ });
103
+ });
104
+
105
+ describe('GET /health', () => {
106
+ it('should return health status', async () => {
107
+ const { default: supertest } = await import('supertest');
108
+ const res = await supertest(app).get('/health');
109
+ expect(res.status).toBe(200);
110
+ expect(res.body.status).toBe('ok');
111
+ expect(res.body.version).toBe('1.0.0');
112
+ expect(res.body.timestamp).toBeTruthy();
113
+ });
114
+ });
115
+
116
+ describe('GET /v1/models', () => {
117
+ it('should return list of models', async () => {
118
+ const { default: supertest } = await import('supertest');
119
+ const res = await supertest(app).get('/v1/models');
120
+ expect(res.status).toBe(200);
121
+ expect(res.body.object).toBe('list');
122
+ expect(Array.isArray(res.body.data)).toBe(true);
123
+ expect(res.body.data.length).toBeGreaterThan(0);
124
+ });
125
+
126
+ it('should include model details', async () => {
127
+ const { default: supertest } = await import('supertest');
128
+ const res = await supertest(app).get('/v1/models');
129
+ const model = res.body.data[0];
130
+ expect(model.id).toBeTruthy();
131
+ expect(model.object).toBe('model');
132
+ expect(model.owned_by).toBeTruthy();
133
+ });
134
+ });
135
+
136
+ describe('POST /v1/chat/completions', () => {
137
+ it('should reject requests without messages', async () => {
138
+ const { default: supertest } = await import('supertest');
139
+ const res = await supertest(app)
140
+ .post('/v1/chat/completions')
141
+ .send({});
142
+ expect(res.status).toBe(400);
143
+ expect(res.body.error.message).toContain('messages');
144
+ });
145
+
146
+ it('should reject requests with non-array messages', async () => {
147
+ const { default: supertest } = await import('supertest');
148
+ const res = await supertest(app)
149
+ .post('/v1/chat/completions')
150
+ .send({ messages: 'not an array' });
151
+ expect(res.status).toBe(400);
152
+ });
153
+
154
+ it('should accept valid requests', async () => {
155
+ const { default: supertest } = await import('supertest');
156
+ const res = await supertest(app)
157
+ .post('/v1/chat/completions')
158
+ .send({
159
+ messages: [{ role: 'user', content: 'Hello' }],
160
+ });
161
+ expect(res.status).toBe(200);
162
+ expect(res.body.object).toBe('chat.completion');
163
+ expect(res.body.choices[0].message.content).toBeTruthy();
164
+ });
165
+
166
+ it('should include OpenAI-compatible response fields', async () => {
167
+ const { default: supertest } = await import('supertest');
168
+ const res = await supertest(app)
169
+ .post('/v1/chat/completions')
170
+ .send({
171
+ messages: [{ role: 'user', content: 'Hello' }],
172
+ });
173
+ expect(res.body.id).toBeTruthy();
174
+ expect(res.body.created).toBeTruthy();
175
+ expect(res.body.model).toBeTruthy();
176
+ expect(res.body.usage).toBeTruthy();
177
+ expect(res.body.usage.prompt_tokens).toBeGreaterThanOrEqual(0);
178
+ expect(res.body.usage.completion_tokens).toBeGreaterThanOrEqual(0);
179
+ expect(res.body.usage.total_tokens).toBeGreaterThanOrEqual(0);
180
+ });
181
+ });
182
+
183
+ describe('GET /api/dashboard', () => {
184
+ it('should return dashboard data', async () => {
185
+ const { default: supertest } = await import('supertest');
186
+ const res = await supertest(app).get('/api/dashboard');
187
+ expect(res.status).toBe(200);
188
+ expect(res.body.overview).toBeTruthy();
189
+ expect(res.body.budget).toBeTruthy();
190
+ expect(Array.isArray(res.body.recentRequests)).toBe(true);
191
+ expect(Array.isArray(res.body.costByDay)).toBe(true);
192
+ expect(Array.isArray(res.body.modelDistribution)).toBe(true);
193
+ });
194
+ });
195
+
196
+ describe('GET /api/costs', () => {
197
+ it('should return cost summary', async () => {
198
+ const { default: supertest } = await import('supertest');
199
+ const res = await supertest(app).get('/api/costs');
200
+ expect(res.status).toBe(200);
201
+ expect(res.body.totalSpent).toBeGreaterThanOrEqual(0);
202
+ expect(res.body.totalRequests).toBeGreaterThanOrEqual(0);
203
+ });
204
+
205
+ it('should accept days parameter', async () => {
206
+ const { default: supertest } = await import('supertest');
207
+ const res = await supertest(app).get('/api/costs?days=7');
208
+ expect(res.status).toBe(200);
209
+ });
210
+ });
211
+
212
+ describe('GET /api/budget', () => {
213
+ it('should return budget config and status', async () => {
214
+ const { default: supertest } = await import('supertest');
215
+ const res = await supertest(app).get('/api/budget');
216
+ expect(res.status).toBe(200);
217
+ expect(res.body.config).toBeTruthy();
218
+ expect(res.body.status).toBeTruthy();
219
+ expect(res.body.status.daily).toBeTruthy();
220
+ expect(res.body.status.monthly).toBeTruthy();
221
+ });
222
+ });
223
+
224
+ describe('PUT /api/budget', () => {
225
+ it('should update budget configuration', async () => {
226
+ const { default: supertest } = await import('supertest');
227
+ const res = await supertest(app)
228
+ .put('/api/budget')
229
+ .send({ dailyBudget: 15 });
230
+ expect(res.status).toBe(200);
231
+ expect(res.body.config.dailyBudget).toBe(15);
232
+ });
233
+ });
234
+
235
+ describe('DELETE /api/cache', () => {
236
+ it('should clear cache', async () => {
237
+ const { default: supertest } = await import('supertest');
238
+ const res = await supertest(app).delete('/api/cache');
239
+ expect(res.status).toBe(200);
240
+ expect(typeof res.body.cleared).toBe('number');
241
+ });
242
+ });
243
+
244
+ describe('GET /api/rate-limit', () => {
245
+ it('should return rate limit status', async () => {
246
+ const { default: supertest } = await import('supertest');
247
+ const res = await supertest(app).get('/api/rate-limit');
248
+ expect(res.status).toBe(200);
249
+ expect(typeof res.body.allowed).toBe('boolean');
250
+ expect(typeof res.body.remaining).toBe('number');
251
+ });
252
+ });
253
+ });
package/tests/setup.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+
3
+ // Set test environment variables before importing any modules
4
+ process.env.NODE_ENV = 'test';
5
+ process.env.PORT = '3211';
6
+ process.env.ENCRYPTION_KEY = 'test_key_for_testing_exactly_32_chars!!';
7
+ process.env.OPENAI_API_KEY = 'test-openai-key';
8
+ process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
9
+ process.env.GOOGLE_API_KEY = 'test-google-key';
10
+ process.env.DEEPSEEK_API_KEY = 'test-deepseek-key';
11
+ process.env.DEFAULT_DAILY_BUDGET = '10';
12
+ process.env.DEFAULT_MONTHLY_BUDGET = '200';
13
+ process.env.DEFAULT_PER_REQUEST_CAP = '1';
14
+ process.env.CACHE_TTL = '3600';
15
+ process.env.RATE_LIMIT_MAX_REQUESTS = '100';
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import './setup';
3
+ import { classifyTask, estimateTokens } from '../src/task-classifier';
4
+ import type { ChatMessage } from '../src/types';
5
+
6
+ describe('classifyTask', () => {
7
+ it('should classify code tasks', () => {
8
+ const messages: ChatMessage[] = [
9
+ { role: 'user', content: 'Write a function that sorts an array using quicksort' },
10
+ ];
11
+ expect(classifyTask(messages)).toBe('code');
12
+ });
13
+
14
+ it('should classify code tasks with system instructions', () => {
15
+ const messages: ChatMessage[] = [
16
+ { role: 'system', content: 'You are a helpful coding assistant' },
17
+ { role: 'user', content: 'Implement a binary search algorithm in TypeScript' },
18
+ ];
19
+ expect(classifyTask(messages)).toBe('code');
20
+ });
21
+
22
+ it('should classify creative tasks', () => {
23
+ const messages: ChatMessage[] = [
24
+ { role: 'user', content: 'Write a poem about the ocean at sunset' },
25
+ ];
26
+ expect(classifyTask(messages)).toBe('creative');
27
+ });
28
+
29
+ it('should classify translation tasks', () => {
30
+ const messages: ChatMessage[] = [
31
+ { role: 'user', content: 'Translate this sentence to French' },
32
+ ];
33
+ expect(classifyTask(messages)).toBe('translation');
34
+ });
35
+
36
+ it('should classify summarization tasks', () => {
37
+ const messages: ChatMessage[] = [
38
+ { role: 'user', content: 'Summarize this article in a few bullet points' },
39
+ ];
40
+ expect(classifyTask(messages)).toBe('summarization');
41
+ });
42
+
43
+ it('should classify reasoning tasks', () => {
44
+ const messages: ChatMessage[] = [
45
+ { role: 'user', content: 'Analyze the pros and cons of microservices vs monolithic architecture' },
46
+ ];
47
+ expect(classifyTask(messages)).toBe('reasoning');
48
+ });
49
+
50
+ it('should classify math tasks', () => {
51
+ const messages: ChatMessage[] = [
52
+ { role: 'user', content: 'Calculate the derivative of x^3 + 2x^2 - 5x + 3' },
53
+ ];
54
+ expect(classifyTask(messages)).toBe('math');
55
+ });
56
+
57
+ it('should default to chat for simple greetings', () => {
58
+ const messages: ChatMessage[] = [
59
+ { role: 'user', content: 'Hello, how are you today?' },
60
+ ];
61
+ expect(classifyTask(messages)).toBe('chat');
62
+ });
63
+
64
+ it('should classify analysis tasks', () => {
65
+ const messages: ChatMessage[] = [
66
+ { role: 'user', content: 'Analyze the trends in this data and provide insights' },
67
+ ];
68
+ expect(classifyTask(messages)).toBe('analysis');
69
+ });
70
+
71
+ it('should handle multiple messages', () => {
72
+ const messages: ChatMessage[] = [
73
+ { role: 'system', content: 'You help with code' },
74
+ { role: 'user', content: 'Can you fix the bug in my Python script?' },
75
+ { role: 'assistant', content: 'Sure, show me the code' },
76
+ { role: 'user', content: 'Here is the code, the debug says error on line 5' },
77
+ ];
78
+ // Multiple code-related keywords should make this 'code'
79
+ const result = classifyTask(messages);
80
+ expect(['code', 'chat']).toContain(result);
81
+ });
82
+
83
+ it('should return a valid TaskType for empty messages', () => {
84
+ const messages: ChatMessage[] = [
85
+ { role: 'user', content: 'xyzabc123' },
86
+ ];
87
+ const result = classifyTask(messages);
88
+ expect(result).toBeTruthy();
89
+ });
90
+ });
91
+
92
+ describe('estimateTokens', () => {
93
+ it('should estimate tokens based on character count', () => {
94
+ const messages: ChatMessage[] = [
95
+ { role: 'user', content: 'Hello world' }, // 11 chars
96
+ ];
97
+ const tokens = estimateTokens(messages);
98
+ // ~4 chars per token => ~3 tokens for 11 chars
99
+ expect(tokens).toBeGreaterThan(0);
100
+ expect(tokens).toBeLessThan(100);
101
+ });
102
+
103
+ it('should increase with longer messages', () => {
104
+ const short = estimateTokens([{ role: 'user', content: 'Hi' }]);
105
+ const long = estimateTokens([{ role: 'user', content: 'This is a much longer message with many more words and characters in it' }]);
106
+ expect(long).toBeGreaterThan(short);
107
+ });
108
+
109
+ it('should account for multiple messages', () => {
110
+ const one = estimateTokens([{ role: 'user', content: 'Hello' }]);
111
+ const two = estimateTokens([
112
+ { role: 'user', content: 'Hello' },
113
+ { role: 'assistant', content: 'World' },
114
+ ]);
115
+ expect(two).toBeGreaterThan(one);
116
+ });
117
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "lib": ["ES2022"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+ "noImplicitReturns": true,
19
+ "noFallthroughCasesInSwitch": true,
20
+ "exactOptionalPropertyTypes": false,
21
+ "noUncheckedIndexedAccess": true
22
+ },
23
+ "include": ["src/**/*"],
24
+ "exclude": ["node_modules", "dist", "tests"]
25
+ }
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['tests/**/*.test.ts'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ include: ['src/**/*.ts'],
11
+ exclude: ['src/server.ts'],
12
+ },
13
+ testTimeout: 10000,
14
+ },
15
+ });