llmflow 0.3.1

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,1222 @@
1
+ const express = require('express');
2
+ const http = require('http');
3
+ const { v4: uuidv4 } = require('uuid');
4
+ const path = require('path');
5
+ const https = require('https');
6
+ const WebSocket = require('ws');
7
+ const db = require('./db');
8
+ const { calculateCost } = require('./pricing');
9
+ const log = require('./logger');
10
+ const { createOtlpHandler } = require('./otlp');
11
+ const { createLogsHandler } = require('./otlp-logs');
12
+ const { createMetricsHandler } = require('./otlp-metrics');
13
+ const { initExportHooks, getConfig: getExportConfig, flushAll: flushExports } = require('./otlp-export');
14
+ const { registry } = require('./providers');
15
+ const { AnthropicPassthrough, GeminiPassthrough, OpenAIPassthrough, HeliconePassthrough } = require('./providers/passthrough');
16
+
17
+ // Passthrough handlers for native API formats
18
+ const passthroughHandlers = {
19
+ anthropic: new AnthropicPassthrough(),
20
+ gemini: new GeminiPassthrough(),
21
+ openai: new OpenAIPassthrough(),
22
+ helicone: new HeliconePassthrough()
23
+ };
24
+
25
+ const PROXY_PORT = process.env.PROXY_PORT || 8080;
26
+ const DASHBOARD_PORT = process.env.DASHBOARD_PORT || 3000;
27
+
28
+ // Log request/response to database
29
+ /**
30
+ * Extract custom tags from X-LLMFlow-Tag headers
31
+ * Supports: X-LLMFlow-Tag: value or X-LLMFlow-Tag: key:value
32
+ * Multiple tags via comma separation or multiple headers
33
+ */
34
+ function extractTagsFromHeaders(headers) {
35
+ const tags = [];
36
+ const tagHeader = headers['x-llmflow-tag'] || headers['x-llmflow-tags'];
37
+
38
+ if (!tagHeader) return tags;
39
+
40
+ // Handle array of headers or comma-separated string
41
+ const headerValues = Array.isArray(tagHeader) ? tagHeader : [tagHeader];
42
+
43
+ for (const value of headerValues) {
44
+ const parts = value.split(',').map(t => t.trim()).filter(Boolean);
45
+ tags.push(...parts);
46
+ }
47
+
48
+ return tags;
49
+ }
50
+
51
+ function logInteraction(traceId, req, responseData, duration, error = null, providerName = 'openai') {
52
+ try {
53
+ const timestamp = Date.now();
54
+ const usage = responseData?.usage || {};
55
+ const model = responseData?.model || req.body?.model || 'unknown';
56
+ const provider = providerName;
57
+
58
+ const promptTokens = usage.prompt_tokens || 0;
59
+ const completionTokens = usage.completion_tokens || 0;
60
+ const totalTokens = usage.total_tokens || promptTokens + completionTokens;
61
+ const estimatedCost = calculateCost(model, promptTokens, completionTokens);
62
+ const status = responseData?.status || (error ? 500 : 200);
63
+
64
+ // Extract custom tags from headers
65
+ const customTags = extractTagsFromHeaders(req.headers);
66
+
67
+ db.insertTrace({
68
+ id: traceId,
69
+ timestamp,
70
+ duration_ms: duration,
71
+ provider,
72
+ model,
73
+ prompt_tokens: promptTokens,
74
+ completion_tokens: completionTokens,
75
+ total_tokens: totalTokens,
76
+ estimated_cost: estimatedCost,
77
+ status,
78
+ error,
79
+ request_method: req.method,
80
+ request_path: req.path,
81
+ request_headers: req.headers,
82
+ request_body: req.body,
83
+ response_status: status,
84
+ response_headers: responseData?.headers || {},
85
+ response_body: responseData?.data || { error },
86
+ tags: customTags,
87
+ trace_id: req.headers['x-trace-id'] || traceId,
88
+ parent_id: req.headers['x-parent-id'] || null
89
+ });
90
+ } catch (err) {
91
+ log.error(`Failed to log: ${err.message}`);
92
+ }
93
+ }
94
+
95
+ // Proxy Server
96
+ const proxyApp = express();
97
+ proxyApp.use(express.json({ limit: '50mb' }));
98
+
99
+ // Health check endpoint
100
+ proxyApp.get('/health', (req, res) => {
101
+ res.json({
102
+ status: 'ok',
103
+ service: 'proxy',
104
+ port: PROXY_PORT,
105
+ traces: db.getTraceCount(),
106
+ uptime: process.uptime(),
107
+ providers: registry.list().map(p => p.name)
108
+ });
109
+ });
110
+
111
+ // List available providers
112
+ proxyApp.get('/providers', (req, res) => {
113
+ res.json({
114
+ providers: registry.list(),
115
+ passthrough: Object.keys(passthroughHandlers).map(name => ({
116
+ name: passthroughHandlers[name].name,
117
+ displayName: passthroughHandlers[name].displayName,
118
+ prefix: `/passthrough/${name}/*`
119
+ })),
120
+ usage: {
121
+ default: 'Use /v1/* for OpenAI (default provider)',
122
+ custom: 'Use /{provider}/v1/* for other providers (e.g., /ollama/v1/chat/completions)',
123
+ header: 'Or set X-LLMFlow-Provider header to override',
124
+ passthrough: 'Use /passthrough/{provider}/* for native API formats (e.g., /passthrough/anthropic/v1/messages)'
125
+ }
126
+ });
127
+ });
128
+
129
+ // CORS headers
130
+ proxyApp.use((req, res, next) => {
131
+ res.header('Access-Control-Allow-Origin', '*');
132
+ res.header('Access-Control-Allow-Headers', '*');
133
+ res.header('Access-Control-Allow-Methods', '*');
134
+ if (req.method === 'OPTIONS') {
135
+ res.sendStatus(200);
136
+ } else {
137
+ next();
138
+ }
139
+ });
140
+
141
+ // Proxy handler for all provider routes
142
+ function createProxyHandler() {
143
+ return async (req, res) => {
144
+ const startTime = Date.now();
145
+ const traceId = req.headers['x-trace-id'] || uuidv4();
146
+
147
+ // Resolve provider based on path or header
148
+ const { provider, cleanPath } = registry.resolve(req);
149
+
150
+ // Create a modified request with the clean path (preserving headers and body)
151
+ const proxyReq = { ...req, path: cleanPath, headers: req.headers, body: req.body };
152
+
153
+ const isStreamingRequest = provider.isStreamingRequest(req);
154
+
155
+ log.request(req.method, req.path, traceId);
156
+ log.debug(`Provider: ${provider.name}, Model: ${req.body?.model || 'N/A'}, Stream: ${isStreamingRequest}`);
157
+
158
+ try {
159
+ // Transform request body for this provider
160
+ const transformedBody = provider.transformRequestBody(req.body, proxyReq);
161
+ const postData = req.method !== 'GET' ? JSON.stringify(transformedBody) : '';
162
+
163
+ // Get target configuration
164
+ const target = provider.getTarget(proxyReq);
165
+
166
+ // Transform headers
167
+ const headers = provider.transformRequestHeaders(req.headers, proxyReq);
168
+ headers['Content-Length'] = Buffer.byteLength(postData);
169
+
170
+ const options = {
171
+ hostname: target.hostname,
172
+ port: target.port,
173
+ path: target.path,
174
+ method: req.method,
175
+ headers: headers
176
+ };
177
+
178
+ // Select HTTP or HTTPS module
179
+ const httpModule = provider.getHttpModule();
180
+
181
+ const upstreamReq = httpModule.request(options, (upstreamRes) => {
182
+ if (!isStreamingRequest) {
183
+ // Non-streaming: buffer entire response
184
+ let responseBody = '';
185
+
186
+ upstreamRes.on('data', (chunk) => {
187
+ responseBody += chunk;
188
+ });
189
+
190
+ upstreamRes.on('end', () => {
191
+ const duration = Date.now() - startTime;
192
+ let rawResponse;
193
+
194
+ try {
195
+ rawResponse = JSON.parse(responseBody);
196
+ } catch (e) {
197
+ rawResponse = { error: 'Invalid JSON response', body: responseBody };
198
+ }
199
+
200
+ // Normalize response through provider
201
+ const normalized = provider.normalizeResponse(rawResponse, req);
202
+ const usage = provider.extractUsage(normalized.data);
203
+ const cost = calculateCost(normalized.model, usage.prompt_tokens, usage.completion_tokens);
204
+
205
+ log.proxy({
206
+ provider: provider.name,
207
+ model: normalized.model,
208
+ tokens: usage.total_tokens,
209
+ cost,
210
+ duration,
211
+ streaming: false
212
+ });
213
+
214
+ logInteraction(traceId, req, {
215
+ status: upstreamRes.statusCode,
216
+ headers: upstreamRes.headers,
217
+ data: normalized.data,
218
+ usage: usage,
219
+ model: normalized.model
220
+ }, duration, null, provider.name);
221
+
222
+ res.status(upstreamRes.statusCode).json(normalized.data);
223
+ });
224
+ } else {
225
+ // Streaming: forward chunks while buffering for logging
226
+ res.status(upstreamRes.statusCode);
227
+ Object.entries(upstreamRes.headers).forEach(([key, value]) => {
228
+ res.setHeader(key, value);
229
+ });
230
+
231
+ let fullContent = '';
232
+ let finalUsage = null;
233
+ let chunkCount = 0;
234
+
235
+ upstreamRes.on('data', (chunk) => {
236
+ const text = chunk.toString('utf8');
237
+ chunkCount++;
238
+
239
+ res.write(chunk);
240
+
241
+ // Parse chunks through provider
242
+ const parsed = provider.parseStreamChunk(text);
243
+ if (parsed.content) fullContent += parsed.content;
244
+ if (parsed.usage) finalUsage = parsed.usage;
245
+ });
246
+
247
+ upstreamRes.on('end', () => {
248
+ const duration = Date.now() - startTime;
249
+ const usage = finalUsage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
250
+ const cost = calculateCost(req.body?.model || 'unknown', usage.prompt_tokens, usage.completion_tokens);
251
+
252
+ log.proxy({
253
+ provider: provider.name,
254
+ model: req.body?.model,
255
+ tokens: usage.total_tokens,
256
+ cost,
257
+ duration,
258
+ streaming: true
259
+ });
260
+
261
+ log.debug(`Chunks: ${chunkCount}, Content: ${fullContent.length} chars`);
262
+
263
+ const assembledResponse = provider.assembleStreamingResponse(
264
+ fullContent, finalUsage, req, traceId
265
+ );
266
+ assembledResponse._chunks = chunkCount;
267
+
268
+ logInteraction(traceId, req, {
269
+ status: upstreamRes.statusCode,
270
+ headers: upstreamRes.headers,
271
+ data: assembledResponse,
272
+ usage: finalUsage,
273
+ model: req.body?.model
274
+ }, duration, null, provider.name);
275
+
276
+ res.end();
277
+ });
278
+ }
279
+ });
280
+
281
+ upstreamReq.on('error', (error) => {
282
+ const duration = Date.now() - startTime;
283
+ log.proxy({ provider: provider.name, error: error.message, duration });
284
+ logInteraction(traceId, req, null, duration, error.message, provider.name);
285
+ res.status(500).json({ error: 'Proxy request failed', message: error.message, provider: provider.name });
286
+ });
287
+
288
+ if (postData) {
289
+ upstreamReq.write(postData);
290
+ }
291
+ upstreamReq.end();
292
+
293
+ } catch (error) {
294
+ const duration = Date.now() - startTime;
295
+ log.proxy({ provider: provider.name, error: error.message, duration });
296
+ logInteraction(traceId, req, null, duration, error.message, provider.name);
297
+ res.status(500).json({ error: 'Proxy request failed', message: error.message, provider: provider.name });
298
+ }
299
+ };
300
+ }
301
+
302
+ // Passthrough proxy handler - forwards requests without body transformation
303
+ function createPassthroughHandler(handler) {
304
+ return async (req, res) => {
305
+ const startTime = Date.now();
306
+ const traceId = req.headers['x-trace-id'] || uuidv4();
307
+
308
+ const isStreaming = handler.isStreamingRequest(req);
309
+
310
+ log.request(req.method, req.path, traceId);
311
+ log.debug(`Passthrough: ${handler.name}, Model: ${req.body?.model || 'N/A'}, Stream: ${isStreaming}`);
312
+
313
+ try {
314
+ // Get target configuration
315
+ const target = handler.getTarget(req);
316
+
317
+ // Transform only headers, NOT body
318
+ const headers = handler.defaultHeaderTransform(req.headers);
319
+ const postData = req.method !== 'GET' ? JSON.stringify(req.body) : '';
320
+ headers['Content-Length'] = Buffer.byteLength(postData);
321
+
322
+ const options = {
323
+ hostname: target.hostname,
324
+ port: target.port,
325
+ path: target.path,
326
+ method: req.method,
327
+ headers: headers
328
+ };
329
+
330
+ const httpModule = handler.getHttpModule();
331
+
332
+ const upstreamReq = httpModule.request(options, (upstreamRes) => {
333
+ if (!isStreaming) {
334
+ // Non-streaming: buffer entire response
335
+ let responseBody = '';
336
+
337
+ upstreamRes.on('data', (chunk) => {
338
+ responseBody += chunk;
339
+ });
340
+
341
+ upstreamRes.on('end', () => {
342
+ const duration = Date.now() - startTime;
343
+ let rawResponse;
344
+
345
+ try {
346
+ rawResponse = JSON.parse(responseBody);
347
+ } catch (e) {
348
+ rawResponse = { error: 'Invalid JSON response', body: responseBody };
349
+ }
350
+
351
+ // Extract usage from native response format
352
+ const usage = handler.defaultExtractUsage(rawResponse);
353
+ const model = handler.defaultIdentifyModel(req.body, rawResponse);
354
+ const cost = calculateCost(model, usage.prompt_tokens, usage.completion_tokens);
355
+
356
+ log.proxy({
357
+ provider: handler.name,
358
+ model: model,
359
+ tokens: usage.total_tokens,
360
+ cost,
361
+ duration,
362
+ streaming: false,
363
+ passthrough: true
364
+ });
365
+
366
+ logInteraction(traceId, req, {
367
+ status: upstreamRes.statusCode,
368
+ headers: upstreamRes.headers,
369
+ data: rawResponse,
370
+ usage: usage,
371
+ model: model
372
+ }, duration, null, handler.name);
373
+
374
+ // Return original response as-is (no normalization)
375
+ res.status(upstreamRes.statusCode);
376
+ Object.entries(upstreamRes.headers).forEach(([key, value]) => {
377
+ if (key.toLowerCase() !== 'content-length' &&
378
+ key.toLowerCase() !== 'transfer-encoding') {
379
+ res.setHeader(key, value);
380
+ }
381
+ });
382
+ res.json(rawResponse);
383
+ });
384
+ } else {
385
+ // Streaming: forward chunks while extracting usage
386
+ res.status(upstreamRes.statusCode);
387
+ Object.entries(upstreamRes.headers).forEach(([key, value]) => {
388
+ res.setHeader(key, value);
389
+ });
390
+
391
+ let fullContent = '';
392
+ let finalUsage = null;
393
+ let chunkCount = 0;
394
+
395
+ upstreamRes.on('data', (chunk) => {
396
+ const text = chunk.toString('utf8');
397
+ chunkCount++;
398
+
399
+ // Forward chunk immediately (passthrough)
400
+ res.write(chunk);
401
+
402
+ // Parse chunk for usage extraction
403
+ const parsed = handler.defaultParseStreamChunk(text);
404
+ if (parsed.content) fullContent += parsed.content;
405
+ if (parsed.usage) finalUsage = parsed.usage;
406
+ });
407
+
408
+ upstreamRes.on('end', () => {
409
+ const duration = Date.now() - startTime;
410
+ const model = handler.defaultIdentifyModel(req.body, {});
411
+ const usage = finalUsage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
412
+ const cost = calculateCost(model, usage.prompt_tokens, usage.completion_tokens);
413
+
414
+ log.proxy({
415
+ provider: handler.name,
416
+ model: model,
417
+ tokens: usage.total_tokens,
418
+ cost,
419
+ duration,
420
+ streaming: true,
421
+ passthrough: true
422
+ });
423
+
424
+ log.debug(`Passthrough chunks: ${chunkCount}, Content: ${fullContent.length} chars`);
425
+
426
+ const assembledResponse = {
427
+ id: traceId,
428
+ model: model,
429
+ content: fullContent,
430
+ usage: finalUsage,
431
+ _streaming: true,
432
+ _chunks: chunkCount,
433
+ _passthrough: true
434
+ };
435
+
436
+ logInteraction(traceId, req, {
437
+ status: upstreamRes.statusCode,
438
+ headers: upstreamRes.headers,
439
+ data: assembledResponse,
440
+ usage: finalUsage,
441
+ model: model
442
+ }, duration, null, handler.name);
443
+
444
+ res.end();
445
+ });
446
+ }
447
+ });
448
+
449
+ upstreamReq.on('error', (error) => {
450
+ const duration = Date.now() - startTime;
451
+ log.proxy({ provider: handler.name, error: error.message, duration, passthrough: true });
452
+ logInteraction(traceId, req, null, duration, error.message, handler.name);
453
+ res.status(502).json({
454
+ error: 'Passthrough failed',
455
+ message: error.message,
456
+ provider: handler.name
457
+ });
458
+ });
459
+
460
+ if (postData) {
461
+ upstreamReq.write(postData);
462
+ }
463
+ upstreamReq.end();
464
+
465
+ } catch (error) {
466
+ const duration = Date.now() - startTime;
467
+ log.proxy({ provider: handler.name, error: error.message, duration, passthrough: true });
468
+ logInteraction(traceId, req, null, duration, error.message, handler.name);
469
+ res.status(500).json({
470
+ error: 'Passthrough failed',
471
+ message: error.message,
472
+ provider: handler.name
473
+ });
474
+ }
475
+ };
476
+ }
477
+
478
+ // Passthrough routes - forward native API formats without transformation
479
+ // /passthrough/anthropic/* -> api.anthropic.com (native Anthropic format)
480
+ // /passthrough/gemini/* -> generativelanguage.googleapis.com (native Gemini format)
481
+ // /passthrough/openai/* -> api.openai.com (native OpenAI format)
482
+ proxyApp.all('/passthrough/anthropic/*', (req, res, next) => {
483
+ req.path = req.path.replace('/passthrough/anthropic', '');
484
+ createPassthroughHandler(passthroughHandlers.anthropic)(req, res, next);
485
+ });
486
+
487
+ proxyApp.all('/passthrough/gemini/*', (req, res, next) => {
488
+ req.path = req.path.replace('/passthrough/gemini', '');
489
+ createPassthroughHandler(passthroughHandlers.gemini)(req, res, next);
490
+ });
491
+
492
+ proxyApp.all('/passthrough/openai/*', (req, res, next) => {
493
+ req.path = req.path.replace('/passthrough/openai', '');
494
+ createPassthroughHandler(passthroughHandlers.openai)(req, res, next);
495
+ });
496
+
497
+ // Proxy all API calls - supports multiple providers via path prefix
498
+ // /v1/* -> OpenAI (default)
499
+ // /ollama/v1/* -> Ollama
500
+ // /anthropic/v1/* -> Anthropic (with transformation to/from OpenAI format)
501
+ // /groq/v1/* -> Groq
502
+ // etc.
503
+ proxyApp.all('/*', createProxyHandler());
504
+
505
+ // Dashboard Server
506
+ const dashboardApp = express();
507
+ dashboardApp.use(express.json());
508
+
509
+ // Health check endpoint
510
+ dashboardApp.get('/api/health', (req, res) => {
511
+ res.json({
512
+ status: 'ok',
513
+ service: 'dashboard',
514
+ port: DASHBOARD_PORT,
515
+ traces: db.getTraceCount(),
516
+ uptime: process.uptime()
517
+ });
518
+ });
519
+
520
+ // Provider health check endpoint
521
+ dashboardApp.get('/api/health/providers', async (req, res) => {
522
+ const results = {};
523
+ const providers = registry.list();
524
+
525
+ const checkProvider = async (name, checkFn) => {
526
+ try {
527
+ const start = Date.now();
528
+ const result = await checkFn();
529
+ return {
530
+ status: result.ok ? 'ok' : 'error',
531
+ latency_ms: Date.now() - start,
532
+ message: result.message || null
533
+ };
534
+ } catch (err) {
535
+ return { status: 'error', message: err.message };
536
+ }
537
+ };
538
+
539
+ // Check OpenAI
540
+ if (process.env.OPENAI_API_KEY) {
541
+ results.openai = await checkProvider('openai', () => {
542
+ return new Promise((resolve) => {
543
+ const req = https.request({
544
+ hostname: 'api.openai.com',
545
+ path: '/v1/models',
546
+ method: 'GET',
547
+ headers: { 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}` },
548
+ timeout: 5000
549
+ }, (res) => {
550
+ resolve({ ok: res.statusCode === 200 });
551
+ });
552
+ req.on('error', (e) => resolve({ ok: false, message: e.message }));
553
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, message: 'timeout' }); });
554
+ req.end();
555
+ });
556
+ });
557
+ } else {
558
+ results.openai = { status: 'unconfigured', message: 'OPENAI_API_KEY not set' };
559
+ }
560
+
561
+ // Check Anthropic
562
+ if (process.env.ANTHROPIC_API_KEY) {
563
+ results.anthropic = await checkProvider('anthropic', () => {
564
+ return new Promise((resolve) => {
565
+ const req = https.request({
566
+ hostname: 'api.anthropic.com',
567
+ path: '/v1/messages',
568
+ method: 'POST',
569
+ headers: {
570
+ 'x-api-key': process.env.ANTHROPIC_API_KEY,
571
+ 'anthropic-version': '2023-06-01',
572
+ 'Content-Type': 'application/json'
573
+ },
574
+ timeout: 5000
575
+ }, (res) => {
576
+ // 400 means API key is valid but request body invalid (expected)
577
+ resolve({ ok: res.statusCode === 400 || res.statusCode === 200 });
578
+ });
579
+ req.on('error', (e) => resolve({ ok: false, message: e.message }));
580
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, message: 'timeout' }); });
581
+ req.write('{}');
582
+ req.end();
583
+ });
584
+ });
585
+ } else {
586
+ results.anthropic = { status: 'unconfigured', message: 'ANTHROPIC_API_KEY not set' };
587
+ }
588
+
589
+ // Check Gemini
590
+ const geminiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
591
+ if (geminiKey) {
592
+ results.gemini = await checkProvider('gemini', () => {
593
+ return new Promise((resolve) => {
594
+ const req = https.request({
595
+ hostname: 'generativelanguage.googleapis.com',
596
+ path: `/v1beta/models?key=${geminiKey}`,
597
+ method: 'GET',
598
+ timeout: 5000
599
+ }, (res) => {
600
+ resolve({ ok: res.statusCode === 200 });
601
+ });
602
+ req.on('error', (e) => resolve({ ok: false, message: e.message }));
603
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, message: 'timeout' }); });
604
+ req.end();
605
+ });
606
+ });
607
+ } else {
608
+ results.gemini = { status: 'unconfigured', message: 'GOOGLE_API_KEY/GEMINI_API_KEY not set' };
609
+ }
610
+
611
+ // Check Groq
612
+ if (process.env.GROQ_API_KEY) {
613
+ results.groq = await checkProvider('groq', () => {
614
+ return new Promise((resolve) => {
615
+ const req = https.request({
616
+ hostname: 'api.groq.com',
617
+ path: '/openai/v1/models',
618
+ method: 'GET',
619
+ headers: { 'Authorization': `Bearer ${process.env.GROQ_API_KEY}` },
620
+ timeout: 5000
621
+ }, (res) => {
622
+ resolve({ ok: res.statusCode === 200 });
623
+ });
624
+ req.on('error', (e) => resolve({ ok: false, message: e.message }));
625
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, message: 'timeout' }); });
626
+ req.end();
627
+ });
628
+ });
629
+ } else {
630
+ results.groq = { status: 'unconfigured', message: 'GROQ_API_KEY not set' };
631
+ }
632
+
633
+ // Check Ollama (local, no API key needed)
634
+ const ollamaHost = process.env.OLLAMA_HOST || 'localhost';
635
+ const ollamaPort = process.env.OLLAMA_PORT || 11434;
636
+ results.ollama = await checkProvider('ollama', () => {
637
+ return new Promise((resolve) => {
638
+ const req = http.request({
639
+ hostname: ollamaHost,
640
+ port: ollamaPort,
641
+ path: '/api/tags',
642
+ method: 'GET',
643
+ timeout: 2000
644
+ }, (res) => {
645
+ resolve({ ok: res.statusCode === 200 });
646
+ });
647
+ req.on('error', () => resolve({ ok: false, message: `not reachable at ${ollamaHost}:${ollamaPort}` }));
648
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, message: 'timeout' }); });
649
+ req.end();
650
+ });
651
+ });
652
+
653
+ const okCount = Object.values(results).filter(r => r.status === 'ok').length;
654
+ const totalConfigured = Object.values(results).filter(r => r.status !== 'unconfigured').length;
655
+
656
+ res.json({
657
+ summary: `${okCount}/${totalConfigured} providers healthy`,
658
+ providers: results
659
+ });
660
+ });
661
+
662
+ // OTLP Export configuration endpoint
663
+ dashboardApp.get('/api/export', (req, res) => {
664
+ res.json(getExportConfig());
665
+ });
666
+
667
+ // Flush pending exports
668
+ dashboardApp.post('/api/export/flush', async (req, res) => {
669
+ try {
670
+ await flushExports();
671
+ res.json({ success: true, message: 'Export flush completed' });
672
+ } catch (error) {
673
+ res.status(500).json({ error: error.message });
674
+ }
675
+ });
676
+
677
+ // Serve static files
678
+ dashboardApp.use(express.static(path.join(__dirname, 'public')));
679
+
680
+ // API endpoints for dashboard
681
+ dashboardApp.get('/api/traces', (req, res) => {
682
+ const start = Date.now();
683
+ try {
684
+ const limit = parseInt(req.query.limit) || 50;
685
+ const offset = parseInt(req.query.offset) || 0;
686
+
687
+ const filters = {};
688
+ if (req.query.model) filters.model = req.query.model;
689
+ if (req.query.status) filters.status = req.query.status;
690
+ if (req.query.q) filters.q = req.query.q;
691
+ if (req.query.date_from) filters.date_from = parseInt(req.query.date_from, 10);
692
+ if (req.query.date_to) filters.date_to = parseInt(req.query.date_to, 10);
693
+ if (req.query.cost_min) filters.cost_min = parseFloat(req.query.cost_min);
694
+ if (req.query.cost_max) filters.cost_max = parseFloat(req.query.cost_max);
695
+
696
+ const traces = db.getTraces({ limit, offset, filters });
697
+ log.dashboard('GET', '/api/traces', Date.now() - start);
698
+ res.json(traces);
699
+ } catch (error) {
700
+ res.status(500).json({ error: error.message });
701
+ }
702
+ });
703
+
704
+ dashboardApp.get('/api/traces/:id', (req, res) => {
705
+ const start = Date.now();
706
+ try {
707
+ const { id } = req.params;
708
+ const trace = db.getTraceById(id);
709
+
710
+ if (!trace) {
711
+ return res.status(404).json({ error: 'Trace not found' });
712
+ }
713
+
714
+ log.dashboard('GET', `/api/traces/${id.slice(0, 8)}`, Date.now() - start);
715
+ res.json({
716
+ trace: {
717
+ id: trace.id,
718
+ timestamp: trace.timestamp,
719
+ duration_ms: trace.duration_ms,
720
+ model: trace.model,
721
+ prompt_tokens: trace.prompt_tokens,
722
+ completion_tokens: trace.completion_tokens,
723
+ total_tokens: trace.total_tokens,
724
+ status: trace.status,
725
+ error: trace.error,
726
+ estimated_cost: trace.estimated_cost
727
+ },
728
+ request: {
729
+ method: trace.request_method,
730
+ path: trace.request_path,
731
+ headers: JSON.parse(trace.request_headers || '{}'),
732
+ body: JSON.parse(trace.request_body || '{}')
733
+ },
734
+ response: {
735
+ status: trace.response_status,
736
+ headers: JSON.parse(trace.response_headers || '{}'),
737
+ body: JSON.parse(trace.response_body || '{}')
738
+ }
739
+ });
740
+ } catch (error) {
741
+ res.status(500).json({ error: error.message });
742
+ }
743
+ });
744
+
745
+ dashboardApp.get('/api/stats', (req, res) => {
746
+ const start = Date.now();
747
+ try {
748
+ const stats = db.getStats();
749
+ log.dashboard('GET', '/api/stats', Date.now() - start);
750
+ res.json(stats);
751
+ } catch (error) {
752
+ res.status(500).json({ error: error.message });
753
+ }
754
+ });
755
+
756
+ dashboardApp.get('/api/models', (req, res) => {
757
+ try {
758
+ const models = db.getDistinctModels();
759
+ res.json(models);
760
+ } catch (error) {
761
+ res.status(500).json({ error: error.message });
762
+ }
763
+ });
764
+
765
+ // ==================== Trace Export Endpoint ====================
766
+
767
+ dashboardApp.get('/api/traces/export', (req, res) => {
768
+ const start = Date.now();
769
+ try {
770
+ const format = req.query.format || 'json';
771
+ const limit = parseInt(req.query.limit) || 1000;
772
+
773
+ const filters = {};
774
+ if (req.query.model) filters.model = req.query.model;
775
+ if (req.query.status) filters.status = req.query.status;
776
+ if (req.query.date_from) filters.date_from = parseInt(req.query.date_from, 10);
777
+ if (req.query.date_to) filters.date_to = parseInt(req.query.date_to, 10);
778
+ if (req.query.tag) filters.tag = req.query.tag;
779
+
780
+ const traces = db.getTraces({ limit, offset: 0, filters });
781
+
782
+ log.dashboard('GET', '/api/traces/export', Date.now() - start);
783
+
784
+ if (format === 'jsonl') {
785
+ res.setHeader('Content-Type', 'application/x-ndjson');
786
+ res.setHeader('Content-Disposition', 'attachment; filename="traces.jsonl"');
787
+ for (const trace of traces) {
788
+ res.write(JSON.stringify(trace) + '\n');
789
+ }
790
+ res.end();
791
+ } else {
792
+ res.setHeader('Content-Type', 'application/json');
793
+ res.setHeader('Content-Disposition', 'attachment; filename="traces.json"');
794
+ res.json({
795
+ exported_at: new Date().toISOString(),
796
+ count: traces.length,
797
+ filters,
798
+ traces
799
+ });
800
+ }
801
+ } catch (error) {
802
+ res.status(500).json({ error: error.message });
803
+ }
804
+ });
805
+
806
+ // ==================== Analytics API Endpoints ====================
807
+
808
+ dashboardApp.get('/api/analytics/token-trends', (req, res) => {
809
+ const start = Date.now();
810
+ try {
811
+ const interval = req.query.interval || 'hour';
812
+ const days = parseInt(req.query.days) || 7;
813
+
814
+ const trends = db.getTokenTrends({ interval, days });
815
+ log.dashboard('GET', '/api/analytics/token-trends', Date.now() - start);
816
+ res.json({ trends, interval, days });
817
+ } catch (error) {
818
+ res.status(500).json({ error: error.message });
819
+ }
820
+ });
821
+
822
+ dashboardApp.get('/api/analytics/cost-by-tool', (req, res) => {
823
+ const start = Date.now();
824
+ try {
825
+ const days = parseInt(req.query.days) || 30;
826
+
827
+ const byTool = db.getCostByTool({ days });
828
+ log.dashboard('GET', '/api/analytics/cost-by-tool', Date.now() - start);
829
+ res.json({ by_tool: byTool, days });
830
+ } catch (error) {
831
+ res.status(500).json({ error: error.message });
832
+ }
833
+ });
834
+
835
+ dashboardApp.get('/api/analytics/cost-by-model', (req, res) => {
836
+ const start = Date.now();
837
+ try {
838
+ const days = parseInt(req.query.days) || 30;
839
+
840
+ const byModel = db.getCostByModel({ days });
841
+ log.dashboard('GET', '/api/analytics/cost-by-model', Date.now() - start);
842
+ res.json({ by_model: byModel, days });
843
+ } catch (error) {
844
+ res.status(500).json({ error: error.message });
845
+ }
846
+ });
847
+
848
+ dashboardApp.get('/api/analytics/daily', (req, res) => {
849
+ const start = Date.now();
850
+ try {
851
+ const days = parseInt(req.query.days) || 30;
852
+
853
+ const daily = db.getDailyStats({ days });
854
+ log.dashboard('GET', '/api/analytics/daily', Date.now() - start);
855
+ res.json({ daily, days });
856
+ } catch (error) {
857
+ res.status(500).json({ error: error.message });
858
+ }
859
+ });
860
+
861
+ // ==================== Logs API Endpoints ====================
862
+
863
+ dashboardApp.get('/api/logs', (req, res) => {
864
+ const start = Date.now();
865
+ try {
866
+ const limit = parseInt(req.query.limit) || 50;
867
+ const offset = parseInt(req.query.offset) || 0;
868
+
869
+ const filters = {};
870
+ if (req.query.service_name) filters.service_name = req.query.service_name;
871
+ if (req.query.event_name) filters.event_name = req.query.event_name;
872
+ if (req.query.trace_id) filters.trace_id = req.query.trace_id;
873
+ if (req.query.severity_min) filters.severity_min = parseInt(req.query.severity_min, 10);
874
+ if (req.query.date_from) filters.date_from = parseInt(req.query.date_from, 10);
875
+ if (req.query.date_to) filters.date_to = parseInt(req.query.date_to, 10);
876
+ if (req.query.q) filters.q = req.query.q;
877
+
878
+ const logs = db.getLogs({ limit, offset, filters });
879
+ const total = db.getLogCount(filters);
880
+ log.dashboard('GET', '/api/logs', Date.now() - start);
881
+ res.json({ logs, total });
882
+ } catch (error) {
883
+ res.status(500).json({ error: error.message });
884
+ }
885
+ });
886
+
887
+ dashboardApp.get('/api/logs/filters', (req, res) => {
888
+ try {
889
+ res.json({
890
+ services: db.getDistinctLogServices(),
891
+ event_names: db.getDistinctEventNames()
892
+ });
893
+ } catch (error) {
894
+ res.status(500).json({ error: error.message });
895
+ }
896
+ });
897
+
898
+ dashboardApp.get('/api/logs/:id', (req, res) => {
899
+ const start = Date.now();
900
+ try {
901
+ const { id } = req.params;
902
+ const logRecord = db.getLogById(id);
903
+
904
+ if (!logRecord) {
905
+ return res.status(404).json({ error: 'Log not found' });
906
+ }
907
+
908
+ log.dashboard('GET', `/api/logs/${id.slice(0, 8)}`, Date.now() - start);
909
+ res.json(logRecord);
910
+ } catch (error) {
911
+ res.status(500).json({ error: error.message });
912
+ }
913
+ });
914
+
915
+ // ==================== Metrics API Endpoints ====================
916
+
917
+ dashboardApp.get('/api/metrics', (req, res) => {
918
+ const start = Date.now();
919
+ try {
920
+ const limit = parseInt(req.query.limit) || 50;
921
+ const offset = parseInt(req.query.offset) || 0;
922
+ const aggregation = req.query.aggregation;
923
+
924
+ if (aggregation === 'summary') {
925
+ const filters = {};
926
+ if (req.query.date_from) filters.date_from = parseInt(req.query.date_from, 10);
927
+ if (req.query.date_to) filters.date_to = parseInt(req.query.date_to, 10);
928
+ const summary = db.getMetricsSummary(filters);
929
+ log.dashboard('GET', '/api/metrics?aggregation=summary', Date.now() - start);
930
+ return res.json({ summary });
931
+ }
932
+
933
+ const filters = {};
934
+ if (req.query.name) filters.name = req.query.name;
935
+ if (req.query.service_name) filters.service_name = req.query.service_name;
936
+ if (req.query.metric_type) filters.metric_type = req.query.metric_type;
937
+ if (req.query.date_from) filters.date_from = parseInt(req.query.date_from, 10);
938
+ if (req.query.date_to) filters.date_to = parseInt(req.query.date_to, 10);
939
+
940
+ const metrics = db.getMetrics({ limit, offset, filters });
941
+ const total = db.getMetricCount(filters);
942
+ log.dashboard('GET', '/api/metrics', Date.now() - start);
943
+ res.json({ metrics, total });
944
+ } catch (error) {
945
+ res.status(500).json({ error: error.message });
946
+ }
947
+ });
948
+
949
+ dashboardApp.get('/api/metrics/tokens', (req, res) => {
950
+ try {
951
+ const usage = db.getTokenUsage();
952
+ res.json({ usage });
953
+ } catch (error) {
954
+ res.status(500).json({ error: error.message });
955
+ }
956
+ });
957
+
958
+ dashboardApp.get('/api/metrics/filters', (req, res) => {
959
+ try {
960
+ res.json({
961
+ names: db.getDistinctMetricNames(),
962
+ services: db.getDistinctMetricServices()
963
+ });
964
+ } catch (error) {
965
+ res.status(500).json({ error: error.message });
966
+ }
967
+ });
968
+
969
+ dashboardApp.get('/api/metrics/:id', (req, res) => {
970
+ const start = Date.now();
971
+ try {
972
+ const { id } = req.params;
973
+ const metric = db.getMetricById(id);
974
+
975
+ if (!metric) {
976
+ return res.status(404).json({ error: 'Metric not found' });
977
+ }
978
+
979
+ log.dashboard('GET', `/api/metrics/${id.slice(0, 8)}`, Date.now() - start);
980
+ res.json(metric);
981
+ } catch (error) {
982
+ res.status(500).json({ error: error.message });
983
+ }
984
+ });
985
+
986
+ // Span ingest endpoint for SDK
987
+ dashboardApp.post('/api/spans', (req, res) => {
988
+ try {
989
+ const {
990
+ id,
991
+ trace_id,
992
+ parent_id,
993
+ span_type,
994
+ span_name,
995
+ start_time,
996
+ end_time,
997
+ duration_ms,
998
+ status,
999
+ error,
1000
+ attributes,
1001
+ input,
1002
+ output,
1003
+ tags,
1004
+ service_name,
1005
+ model,
1006
+ provider
1007
+ } = req.body;
1008
+
1009
+ const spanId = id || uuidv4();
1010
+ const startTime = start_time || Date.now();
1011
+ const duration = duration_ms || (end_time ? end_time - startTime : null);
1012
+
1013
+ db.insertTrace({
1014
+ id: spanId,
1015
+ timestamp: startTime,
1016
+ duration_ms: duration,
1017
+ provider: provider || null,
1018
+ model: model || null,
1019
+ prompt_tokens: 0,
1020
+ completion_tokens: 0,
1021
+ total_tokens: 0,
1022
+ estimated_cost: 0,
1023
+ status: status || 200,
1024
+ error: error || null,
1025
+ request_method: null,
1026
+ request_path: null,
1027
+ request_headers: {},
1028
+ request_body: {},
1029
+ response_status: status || 200,
1030
+ response_headers: {},
1031
+ response_body: {},
1032
+ tags: tags || [],
1033
+ trace_id: trace_id || spanId,
1034
+ parent_id: parent_id || null,
1035
+ span_type: span_type || 'custom',
1036
+ span_name: span_name || span_type || 'span',
1037
+ input: input,
1038
+ output: output,
1039
+ attributes: attributes || {},
1040
+ service_name: service_name || 'app'
1041
+ });
1042
+
1043
+ res.status(201).json({ id: spanId, trace_id: trace_id || spanId });
1044
+ } catch (error) {
1045
+ res.status(500).json({ error: error.message });
1046
+ }
1047
+ });
1048
+
1049
+ // OTLP/HTTP trace ingestion endpoint
1050
+ // Accepts OTLP/HTTP JSON format for OpenTelemetry/OpenLLMetry integration
1051
+ dashboardApp.post('/v1/traces', createOtlpHandler());
1052
+
1053
+ // OTLP/HTTP logs ingestion endpoint
1054
+ // Accepts OTLP/HTTP JSON logs from AI CLI tools (Claude Code, Codex CLI, Gemini CLI)
1055
+ dashboardApp.post('/v1/logs', createLogsHandler());
1056
+
1057
+ // OTLP/HTTP metrics ingestion endpoint
1058
+ // Accepts OTLP/HTTP JSON metrics from AI CLI tools (Claude Code, Gemini CLI)
1059
+ dashboardApp.post('/v1/metrics', createMetricsHandler());
1060
+
1061
+ // Get trace with all spans as a tree
1062
+ dashboardApp.get('/api/traces/:id/tree', (req, res) => {
1063
+ try {
1064
+ const { id } = req.params;
1065
+
1066
+ const rootSpan = db.getTraceById(id);
1067
+ if (!rootSpan) {
1068
+ return res.status(404).json({ error: 'Span not found' });
1069
+ }
1070
+
1071
+ const traceId = rootSpan.trace_id || rootSpan.id;
1072
+ const spans = db.getSpansByTraceId(traceId);
1073
+
1074
+ // Parse JSON fields
1075
+ const parsedSpans = spans.map(s => ({
1076
+ ...s,
1077
+ request_headers: JSON.parse(s.request_headers || '{}'),
1078
+ request_body: JSON.parse(s.request_body || '{}'),
1079
+ response_headers: JSON.parse(s.response_headers || '{}'),
1080
+ response_body: JSON.parse(s.response_body || '{}'),
1081
+ input: JSON.parse(s.input || 'null'),
1082
+ output: JSON.parse(s.output || 'null'),
1083
+ attributes: JSON.parse(s.attributes || '{}'),
1084
+ tags: JSON.parse(s.tags || '[]'),
1085
+ children: []
1086
+ }));
1087
+
1088
+ // Build tree
1089
+ const byId = new Map();
1090
+ parsedSpans.forEach(s => byId.set(s.id, s));
1091
+ const roots = [];
1092
+
1093
+ for (const span of parsedSpans) {
1094
+ if (span.parent_id && byId.has(span.parent_id)) {
1095
+ byId.get(span.parent_id).children.push(span);
1096
+ } else {
1097
+ roots.push(span);
1098
+ }
1099
+ }
1100
+
1101
+ // Aggregate stats
1102
+ const totalCost = spans.reduce((acc, s) => acc + (s.estimated_cost || 0), 0);
1103
+ const totalTokens = spans.reduce((acc, s) => acc + (s.total_tokens || 0), 0);
1104
+ const startTs = Math.min(...spans.map(s => s.timestamp || Infinity));
1105
+ const endTs = Math.max(...spans.map(s => (s.timestamp || 0) + (s.duration_ms || 0)));
1106
+
1107
+ res.json({
1108
+ trace: {
1109
+ trace_id: traceId,
1110
+ start_time: startTs,
1111
+ end_time: endTs,
1112
+ duration_ms: endTs - startTs,
1113
+ total_cost: totalCost,
1114
+ total_tokens: totalTokens,
1115
+ span_count: spans.length
1116
+ },
1117
+ spans: roots
1118
+ });
1119
+ } catch (error) {
1120
+ res.status(500).json({ error: error.message });
1121
+ }
1122
+ });
1123
+
1124
+ // Start servers
1125
+ proxyApp.listen(PROXY_PORT, () => {
1126
+ log.startup(`Proxy running on http://localhost:${PROXY_PORT}`);
1127
+ log.info(`Set base_url to http://localhost:${PROXY_PORT}/v1`);
1128
+ });
1129
+
1130
+ // Create HTTP server for dashboard (needed for WebSocket)
1131
+ const dashboardServer = http.createServer(dashboardApp);
1132
+
1133
+ // WebSocket server for real-time updates
1134
+ const wss = new WebSocket.Server({ server: dashboardServer, path: '/ws' });
1135
+ const wsClients = new Set();
1136
+
1137
+ wss.on('connection', (ws) => {
1138
+ wsClients.add(ws);
1139
+ log.debug(`WebSocket client connected (${wsClients.size} total)`);
1140
+
1141
+ ws.on('close', () => {
1142
+ wsClients.delete(ws);
1143
+ log.debug(`WebSocket client disconnected (${wsClients.size} remaining)`);
1144
+ });
1145
+
1146
+ ws.on('error', () => {
1147
+ wsClients.delete(ws);
1148
+ });
1149
+
1150
+ // Send hello message
1151
+ ws.send(JSON.stringify({ type: 'hello', time: Date.now() }));
1152
+ });
1153
+
1154
+ function broadcast(messageObj) {
1155
+ const data = JSON.stringify(messageObj);
1156
+ for (const ws of wsClients) {
1157
+ if (ws.readyState === WebSocket.OPEN) {
1158
+ ws.send(data);
1159
+ }
1160
+ }
1161
+ }
1162
+
1163
+ // Throttle stats updates (max once per second)
1164
+ let lastStatsUpdate = 0;
1165
+ const STATS_THROTTLE_MS = 1000;
1166
+
1167
+ // Hook into db.insertTrace for real-time updates
1168
+ db.setInsertTraceHook((spanSummary) => {
1169
+ // Broadcast new span
1170
+ broadcast({ type: 'new_span', payload: spanSummary });
1171
+
1172
+ // If root span, also broadcast new_trace
1173
+ if (!spanSummary.parent_id) {
1174
+ broadcast({ type: 'new_trace', payload: spanSummary });
1175
+ }
1176
+
1177
+ // Throttled stats update
1178
+ const now = Date.now();
1179
+ if (now - lastStatsUpdate > STATS_THROTTLE_MS) {
1180
+ lastStatsUpdate = now;
1181
+ const stats = db.getStats();
1182
+ broadcast({ type: 'stats_update', payload: stats });
1183
+ }
1184
+ });
1185
+
1186
+ // Hook into db.insertLog for real-time log updates
1187
+ db.setInsertLogHook((logSummary) => {
1188
+ broadcast({ type: 'new_log', payload: logSummary });
1189
+
1190
+ // If log has trace_id, notify trace subscribers
1191
+ if (logSummary.trace_id) {
1192
+ broadcast({
1193
+ type: 'trace_log_added',
1194
+ payload: { trace_id: logSummary.trace_id, log: logSummary }
1195
+ });
1196
+ }
1197
+ });
1198
+
1199
+ // Hook into db.insertMetric for real-time metric updates
1200
+ db.setInsertMetricHook((metricSummary) => {
1201
+ broadcast({ type: 'new_metric', payload: metricSummary });
1202
+ });
1203
+
1204
+ // Initialize OTLP export to external backends
1205
+ initExportHooks(db);
1206
+
1207
+ dashboardServer.listen(DASHBOARD_PORT, () => {
1208
+ log.startup(`Dashboard running on http://localhost:${DASHBOARD_PORT}`);
1209
+ log.info(`Database: ${db.DB_PATH}`);
1210
+ log.info(`Traces: ${db.getTraceCount()}, Logs: ${db.getLogCount()}, Metrics: ${db.getMetricCount()}`);
1211
+ log.info(`WebSocket: ws://localhost:${DASHBOARD_PORT}/ws`);
1212
+
1213
+ const exportConfig = getExportConfig();
1214
+ if (exportConfig.enabled) {
1215
+ log.info(`OTLP Export: ${exportConfig.endpoints.traces || 'disabled'}`);
1216
+ }
1217
+
1218
+ if (log.isVerbose()) {
1219
+ log.info('Verbose logging enabled');
1220
+ }
1221
+ console.log('');
1222
+ });