@layer-ai/core 2.0.19 → 2.0.21

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 (35) hide show
  1. package/dist/index.d.ts +4 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +5 -0
  4. package/dist/lib/db/migrations/007_add_spending_controls.sql +41 -0
  5. package/dist/lib/db/postgres.d.ts +19 -0
  6. package/dist/lib/db/postgres.d.ts.map +1 -1
  7. package/dist/lib/db/postgres.js +53 -0
  8. package/dist/lib/db/redis.d.ts +5 -0
  9. package/dist/lib/db/redis.d.ts.map +1 -1
  10. package/dist/lib/db/redis.js +54 -1
  11. package/dist/lib/openai-conversion.d.ts +6 -0
  12. package/dist/lib/openai-conversion.d.ts.map +1 -0
  13. package/dist/lib/openai-conversion.js +215 -0
  14. package/dist/lib/spending-jobs.d.ts +6 -0
  15. package/dist/lib/spending-jobs.d.ts.map +1 -0
  16. package/dist/lib/spending-jobs.js +56 -0
  17. package/dist/lib/spending-tracker.d.ts +17 -0
  18. package/dist/lib/spending-tracker.d.ts.map +1 -0
  19. package/dist/lib/spending-tracker.js +89 -0
  20. package/dist/middleware/auth.d.ts.map +1 -1
  21. package/dist/middleware/auth.js +22 -0
  22. package/dist/routes/tests/test-openai-endpoint.d.ts +3 -0
  23. package/dist/routes/tests/test-openai-endpoint.d.ts.map +1 -0
  24. package/dist/routes/tests/test-openai-endpoint.js +292 -0
  25. package/dist/routes/v1/chat-completions.d.ts +4 -0
  26. package/dist/routes/v1/chat-completions.d.ts.map +1 -0
  27. package/dist/routes/v1/chat-completions.js +269 -0
  28. package/dist/routes/v1/spending.d.ts +4 -0
  29. package/dist/routes/v1/spending.d.ts.map +1 -0
  30. package/dist/routes/v1/spending.js +94 -0
  31. package/dist/routes/v2/complete.d.ts.map +1 -1
  32. package/dist/routes/v2/complete.js +4 -0
  33. package/dist/routes/v3/chat.d.ts.map +1 -1
  34. package/dist/routes/v3/chat.js +7 -0
  35. package/package.json +2 -2
