protocol-proxy 2.7.0 → 2.8.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/lib/proxy-server.js +75 -1
- package/lib/request-log.js +27 -0
- package/lib/stats-store.js +26 -2
- package/lib/ws-server.js +46 -0
- package/package.json +4 -3
- package/public/app.js +254 -3
- package/public/index.html +59 -0
- package/public/style.css +123 -0
- package/server.js +118 -1
package/lib/proxy-server.js
CHANGED
|
@@ -8,6 +8,7 @@ const a2g = require('./converters/anthropic-to-gemini');
|
|
|
8
8
|
const g2a = require('./converters/gemini-to-anthropic');
|
|
9
9
|
const { recordUsage } = require('./stats-store');
|
|
10
10
|
const logger = require('./logger');
|
|
11
|
+
const requestLog = require('./request-log');
|
|
11
12
|
|
|
12
13
|
function createProxyApp(proxyConfigOrGetter) {
|
|
13
14
|
const getProxyConfig = typeof proxyConfigOrGetter === 'function'
|
|
@@ -343,6 +344,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
343
344
|
|
|
344
345
|
async function handleRequest(req, res) {
|
|
345
346
|
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
347
|
+
const requestStart = Date.now();
|
|
346
348
|
const proxyConfig = getProxyConfig();
|
|
347
349
|
const inboundProtocol = detectInboundProtocol(req, req.body);
|
|
348
350
|
const candidates = buildCandidates(proxyConfig);
|
|
@@ -353,6 +355,8 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
353
355
|
|
|
354
356
|
const isStream = req.body?.stream === true;
|
|
355
357
|
const proxyId = proxyConfig.id || 'default';
|
|
358
|
+
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
|
359
|
+
const proxyName = proxyConfig.name || '';
|
|
356
360
|
const inboundModel = req.body?.model;
|
|
357
361
|
const effectiveModel = proxyConfig.target?.defaultModel || inboundModel;
|
|
358
362
|
const baseRequestBody = effectiveModel ? { ...req.body, model: effectiveModel } : { ...req.body };
|
|
@@ -448,6 +452,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
448
452
|
|
|
449
453
|
const maxKeyRetries = (candidate.apiKeys || []).filter(k => typeof k === 'object' ? k.enabled !== false : true).length || 1;
|
|
450
454
|
let lastKeyError = null;
|
|
455
|
+
let keyLabel = '';
|
|
451
456
|
|
|
452
457
|
for (let keyAttempt = 0; keyAttempt < maxKeyRetries; keyAttempt++) {
|
|
453
458
|
const currentKey = selectKey(candidate.providerId, candidate.apiKeys || []);
|
|
@@ -493,7 +498,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
493
498
|
recordSuccess(proxyId, candidate.providerId, Date.now() - startedAt);
|
|
494
499
|
const keyEntry = (candidate.apiKeys || []).find(k => (typeof k === 'string' ? k : k.key) === currentKey);
|
|
495
500
|
const alias = keyEntry && typeof keyEntry === 'object' ? keyEntry.alias : '';
|
|
496
|
-
|
|
501
|
+
keyLabel = alias ? `${alias}(…${currentKey.slice(-4)})` : (currentKey ? `…${currentKey.slice(-4)}` : '-');
|
|
497
502
|
logger.log(`[${requestId}] ✓ ${candidate.providerName} | model=${candidateModel || '(default)'} key=${keyLabel} (${Date.now() - startedAt}ms)`);
|
|
498
503
|
|
|
499
504
|
if (isStream) {
|
|
@@ -543,6 +548,18 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
543
548
|
|
|
544
549
|
if (streamUsage) {
|
|
545
550
|
recordUsage(proxyConfig.id, candidate.providerName, candidateModel, streamUsage, false);
|
|
551
|
+
requestLog.add({
|
|
552
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
553
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
554
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
555
|
+
status: 'success', upstreamStatusCode: null,
|
|
556
|
+
latencyMs: Date.now() - startedAt,
|
|
557
|
+
promptTokens: streamUsage.prompt_tokens || streamUsage.input_tokens || 0,
|
|
558
|
+
completionTokens: streamUsage.completion_tokens || streamUsage.output_tokens || 0,
|
|
559
|
+
totalTokens: (streamUsage.prompt_tokens || streamUsage.input_tokens || 0)
|
|
560
|
+
+ (streamUsage.completion_tokens || streamUsage.output_tokens || 0),
|
|
561
|
+
isEstimated: false, stream: true, keyAlias: keyLabel, errorMessage: null, clientIP,
|
|
562
|
+
});
|
|
546
563
|
} else if (responseText || toolCallCount > 0) {
|
|
547
564
|
const inputTokens = estimateInputTokens(req.body);
|
|
548
565
|
const outputTokens = estimateTokens(responseText) + toolCallCount * 15;
|
|
@@ -550,6 +567,16 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
550
567
|
prompt_tokens: inputTokens,
|
|
551
568
|
completion_tokens: outputTokens,
|
|
552
569
|
}, true);
|
|
570
|
+
requestLog.add({
|
|
571
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
572
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
573
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
574
|
+
status: 'success', upstreamStatusCode: null,
|
|
575
|
+
latencyMs: Date.now() - startedAt,
|
|
576
|
+
promptTokens: inputTokens, completionTokens: outputTokens,
|
|
577
|
+
totalTokens: inputTokens + outputTokens,
|
|
578
|
+
isEstimated: true, stream: true, keyAlias: keyLabel, errorMessage: null, clientIP,
|
|
579
|
+
});
|
|
553
580
|
}
|
|
554
581
|
|
|
555
582
|
if (sseConverter) {
|
|
@@ -559,6 +586,15 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
559
586
|
} catch (err) {
|
|
560
587
|
recordFailure(proxyId, candidate.providerId);
|
|
561
588
|
logger.error(`[${requestId}] Stream error:`, err.message);
|
|
589
|
+
requestLog.add({
|
|
590
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
591
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
592
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
593
|
+
status: 'failure', upstreamStatusCode: null,
|
|
594
|
+
latencyMs: Date.now() - startedAt,
|
|
595
|
+
promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
|
|
596
|
+
stream: true, keyAlias: keyLabel, errorMessage: err.message, clientIP,
|
|
597
|
+
});
|
|
562
598
|
if (!res.writableEnded) {
|
|
563
599
|
try {
|
|
564
600
|
res.write(`data: ${JSON.stringify({ error: { message: err.message, type: 'proxy_error' } })}\n\n`);
|
|
@@ -574,6 +610,18 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
574
610
|
extractReasoningFromResponse(responseBody);
|
|
575
611
|
extractAnthropicThinking(responseBody);
|
|
576
612
|
recordUsage(proxyConfig.id, candidate.providerName, candidateModel, responseBody.usage);
|
|
613
|
+
requestLog.add({
|
|
614
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
615
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
616
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
617
|
+
status: 'success', upstreamStatusCode: fetchRes.status,
|
|
618
|
+
latencyMs: Date.now() - startedAt,
|
|
619
|
+
promptTokens: responseBody.usage?.prompt_tokens || responseBody.usage?.input_tokens || 0,
|
|
620
|
+
completionTokens: responseBody.usage?.completion_tokens || responseBody.usage?.output_tokens || 0,
|
|
621
|
+
totalTokens: (responseBody.usage?.prompt_tokens || responseBody.usage?.input_tokens || 0)
|
|
622
|
+
+ (responseBody.usage?.completion_tokens || responseBody.usage?.output_tokens || 0),
|
|
623
|
+
isEstimated: false, stream: false, keyAlias: keyLabel, errorMessage: null, clientIP,
|
|
624
|
+
});
|
|
577
625
|
const convertedBody = convertRes(responseBody);
|
|
578
626
|
return res.json(convertedBody);
|
|
579
627
|
} catch (err) {
|
|
@@ -584,6 +632,15 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
584
632
|
}
|
|
585
633
|
recordFailure(proxyId, candidate.providerId);
|
|
586
634
|
logger.error(`[${requestId}] ✗ ${candidate.providerName} | model=${candidateModel || '(default)'} - ${err.message}`);
|
|
635
|
+
requestLog.add({
|
|
636
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
637
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
638
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
639
|
+
status: 'failure', upstreamStatusCode: err?.status || null,
|
|
640
|
+
latencyMs: Date.now() - startedAt,
|
|
641
|
+
promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
|
|
642
|
+
stream: isStream, keyAlias: keyLabel, errorMessage: err.message, clientIP,
|
|
643
|
+
});
|
|
587
644
|
if (err?.status && !isRetryableStatus(err.status)) {
|
|
588
645
|
return res.status(err.status).json({ error: err.message });
|
|
589
646
|
}
|
|
@@ -596,10 +653,27 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
596
653
|
if (lastKeyError) {
|
|
597
654
|
recordFailure(proxyId, candidate.providerId);
|
|
598
655
|
logger.error(`[${requestId}] ✗ ${candidate.providerName} | all keys rate-limited (429)`);
|
|
656
|
+
requestLog.add({
|
|
657
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
658
|
+
inboundProtocol, targetProtocol: candidate.protocol,
|
|
659
|
+
providerName: candidate.providerName, model: candidateModel || '',
|
|
660
|
+
status: '429', upstreamStatusCode: 429,
|
|
661
|
+
latencyMs: Date.now() - startedAt,
|
|
662
|
+
promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
|
|
663
|
+
stream: isStream, keyAlias: keyLabel, errorMessage: 'All keys rate-limited', clientIP,
|
|
664
|
+
});
|
|
599
665
|
}
|
|
600
666
|
} // end candidate loop
|
|
601
667
|
|
|
602
668
|
logger.error(`[${requestId}] 所有供应商均失败`);
|
|
669
|
+
requestLog.add({
|
|
670
|
+
id: requestId, proxyId, proxyName, method: req.method, path: req.path,
|
|
671
|
+
inboundProtocol, targetProtocol: '', providerName: 'N/A', model: effectiveModel || '',
|
|
672
|
+
status: 'failure', upstreamStatusCode: null,
|
|
673
|
+
latencyMs: Date.now() - requestStart,
|
|
674
|
+
promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
|
|
675
|
+
stream: isStream, keyAlias: '', errorMessage: 'All providers failed', clientIP,
|
|
676
|
+
});
|
|
603
677
|
return res.status(502).json({ error: 'All providers failed' });
|
|
604
678
|
}
|
|
605
679
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const MAX_ENTRIES = 1000;
|
|
2
|
+
|
|
3
|
+
const entries = [];
|
|
4
|
+
let listener = null;
|
|
5
|
+
|
|
6
|
+
function add(entry) {
|
|
7
|
+
entry.timestamp = entry.timestamp || new Date().toISOString();
|
|
8
|
+
entries.unshift(entry);
|
|
9
|
+
if (entries.length > MAX_ENTRIES) entries.pop();
|
|
10
|
+
if (listener) {
|
|
11
|
+
try { listener(entry); } catch { /* ignore */ }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getAll(limit) {
|
|
16
|
+
return entries.slice(0, limit || MAX_ENTRIES);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getCount() {
|
|
20
|
+
return entries.length;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function onEntry(callback) {
|
|
24
|
+
listener = callback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { add, getAll, getCount, onEntry, MAX_ENTRIES };
|
package/lib/stats-store.js
CHANGED
|
@@ -29,6 +29,7 @@ function addToBuffer(period, date, statsKey, prompt, completion, estimated) {
|
|
|
29
29
|
function flush() {
|
|
30
30
|
const stats = readStats();
|
|
31
31
|
if (!dirty) return stats;
|
|
32
|
+
if (!stats.hourly) stats.hourly = {};
|
|
32
33
|
if (!stats.daily) stats.daily = {};
|
|
33
34
|
if (!stats.monthly) stats.monthly = {};
|
|
34
35
|
|
|
@@ -92,15 +93,31 @@ function yearKey(d) {
|
|
|
92
93
|
return String(d.getFullYear());
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
function hourKey(d) {
|
|
97
|
+
return dateKey(d) + '-' + String(d.getHours()).padStart(2, '0');
|
|
98
|
+
}
|
|
99
|
+
|
|
95
100
|
// ==================== 分层合并 ====================
|
|
96
101
|
|
|
97
102
|
function mergeIfNeeded() {
|
|
98
103
|
const stats = flush();
|
|
99
104
|
let changed = false;
|
|
100
105
|
|
|
106
|
+
const todayStr = dateKey(new Date());
|
|
101
107
|
const currentMonth = monthKey(new Date()); // "2026-05"
|
|
102
108
|
const currentYear = String(new Date().getFullYear());
|
|
103
109
|
|
|
110
|
+
// 小时级:清理昨天及更早的数据(daily 已有汇总,hourly 仅保留当天)
|
|
111
|
+
if (stats.hourly) {
|
|
112
|
+
const todayStart = todayStr + '-00';
|
|
113
|
+
for (const hk of Object.keys(stats.hourly)) {
|
|
114
|
+
if (hk < todayStart) {
|
|
115
|
+
delete stats.hourly[hk];
|
|
116
|
+
changed = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
104
121
|
// 日 → 月:非当月的日级数据合并为月级
|
|
105
122
|
if (stats.daily) {
|
|
106
123
|
const toMerge = {};
|
|
@@ -183,6 +200,7 @@ function recordUsage(proxyId, provider, model, usage, estimated = false) {
|
|
|
183
200
|
if (!prompt && !completion) return;
|
|
184
201
|
|
|
185
202
|
const now = new Date();
|
|
203
|
+
const hk = hourKey(now);
|
|
186
204
|
const dk = dateKey(now);
|
|
187
205
|
const mk = monthKey(now);
|
|
188
206
|
|
|
@@ -195,6 +213,7 @@ function recordUsage(proxyId, provider, model, usage, estimated = false) {
|
|
|
195
213
|
];
|
|
196
214
|
|
|
197
215
|
for (const key of keys) {
|
|
216
|
+
addToBuffer('hourly', hk, key, prompt, completion, estimated);
|
|
198
217
|
addToBuffer('daily', dk, key, prompt, completion, estimated);
|
|
199
218
|
}
|
|
200
219
|
}
|
|
@@ -208,9 +227,14 @@ function getStats(opts = {}) {
|
|
|
208
227
|
const stats = readStats();
|
|
209
228
|
const src = stats[range] || {};
|
|
210
229
|
|
|
230
|
+
// hourly keys are "YYYY-MM-DD-HH", need to pad day-level dates for comparison
|
|
231
|
+
const isHourly = range === 'hourly';
|
|
232
|
+
const filterStart = startDate ? (isHourly ? startDate + '-00' : startDate) : null;
|
|
233
|
+
const filterEnd = endDate ? (isHourly ? endDate + '-99' : endDate) : null;
|
|
234
|
+
|
|
211
235
|
const dates = Object.keys(src).filter(d => {
|
|
212
|
-
if (
|
|
213
|
-
if (
|
|
236
|
+
if (filterStart && d < filterStart) return false;
|
|
237
|
+
if (filterEnd && d > filterEnd) return false;
|
|
214
238
|
return true;
|
|
215
239
|
}).sort();
|
|
216
240
|
|
package/lib/ws-server.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const logger = require('./logger');
|
|
2
|
+
|
|
3
|
+
let wss = null;
|
|
4
|
+
let WebSocketServer = null;
|
|
5
|
+
|
|
6
|
+
function init(httpServer) {
|
|
7
|
+
try {
|
|
8
|
+
const ws = require('ws');
|
|
9
|
+
WebSocketServer = ws.WebSocketServer || ws.Server;
|
|
10
|
+
wss = new WebSocketServer({ server: httpServer });
|
|
11
|
+
|
|
12
|
+
wss.on('connection', (ws) => {
|
|
13
|
+
logger.log('[WS] Client connected');
|
|
14
|
+
try { ws.send(JSON.stringify({ type: 'connected', count: getClientCount() })); } catch { /* ignore */ }
|
|
15
|
+
ws.on('close', () => { logger.log('[WS] Client disconnected'); });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
logger.log('[WS] WebSocket server initialized');
|
|
19
|
+
} catch (err) {
|
|
20
|
+
logger.error('[WS] Failed to initialize WebSocket server:', err.message);
|
|
21
|
+
wss = null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function broadcast(data) {
|
|
26
|
+
if (!wss) return;
|
|
27
|
+
const msg = JSON.stringify(data);
|
|
28
|
+
for (const client of wss.clients) {
|
|
29
|
+
if (client.readyState === 1 /* OPEN */) {
|
|
30
|
+
try { client.send(msg); } catch { /* ignore */ }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getClientCount() {
|
|
36
|
+
return wss ? wss.clients.size : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function close() {
|
|
40
|
+
if (wss) {
|
|
41
|
+
try { wss.close(); } catch { /* ignore */ }
|
|
42
|
+
wss = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { init, broadcast, getClientCount, close };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "protocol-proxy",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"description": "OpenAI / Anthropic 协议转换透明代理",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"cors": "^2.8.5",
|
|
30
|
-
"express": "^4.19.2"
|
|
30
|
+
"express": "^4.19.2",
|
|
31
|
+
"ws": "^8.20.1"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"pkg": "^5.8.1"
|
|
@@ -48,4 +49,4 @@
|
|
|
48
49
|
"engines": {
|
|
49
50
|
"node": ">=20.0.0"
|
|
50
51
|
}
|
|
51
|
-
}
|
|
52
|
+
}
|
package/public/app.js
CHANGED
|
@@ -6,6 +6,8 @@ let importData = null;
|
|
|
6
6
|
let statsRange = 'daily';
|
|
7
7
|
let statsProxyId = '';
|
|
8
8
|
let providerPoolItems = [];
|
|
9
|
+
let keyHealth = {};
|
|
10
|
+
let statsAutoRefreshTimer = null;
|
|
9
11
|
|
|
10
12
|
// ==================== 主题切换 ====================
|
|
11
13
|
|
|
@@ -81,6 +83,30 @@ function updateStats() {
|
|
|
81
83
|
proxies.filter(p => p.running).length;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
async function loadKeyHealth() {
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch('/api/key-health');
|
|
89
|
+
keyHealth = await res.json();
|
|
90
|
+
refreshHealthUI();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error('加载 Key 健康状态失败:', err);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function refreshHealthUI() {
|
|
97
|
+
// 更新每个代理卡片上的健康点
|
|
98
|
+
document.querySelectorAll('.health-dot[data-provider]').forEach(dot => {
|
|
99
|
+
const h = keyHealth[dot.dataset.provider];
|
|
100
|
+
dot.className = 'health-dot';
|
|
101
|
+
if (!h || h.status === 'unknown') { dot.classList.add('health-unknown'); dot.title = '未检测'; }
|
|
102
|
+
else if (h.status === 'healthy') { dot.classList.add('health-ok'); dot.title = 'Key 正常'; }
|
|
103
|
+
else if (h.status === 'partial') { dot.classList.add('health-warn'); dot.title = '部分 Key 异常'; }
|
|
104
|
+
else { dot.classList.add('health-error'); dot.title = 'Key 全部异常'; }
|
|
105
|
+
});
|
|
106
|
+
// 更新汇总卡片
|
|
107
|
+
renderProviderHealthSummary();
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
function parseProviderPool(value) {
|
|
85
111
|
const text = (value || '').trim();
|
|
86
112
|
if (!text) return [];
|
|
@@ -991,9 +1017,18 @@ function initStatsRangeBtns() {
|
|
|
991
1017
|
document.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
|
|
992
1018
|
btn.classList.add('active');
|
|
993
1019
|
statsRange = btn.dataset.range;
|
|
994
|
-
//
|
|
995
|
-
|
|
996
|
-
|
|
1020
|
+
// 清除自动刷新
|
|
1021
|
+
if (statsAutoRefreshTimer) { clearInterval(statsAutoRefreshTimer); statsAutoRefreshTimer = null; }
|
|
1022
|
+
if (statsRange === 'hourly') {
|
|
1023
|
+
// 实时模式:设为今天 + 30 秒自动刷新
|
|
1024
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1025
|
+
document.getElementById('stats-start-date').value = today;
|
|
1026
|
+
document.getElementById('stats-end-date').value = today;
|
|
1027
|
+
statsAutoRefreshTimer = setInterval(loadStats, 30000);
|
|
1028
|
+
} else {
|
|
1029
|
+
document.getElementById('stats-start-date').value = '';
|
|
1030
|
+
document.getElementById('stats-end-date').value = '';
|
|
1031
|
+
}
|
|
997
1032
|
loadStats();
|
|
998
1033
|
});
|
|
999
1034
|
});
|
|
@@ -1054,7 +1089,16 @@ function initProviderPoolDropdown() {
|
|
|
1054
1089
|
}
|
|
1055
1090
|
|
|
1056
1091
|
async function init() {
|
|
1092
|
+
// 默认统计范围:当天(HTML 内联脚本已优先设置,此处兜底)
|
|
1093
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1094
|
+
const sd = document.getElementById('stats-start-date');
|
|
1095
|
+
const ed = document.getElementById('stats-end-date');
|
|
1096
|
+
if (!sd.value) sd.value = today;
|
|
1097
|
+
if (!ed.value) ed.value = today;
|
|
1057
1098
|
await Promise.all([loadProxies(), loadProviders(), loadStats()]);
|
|
1099
|
+
// 延迟加载 health(等后端启动检测完成),之后每 5 分钟刷新
|
|
1100
|
+
setTimeout(() => loadKeyHealth(), 6000);
|
|
1101
|
+
setInterval(() => loadKeyHealth(), 5 * 60 * 1000);
|
|
1058
1102
|
renderProxies();
|
|
1059
1103
|
initProviderDropdown();
|
|
1060
1104
|
initModelDropdown();
|
|
@@ -1087,6 +1131,7 @@ async function init() {
|
|
|
1087
1131
|
if (document.getElementById('confirm-modal').classList.contains('active')) return;
|
|
1088
1132
|
if (document.getElementById('log-modal').classList.contains('active')) { closeLogViewer(); return; }
|
|
1089
1133
|
if (document.getElementById('history-modal').classList.contains('active')) { closeHistoryViewer(); return; }
|
|
1134
|
+
if (document.getElementById('request-log-modal').classList.contains('active')) { closeRequestLog(); return; }
|
|
1090
1135
|
if (document.getElementById('test-result-modal').classList.contains('active')) { document.getElementById('test-result-modal').classList.remove('active'); return; }
|
|
1091
1136
|
if (document.getElementById('import-modal').classList.contains('active')) { closeImportModal(); return; }
|
|
1092
1137
|
if (document.getElementById('modal').classList.contains('active')) { closeModal(); return; }
|
|
@@ -1227,6 +1272,156 @@ async function loadLogs() {
|
|
|
1227
1272
|
}
|
|
1228
1273
|
}
|
|
1229
1274
|
|
|
1275
|
+
// ==================== 实时请求日志 ====================
|
|
1276
|
+
|
|
1277
|
+
let requestLogWs = null;
|
|
1278
|
+
let requestLogEntries = [];
|
|
1279
|
+
let requestLogReconnectTimer = null;
|
|
1280
|
+
|
|
1281
|
+
function openRequestLog() {
|
|
1282
|
+
document.getElementById('request-log-modal').classList.add('active');
|
|
1283
|
+
populateRequestLogFilters();
|
|
1284
|
+
loadInitialRequestLogs();
|
|
1285
|
+
connectRequestLogWs();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function closeRequestLog() {
|
|
1289
|
+
document.getElementById('request-log-modal').classList.remove('active');
|
|
1290
|
+
disconnectRequestLogWs();
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function connectRequestLogWs() {
|
|
1294
|
+
if (requestLogWs) return;
|
|
1295
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1296
|
+
const url = `${protocol}//${location.host}`;
|
|
1297
|
+
requestLogWs = new WebSocket(url);
|
|
1298
|
+
|
|
1299
|
+
requestLogWs.onopen = () => {
|
|
1300
|
+
const el = document.getElementById('request-log-ws-status');
|
|
1301
|
+
el.textContent = '已连接';
|
|
1302
|
+
el.style.color = '#34d399';
|
|
1303
|
+
if (requestLogReconnectTimer) { clearTimeout(requestLogReconnectTimer); requestLogReconnectTimer = null; }
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
requestLogWs.onmessage = (event) => {
|
|
1307
|
+
try {
|
|
1308
|
+
const entry = JSON.parse(event.data);
|
|
1309
|
+
if (entry.type === 'connected') return;
|
|
1310
|
+
requestLogEntries.unshift(entry);
|
|
1311
|
+
if (requestLogEntries.length > 2000) requestLogEntries.pop();
|
|
1312
|
+
appendRequestLogRow(entry);
|
|
1313
|
+
updateRequestLogCount();
|
|
1314
|
+
} catch { /* ignore */ }
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
requestLogWs.onclose = () => {
|
|
1318
|
+
requestLogWs = null;
|
|
1319
|
+
const el = document.getElementById('request-log-ws-status');
|
|
1320
|
+
el.textContent = '已断开';
|
|
1321
|
+
el.style.color = '#ef4444';
|
|
1322
|
+
if (document.getElementById('request-log-modal').classList.contains('active')) {
|
|
1323
|
+
requestLogReconnectTimer = setTimeout(connectRequestLogWs, 3000);
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
requestLogWs.onerror = () => {};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function disconnectRequestLogWs() {
|
|
1331
|
+
if (requestLogWs) { requestLogWs.close(); requestLogWs = null; }
|
|
1332
|
+
if (requestLogReconnectTimer) { clearTimeout(requestLogReconnectTimer); requestLogReconnectTimer = null; }
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
async function loadInitialRequestLogs() {
|
|
1336
|
+
try {
|
|
1337
|
+
const res = await fetch('/api/request-logs?limit=200');
|
|
1338
|
+
const data = await res.json();
|
|
1339
|
+
requestLogEntries = data.entries || [];
|
|
1340
|
+
renderRequestLogTable();
|
|
1341
|
+
updateRequestLogCount();
|
|
1342
|
+
} catch { /* ignore */ }
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function populateRequestLogFilters() {
|
|
1346
|
+
const select = document.getElementById('request-log-proxy-filter');
|
|
1347
|
+
select.innerHTML = '<option value="">全部代理</option>' +
|
|
1348
|
+
(proxies || []).map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join('');
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function filterRequestLogs() {
|
|
1352
|
+
renderRequestLogTable();
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function getFilteredRequestLogs() {
|
|
1356
|
+
const proxyId = document.getElementById('request-log-proxy-filter').value;
|
|
1357
|
+
const status = document.getElementById('request-log-status-filter').value;
|
|
1358
|
+
const model = (document.getElementById('request-log-model-filter').value || '').trim().toLowerCase();
|
|
1359
|
+
|
|
1360
|
+
return requestLogEntries.filter(e => {
|
|
1361
|
+
if (proxyId && e.proxyId !== proxyId) return false;
|
|
1362
|
+
if (status && e.status !== status) return false;
|
|
1363
|
+
if (model && !(e.model || '').toLowerCase().includes(model)) return false;
|
|
1364
|
+
return true;
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function updateRequestLogCount() {
|
|
1369
|
+
const filtered = getFilteredRequestLogs();
|
|
1370
|
+
document.getElementById('request-log-count').textContent =
|
|
1371
|
+
`(显示 ${filtered.length}/${requestLogEntries.length})`;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function renderRequestLogTable() {
|
|
1375
|
+
const tbody = document.getElementById('request-log-tbody');
|
|
1376
|
+
const filtered = getFilteredRequestLogs();
|
|
1377
|
+
tbody.innerHTML = filtered.map(e => entryToRowHtml(e)).join('');
|
|
1378
|
+
updateRequestLogCount();
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function appendRequestLogRow(entry) {
|
|
1382
|
+
const proxyId = document.getElementById('request-log-proxy-filter').value;
|
|
1383
|
+
const status = document.getElementById('request-log-status-filter').value;
|
|
1384
|
+
const model = (document.getElementById('request-log-model-filter').value || '').trim().toLowerCase();
|
|
1385
|
+
if (proxyId && entry.proxyId !== proxyId) return;
|
|
1386
|
+
if (status && entry.status !== status) return;
|
|
1387
|
+
if (model && !(entry.model || '').toLowerCase().includes(model)) return;
|
|
1388
|
+
|
|
1389
|
+
const tbody = document.getElementById('request-log-tbody');
|
|
1390
|
+
const row = document.createElement('tr');
|
|
1391
|
+
row.className = 'request-log-row request-log-' + entry.status;
|
|
1392
|
+
row.innerHTML = entryToCellHtml(entry);
|
|
1393
|
+
tbody.insertBefore(row, tbody.firstChild);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function entryToRowHtml(e) {
|
|
1397
|
+
return `<tr class="request-log-row request-log-${e.status}">${entryToCellHtml(e)}</tr>`;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function entryToCellHtml(e) {
|
|
1401
|
+
const time = new Date(e.timestamp).toLocaleTimeString('zh-CN', { hour12: false });
|
|
1402
|
+
const statusLabel = e.status === 'success' ? '成功' : e.status === '429' ? '429' : '失败';
|
|
1403
|
+
const tokens = e.totalTokens > 0 ? `${e.promptTokens}+${e.completionTokens}` : '-';
|
|
1404
|
+
const latency = e.latencyMs != null ? `${e.latencyMs}ms` : '-';
|
|
1405
|
+
const statusSuffix = e.upstreamStatusCode ? ` (${e.upstreamStatusCode})` : '';
|
|
1406
|
+
return [
|
|
1407
|
+
`<td>${time}</td>`,
|
|
1408
|
+
`<td>${escapeHtml(e.proxyName || '-')}</td>`,
|
|
1409
|
+
`<td><span class="badge" style="font-size:11px">${escapeHtml(e.inboundProtocol || '-')}</span></td>`,
|
|
1410
|
+
`<td><code style="font-size:12px">${escapeHtml(e.model || '-')}</code></td>`,
|
|
1411
|
+
`<td><span class="request-log-status-badge request-log-status-${e.status}">${statusLabel}${statusSuffix}</span></td>`,
|
|
1412
|
+
`<td>${tokens}${e.isEstimated ? ' <span title="估算值" style="color:var(--text-dim)">~</span>' : ''}</td>`,
|
|
1413
|
+
`<td>${latency}</td>`,
|
|
1414
|
+
`<td>${escapeHtml(e.providerName || '-')}</td>`,
|
|
1415
|
+
`<td>${escapeHtml(e.keyAlias || '-')}</td>`,
|
|
1416
|
+
].join('');
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function clearRequestLogs() {
|
|
1420
|
+
requestLogEntries = [];
|
|
1421
|
+
document.getElementById('request-log-tbody').innerHTML = '';
|
|
1422
|
+
document.getElementById('request-log-count').textContent = '';
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1230
1425
|
// ==================== 版本历史 ====================
|
|
1231
1426
|
|
|
1232
1427
|
async function openHistoryViewer() {
|
|
@@ -1327,9 +1522,64 @@ function filterProxies() {
|
|
|
1327
1522
|
renderProxies();
|
|
1328
1523
|
}
|
|
1329
1524
|
|
|
1525
|
+
function healthDot(providerId) {
|
|
1526
|
+
const h = keyHealth[providerId];
|
|
1527
|
+
const cls = !h || h.status === 'unknown' ? 'health-unknown'
|
|
1528
|
+
: h.status === 'healthy' ? 'health-ok'
|
|
1529
|
+
: h.status === 'partial' ? 'health-warn' : 'health-error';
|
|
1530
|
+
const title = !h || h.status === 'unknown' ? '未检测'
|
|
1531
|
+
: h.status === 'healthy' ? 'Key 正常'
|
|
1532
|
+
: h.status === 'partial' ? '部分 Key 异常' : 'Key 全部异常';
|
|
1533
|
+
return `<span class="health-dot ${cls}" data-provider="${escapeHtml(providerId)}" title="${title}"></span>`;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function renderProviderHealthSummary() {
|
|
1537
|
+
const el = document.getElementById('provider-health-summary');
|
|
1538
|
+
if (!el) return;
|
|
1539
|
+
const allProviders = proxies.map(p => p.providerId).filter(Boolean);
|
|
1540
|
+
const unique = [...new Set(allProviders)];
|
|
1541
|
+
if (unique.length === 0) { el.style.display = 'none'; return; }
|
|
1542
|
+
let healthy = 0, partial = 0, unhealthy = 0, unknown = 0;
|
|
1543
|
+
for (const id of unique) {
|
|
1544
|
+
const h = keyHealth[id];
|
|
1545
|
+
if (!h || h.status === 'unknown') unknown++;
|
|
1546
|
+
else if (h.status === 'healthy') healthy++;
|
|
1547
|
+
else if (h.status === 'partial') partial++;
|
|
1548
|
+
else unhealthy++;
|
|
1549
|
+
}
|
|
1550
|
+
el.style.display = '';
|
|
1551
|
+
el.innerHTML = `
|
|
1552
|
+
<div class="health-stat"><span class="health-dot health-ok"></span><span>正常 ${healthy}</span></div>
|
|
1553
|
+
<div class="health-stat"><span class="health-dot health-warn"></span><span>部分异常 ${partial}</span></div>
|
|
1554
|
+
<div class="health-stat"><span class="health-dot health-error"></span><span>异常 ${unhealthy}</span></div>
|
|
1555
|
+
<div class="health-stat"><span class="health-dot health-unknown"></span><span>未检测 ${unknown}</span></div>
|
|
1556
|
+
<button class="btn btn-sm" onclick="recheckKeys()">重新检测</button>
|
|
1557
|
+
`;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
let rechecking = false;
|
|
1561
|
+
async function recheckKeys() {
|
|
1562
|
+
if (rechecking) return;
|
|
1563
|
+
rechecking = true;
|
|
1564
|
+
const btn = document.querySelector('.provider-health-summary .btn');
|
|
1565
|
+
if (btn) { btn.disabled = true; btn.textContent = '检测中...'; }
|
|
1566
|
+
showToast('正在检测...');
|
|
1567
|
+
try {
|
|
1568
|
+
await fetch('/api/key-health/check', { method: 'POST' });
|
|
1569
|
+
await loadKeyHealth();
|
|
1570
|
+
showToast('检测完成');
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
showToast('检测失败: ' + err.message, true);
|
|
1573
|
+
} finally {
|
|
1574
|
+
rechecking = false;
|
|
1575
|
+
if (btn) { btn.disabled = false; btn.textContent = '重新检测'; }
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1330
1579
|
function renderProxies() {
|
|
1331
1580
|
const container = document.getElementById('proxy-list');
|
|
1332
1581
|
const list = getFilteredProxies();
|
|
1582
|
+
renderProviderHealthSummary();
|
|
1333
1583
|
if (proxies.length === 0) {
|
|
1334
1584
|
container.innerHTML = '<div class="empty">暂无代理配置,点击右上角创建</div>';
|
|
1335
1585
|
return;
|
|
@@ -1374,6 +1624,7 @@ function renderProxies() {
|
|
|
1374
1624
|
</div>
|
|
1375
1625
|
<div class="proxy-meta">
|
|
1376
1626
|
<span>端口: <strong>${p.port}</strong></span>
|
|
1627
|
+
<span>供应商: ${healthDot(p.providerId)} ${escapeHtml(p.providerName || '-')}</span>
|
|
1377
1628
|
<span>认证: ${p.requireAuth ? '已启用' : '未启用'}</span>
|
|
1378
1629
|
</div>
|
|
1379
1630
|
<div class="proxy-address">
|
package/public/index.html
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
<link rel="stylesheet" href="style.css">
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
|
+
<script>
|
|
11
|
+
// 尽早设置统计日期默认值,避免 init() 时序问题
|
|
12
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
13
|
+
var today = new Date().toISOString().slice(0, 10);
|
|
14
|
+
var sd = document.getElementById('stats-start-date');
|
|
15
|
+
var ed = document.getElementById('stats-end-date');
|
|
16
|
+
if (sd && !sd.value) sd.value = today;
|
|
17
|
+
if (ed && !ed.value) ed.value = today;
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
10
20
|
<div class="container">
|
|
11
21
|
<button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="切换主题">
|
|
12
22
|
<span id="theme-icon">☾</span>
|
|
@@ -43,6 +53,7 @@
|
|
|
43
53
|
</div>
|
|
44
54
|
</div>
|
|
45
55
|
<div class="stats-range-btns">
|
|
56
|
+
<button class="btn btn-sm stats-range-btn" data-range="hourly">实时</button>
|
|
46
57
|
<button class="btn btn-sm stats-range-btn active" data-range="daily">每日</button>
|
|
47
58
|
<button class="btn btn-sm stats-range-btn" data-range="monthly">每月</button>
|
|
48
59
|
<button class="btn btn-sm stats-range-btn" data-range="yearly">每年</button>
|
|
@@ -95,8 +106,10 @@
|
|
|
95
106
|
<button class="btn btn-sm" onclick="startAllProxies()">全部启动</button>
|
|
96
107
|
<button class="btn btn-sm" onclick="stopAllProxies()">全部停止</button>
|
|
97
108
|
<button class="btn btn-sm" onclick="openLogViewer()">日志</button>
|
|
109
|
+
<button class="btn btn-sm" onclick="openRequestLog()">请求日志</button>
|
|
98
110
|
</div>
|
|
99
111
|
</div>
|
|
112
|
+
<div class="provider-health-summary" id="provider-health-summary" style="display:none"></div>
|
|
100
113
|
<div id="proxy-list" class="proxy-list">
|
|
101
114
|
<div class="empty">加载中...</div>
|
|
102
115
|
</div>
|
|
@@ -331,6 +344,52 @@
|
|
|
331
344
|
</div>
|
|
332
345
|
</div>
|
|
333
346
|
|
|
347
|
+
<!-- 实时请求日志弹窗 -->
|
|
348
|
+
<div class="modal" id="request-log-modal">
|
|
349
|
+
<div class="modal-content" style="max-width:1200px">
|
|
350
|
+
<div class="modal-header">
|
|
351
|
+
<h3>实时请求日志 <span id="request-log-count" style="color:#64748b;font-size:0.8rem;font-weight:400"></span></h3>
|
|
352
|
+
<button class="btn-close" onclick="closeRequestLog()">×</button>
|
|
353
|
+
</div>
|
|
354
|
+
<div class="request-log-toolbar">
|
|
355
|
+
<div class="request-log-filters">
|
|
356
|
+
<select id="request-log-proxy-filter" onchange="filterRequestLogs()">
|
|
357
|
+
<option value="">全部代理</option>
|
|
358
|
+
</select>
|
|
359
|
+
<select id="request-log-status-filter" onchange="filterRequestLogs()">
|
|
360
|
+
<option value="">全部状态</option>
|
|
361
|
+
<option value="success">成功</option>
|
|
362
|
+
<option value="failure">失败</option>
|
|
363
|
+
<option value="429">429 限流</option>
|
|
364
|
+
</select>
|
|
365
|
+
<input type="text" id="request-log-model-filter" placeholder="模型过滤..." oninput="filterRequestLogs()">
|
|
366
|
+
</div>
|
|
367
|
+
<div class="request-log-controls">
|
|
368
|
+
<button class="btn btn-sm" id="request-log-ws-status" style="color:var(--text-dim);cursor:default">连接中...</button>
|
|
369
|
+
<button class="btn btn-sm" onclick="clearRequestLogs()">清空</button>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
<div class="request-log-table-wrap" id="request-log-table-wrap">
|
|
373
|
+
<table class="request-log-table">
|
|
374
|
+
<thead>
|
|
375
|
+
<tr>
|
|
376
|
+
<th>时间</th>
|
|
377
|
+
<th>代理</th>
|
|
378
|
+
<th>协议</th>
|
|
379
|
+
<th>模型</th>
|
|
380
|
+
<th>状态</th>
|
|
381
|
+
<th>Tokens</th>
|
|
382
|
+
<th>延迟</th>
|
|
383
|
+
<th>供应商</th>
|
|
384
|
+
<th>Key</th>
|
|
385
|
+
</tr>
|
|
386
|
+
</thead>
|
|
387
|
+
<tbody id="request-log-tbody"></tbody>
|
|
388
|
+
</table>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
334
393
|
<!-- 导入预览弹窗 -->
|
|
335
394
|
<div class="modal" id="import-modal">
|
|
336
395
|
<div class="modal-content" style="max-width:500px">
|
package/public/style.css
CHANGED
|
@@ -1871,3 +1871,126 @@ form {
|
|
|
1871
1871
|
font-size: 0.78rem;
|
|
1872
1872
|
white-space: nowrap;
|
|
1873
1873
|
}
|
|
1874
|
+
|
|
1875
|
+
/* Health dots */
|
|
1876
|
+
.health-dot {
|
|
1877
|
+
display: inline-block;
|
|
1878
|
+
width: 8px;
|
|
1879
|
+
height: 8px;
|
|
1880
|
+
border-radius: 50%;
|
|
1881
|
+
vertical-align: middle;
|
|
1882
|
+
margin-right: 4px;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
.health-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); }
|
|
1886
|
+
.health-warn { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.4); }
|
|
1887
|
+
.health-error { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); }
|
|
1888
|
+
.health-unknown { background: #64748b; }
|
|
1889
|
+
|
|
1890
|
+
/* Provider health summary */
|
|
1891
|
+
.provider-health-summary {
|
|
1892
|
+
display: flex;
|
|
1893
|
+
align-items: center;
|
|
1894
|
+
gap: 18px;
|
|
1895
|
+
padding: 12px 28px;
|
|
1896
|
+
border-bottom: 1px solid var(--border-light);
|
|
1897
|
+
background: var(--bg-surface-alt);
|
|
1898
|
+
font-size: 0.82rem;
|
|
1899
|
+
color: var(--text-muted);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
.health-stat {
|
|
1903
|
+
display: flex;
|
|
1904
|
+
align-items: center;
|
|
1905
|
+
gap: 5px;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/* ==================== 实时请求日志面板 ==================== */
|
|
1909
|
+
|
|
1910
|
+
.request-log-toolbar {
|
|
1911
|
+
display: flex;
|
|
1912
|
+
justify-content: space-between;
|
|
1913
|
+
align-items: center;
|
|
1914
|
+
padding: 8px 0;
|
|
1915
|
+
gap: 8px;
|
|
1916
|
+
flex-wrap: wrap;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
.request-log-filters {
|
|
1920
|
+
display: flex;
|
|
1921
|
+
gap: 8px;
|
|
1922
|
+
align-items: center;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
.request-log-filters select,
|
|
1926
|
+
.request-log-filters input {
|
|
1927
|
+
background: var(--bg-surface);
|
|
1928
|
+
color: var(--text-primary);
|
|
1929
|
+
border: 1px solid var(--border-input);
|
|
1930
|
+
border-radius: 6px;
|
|
1931
|
+
padding: 4px 8px;
|
|
1932
|
+
font-size: 12px;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
.request-log-controls {
|
|
1936
|
+
display: flex;
|
|
1937
|
+
gap: 8px;
|
|
1938
|
+
align-items: center;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
.request-log-table-wrap {
|
|
1942
|
+
max-height: 60vh;
|
|
1943
|
+
overflow-y: auto;
|
|
1944
|
+
border: 1px solid var(--border-light);
|
|
1945
|
+
border-radius: 8px;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
.request-log-table {
|
|
1949
|
+
width: 100%;
|
|
1950
|
+
border-collapse: collapse;
|
|
1951
|
+
font-size: 12px;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
.request-log-table thead {
|
|
1955
|
+
position: sticky;
|
|
1956
|
+
top: 0;
|
|
1957
|
+
z-index: 1;
|
|
1958
|
+
background: var(--bg-elevated);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
.request-log-table th {
|
|
1962
|
+
padding: 8px 10px;
|
|
1963
|
+
text-align: left;
|
|
1964
|
+
font-weight: 600;
|
|
1965
|
+
color: var(--text-muted);
|
|
1966
|
+
border-bottom: 1px solid var(--border-main);
|
|
1967
|
+
font-size: 11px;
|
|
1968
|
+
text-transform: uppercase;
|
|
1969
|
+
letter-spacing: 0.05em;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
.request-log-table td {
|
|
1973
|
+
padding: 6px 10px;
|
|
1974
|
+
border-bottom: 1px solid var(--border-light);
|
|
1975
|
+
color: var(--text-secondary);
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
.request-log-row:hover td {
|
|
1979
|
+
background: var(--bg-surface-alt);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
.request-log-success td:first-child { border-left: 3px solid #34d399; }
|
|
1983
|
+
.request-log-failure td:first-child { border-left: 3px solid #ef4444; }
|
|
1984
|
+
.request-log-429 td:first-child { border-left: 3px solid #fbbf24; }
|
|
1985
|
+
|
|
1986
|
+
.request-log-status-badge {
|
|
1987
|
+
display: inline-block;
|
|
1988
|
+
padding: 2px 8px;
|
|
1989
|
+
border-radius: 10px;
|
|
1990
|
+
font-size: 11px;
|
|
1991
|
+
font-weight: 500;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
.request-log-status-success { background: rgba(52,211,153,0.15); color: #34d399; }
|
|
1995
|
+
.request-log-status-failure { background: rgba(239,68,68,0.15); color: #ef4444; }
|
|
1996
|
+
.request-log-status-429 { background: rgba(251,191,36,0.15); color: #fbbf24; }
|
package/server.js
CHANGED
|
@@ -249,6 +249,89 @@ async function init() {
|
|
|
249
249
|
return proxyManager.startProxy(proxyConfig);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// ==================== API Key 健康检查 ====================
|
|
253
|
+
|
|
254
|
+
const keyHealth = new Map(); // providerId -> { status, lastCheck, keys: [{index, ok, message}] }
|
|
255
|
+
let healthCheckRunning = false;
|
|
256
|
+
|
|
257
|
+
async function checkAllProviderKeys() {
|
|
258
|
+
if (healthCheckRunning) return;
|
|
259
|
+
healthCheckRunning = true;
|
|
260
|
+
try {
|
|
261
|
+
const providers = configStore.getProviders();
|
|
262
|
+
logger.log(`[Health] 开始检查 ${providers.length} 个供应商的 API Key...`);
|
|
263
|
+
for (const provider of providers) {
|
|
264
|
+
await checkProviderKeys(provider);
|
|
265
|
+
}
|
|
266
|
+
logger.log('[Health] API Key 健康检查完成');
|
|
267
|
+
} finally {
|
|
268
|
+
healthCheckRunning = false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function checkProviderKeys(provider) {
|
|
273
|
+
const keys = (provider.apiKeys || []).filter(k => k.enabled !== false);
|
|
274
|
+
if (keys.length === 0) {
|
|
275
|
+
keyHealth.set(provider.id, { status: 'unknown', lastCheck: Date.now(), keys: [] });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const protocol = provider.protocol || 'openai';
|
|
280
|
+
const base = provider.url.replace(/\/$/, '');
|
|
281
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
282
|
+
const isAzure = protocol === 'openai' && !!provider.azureDeployment;
|
|
283
|
+
|
|
284
|
+
const results = await Promise.all(keys.map(async (k, i) => {
|
|
285
|
+
try {
|
|
286
|
+
let testUrl, fetchOpts;
|
|
287
|
+
if (protocol === 'openai') {
|
|
288
|
+
if (isAzure) {
|
|
289
|
+
const ver = provider.azureApiVersion || '2024-02-01';
|
|
290
|
+
testUrl = `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`;
|
|
291
|
+
fetchOpts = { headers: { 'api-key': k.key } };
|
|
292
|
+
} else {
|
|
293
|
+
testUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
294
|
+
fetchOpts = { headers: { 'Authorization': `Bearer ${k.key}` } };
|
|
295
|
+
}
|
|
296
|
+
} else if (protocol === 'anthropic') {
|
|
297
|
+
const testModel = (provider.models && provider.models[0]) || 'claude-3-haiku-20240307';
|
|
298
|
+
testUrl = hasV1Suffix ? `${base}/messages` : `${base}/v1/messages`;
|
|
299
|
+
fetchOpts = {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': k.key, 'anthropic-version': '2023-06-01' },
|
|
302
|
+
body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
|
|
303
|
+
};
|
|
304
|
+
} else if (protocol === 'gemini') {
|
|
305
|
+
testUrl = `${base}/v1beta/models?key=${k.key}`;
|
|
306
|
+
fetchOpts = {};
|
|
307
|
+
} else {
|
|
308
|
+
return { index: i, ok: false, message: '不支持的协议' };
|
|
309
|
+
}
|
|
310
|
+
const res = await fetch(testUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
|
|
311
|
+
if (!res.ok) {
|
|
312
|
+
const hint = res.status === 401 || res.status === 403 ? 'Key 无效或无权限' : `HTTP ${res.status}`;
|
|
313
|
+
return { index: i, ok: false, message: hint };
|
|
314
|
+
}
|
|
315
|
+
return { index: i, ok: true };
|
|
316
|
+
} catch (err) {
|
|
317
|
+
return { index: i, ok: false, message: err.name === 'TimeoutError' ? '连接超时' : err.message };
|
|
318
|
+
}
|
|
319
|
+
}));
|
|
320
|
+
|
|
321
|
+
const allOk = results.every(r => r.ok);
|
|
322
|
+
const anyOk = results.some(r => r.ok);
|
|
323
|
+
keyHealth.set(provider.id, {
|
|
324
|
+
status: allOk ? 'healthy' : anyOk ? 'partial' : 'unhealthy',
|
|
325
|
+
lastCheck: Date.now(),
|
|
326
|
+
keys: results,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 启动后延迟 5 秒执行首次检查
|
|
331
|
+
setTimeout(() => checkAllProviderKeys(), 5000);
|
|
332
|
+
// 每 24 小时检查一次
|
|
333
|
+
setInterval(() => checkAllProviderKeys(), 24 * 60 * 60 * 1000);
|
|
334
|
+
|
|
252
335
|
// ==================== 供应商 API ====================
|
|
253
336
|
|
|
254
337
|
app.get('/api/providers', (req, res) => {
|
|
@@ -720,6 +803,21 @@ async function init() {
|
|
|
720
803
|
});
|
|
721
804
|
});
|
|
722
805
|
|
|
806
|
+
// API Key 健康状态
|
|
807
|
+
app.get('/api/key-health', (req, res) => {
|
|
808
|
+
const result = {};
|
|
809
|
+
for (const [providerId, health] of keyHealth) {
|
|
810
|
+
result[providerId] = health;
|
|
811
|
+
}
|
|
812
|
+
res.json(result);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// 手动触发健康检查
|
|
816
|
+
app.post('/api/key-health/check', async (req, res) => {
|
|
817
|
+
await checkAllProviderKeys();
|
|
818
|
+
res.json({ success: true });
|
|
819
|
+
});
|
|
820
|
+
|
|
723
821
|
// 设置
|
|
724
822
|
app.get('/api/settings', (req, res) => {
|
|
725
823
|
res.json(configStore.getSettings());
|
|
@@ -769,6 +867,13 @@ async function init() {
|
|
|
769
867
|
}
|
|
770
868
|
});
|
|
771
869
|
|
|
870
|
+
// 实时请求日志
|
|
871
|
+
const requestLog = require('./lib/request-log');
|
|
872
|
+
app.get('/api/request-logs', (req, res) => {
|
|
873
|
+
const limit = Math.min(parseInt(req.query.limit) || 200, 2000);
|
|
874
|
+
res.json({ entries: requestLog.getAll(limit), total: requestLog.getCount() });
|
|
875
|
+
});
|
|
876
|
+
|
|
772
877
|
// ==================== 配置导入/导出 ====================
|
|
773
878
|
|
|
774
879
|
app.get('/api/config/export', (req, res) => {
|
|
@@ -938,11 +1043,19 @@ async function init() {
|
|
|
938
1043
|
}
|
|
939
1044
|
}));
|
|
940
1045
|
|
|
941
|
-
|
|
1046
|
+
const http = require('http');
|
|
1047
|
+
const server = app.listen(PORT, () => {
|
|
942
1048
|
const adminUrl = `http://localhost:${PORT}`;
|
|
943
1049
|
logger.log(`[Admin] Management server running on ${adminUrl}`);
|
|
944
1050
|
logger.log(`[Admin] ${proxies.length} proxy config(s) loaded`);
|
|
945
1051
|
logger.log(`[Admin] 日志文件: ${logger.LOG_FILE}`);
|
|
1052
|
+
|
|
1053
|
+
// 初始化 WebSocket 实时日志
|
|
1054
|
+
const wsServer = require('./lib/ws-server');
|
|
1055
|
+
wsServer.init(server);
|
|
1056
|
+
requestLog.onEntry((entry) => wsServer.broadcast(entry));
|
|
1057
|
+
logger.log(`[Admin] WebSocket 已附加 (ws://localhost:${PORT})`);
|
|
1058
|
+
|
|
946
1059
|
openBrowser(adminUrl);
|
|
947
1060
|
});
|
|
948
1061
|
}
|
|
@@ -952,6 +1065,8 @@ process.on('SIGINT', async () => {
|
|
|
952
1065
|
logger.log('[Shutdown] Shutting down...');
|
|
953
1066
|
removePid();
|
|
954
1067
|
try {
|
|
1068
|
+
const wsServer = require('./lib/ws-server');
|
|
1069
|
+
wsServer.close();
|
|
955
1070
|
const proxyManager = require('./lib/proxy-manager');
|
|
956
1071
|
const statsStore = require('./lib/stats-store');
|
|
957
1072
|
statsStore.flush();
|
|
@@ -965,6 +1080,8 @@ process.on('SIGINT', async () => {
|
|
|
965
1080
|
process.on('SIGTERM', async () => {
|
|
966
1081
|
removePid();
|
|
967
1082
|
try {
|
|
1083
|
+
const wsServer = require('./lib/ws-server');
|
|
1084
|
+
wsServer.close();
|
|
968
1085
|
const proxyManager = require('./lib/proxy-manager');
|
|
969
1086
|
const statsStore = require('./lib/stats-store');
|
|
970
1087
|
statsStore.flush();
|