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/README.md +142 -0
- package/bin/llmflow.js +91 -0
- package/db.js +857 -0
- package/logger.js +122 -0
- package/otlp-export.js +564 -0
- package/otlp-logs.js +238 -0
- package/otlp-metrics.js +300 -0
- package/otlp.js +398 -0
- package/package.json +62 -0
- package/pricing.fallback.json +58 -0
- package/pricing.js +154 -0
- package/providers/anthropic.js +195 -0
- package/providers/azure.js +159 -0
- package/providers/base.js +145 -0
- package/providers/cohere.js +225 -0
- package/providers/gemini.js +278 -0
- package/providers/index.js +130 -0
- package/providers/ollama.js +36 -0
- package/providers/openai-compatible.js +77 -0
- package/providers/openai.js +217 -0
- package/providers/passthrough.js +573 -0
- package/public/app.js +1484 -0
- package/public/index.html +367 -0
- package/public/style.css +1152 -0
- package/server.js +1222 -0
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
|
+
});
|