@@ -0,0 +1,89 @@
1
+ import { db } from './db/postgres.js';
2
+ import { cache } from './db/redis.js';
3
+ export const spendingTracker = {
4
+ async trackSpending(userId, cost) {
5
+ try {
6
+ const newSpending = await cache.incrementUserSpending(userId, cost);
7
+ const spendingInfo = await db.getUserSpending(userId);
8
+ if (!spendingInfo) {
9
+ return { userId, cost, newSpending };
10
+ }
11
+ const { limit, status } = spendingInfo;
12
+ const exceeded = limit !== null && newSpending > limit;
13
+ if (exceeded && status === 'active') {
14
+ await db.setUserStatus(userId, 'over_limit');
15
+ console.log(`[Spending] User ${userId} exceeded limit: ${newSpending} > ${limit}`);
16
+ }
17
+ await this.checkAlertThresholds(userId, newSpending, limit);
18
+ return { userId, cost, exceeded, newSpending };
19
+ }
20
+ catch (error) {
21
+ console.error('[Spending] Redis error, falling back to DB:', error);
22
+ return await this.trackSpendingDB(userId, cost);
23
+ }
24
+ },
25
+ async trackSpendingDB(userId, cost) {
26
+ const result = await db.incrementUserSpending(userId, cost);
27
+ if (result.exceeded) {
28
+ await db.setUserStatus(userId, 'over_limit');
29
+ console.log(`[Spending] User ${userId} exceeded limit: ${result.newSpending} > ${result.limit}`);
30
+ }
31
+ await this.checkAlertThresholds(userId, result.newSpending, result.limit);
32
+ return {
33
+ userId,
34
+ cost,
35
+ exceeded: result.exceeded,
36
+ newSpending: result.newSpending,
37
+ };
38
+ },
39
+ async checkAlertThresholds(userId, currentSpending, limit) {
40
+ if (!limit || limit === 0)
41
+ return;
42
+ const percentage = (currentSpending / limit) * 100;
43
+ const thresholds = [50, 80, 95, 100];
44
+ for (const threshold of thresholds) {
45
+ if (percentage >= threshold) {
46
+ await this.sendAlertIfNeeded(userId, threshold, currentSpending, limit);
47
+ break;
48
+ }
49
+ }
50
+ },
51
+ async sendAlertIfNeeded(userId, threshold, currentSpending, limit) {
52
+ console.log(`[Spending] Alert: User ${userId} at ${threshold}% of limit ($${currentSpending}/$${limit})`);
53
+ await db.recordSpendingAlert(userId);
54
+ },
55
+ async syncSpendingToDB(userId) {
56
+ try {
57
+ const cachedSpending = await cache.getUserSpending(userId);
58
+ if (cachedSpending !== null) {
59
+ await db.updateUserSpending(userId, cachedSpending);
60
+ console.log(`[Spending] Synced user ${userId}: $${cachedSpending}`);
61
+ }
62
+ }
63
+ catch (error) {
64
+ console.error(`[Spending] Sync error for user ${userId}:`, error);
65
+ }
66
+ },
67
+ async syncAllSpending() {
68
+ try {
69
+ const userIds = await cache.getAllCachedSpendingUsers();
70
+ console.log(`[Spending] Syncing ${userIds.length} users to DB`);
71
+ await Promise.all(userIds.map(userId => this.syncSpendingToDB(userId)));
72
+ console.log('[Spending] Sync complete');
73
+ }
74
+ catch (error) {
75
+ console.error('[Spending] Bulk sync error:', error);
76
+ }
77
+ },
78
+ async warmCache(userId) {
79
+ try {
80
+ const spendingInfo = await db.getUserSpending(userId);
81
+ if (spendingInfo) {
82
+ await cache.setUserSpending(userId, spendingInfo.currentSpending);
83
+ }
84
+ }
85
+ catch (error) {
86
+ console.error(`[Spending] Cache warm error for user ${userId}:`, error);
87
+ }
88
+ },
89
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAK1D,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,OAAO,CAAC;QAChB,UAAU,OAAO;YACf,MAAM,CAAC,EAAE,MAAM,CAAC;YAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;YAClB,UAAU,CAAC,EAAE,MAAM,CAAC;SACrB;KACF;CACF;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,QAAQ,EACb,IAAI,EAAE,YAAY,GACjB,OAAO,CAAC,IAAI,CAAC,CAyHf;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,QAAQ,EACb,IAAI,EAAE,YAAY,GACjB,IAAI,CAWN"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAK1D,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,OAAO,CAAC;QAChB,UAAU,OAAO;YACf,MAAM,CAAC,EAAE,MAAM,CAAC;YAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;YAClB,UAAU,CAAC,EAAE,MAAM,CAAC;SACrB;KACF;CACF;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,QAAQ,EACb,IAAI,EAAE,YAAY,GACjB,OAAO,CAAC,IAAI,CAAC,CAiJf;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,QAAQ,EACb,IAAI,EAAE,YAAY,GACjB,IAAI,CAWN"}
@@ -64,6 +64,17 @@ export async function authenticate(req, res, next) {
64
64
  });
65
65
  return;
66
66
  }
67
+ // Check spending limits if user has exceeded and enforcement is set to block
68
+ if (userStatus === 'over_limit') {
69
+ const spendingInfo = await db.getUserSpending(apiKeyRecord.userId);
70
+ if (spendingInfo?.limitEnforcementType === 'block') {
71
+ res.status(403).json({
72
+ error: 'spending_limit_exceeded',
73
+ message: 'You have exceeded your spending limit. Requests are blocked until your next billing period or until you increase your limit.',
74
+ });
75
+ return;
76
+ }
77
+ }
67
78
  // Attach userId to request for downstream handlers
68
79
  req.userId = apiKeyRecord.userId;
69
80
  req.apiKeyId = apiKeyRecord.id;
@@ -94,6 +105,17 @@ export async function authenticate(req, res, next) {
94
105
  });
95
106
  return;
96
107
  }
