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/Dockerfile +37 -0
- package/Omnigate AI.txt +72 -0
- package/bin/omnigate.js +2 -0
- package/docker-compose.yml +16 -0
- package/package.json +30 -0
- package/payload-theme.json +1 -0
- package/proxy-helper.js +60 -0
- package/public/app.js +432 -0
- package/public/index.html +367 -0
- package/public/styles.css +1060 -0
- package/server.js +586 -0
- package/test-payload.json +1 -0
- package/test-payload2.json +1 -0
- package/test-real-api.js +228 -0
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}
|