omnigate-ai 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/server.js ADDED
@@ -0,0 +1,586 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import dotenv from 'dotenv';
4
+ import EventEmitter from 'events';
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import crypto from 'crypto';
9
+
10
+ // Load environment variables for local development
11
+ dotenv.config();
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+ const CONFIG_FILE = path.join(__dirname, 'config.json');
16
+
17
+ let activeConfig = {
18
+ agencyGatewayKey: process.env.AGENCY_GATEWAY_KEY || 'stub-agency-key-for-local-testing',
19
+ openaiApiKey: process.env.OPENAI_API_KEY || '',
20
+ openaiApiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
21
+ anthropicApiKey: process.env.ANTHROPIC_API_KEY || '',
22
+ anthropicApiUrl: process.env.ANTHROPIC_API_URL || 'https://api.anthropic.com/v1/messages'
23
+ };
24
+
25
+ try {
26
+ if (fs.existsSync(CONFIG_FILE)) {
27
+ const data = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
28
+ activeConfig = { ...activeConfig, ...data };
29
+ console.log('Loaded configurations from config.json');
30
+ }
31
+ } catch (err) {
32
+ console.error('Error loading config.json:', err.message);
33
+ }
34
+
35
+ const telemetryEmitter = new EventEmitter();
36
+ const telemetryHistory = [];
37
+ const MAX_HISTORY = 100;
38
+
39
+ const app = express();
40
+ const PORT = process.env.PORT || 8080;
41
+
42
+ // Middleware configuration
43
+ app.use(cors());
44
+ // Parse incoming JSON payloads. Max limit is configured high enough to support large system prompts.
45
+ app.use(express.json({ limit: '5mb' }));
46
+
47
+ /**
48
+ * Asynchronously log telemetry metadata.
49
+ * Designed to be zero-retention for security and privacy.
50
+ * Prompts, completions, and code snippets are never stored. Only counts are kept.
51
+ * @param {object} metadata - Metadata to log
52
+ */
53
+ function logTelemetry(metadata) {
54
+ // Use nextTick to defer processing, ensuring zero impact on the proxy response cycle.
55
+ process.nextTick(() => {
56
+ const telemetry = {
57
+ id: Math.random().toString(36).substring(2, 11),
58
+ timestamp: new Date().toISOString(),
59
+ provider: metadata.provider,
60
+ model: metadata.model || 'unknown',
61
+ userId: metadata.userId || 'anonymous',
62
+ projectId: metadata.projectId || 'default-project',
63
+ inputTokens: Number(metadata.inputTokens) || 0,
64
+ outputTokens: Number(metadata.outputTokens) || 0
65
+ };
66
+ // Clean JSON output for cloud collectors/logging agents
67
+ console.log(JSON.stringify({ telemetry }));
68
+
69
+ // Update local history for the dashboard widget
70
+ telemetryHistory.unshift(telemetry);
71
+ if (telemetryHistory.length > MAX_HISTORY) {
72
+ telemetryHistory.pop();
73
+ }
74
+ telemetryEmitter.emit('new_telemetry', telemetry);
75
+ });
76
+ }
77
+
78
+ // ========================================================
79
+ // Dashboard & Telemetry API Routes (Zero-Auth for Local View)
80
+ // ========================================================
81
+ app.use('/dashboard', express.static(path.join(__dirname, 'public')));
82
+
83
+ app.get('/api/telemetry', (req, res) => {
84
+ res.json(telemetryHistory);
85
+ });
86
+
87
+ app.get('/api/telemetry/stream', (req, res) => {
88
+ res.setHeader('Content-Type', 'text/event-stream');
89
+ res.setHeader('Cache-Control', 'no-cache');
90
+ res.setHeader('Connection', 'keep-alive');
91
+ res.flushHeaders();
92
+
93
+ const onTelemetry = (data) => {
94
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
95
+ };
96
+
97
+ telemetryEmitter.on('new_telemetry', onTelemetry);
98
+
99
+ req.on('close', () => {
100
+ telemetryEmitter.off('new_telemetry', onTelemetry);
101
+ });
102
+ });
103
+
104
+ // ========================================================
105
+ // Dynamic Configuration API
106
+ // ========================================================
107
+ app.get('/api/config', (req, res) => {
108
+ const maskedConfig = {
109
+ agencyGatewayKey: activeConfig.agencyGatewayKey,
110
+ openaiApiUrl: activeConfig.openaiApiUrl,
111
+ anthropicApiUrl: activeConfig.anthropicApiUrl,
112
+ openaiApiKey: activeConfig.openaiApiKey ? '••••••••••••••••' : '',
113
+ anthropicApiKey: activeConfig.anthropicApiKey ? '••••••••••••••••' : ''
114
+ };
115
+ res.json(maskedConfig);
116
+ });
117
+
118
+ app.post('/api/config', (req, res) => {
119
+ const { agencyGatewayKey, openaiApiKey, openaiApiUrl, anthropicApiKey, anthropicApiUrl } = req.body;
120
+
121
+ if (agencyGatewayKey !== undefined) activeConfig.agencyGatewayKey = agencyGatewayKey;
122
+ if (openaiApiUrl !== undefined) activeConfig.openaiApiUrl = openaiApiUrl;
123
+ if (anthropicApiUrl !== undefined) activeConfig.anthropicApiUrl = anthropicApiUrl;
124
+
125
+ if (openaiApiKey !== undefined && openaiApiKey !== '••••••••••••••••') {
126
+ activeConfig.openaiApiKey = openaiApiKey;
127
+ }
128
+ if (anthropicApiKey !== undefined && anthropicApiKey !== '••••••••••••••••') {
129
+ activeConfig.anthropicApiKey = anthropicApiKey;
130
+ }
131
+
132
+ try {
133
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(activeConfig, null, 2), 'utf8');
134
+ console.log('Saved updated configurations to config.json');
135
+ res.json({ status: 'success', message: 'Configurations saved successfully' });
136
+ } catch (err) {
137
+ console.error('Error saving config.json:', err.message);
138
+ res.status(500).json({ error: 'Failed to write configurations to disk' });
139
+ }
140
+ });
141
+
142
+ // 1. Gateway Authentication & Custom Header extraction
143
+ app.use((req, res, next) => {
144
+ // Allow health check, dashboard, and telemetry routes to pass without authentication
145
+ if (
146
+ (req.path === '/health' && req.method === 'GET') ||
147
+ req.path.startsWith('/dashboard') ||
148
+ req.path.startsWith('/api/telemetry')
149
+ ) {
150
+ return next();
151
+ }
152
+
153
+ const incomingGatewayKey = req.headers['x-gateway-key'] || '';
154
+ const expectedKey = activeConfig.agencyGatewayKey || '';
155
+
156
+ const incomingBuffer = Buffer.from(incomingGatewayKey);
157
+ const expectedBuffer = Buffer.from(expectedKey);
158
+
159
+ if (incomingBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(incomingBuffer, expectedBuffer)) {
160
+ return res.status(401).json({
161
+ error: {
162
+ message: 'Unauthorized. The custom X-Gateway-Key header is missing or incorrect.',
163
+ type: 'gateway_authorization_error',
164
+ code: 'unauthorized_access'
165
+ }
166
+ });
167
+ }
168
+ next();
169
+ });
170
+
171
+ // Health check endpoint
172
+ app.get('/health', (req, res) => {
173
+ res.status(200).json({
174
+ status: 'healthy',
175
+ timestamp: new Date().toISOString(),
176
+ service: 'OmniGate AI Proxy'
177
+ });
178
+ });
179
+
180
+ // 2. OpenAI Stream / Non-Stream Proxy Handler
181
+ app.post('/v1/chat/completions', async (req, res) => {
182
+ const openaiApiKey = activeConfig.openaiApiKey;
183
+ if (!openaiApiKey) {
184
+ return res.status(500).json({
185
+ error: {
186
+ message: 'OpenAI API key (openaiApiKey) is not configured on this server.',
187
+ type: 'gateway_configuration_error',
188
+ code: 'missing_provider_key'
189
+ }
190
+ });
191
+ }
192
+
193
+ // Extract metadata headers for telemetry tracking
194
+ const userId = req.headers['x-gateway-user-id'] || 'anonymous';
195
+ const projectId = req.headers['x-gateway-project-id'] || 'default-project';
196
+ const isStreaming = req.body && req.body.stream === true;
197
+ const requestedModel = req.body && req.body.model;
198
+
199
+ const targetUrl = activeConfig.openaiApiUrl || 'https://api.openai.com/v1/chat/completions';
200
+ const abortController = new AbortController();
201
+
202
+ // If the client disconnects or aborts, clean up the upstream connection immediately.
203
+ res.on('close', () => {
204
+ if (!res.writableEnded) {
205
+ abortController.abort();
206
+ }
207
+ });
208
+
209
+ // Inject stream options to force usage inclusion inside the SSE stream if streaming
210
+ if (isStreaming) {
211
+ if (!req.body.stream_options) {
212
+ req.body.stream_options = {};
213
+ }
214
+ req.body.stream_options.include_usage = true;
215
+ }
216
+
217
+ const headers = {
218
+ 'Content-Type': 'application/json',
219
+ 'Authorization': `Bearer ${openaiApiKey}`
220
+ };
221
+
222
+ try {
223
+ const response = await fetch(targetUrl, {
224
+ method: 'POST',
225
+ headers,
226
+ body: JSON.stringify(req.body),
227
+ signal: abortController.signal
228
+ });
229
+
230
+ if (!response.ok) {
231
+ const errorText = await response.text();
232
+ let errorJson;
233
+ try {
234
+ errorJson = JSON.parse(errorText);
235
+ } catch {
236
+ errorJson = { error: errorText };
237
+ }
238
+ return res.status(response.status).json(errorJson);
239
+ }
240
+
241
+ // Pipe response headers to client
242
+ res.setHeader('Content-Type', response.headers.get('content-type') || 'application/json');
243
+ if (response.headers.has('openai-processing-ms')) {
244
+ res.setHeader('openai-processing-ms', response.headers.get('openai-processing-ms'));
245
+ }
246
+
247
+ if (isStreaming) {
248
+ // Setup SSE headers
249
+ res.setHeader('Cache-Control', 'no-cache');
250
+ res.setHeader('Connection', 'keep-alive');
251
+ res.writeHead(response.status);
252
+
253
+ const reader = response.body.getReader();
254
+ const decoder = new TextDecoder();
255
+ let buffer = '';
256
+ let usage = null;
257
+ let actualModel = requestedModel;
258
+
259
+ try {
260
+ while (true) {
261
+ const { done, value } = await reader.read();
262
+ if (done) break;
263
+
264
+ // 1. TTFT Optimization: Pipe data directly to the client with zero buffering delay
265
+ res.write(value);
266
+
267
+ // 2. Intercept and parse the chunk asynchronously to extract token usage
268
+ const chunkStr = decoder.decode(value, { stream: true });
269
+ buffer += chunkStr;
270
+
271
+ let boundary = buffer.lastIndexOf('\n');
272
+ if (boundary !== -1) {
273
+ const completeText = buffer.slice(0, boundary);
274
+ buffer = buffer.slice(boundary + 1);
275
+
276
+ const lines = completeText.split('\n');
277
+ for (const line of lines) {
278
+ const trimmed = line.trim();
279
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
280
+
281
+ const dataStr = trimmed.slice(6).trim();
282
+ if (dataStr === '[DONE]') continue;
283
+
284
+ // Heuristic: check for usage block without JSON parsing every single chunk
285
+ if (dataStr.includes('"usage"')) {
286
+ try {
287
+ const parsed = JSON.parse(dataStr);
288
+ if (parsed.usage) {
289
+ usage = parsed.usage;
290
+ }
291
+ if (parsed.model) {
292
+ actualModel = parsed.model;
293
+ }
294
+ } catch {
295
+ // Ignore parse errors from chunk fragmentation
296
+ }
297
+ } else if (!actualModel && dataStr.includes('"model"')) {
298
+ try {
299
+ const parsed = JSON.parse(dataStr);
300
+ if (parsed.model) {
301
+ actualModel = parsed.model;
302
+ }
303
+ } catch {}
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ // Flush any remaining text in the parser buffer
310
+ const remaining = buffer.trim();
311
+ if (remaining && remaining.startsWith('data: ')) {
312
+ const dataStr = remaining.slice(6).trim();
313
+ if (dataStr !== '[DONE]' && dataStr.includes('"usage"')) {
314
+ try {
315
+ const parsed = JSON.parse(dataStr);
316
+ if (parsed.usage) {
317
+ usage = parsed.usage;
318
+ }
319
+ if (parsed.model) {
320
+ actualModel = parsed.model;
321
+ }
322
+ } catch {}
323
+ }
324
+ }
325
+ } catch (streamError) {
326
+ console.error('Error during OpenAI stream pipe:', streamError);
327
+ } finally {
328
+ res.end();
329
+ if (usage) {
330
+ logTelemetry({
331
+ provider: 'openai',
332
+ model: actualModel,
333
+ userId,
334
+ projectId,
335
+ inputTokens: usage.prompt_tokens,
336
+ outputTokens: usage.completion_tokens
337
+ });
338
+ } else {
339
+ console.warn('[OpenAI Stream] Stream closed. No usage telemetry metadata found (aborted or failed).');
340
+ }
341
+ }
342
+ } else {
343
+ // Non-streaming JSON response
344
+ const responseData = await response.json();
345
+ res.status(response.status).json(responseData);
346
+
347
+ const usage = responseData.usage;
348
+ const actualModel = responseData.model || requestedModel;
349
+ if (usage) {
350
+ logTelemetry({
351
+ provider: 'openai',
352
+ model: actualModel,
353
+ userId,
354
+ projectId,
355
+ inputTokens: usage.prompt_tokens,
356
+ outputTokens: usage.completion_tokens
357
+ });
358
+ }
359
+ }
360
+ } catch (err) {
361
+ if (err.name === 'AbortError') {
362
+ console.log('OpenAI upstream request was aborted due to client disconnect.');
363
+ if (!res.headersSent) {
364
+ res.status(499).json({ error: 'Client aborted the request' });
365
+ }
366
+ } else {
367
+ console.error('OpenAI proxy connection error:', err);
368
+ if (!res.headersSent) {
369
+ res.status(500).json({ error: 'Gateway Error: Unable to connect to OpenAI API' });
370
+ }
371
+ }
372
+ }
373
+ });
374
+
375
+ // 3. Anthropic Stream / Non-Stream Proxy Handler
376
+ app.post('/v1/messages', async (req, res) => {
377
+ const anthropicApiKey = activeConfig.anthropicApiKey;
378
+ if (!anthropicApiKey) {
379
+ return res.status(500).json({
380
+ error: {
381
+ message: 'Anthropic API key (anthropicApiKey) is not configured on this server.',
382
+ type: 'gateway_configuration_error',
383
+ code: 'missing_provider_key'
384
+ }
385
+ });
386
+ }
387
+
388
+ // Extract metadata headers for telemetry tracking
389
+ const userId = req.headers['x-gateway-user-id'] || 'anonymous';
390
+ const projectId = req.headers['x-gateway-project-id'] || 'default-project';
391
+ const isStreaming = req.body && req.body.stream === true;
392
+ const requestedModel = req.body && req.body.model;
393
+
394
+ const targetUrl = activeConfig.anthropicApiUrl || 'https://api.anthropic.com/v1/messages';
395
+ const abortController = new AbortController();
396
+
397
+ // If the client disconnects or aborts, clean up the upstream connection immediately.
398
+ res.on('close', () => {
399
+ if (!res.writableEnded) {
400
+ abortController.abort();
401
+ }
402
+ });
403
+
404
+ const headers = {
405
+ 'Content-Type': 'application/json',
406
+ 'x-api-key': anthropicApiKey,
407
+ 'anthropic-version': req.headers['anthropic-version'] || '2023-06-01'
408
+ };
409
+
410
+ // Forward optional thinking beta headers if supplied
411
+ if (req.headers['anthropic-beta']) {
412
+ headers['anthropic-beta'] = req.headers['anthropic-beta'];
413
+ }
414
+
415
+ try {
416
+ const response = await fetch(targetUrl, {
417
+ method: 'POST',
418
+ headers,
419
+ body: JSON.stringify(req.body),
420
+ signal: abortController.signal
421
+ });
422
+
423
+ if (!response.ok) {
424
+ const errorText = await response.text();
425
+ let errorJson;
426
+ try {
427
+ errorJson = JSON.parse(errorText);
428
+ } catch {
429
+ errorJson = { error: errorText };
430
+ }
431
+ return res.status(response.status).json(errorJson);
432
+ }
433
+
434
+ // Pipe response headers to client
435
+ res.setHeader('Content-Type', response.headers.get('content-type') || 'application/json');
436
+
437
+ if (isStreaming) {
438
+ // Setup SSE headers
439
+ res.setHeader('Cache-Control', 'no-cache');
440
+ res.setHeader('Connection', 'keep-alive');
441
+ res.writeHead(response.status);
442
+
443
+ const reader = response.body.getReader();
444
+ const decoder = new TextDecoder();
445
+ let buffer = '';
446
+ let inputTokens = 0;
447
+ let outputTokens = 0;
448
+ let actualModel = requestedModel;
449
+
450
+ try {
451
+ while (true) {
452
+ const { done, value } = await reader.read();
453
+ if (done) break;
454
+
455
+ // 1. TTFT Optimization: Pipe data directly to the client with zero buffering delay
456
+ res.write(value);
457
+
458
+ // 2. Intercept and parse the chunk asynchronously to extract token usage
459
+ const chunkStr = decoder.decode(value, { stream: true });
460
+ buffer += chunkStr;
461
+
462
+ let boundary = buffer.lastIndexOf('\n');
463
+ if (boundary !== -1) {
464
+ const completeText = buffer.slice(0, boundary);
465
+ buffer = buffer.slice(boundary + 1);
466
+
467
+ const lines = completeText.split('\n');
468
+ for (const line of lines) {
469
+ const trimmed = line.trim();
470
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
471
+
472
+ const dataStr = trimmed.slice(6).trim();
473
+ if (dataStr.startsWith('{')) {
474
+ // Heuristic: check if the string contains key telemetry events before parsing JSON
475
+ if (dataStr.includes('"message_start"') || dataStr.includes('"message_delta"') || dataStr.includes('"message_stop"')) {
476
+ try {
477
+ const parsed = JSON.parse(dataStr);
478
+ if (parsed.type === 'message_start' && parsed.message?.usage) {
479
+ inputTokens = parsed.message.usage.input_tokens || inputTokens;
480
+ if (parsed.message.model) {
481
+ actualModel = parsed.message.model;
482
+ }
483
+ } else if (parsed.type === 'message_delta' && parsed.usage) {
484
+ outputTokens = parsed.usage.output_tokens || outputTokens;
485
+ } else if (parsed.type === 'message_stop' && parsed.usage) {
486
+ outputTokens = parsed.usage.output_tokens || outputTokens;
487
+ }
488
+ } catch {
489
+ // Ignore parse errors from chunk fragmentation
490
+ }
491
+ }
492
+ }
493
+ }
494
+ }
495
+ }
496
+
497
+ // Flush any remaining text in the parser buffer
498
+ const remaining = buffer.trim();
499
+ if (remaining && remaining.startsWith('data: ')) {
500
+ const dataStr = remaining.slice(6).trim();
501
+ if (dataStr.startsWith('{')) {
502
+ try {
503
+ const parsed = JSON.parse(dataStr);
504
+ if (parsed.type === 'message_start' && parsed.message?.usage) {
505
+ inputTokens = parsed.message.usage.input_tokens || inputTokens;
506
+ if (parsed.message.model) {
507
+ actualModel = parsed.message.model;
508
+ }
509
+ } else if (parsed.type === 'message_delta' && parsed.usage) {
510
+ outputTokens = parsed.usage.output_tokens || outputTokens;
511
+ } else if (parsed.type === 'message_stop' && parsed.usage) {
512
+ outputTokens = parsed.usage.output_tokens || outputTokens;
513
+ }
514
+ } catch {}
515
+ }
516
+ }
517
+ } catch (streamError) {
518
+ console.error('Error during Anthropic stream pipe:', streamError);
519
+ } finally {
520
+ res.end();
521
+ if (inputTokens > 0 || outputTokens > 0) {
522
+ logTelemetry({
523
+ provider: 'anthropic',
524
+ model: actualModel,
525
+ userId,
526
+ projectId,
527
+ inputTokens,
528
+ outputTokens
529
+ });
530
+ } else {
531
+ console.warn('[Anthropic Stream] Stream closed. No usage telemetry metadata found (aborted or failed).');
532
+ }
533
+ }
534
+ } else {
535
+ // Non-streaming JSON response
536
+ const responseData = await response.json();
537
+ res.status(response.status).json(responseData);
538
+
539
+ const usage = responseData.usage;
540
+ const actualModel = responseData.model || requestedModel;
541
+ if (usage) {
542
+ logTelemetry({
543
+ provider: 'anthropic',
544
+ model: actualModel,
545
+ userId,
546
+ projectId,
547
+ inputTokens: usage.input_tokens,
548
+ outputTokens: usage.output_tokens
549
+ });
550
+ }
551
+ }
552
+ } catch (err) {
553
+ if (err.name === 'AbortError') {
554
+ console.log('Anthropic upstream request was aborted due to client disconnect.');
555
+ if (!res.headersSent) {
556
+ res.status(499).json({ error: 'Client aborted the request' });
557
+ }
558
+ } else {
559
+ console.error('Anthropic proxy connection error:', err);
560
+ if (!res.headersSent) {
561
+ res.status(500).json({ error: 'Gateway Error: Unable to connect to Anthropic API' });
562
+ }
563
+ }
564
+ }
565
+ });
566
+
567
+ // Catch-all route to reject unsupported endpoints
568
+ app.use((req, res) => {
569
+ res.status(404).json({
570
+ error: {
571
+ message: `The endpoint ${req.method} ${req.path} is not supported by OmniGate AI gateway. Only /v1/chat/completions (OpenAI) and /v1/messages (Anthropic) are valid proxy routes.`,
572
+ type: 'gateway_route_error',
573
+ code: 'unsupported_route'
574
+ }
575
+ });
576
+ });
577
+
578
+ // Run server
579
+ app.listen(PORT, () => {
580
+ console.log(`========================================================`);
581
+ console.log(` OmniGate AI Privacy API Gateway Server `);
582
+ console.log(` Status: ACTIVE | Listening on Port: ${PORT} `);
583
+ console.log(`========================================================`);
584
+ });
585
+
586
+ // Trigger watcher reload
@@ -0,0 +1 @@
1
+ {"model":"gpt-4o","messages":[{"role":"user","content":"Say hello in one word"}]}
@@ -0,0 +1 @@
1
+ {"model":"deepseek-chat","messages":[{"role":"user","content":"Explain the architecture of OmniGate AI proxy in 3 sentences"}],"stream":false}