108
+ // Check spending limits if user has exceeded and enforcement is set to block
109
+ if (userStatus === 'over_limit') {
110
+ const spendingInfo = await db.getUserSpending(sessionKey.userId);
111
+ if (spendingInfo?.limitEnforcementType === 'block') {
112
+ res.status(403).json({
113
+ error: 'spending_limit_exceeded',
114
+ message: 'You have exceeded your spending limit. Requests are blocked until your next billing period or until you increase your limit.',
115
+ });
116
+ return;
117
+ }
118
+ }
97
119
  req.userId = sessionKey.userId;
98
120
  next();
99
121
  return;
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env npx tsx
2
+ export {};
3
+ //# sourceMappingURL=test-openai-endpoint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-openai-endpoint.d.ts","sourceRoot":"","sources":["../../../src/routes/tests/test-openai-endpoint.ts"],"names":[],"mappings":""}
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env npx tsx
2
+ console.log('='.repeat(80));
3
+ console.log('OPENAI-COMPATIBLE ENDPOINT TESTS');
4
+ console.log('='.repeat(80));
5
+ console.log('');
6
+ const BASE_URL = process.env.API_URL || 'http://localhost:3004';
7
+ const API_KEY = process.env.LAYER_API_KEY;
8
+ const GATE_ID = process.env.TEST_GATE_ID;
9
+ if (!API_KEY) {
10
+ console.error('❌ Error: LAYER_API_KEY environment variable not set');
11
+ process.exit(1);
12
+ }
13
+ if (!GATE_ID) {
14
+ console.error('❌ Error: TEST_GATE_ID environment variable not set');
15
+ process.exit(1);
16
+ }
17
+ async function testNonStreamingBasic() {
18
+ console.log('Test 1: Non-streaming basic chat completion');
19
+ console.log('-'.repeat(80));
20
+ const request = {
21
+ model: 'gpt-4o',
22
+ messages: [
23
+ { role: 'user', content: 'Say "test passed" and nothing else.' }
24
+ ],
25
+ max_tokens: 10,
26
+ gateId: GATE_ID,
27
+ };
28
+ const response = await fetch(`${BASE_URL}/v1/chat/completions`, {
29
+ method: 'POST',
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ 'Authorization': `Bearer ${API_KEY}`,
33
+ },
34
+ body: JSON.stringify(request),
35
+ });
36
+ if (!response.ok) {
37
+ const error = await response.json();
38
+ throw new Error(`Request failed: ${JSON.stringify(error)}`);
39
+ }
40
+ const data = await response.json();
41
+ console.log(' Response ID:', data.id);
42
+ console.log(' Model:', data.model);
43
+ console.log(' Content:', data.choices[0].message.content);
44
+ console.log(' Finish reason:', data.choices[0].finish_reason);
45
+ console.log(' Usage:', data.usage);
46
+ console.log(' ✅ Non-streaming basic test passed\n');
47
+ }
48
+ async function testNonStreamingWithGateIdInHeader() {
49
+ console.log('Test 2: Non-streaming with gateId in header');
50
+ console.log('-'.repeat(80));
51
+ const request = {
52
+ model: 'gpt-4o',
53
+ messages: [
54
+ { role: 'user', content: 'Say "header test passed" and nothing else.' }
55
+ ],
56
+ max_tokens: 10,
57
+ };
58
+ const response = await fetch(`${BASE_URL}/v1/chat/completions`, {
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ 'Authorization': `Bearer ${API_KEY}`,
63
+ 'X-Layer-Gate-Id': GATE_ID,
64
+ },
65
+ body: JSON.stringify(request),
66
+ });
67
+ if (!response.ok) {
68
+ const error = await response.json();
69
+ throw new Error(`Request failed: ${JSON.stringify(error)}`);
70
+ }
71
+ const data = await response.json();
72
+ console.log(' Content:', data.choices[0].message.content);
73
+ console.log(' ✅ Header gateId test passed\n');
74
+ }
75
+ async function testStreamingBasic() {
76
+ console.log('Test 3: Streaming basic chat completion');
77
+ console.log('-'.repeat(80));
78
+ const request = {
79
+ model: 'gpt-4o',
80
+ messages: [
81
+ { role: 'user', content: 'Count from 1 to 3, one number per line.' }
82
+ ],
83
+ max_tokens: 50,
84
+ stream: true,
85
+ gateId: GATE_ID,
86
+ };
87
+ const response = await fetch(`${BASE_URL}/v1/chat/completions`, {
88
+ method: 'POST',
89
+ headers: {
90
+ 'Content-Type': 'application/json',
91
+ 'Authorization': `Bearer ${API_KEY}`,
92
+ },
93
+ body: JSON.stringify(request),
94
+ });
95
+ if (!response.ok) {
96
+ const error = await response.json();
97
+ throw new Error(`Request failed: ${JSON.stringify(error)}`);
98
+ }
99
+ let chunkCount = 0;
100
+ let fullContent = '';
101
+ let finalUsage = null;
102
+ const reader = response.body?.getReader();
103
+ const decoder = new TextDecoder();
104
+ if (!reader) {
105
+ throw new Error('No response body reader');
106
+ }
107
+ while (true) {
108
+ const { done, value } = await reader.read();
109
+ if (done)
110
+ break;
111
+ const text = decoder.decode(value);
112
+ const lines = text.split('\n').filter(line => line.trim().startsWith('data: '));
113
+ for (const line of lines) {
114
+ const data = line.replace('data: ', '').trim();
115
+ if (data === '[DONE]') {
116
+ continue;
117
+ }
118
+ try {
119
+ const chunk = JSON.parse(data);
120
+ chunkCount++;
121
+ if (chunk.choices[0].delta.content) {
122
+ fullContent += chunk.choices[0].delta.content;
123
+ }
124
+ if (chunk.usage) {
125
+ finalUsage = chunk.usage;
126
+ }
127
+ }
128
+ catch (e) {
129
+ // Skip invalid JSON
130
+ }
131
+ }
132
+ }
133
+ console.log(' Chunks received:', chunkCount);
134
+ console.log(' Full content:', fullContent.trim());
135
+ console.log(' Final usage:', finalUsage);
136
+ console.log(' ✅ Streaming basic test passed\n');
137
+ }
138
+ async function testWithToolCalls() {
139
+ console.log('Test 4: Non-streaming with tool calls');
140
+ console.log('-'.repeat(80));
141
+ const request = {
142
+ model: 'gpt-4o',
143
+ messages: [
144
+ { role: 'user', content: 'What is the weather in Paris?' }
145
+ ],
146
+ tools: [
147
+ {
148
+ type: 'function',
149
+ function: {
150
+ name: 'get_weather',
151
+ description: 'Get the current weather for a location',
152
+ parameters: {
153
+ type: 'object',
154
+ properties: {
155
+ location: {
156
+ type: 'string',
157
+ description: 'The city and state, e.g. Paris, France',
158
+ },
159
+ },
160
+ required: ['location'],
161
+ },
162
+ },
163
+ },
164
+ ],
165
+ tool_choice: 'auto',
166
+ gateId: GATE_ID,
167
+ };
168
+ const response = await fetch(`${BASE_URL}/v1/chat/completions`, {
169
+ method: 'POST',
170
+ headers: {
171
+ 'Content-Type': 'application/json',
172
+ 'Authorization': `Bearer ${API_KEY}`,
173
+ },
174
+ body: JSON.stringify(request),
175
+ });
176
+ if (!response.ok) {
177
+ const error = await response.json();
178
+ throw new Error(`Request failed: ${JSON.stringify(error)}`);
179
+ }
180
+ const data = await response.json();
181
+ console.log(' Finish reason:', data.choices[0].finish_reason);
182
+ if (data.choices[0].message.tool_calls && data.choices[0].message.tool_calls.length > 0) {
183
+ console.log(' Tool calls:', JSON.stringify(data.choices[0].message.tool_calls, null, 2));
184
+ console.log(' ✅ Tool calls test passed\n');
185
+ }
186
+ else {
187
+ console.log(' ⚠️ No tool calls received (model may have chosen not to use tools)\n');
188
+ }
189
+ }
190
+ async function testClaudeModel() {
191
+ console.log('Test 5: OpenAI format with Claude model');
192
+ console.log('-'.repeat(80));
193
+ const request = {
194
+ model: 'claude-3-7-sonnet-20250219',
195
+ messages: [
196
+ { role: 'user', content: 'Say "claude via openai format works" and nothing else.' }
197
+ ],
198
+ max_tokens: 20,
199
+ gateId: GATE_ID,
200
+ };
201
+ const response = await fetch(`${BASE_URL}/v1/chat/completions`, {
202
+ method: 'POST',
203
+ headers: {
204
+ 'Content-Type': 'application/json',
205
+ 'Authorization': `Bearer ${API_KEY}`,
206
+ },
207
+ body: JSON.stringify(request),
208
+ });
209
+ if (!response.ok) {
210
+ const error = await response.json();
211
+ throw new Error(`Request failed: ${JSON.stringify(error)}`);
212
+ }
213
+ const data = await response.json();
214
+ console.log(' Model:', data.model);
215
+ console.log(' Content:', data.choices[0].message.content);
216
+ console.log(' ✅ Claude model test passed\n');
217
+ }
218
+ async function testGeminiModel() {
219
+ console.log('Test 6: OpenAI format with Gemini model');
220
+ console.log('-'.repeat(80));
221
+ const request = {
222
+ model: 'gemini-2.0-flash',
223
+ messages: [
224
+ { role: 'user', content: 'Say "gemini via openai format works" and nothing else.' }
225
+ ],
226
+ max_tokens: 20,
227
+ gateId: GATE_ID,
228
+ };
229
+ const response = await fetch(`${BASE_URL}/v1/chat/completions`, {
230
+ method: 'POST',
231
+ headers: {
232
+ 'Content-Type': 'application/json',
233
+ 'Authorization': `Bearer ${API_KEY}`,
234
+ },
235
+ body: JSON.stringify(request),
236
+ });
237
+ if (!response.ok) {
238
+ const error = await response.json();
239
+ throw new Error(`Request failed: ${JSON.stringify(error)}`);
240
+ }
241
+ const data = await response.json();
242
+ console.log(' Model:', data.model);
243
+ console.log(' Content:', data.choices[0].message.content);
244
+ console.log(' ✅ Gemini model test passed\n');
245
+ }
246
+ async function testMistralModel() {
247
+ console.log('Test 7: OpenAI format with Mistral model');
248
+ console.log('-'.repeat(80));
249
+ const request = {
250
+ model: 'mistral-small-2501',
251
+ messages: [
252
+ { role: 'user', content: 'Say "mistral via openai format works" and nothing else.' }
253
+ ],
254
+ max_tokens: 20,
255
+ gateId: GATE_ID,
256
+ };
257
+ const response = await fetch(`${BASE_URL}/v1/chat/completions`, {
258
+ method: 'POST',
259
+ headers: {
260
+ 'Content-Type': 'application/json',
261
+ 'Authorization': `Bearer ${API_KEY}`,
262
+ },
263
+ body: JSON.stringify(request),
264
+ });
265
+ if (!response.ok) {
266
+ const error = await response.json();
267
+ throw new Error(`Request failed: ${JSON.stringify(error)}`);
268
+ }
269
+ const data = await response.json();
270
+ console.log(' Model:', data.model);
271
+ console.log(' Content:', data.choices[0].message.content);
272
+ console.log(' ✅ Mistral model test passed\n');
273
+ }
274
+ (async () => {
275
+ try {
276
+ await testNonStreamingBasic();
277
+ await testNonStreamingWithGateIdInHeader();
278
+ await testStreamingBasic();
279
+ await testWithToolCalls();
280
+ await testClaudeModel();
281
+ await testGeminiModel();
282
+ await testMistralModel();
283
+ console.log('='.repeat(80));
284
+ console.log('✅ ALL OPENAI-COMPATIBLE ENDPOINT TESTS PASSED');
285
+ console.log('='.repeat(80));
286
+ }
287
+ catch (error) {
288
+ console.error('❌ Test failed:', error);
289
+ process.exit(1);
290
+ }
291
+ })();
292
+ export {};
@@ -0,0 +1,4 @@
1
+ import type { Router as RouterType } from 'express';
2
+ declare const router: RouterType;
3
+ export default router;
4
+ //# sourceMappingURL=chat-completions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-completions.d.ts","sourceRoot":"","sources":["../../../src/routes/v1/chat-completions.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,SAAS,CAAC;AAcpD,QAAA,MAAM,MAAM,EAAE,UAAqB,CAAC;AAqSpC,eAAe,MAAM,CAAC"}