@vidda/llm-watcher 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/README.md +30 -0
- package/cli.js +35 -0
- package/client/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/client/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/client/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/client/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/client/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/client/dist/assets/index-BCCqRaer.js +16 -0
- package/client/dist/assets/index-Bs1XvUcJ.css +2 -0
- package/client/dist/frankenstein.txt +7741 -0
- package/client/dist/index.html +23 -0
- package/client/package.json +47 -0
- package/package.json +40 -0
- package/server/dist/server/index.d.ts +3 -0
- package/server/dist/server/index.js +53 -0
- package/server/dist/server/routes/dashboard.d.ts +3 -0
- package/server/dist/server/routes/dashboard.js +205 -0
- package/server/dist/server/routes/proxy.d.ts +3 -0
- package/server/dist/server/routes/proxy.js +135 -0
- package/server/dist/server/services/storage.d.ts +19 -0
- package/server/dist/server/services/storage.js +225 -0
- package/server/dist/server/services/stream.d.ts +25 -0
- package/server/dist/server/services/stream.js +250 -0
- package/server/dist/shared/types.d.ts +33 -0
- package/server/dist/shared/types.js +2 -0
- package/server/package.json +31 -0
- package/shared/types.ts +37 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Promptglass</title>
|
|
8
|
+
<script>
|
|
9
|
+
(function () {
|
|
10
|
+
try {
|
|
11
|
+
var stored = localStorage.getItem('promptglass.theme');
|
|
12
|
+
var theme = stored === 'light' || stored === 'dark' ? stored : 'dark';
|
|
13
|
+
document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
14
|
+
} catch (e) {}
|
|
15
|
+
})();
|
|
16
|
+
</script>
|
|
17
|
+
<script type="module" crossorigin src="/assets/index-BCCqRaer.js"></script>
|
|
18
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Bs1XvUcJ.css">
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<div id="root"></div>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ngvuhuy/promptglass-client",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite --host",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"lint": "eslint .",
|
|
10
|
+
"preview": "vite preview"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@base-ui/react": "^1.3.0",
|
|
14
|
+
"@fontsource-variable/geist": "^5.2.8",
|
|
15
|
+
"@ngvuhuy/promptglass-server": "file:../server",
|
|
16
|
+
"cd": "^0.3.3",
|
|
17
|
+
"class-variance-authority": "^0.7.1",
|
|
18
|
+
"clsx": "^2.1.1",
|
|
19
|
+
"date-fns": "^4.1.0",
|
|
20
|
+
"diff": "^8.0.3",
|
|
21
|
+
"i18next": "^26.3.2",
|
|
22
|
+
"lucide-react": "^0.577.0",
|
|
23
|
+
"react": "^19.2.4",
|
|
24
|
+
"react-dom": "^19.2.4",
|
|
25
|
+
"react-i18next": "^17.0.8",
|
|
26
|
+
"shadcn": "^4.1.0",
|
|
27
|
+
"tailwind-merge": "^3.5.0",
|
|
28
|
+
"tw-animate-css": "^1.4.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@eslint/js": "^9.39.4",
|
|
32
|
+
"@tailwindcss/vite": "^4.2.2",
|
|
33
|
+
"@types/diff": "^8.0.0",
|
|
34
|
+
"@types/node": "^24.12.0",
|
|
35
|
+
"@types/react": "^19.2.14",
|
|
36
|
+
"@types/react-dom": "^19.2.3",
|
|
37
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
38
|
+
"eslint": "^9.39.4",
|
|
39
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
40
|
+
"eslint-plugin-react-refresh": "^0.5.2",
|
|
41
|
+
"globals": "^17.4.0",
|
|
42
|
+
"tailwindcss": "^4.2.2",
|
|
43
|
+
"typescript": "~5.9.3",
|
|
44
|
+
"typescript-eslint": "^8.57.0",
|
|
45
|
+
"vite": "^8.0.1"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vidda/llm-watcher",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "LLM Request Inspector & Benchmarking Tool",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vidda/llm-watcher.git"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"promptglass": "cli.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"cli.js",
|
|
17
|
+
"server/dist",
|
|
18
|
+
"client/dist",
|
|
19
|
+
"shared",
|
|
20
|
+
"package.json"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"install": "npm install && cd client && npm install && cd ../server && npm install",
|
|
24
|
+
"dev": "concurrently --names \"server,client\" --prefix-colors \"yellow,cyan\" \"npm run dev --prefix server\" \"npm run dev --prefix client\"",
|
|
25
|
+
"build": "npm run build --prefix client && npm run build --prefix server",
|
|
26
|
+
"start": "NODE_ENV=production node server/dist/server/index.js",
|
|
27
|
+
"prepublishOnly": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"axios": "^1.13.6",
|
|
31
|
+
"cors": "^2.8.6",
|
|
32
|
+
"dotenv": "^17.3.1",
|
|
33
|
+
"eventsource-parser": "^3.0.6",
|
|
34
|
+
"express": "^5.2.1",
|
|
35
|
+
"zod": "^4.3.6"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"concurrently": "^9.2.1"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { getDb, closeDb } from './services/storage.js';
|
|
8
|
+
import proxyRouter from './routes/proxy.js';
|
|
9
|
+
import dashboardRouter from './routes/dashboard.js';
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
// Try to load .env from the current working directory first, then from the project root
|
|
13
|
+
dotenv.config();
|
|
14
|
+
// In development, the root is one level up from server/. In production (dist), it is three levels up from server/dist/server/
|
|
15
|
+
const isDist = path.basename(path.dirname(__dirname)) === 'dist';
|
|
16
|
+
const projectRoot = isDist ? path.resolve(__dirname, '../../../') : path.resolve(__dirname, '../');
|
|
17
|
+
dotenv.config({ path: path.join(projectRoot, '.env') });
|
|
18
|
+
const app = express();
|
|
19
|
+
const PORT = process.env.PORT || 3001;
|
|
20
|
+
app.use(cors());
|
|
21
|
+
app.use(express.json({ limit: '50mb' }));
|
|
22
|
+
app.get('/api/health', (_req, res) => {
|
|
23
|
+
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
24
|
+
});
|
|
25
|
+
app.use('/v1', proxyRouter);
|
|
26
|
+
app.use('/api', dashboardRouter);
|
|
27
|
+
// Serve React App in production
|
|
28
|
+
if (process.env.NODE_ENV === 'production') {
|
|
29
|
+
const clientDist = path.resolve(projectRoot, 'client/dist');
|
|
30
|
+
if (fs.existsSync(clientDist)) {
|
|
31
|
+
app.use(express.static(clientDist));
|
|
32
|
+
app.get('/*path', (req, res) => {
|
|
33
|
+
res.sendFile(path.join(clientDist, 'index.html'));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
console.warn(`Warning: Static files directory not found at ${clientDist}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const server = app.listen(PORT, () => {
|
|
41
|
+
console.log(`Server running on port ${PORT}`);
|
|
42
|
+
getDb();
|
|
43
|
+
});
|
|
44
|
+
function shutdown() {
|
|
45
|
+
console.log('Shutting down...');
|
|
46
|
+
closeDb();
|
|
47
|
+
server.close(() => {
|
|
48
|
+
process.exit(0);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
process.on('SIGINT', shutdown);
|
|
52
|
+
process.on('SIGTERM', shutdown);
|
|
53
|
+
export default app;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getRequests, getRequestById, saveSetting, getSetting, deleteRequest, deleteRequests, getProxyProfiles, getActiveProxyProfile, setActiveProxyProfile, createProxyProfile, updateProxyProfile, deleteProxyProfile } from '../services/storage.js';
|
|
3
|
+
const router = Router();
|
|
4
|
+
// GET /api/requests
|
|
5
|
+
router.get('/requests', (req, res) => {
|
|
6
|
+
try {
|
|
7
|
+
const limit = parseInt(req.query.limit) || 50;
|
|
8
|
+
const offset = parseInt(req.query.offset) || 0;
|
|
9
|
+
const requests = getRequests(limit, offset);
|
|
10
|
+
res.json(requests);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
console.error('Error fetching requests:', error);
|
|
14
|
+
res.status(500).json({ error: 'Failed to fetch requests' });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
// GET /api/requests/:id
|
|
18
|
+
router.get('/requests/:id', (req, res) => {
|
|
19
|
+
try {
|
|
20
|
+
const id = parseInt(req.params.id);
|
|
21
|
+
if (isNaN(id)) {
|
|
22
|
+
res.status(400).json({ error: 'Invalid request ID' });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const request = getRequestById(id);
|
|
26
|
+
if (!request) {
|
|
27
|
+
res.status(404).json({ error: 'Request not found' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
res.json(request);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
console.error('Error fetching request:', error);
|
|
34
|
+
res.status(500).json({ error: 'Failed to fetch request' });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
// DELETE /api/requests/:id
|
|
38
|
+
router.delete('/requests/:id', (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const id = parseInt(req.params.id);
|
|
41
|
+
if (isNaN(id)) {
|
|
42
|
+
res.status(400).json({ error: 'Invalid request ID' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
deleteRequest(id);
|
|
46
|
+
res.json({ success: true });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error('Error deleting request:', error);
|
|
50
|
+
res.status(500).json({ error: 'Failed to delete request' });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
// DELETE /api/requests
|
|
54
|
+
router.delete('/requests', (req, res) => {
|
|
55
|
+
try {
|
|
56
|
+
const { ids } = req.body;
|
|
57
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
58
|
+
res.status(400).json({ error: 'Invalid request IDs' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
deleteRequests(ids);
|
|
62
|
+
res.json({ success: true });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
console.error('Error deleting requests:', error);
|
|
66
|
+
res.status(500).json({ error: 'Failed to delete requests' });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
// PUT /api/settings
|
|
70
|
+
router.put('/settings', (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const { targetUrl, targetApiKey } = req.body;
|
|
73
|
+
if (targetUrl !== undefined) {
|
|
74
|
+
saveSetting('TARGET_URL', targetUrl);
|
|
75
|
+
}
|
|
76
|
+
if (targetApiKey !== undefined) {
|
|
77
|
+
saveSetting('TARGET_API_KEY', targetApiKey);
|
|
78
|
+
}
|
|
79
|
+
res.json({ success: true });
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
console.error('Error saving settings:', error);
|
|
83
|
+
res.status(500).json({ error: 'Failed to save settings' });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
// GET /api/settings
|
|
87
|
+
router.get('/settings', (_, res) => {
|
|
88
|
+
try {
|
|
89
|
+
const targetUrl = getSetting('TARGET_URL') || process.env.TARGET_URL || '';
|
|
90
|
+
const targetApiKey = getSetting('TARGET_API_KEY') || process.env.TARGET_API_KEY || '';
|
|
91
|
+
res.json({
|
|
92
|
+
targetUrl,
|
|
93
|
+
targetApiKey: targetApiKey ? '***' : '' // Don't send actual key back to client
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
console.error('Error fetching settings:', error);
|
|
98
|
+
res.status(500).json({ error: 'Failed to fetch settings' });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
// Mask API key before sending to client: return '***' if set, '' if empty
|
|
102
|
+
function maskProfile(profile) {
|
|
103
|
+
return {
|
|
104
|
+
...profile,
|
|
105
|
+
targetApiKey: profile.targetApiKey ? '***' : '',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// GET /api/proxy-profiles
|
|
109
|
+
router.get('/proxy-profiles', (_, res) => {
|
|
110
|
+
try {
|
|
111
|
+
const profiles = getProxyProfiles().map(maskProfile);
|
|
112
|
+
const active = getActiveProxyProfile();
|
|
113
|
+
res.json({ profiles, activeId: active?.id || null });
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
console.error('Error fetching proxy profiles:', error);
|
|
117
|
+
res.status(500).json({ error: 'Failed to fetch proxy profiles' });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
// POST /api/proxy-profiles (create)
|
|
121
|
+
router.post('/proxy-profiles', (req, res) => {
|
|
122
|
+
try {
|
|
123
|
+
const { name, targetUrl, targetApiKey, model, inputTokenPrice, outputTokenPrice, cachedInputTokenPrice } = req.body;
|
|
124
|
+
if (!name || !targetUrl) {
|
|
125
|
+
res.status(400).json({ error: 'name and targetUrl are required' });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const profile = createProxyProfile(name, targetUrl, targetApiKey || '', model, inputTokenPrice, outputTokenPrice, cachedInputTokenPrice);
|
|
129
|
+
res.json({ profile: maskProfile(profile) });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
console.error('Error creating proxy profile:', error);
|
|
133
|
+
res.status(500).json({ error: 'Failed to create proxy profile' });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
// PUT /api/proxy-profiles/active (switch active profile)
|
|
137
|
+
// NOTE: must be registered before '/proxy-profiles/:id', otherwise
|
|
138
|
+
// Express matches the literal segment 'active' against the :id param.
|
|
139
|
+
router.put('/proxy-profiles/active', (req, res) => {
|
|
140
|
+
try {
|
|
141
|
+
const { id } = req.body;
|
|
142
|
+
if (!id) {
|
|
143
|
+
res.status(400).json({ error: 'id is required' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const profiles = getProxyProfiles();
|
|
147
|
+
if (!profiles.find((p) => p.id === id)) {
|
|
148
|
+
res.status(404).json({ error: 'Profile not found' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
setActiveProxyProfile(id);
|
|
152
|
+
res.json({ success: true, activeId: id });
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
console.error('Error activating proxy profile:', error);
|
|
156
|
+
res.status(500).json({ error: 'Failed to activate proxy profile' });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
// PUT /api/proxy-profiles/:id (update)
|
|
160
|
+
router.put('/proxy-profiles/:id', (req, res) => {
|
|
161
|
+
try {
|
|
162
|
+
const id = String(req.params.id);
|
|
163
|
+
const { name, targetUrl, targetApiKey, model, inputTokenPrice, outputTokenPrice, cachedInputTokenPrice } = req.body;
|
|
164
|
+
const updates = {};
|
|
165
|
+
if (name !== undefined)
|
|
166
|
+
updates.name = name;
|
|
167
|
+
if (targetUrl !== undefined)
|
|
168
|
+
updates.targetUrl = targetUrl;
|
|
169
|
+
// Only update key if client provided a real value (not the '***' mask)
|
|
170
|
+
if (targetApiKey !== undefined && targetApiKey !== '***')
|
|
171
|
+
updates.targetApiKey = targetApiKey;
|
|
172
|
+
if (model !== undefined)
|
|
173
|
+
updates.model = model;
|
|
174
|
+
if (inputTokenPrice !== undefined)
|
|
175
|
+
updates.inputTokenPrice = inputTokenPrice;
|
|
176
|
+
if (outputTokenPrice !== undefined)
|
|
177
|
+
updates.outputTokenPrice = outputTokenPrice;
|
|
178
|
+
if (cachedInputTokenPrice !== undefined)
|
|
179
|
+
updates.cachedInputTokenPrice = cachedInputTokenPrice;
|
|
180
|
+
const profile = updateProxyProfile(id, updates);
|
|
181
|
+
if (!profile) {
|
|
182
|
+
res.status(404).json({ error: 'Profile not found' });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
res.json({ profile: maskProfile(profile) });
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
console.error('Error updating proxy profile:', error);
|
|
189
|
+
res.status(500).json({ error: 'Failed to update proxy profile' });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
// DELETE /api/proxy-profiles/:id
|
|
193
|
+
router.delete('/proxy-profiles/:id', (req, res) => {
|
|
194
|
+
try {
|
|
195
|
+
const id = String(req.params.id);
|
|
196
|
+
deleteProxyProfile(id);
|
|
197
|
+
const active = getActiveProxyProfile();
|
|
198
|
+
res.json({ success: true, activeId: active?.id || null });
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
console.error('Error deleting proxy profile:', error);
|
|
202
|
+
res.status(500).json({ error: 'Failed to delete proxy profile' });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
export default router;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { saveRequest, getSetting, updateRequest, getActiveProxyProfile } from '../services/storage.js';
|
|
3
|
+
import { processStream } from '../services/stream.js';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
const router = Router();
|
|
6
|
+
// Helper to hash context for cache hit detection
|
|
7
|
+
function hashContext(body) {
|
|
8
|
+
// Handle Chat (messages), Completions (prompt), and Responses (instructions + input)
|
|
9
|
+
const context = body.messages || body.prompt || { instructions: body.instructions, input: body.input };
|
|
10
|
+
const content = JSON.stringify(context);
|
|
11
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
async function handleProxy(req, res, endpoint) {
|
|
14
|
+
const activeProfile = getActiveProxyProfile();
|
|
15
|
+
const configuredUrl = activeProfile?.targetUrl || getSetting('TARGET_URL') || process.env.TARGET_URL;
|
|
16
|
+
// Strip trailing slashes and common suffixes to get the base URL
|
|
17
|
+
let baseUrl = (configuredUrl || '').replace(/\/+$/, '');
|
|
18
|
+
if (baseUrl.endsWith('/chat/completions')) {
|
|
19
|
+
baseUrl = baseUrl.replace(/\/chat\/completions$/, '');
|
|
20
|
+
}
|
|
21
|
+
else if (baseUrl.endsWith('/responses')) {
|
|
22
|
+
baseUrl = baseUrl.replace(/\/responses$/, '');
|
|
23
|
+
}
|
|
24
|
+
const targetUrl = `${baseUrl}/${endpoint}`;
|
|
25
|
+
const targetApiKey = activeProfile?.targetApiKey || getSetting('TARGET_API_KEY') || process.env.TARGET_API_KEY;
|
|
26
|
+
if (!baseUrl) {
|
|
27
|
+
res.status(500).json({ error: 'Target URL is not configured.' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const requestBody = { ...req.body };
|
|
31
|
+
// Override model if the active proxy profile specifies one
|
|
32
|
+
if (activeProfile?.model) {
|
|
33
|
+
requestBody.model = activeProfile.model;
|
|
34
|
+
}
|
|
35
|
+
// OpenAI spec for chat/completions include_usage
|
|
36
|
+
if (endpoint === 'chat/completions' && requestBody.stream === true && !requestBody.stream_options) {
|
|
37
|
+
requestBody.stream_options = { include_usage: true };
|
|
38
|
+
}
|
|
39
|
+
const isStream = requestBody.stream === true;
|
|
40
|
+
const contextHash = hashContext(requestBody);
|
|
41
|
+
// Determine mode from custom header, defaulting to 'observe'
|
|
42
|
+
const modeHeader = req.headers['x-promptglass-mode'];
|
|
43
|
+
const mode = (modeHeader === 'chat' || modeHeader === 'benchmark') ? modeHeader : 'observe';
|
|
44
|
+
// Headers for the target LLM
|
|
45
|
+
const headers = {
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
};
|
|
48
|
+
if (targetApiKey) {
|
|
49
|
+
headers['Authorization'] = `Bearer ${targetApiKey}`;
|
|
50
|
+
}
|
|
51
|
+
// Pass through Authorization if provided and target key isn't set
|
|
52
|
+
if (req.headers.authorization && !targetApiKey) {
|
|
53
|
+
headers['Authorization'] = req.headers.authorization;
|
|
54
|
+
}
|
|
55
|
+
const requestStartTime = performance.now();
|
|
56
|
+
// Save request immediately as "pending"
|
|
57
|
+
const requestId = saveRequest(mode, requestBody, undefined, undefined, contextHash);
|
|
58
|
+
try {
|
|
59
|
+
// console.log(`[PromptGlass] → ${targetUrl}\n${JSON.stringify(requestBody, null, 2)}`);
|
|
60
|
+
const targetResponse = await fetch(targetUrl, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers,
|
|
63
|
+
body: JSON.stringify(requestBody),
|
|
64
|
+
});
|
|
65
|
+
if (!targetResponse.ok) {
|
|
66
|
+
// If error, proxy it back but don't record metrics
|
|
67
|
+
const errorText = await targetResponse.text();
|
|
68
|
+
res.status(targetResponse.status).send(errorText);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (isStream && targetResponse.body) {
|
|
72
|
+
// Set required headers for SSE
|
|
73
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
74
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
75
|
+
res.setHeader('Connection', 'keep-alive');
|
|
76
|
+
let lastDbUpdate = 0;
|
|
77
|
+
const { clientStream, metricsPromise } = processStream(targetResponse, requestStartTime, requestBody, (body) => {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
// Throttled update to database during streaming (every 100ms)
|
|
80
|
+
if (now - lastDbUpdate > 100) {
|
|
81
|
+
updateRequest(requestId, body);
|
|
82
|
+
lastDbUpdate = now;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
const reader = clientStream.getReader();
|
|
86
|
+
const pump = async () => {
|
|
87
|
+
try {
|
|
88
|
+
while (true) {
|
|
89
|
+
const { done, value } = await reader.read();
|
|
90
|
+
if (done)
|
|
91
|
+
break;
|
|
92
|
+
res.write(value);
|
|
93
|
+
}
|
|
94
|
+
res.end();
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
console.error('Error pumping stream to client:', err);
|
|
98
|
+
res.end();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
pump();
|
|
102
|
+
// Wait for the stream to finish and metrics to be computed, then save
|
|
103
|
+
const { metrics, responseBody } = await metricsPromise;
|
|
104
|
+
updateRequest(requestId, responseBody, metrics);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Non-streaming response handling
|
|
108
|
+
const responseData = (await targetResponse.json());
|
|
109
|
+
const totalLatency = performance.now() - requestStartTime;
|
|
110
|
+
let promptTokens = responseData.usage?.prompt_tokens || responseData.usage?.input_tokens || 0;
|
|
111
|
+
if (!promptTokens) {
|
|
112
|
+
const context = requestBody.messages || (requestBody.instructions + (requestBody.input || ''));
|
|
113
|
+
promptTokens = Math.ceil(JSON.stringify(context).length / 4);
|
|
114
|
+
}
|
|
115
|
+
const metrics = {
|
|
116
|
+
ttft: totalLatency,
|
|
117
|
+
totalLatency,
|
|
118
|
+
tokensPerSecond: 0, // Not applicable for non-streaming
|
|
119
|
+
promptPrefillSpeed: (promptTokens && totalLatency > 0) ? (promptTokens / (totalLatency / 1000)) : 0,
|
|
120
|
+
tokenCount: responseData.usage?.completion_tokens || responseData.usage?.output_tokens || 0,
|
|
121
|
+
interTokenLatencies: [],
|
|
122
|
+
completedAt: new Date().toISOString()
|
|
123
|
+
};
|
|
124
|
+
res.json(responseData);
|
|
125
|
+
updateRequest(requestId, responseData, metrics);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
console.error('Proxy Error:', error);
|
|
130
|
+
res.status(500).json({ error: 'Failed to proxy request', details: error.message });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
router.post(['/chat/completions', '/v1/chat/completions'], (req, res) => handleProxy(req, res, 'chat/completions'));
|
|
134
|
+
router.post(['/responses', '/v1/responses'], (req, res) => handleProxy(req, res, 'responses'));
|
|
135
|
+
export default router;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { Metrics, RequestMode, StoredRequest, ProxyProfile } from '../../shared/types.js';
|
|
3
|
+
export declare function getDb(dbPath?: string): DatabaseSync;
|
|
4
|
+
export declare function saveRequest(mode: RequestMode, requestBody: any, responseBody?: any, metrics?: Metrics, contextHash?: string): number;
|
|
5
|
+
export declare function updateRequest(id: number, responseBody: any, metrics?: Metrics): void;
|
|
6
|
+
export declare function getRequests(limit?: number, offset?: number): StoredRequest[];
|
|
7
|
+
export declare function getRequestById(id: number): StoredRequest | null;
|
|
8
|
+
export declare function deleteRequest(id: number): void;
|
|
9
|
+
export declare function deleteRequests(ids: number[]): void;
|
|
10
|
+
export declare function saveSetting(key: string, value: string): void;
|
|
11
|
+
export declare function getSetting(key: string): string | null;
|
|
12
|
+
export declare function closeDb(): void;
|
|
13
|
+
export declare function getProxyProfiles(): ProxyProfile[];
|
|
14
|
+
export declare function saveProxyProfiles(profiles: ProxyProfile[]): void;
|
|
15
|
+
export declare function getActiveProxyProfile(): ProxyProfile | null;
|
|
16
|
+
export declare function setActiveProxyProfile(id: string): void;
|
|
17
|
+
export declare function createProxyProfile(name: string, targetUrl: string, targetApiKey: string, model?: string, inputTokenPrice?: number, outputTokenPrice?: number, cachedInputTokenPrice?: number): ProxyProfile;
|
|
18
|
+
export declare function updateProxyProfile(id: string, updates: Partial<Omit<ProxyProfile, 'id'>>): ProxyProfile | null;
|
|
19
|
+
export declare function deleteProxyProfile(id: string): void;
|