protocol-proxy 2.1.6 → 2.2.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/lib/proxy-server.js +99 -8
- package/lib/stats-store.js +287 -0
- package/package.json +1 -1
- package/public/app.js +253 -2
- package/public/index.html +105 -1
- package/public/style.css +282 -0
- package/server.js +150 -1
package/lib/proxy-server.js
CHANGED
|
@@ -2,6 +2,7 @@ const express = require('express');
|
|
|
2
2
|
const { detectInboundProtocol } = require('./detector');
|
|
3
3
|
const o2a = require('./converters/openai-to-anthropic');
|
|
4
4
|
const a2o = require('./converters/anthropic-to-openai');
|
|
5
|
+
const { recordUsage } = require('./stats-store');
|
|
5
6
|
|
|
6
7
|
function createProxyApp(proxyConfigOrGetter) {
|
|
7
8
|
const getProxyConfig = typeof proxyConfigOrGetter === 'function'
|
|
@@ -35,6 +36,47 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
35
36
|
return reasoningCache.get(getReasoningKey(msg));
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function estimateTokens(text) {
|
|
40
|
+
if (!text) return 0;
|
|
41
|
+
let tokens = 0;
|
|
42
|
+
for (let i = 0; i < text.length; i++) {
|
|
43
|
+
const code = text.charCodeAt(i);
|
|
44
|
+
// CJK 字符 ~1.5 token/字
|
|
45
|
+
if (code >= 0x4E00 && code <= 0x9FFF) tokens += 1.5;
|
|
46
|
+
// 全角标点等 ~1 token
|
|
47
|
+
else if (code >= 0x3000 && code <= 0x303F) tokens += 1;
|
|
48
|
+
// 其他(ASCII 字母、数字、标点、空格)~0.25 token
|
|
49
|
+
else tokens += 0.25;
|
|
50
|
+
}
|
|
51
|
+
return Math.ceil(tokens);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function estimateInputTokens(body) {
|
|
55
|
+
if (!body?.messages) return 0;
|
|
56
|
+
let text = '';
|
|
57
|
+
for (const msg of body.messages) {
|
|
58
|
+
if (typeof msg.content === 'string') {
|
|
59
|
+
text += msg.content;
|
|
60
|
+
} else if (Array.isArray(msg.content)) {
|
|
61
|
+
for (const block of msg.content) {
|
|
62
|
+
if (block.text) text += block.text;
|
|
63
|
+
if (block.type === 'tool_result' && block.content) {
|
|
64
|
+
text += typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (msg.tool_calls) {
|
|
69
|
+
for (const tc of msg.tool_calls) {
|
|
70
|
+
text += (tc.function?.arguments || '') + (tc.function?.name || '');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (body.tools) {
|
|
75
|
+
text += JSON.stringify(body.tools);
|
|
76
|
+
}
|
|
77
|
+
return estimateTokens(text);
|
|
78
|
+
}
|
|
79
|
+
|
|
38
80
|
function injectReasoningToMessages(messages) {
|
|
39
81
|
if (!Array.isArray(messages)) return;
|
|
40
82
|
for (const msg of messages) {
|
|
@@ -119,11 +161,18 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
119
161
|
|
|
120
162
|
const targetBody = convertReq(req.body, effectiveModel);
|
|
121
163
|
|
|
164
|
+
const isAzure = !!target.azureDeployment;
|
|
165
|
+
|
|
166
|
+
// 流式请求时注入 stream_options 以获取 usage 统计(Azure 不支持)
|
|
167
|
+
if (isStream && targetProtocol === 'openai' && !isAzure) {
|
|
168
|
+
targetBody.stream_options = { include_usage: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
122
171
|
// 注入 reasoning_content(针对 DeepSeek 等 reasoning model)
|
|
123
172
|
injectReasoningToMessages(targetBody.messages);
|
|
124
173
|
|
|
125
174
|
// 构建目标 URL
|
|
126
|
-
const targetUrl = buildTargetUrl(target
|
|
175
|
+
const targetUrl = buildTargetUrl(target, req.path);
|
|
127
176
|
console.log(`[${requestId}] 🔗 ${targetUrl} | model=${effectiveModel}`);
|
|
128
177
|
|
|
129
178
|
// 构建请求头
|
|
@@ -133,7 +182,11 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
133
182
|
};
|
|
134
183
|
|
|
135
184
|
if (targetProtocol === 'openai') {
|
|
136
|
-
|
|
185
|
+
if (isAzure) {
|
|
186
|
+
headers['api-key'] = target.apiKey;
|
|
187
|
+
} else {
|
|
188
|
+
headers['Authorization'] = `Bearer ${target.apiKey}`;
|
|
189
|
+
}
|
|
137
190
|
} else if (targetProtocol === 'anthropic') {
|
|
138
191
|
headers['X-Api-Key'] = target.apiKey;
|
|
139
192
|
headers['Anthropic-Version'] = '2023-06-01';
|
|
@@ -155,8 +208,8 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
155
208
|
return res.send(errBody);
|
|
156
209
|
}
|
|
157
210
|
|
|
158
|
-
//
|
|
159
|
-
if (isStream
|
|
211
|
+
// 流式响应(以客户端请求意图为准,不依赖上游 Content-Type)
|
|
212
|
+
if (isStream) {
|
|
160
213
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
161
214
|
res.setHeader('Cache-Control', 'no-cache');
|
|
162
215
|
res.setHeader('Connection', 'keep-alive');
|
|
@@ -164,6 +217,9 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
164
217
|
const sseConverter = createSSEConv ? createSSEConv(effectiveModel) : null;
|
|
165
218
|
const reader = fetchRes.body.getReader();
|
|
166
219
|
const decoder = new TextDecoder();
|
|
220
|
+
let streamUsage = null;
|
|
221
|
+
let responseText = '';
|
|
222
|
+
let toolCallCount = 0;
|
|
167
223
|
|
|
168
224
|
req.on('close', () => {
|
|
169
225
|
try { reader.cancel(); } catch (err) { /* ignore */ }
|
|
@@ -174,6 +230,23 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
174
230
|
const { done, value } = await reader.read();
|
|
175
231
|
if (done) break;
|
|
176
232
|
const chunk = decoder.decode(value, { stream: true });
|
|
233
|
+
// 从流中提取 usage 和响应内容
|
|
234
|
+
const lines = chunk.split('\n');
|
|
235
|
+
for (const line of lines) {
|
|
236
|
+
const trimmed = line.trim();
|
|
237
|
+
if (!trimmed.startsWith('data:') || trimmed === 'data: [DONE]') continue;
|
|
238
|
+
try {
|
|
239
|
+
const d = JSON.parse(trimmed.slice(5).trim());
|
|
240
|
+
if (d.usage) streamUsage = d.usage;
|
|
241
|
+
const delta = d.choices?.[0]?.delta;
|
|
242
|
+
if (delta?.content) responseText += delta.content;
|
|
243
|
+
if (delta?.tool_calls) {
|
|
244
|
+
for (const tc of delta.tool_calls) {
|
|
245
|
+
if (tc.function?.name) toolCallCount++;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch { /* ignore */ }
|
|
249
|
+
}
|
|
177
250
|
if (sseConverter) {
|
|
178
251
|
const converted = sseConverter.convertChunk(chunk);
|
|
179
252
|
if (converted) res.write(converted);
|
|
@@ -181,6 +254,18 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
181
254
|
res.write(chunk);
|
|
182
255
|
}
|
|
183
256
|
}
|
|
257
|
+
|
|
258
|
+
if (streamUsage) {
|
|
259
|
+
recordUsage(proxyConfig.id, proxyConfig.target?.providerName, req.body?.model, streamUsage, false);
|
|
260
|
+
} else if (responseText || toolCallCount > 0) {
|
|
261
|
+
// 上游未返回 usage,从响应内容估算
|
|
262
|
+
const inputTokens = estimateInputTokens(req.body);
|
|
263
|
+
const outputTokens = estimateTokens(responseText) + toolCallCount * 15;
|
|
264
|
+
recordUsage(proxyConfig.id, proxyConfig.target?.providerName, req.body?.model, {
|
|
265
|
+
prompt_tokens: inputTokens,
|
|
266
|
+
completion_tokens: outputTokens,
|
|
267
|
+
}, true);
|
|
268
|
+
}
|
|
184
269
|
if (sseConverter) {
|
|
185
270
|
const flushed = sseConverter.flush();
|
|
186
271
|
if (flushed) res.write(flushed);
|
|
@@ -195,6 +280,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
195
280
|
|
|
196
281
|
const responseBody = await fetchRes.json();
|
|
197
282
|
extractReasoningFromResponse(responseBody);
|
|
283
|
+
recordUsage(proxyConfig.id, proxyConfig.target?.providerName, req.body?.model, responseBody.usage);
|
|
198
284
|
const convertedBody = convertRes(responseBody);
|
|
199
285
|
res.json(convertedBody);
|
|
200
286
|
|
|
@@ -207,16 +293,21 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
207
293
|
return app;
|
|
208
294
|
}
|
|
209
295
|
|
|
210
|
-
function buildTargetUrl(
|
|
211
|
-
const base = providerUrl.replace(/\/$/, '');
|
|
296
|
+
function buildTargetUrl(target, originalPath) {
|
|
297
|
+
const base = target.providerUrl.replace(/\/$/, '');
|
|
212
298
|
const hasV1Suffix = base.endsWith('/v1');
|
|
213
299
|
|
|
214
|
-
if (
|
|
300
|
+
if (target.protocol === 'openai') {
|
|
301
|
+
// Azure OpenAI
|
|
302
|
+
if (target.azureDeployment) {
|
|
303
|
+
const ver = target.azureApiVersion || '2024-02-01';
|
|
304
|
+
return `${base}/openai/deployments/${target.azureDeployment}/chat/completions?api-version=${ver}`;
|
|
305
|
+
}
|
|
215
306
|
if (hasV1Suffix) return `${base}/chat/completions`;
|
|
216
307
|
return `${base}/v1/chat/completions`;
|
|
217
308
|
}
|
|
218
309
|
|
|
219
|
-
if (
|
|
310
|
+
if (target.protocol === 'anthropic') {
|
|
220
311
|
if (hasV1Suffix) return `${base}/messages`;
|
|
221
312
|
return `${base}/v1/messages`;
|
|
222
313
|
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const STATS_PATH = path.join(os.homedir(), '.protocol-proxy', 'stats.json');
|
|
6
|
+
const FLUSH_INTERVAL = 5000;
|
|
7
|
+
|
|
8
|
+
// ==================== 内存缓冲 + 定时刷盘 ====================
|
|
9
|
+
|
|
10
|
+
let buffer = {};
|
|
11
|
+
let dirty = false;
|
|
12
|
+
|
|
13
|
+
function bufferKey(period, date, statsKey) {
|
|
14
|
+
return `${period}:${date}:${statsKey}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function addToBuffer(period, date, statsKey, prompt, completion, estimated) {
|
|
18
|
+
const bk = bufferKey(period, date, statsKey);
|
|
19
|
+
if (!buffer[bk]) {
|
|
20
|
+
buffer[bk] = { period, date, key: statsKey, prompt: 0, completion: 0, requests: 0, estimated: false };
|
|
21
|
+
}
|
|
22
|
+
buffer[bk].prompt += prompt;
|
|
23
|
+
buffer[bk].completion += completion;
|
|
24
|
+
buffer[bk].requests += 1;
|
|
25
|
+
if (estimated) buffer[bk].estimated = true;
|
|
26
|
+
dirty = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function flush() {
|
|
30
|
+
const stats = readStats();
|
|
31
|
+
if (!dirty) return stats;
|
|
32
|
+
if (!stats.daily) stats.daily = {};
|
|
33
|
+
if (!stats.monthly) stats.monthly = {};
|
|
34
|
+
|
|
35
|
+
for (const entry of Object.values(buffer)) {
|
|
36
|
+
const bucket = stats[entry.period];
|
|
37
|
+
if (!bucket[entry.date]) bucket[entry.date] = {};
|
|
38
|
+
if (!bucket[entry.date][entry.key]) {
|
|
39
|
+
bucket[entry.date][entry.key] = { prompt: 0, completion: 0, requests: 0, estimated: false };
|
|
40
|
+
}
|
|
41
|
+
const target = bucket[entry.date][entry.key];
|
|
42
|
+
target.prompt += entry.prompt;
|
|
43
|
+
target.completion += entry.completion;
|
|
44
|
+
target.requests += entry.requests;
|
|
45
|
+
if (entry.estimated) target.estimated = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
buffer = {};
|
|
49
|
+
dirty = false;
|
|
50
|
+
writeStats(stats);
|
|
51
|
+
return stats;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setInterval(flush, FLUSH_INTERVAL);
|
|
55
|
+
|
|
56
|
+
// ==================== 文件读写 ====================
|
|
57
|
+
|
|
58
|
+
function readStats() {
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.existsSync(STATS_PATH)) return {};
|
|
61
|
+
return JSON.parse(fs.readFileSync(STATS_PATH, 'utf-8'));
|
|
62
|
+
} catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeStats(data) {
|
|
68
|
+
try {
|
|
69
|
+
const dir = path.dirname(STATS_PATH);
|
|
70
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
const tmp = STATS_PATH + '.tmp';
|
|
72
|
+
fs.writeFileSync(tmp, JSON.stringify(data), 'utf-8');
|
|
73
|
+
fs.renameSync(tmp, STATS_PATH);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error('[Stats] 写入失败:', err.message);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ==================== 日期工具 ====================
|
|
80
|
+
|
|
81
|
+
function dateKey(d) {
|
|
82
|
+
return d.getFullYear() + '-' +
|
|
83
|
+
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
|
84
|
+
String(d.getDate()).padStart(2, '0');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function monthKey(d) {
|
|
88
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function yearKey(d) {
|
|
92
|
+
return String(d.getFullYear());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ==================== 分层合并 ====================
|
|
96
|
+
|
|
97
|
+
function mergeIfNeeded() {
|
|
98
|
+
const stats = flush();
|
|
99
|
+
let changed = false;
|
|
100
|
+
|
|
101
|
+
const currentMonth = monthKey(new Date()); // "2026-05"
|
|
102
|
+
const currentYear = String(new Date().getFullYear());
|
|
103
|
+
|
|
104
|
+
// 日 → 月:非当月的日级数据合并为月级
|
|
105
|
+
if (stats.daily) {
|
|
106
|
+
const toMerge = {};
|
|
107
|
+
for (const [dk, bucket] of Object.entries(stats.daily)) {
|
|
108
|
+
const mk = dk.slice(0, 7); // "2026-05-09" → "2026-05"
|
|
109
|
+
if (mk >= currentMonth) continue; // 当月的保留
|
|
110
|
+
if (!toMerge[mk]) toMerge[mk] = {};
|
|
111
|
+
for (const [key, val] of Object.entries(bucket)) {
|
|
112
|
+
if (!toMerge[mk][key]) toMerge[mk][key] = { prompt: 0, completion: 0, requests: 0, estimated: false };
|
|
113
|
+
const t = toMerge[mk][key];
|
|
114
|
+
t.prompt += val.prompt;
|
|
115
|
+
t.completion += val.completion;
|
|
116
|
+
t.requests += val.requests;
|
|
117
|
+
if (val.estimated) t.estimated = true;
|
|
118
|
+
}
|
|
119
|
+
delete stats.daily[dk];
|
|
120
|
+
changed = true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!stats.monthly) stats.monthly = {};
|
|
124
|
+
for (const [mk, entries] of Object.entries(toMerge)) {
|
|
125
|
+
if (!stats.monthly[mk]) stats.monthly[mk] = {};
|
|
126
|
+
for (const [key, val] of Object.entries(entries)) {
|
|
127
|
+
if (!stats.monthly[mk][key]) {
|
|
128
|
+
stats.monthly[mk][key] = { prompt: 0, completion: 0, requests: 0, estimated: false };
|
|
129
|
+
}
|
|
130
|
+
const t = stats.monthly[mk][key];
|
|
131
|
+
t.prompt += val.prompt;
|
|
132
|
+
t.completion += val.completion;
|
|
133
|
+
t.requests += val.requests;
|
|
134
|
+
if (val.estimated) t.estimated = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 月 → 年:非当年的月级数据合并为年级
|
|
140
|
+
if (stats.monthly) {
|
|
141
|
+
const toMerge = {};
|
|
142
|
+
for (const [mk, bucket] of Object.entries(stats.monthly)) {
|
|
143
|
+
const yk = mk.slice(0, 4);
|
|
144
|
+
if (yk >= currentYear) continue; // 当年的保留
|
|
145
|
+
if (!toMerge[yk]) toMerge[yk] = {};
|
|
146
|
+
for (const [key, val] of Object.entries(bucket)) {
|
|
147
|
+
if (!toMerge[yk][key]) toMerge[yk][key] = { prompt: 0, completion: 0, requests: 0, estimated: false };
|
|
148
|
+
const t = toMerge[yk][key];
|
|
149
|
+
t.prompt += val.prompt;
|
|
150
|
+
t.completion += val.completion;
|
|
151
|
+
t.requests += val.requests;
|
|
152
|
+
if (val.estimated) t.estimated = true;
|
|
153
|
+
}
|
|
154
|
+
delete stats.monthly[mk];
|
|
155
|
+
changed = true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!stats.yearly) stats.yearly = {};
|
|
159
|
+
for (const [yk, entries] of Object.entries(toMerge)) {
|
|
160
|
+
if (!stats.yearly[yk]) stats.yearly[yk] = {};
|
|
161
|
+
for (const [key, val] of Object.entries(entries)) {
|
|
162
|
+
if (!stats.yearly[yk][key]) {
|
|
163
|
+
stats.yearly[yk][key] = { prompt: 0, completion: 0, requests: 0, estimated: false };
|
|
164
|
+
}
|
|
165
|
+
const t = stats.yearly[yk][key];
|
|
166
|
+
t.prompt += val.prompt;
|
|
167
|
+
t.completion += val.completion;
|
|
168
|
+
t.requests += val.requests;
|
|
169
|
+
if (val.estimated) t.estimated = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (changed) writeStats(stats);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ==================== 记录用量 ====================
|
|
178
|
+
|
|
179
|
+
function recordUsage(proxyId, provider, model, usage, estimated = false) {
|
|
180
|
+
if (!proxyId || !usage) return;
|
|
181
|
+
const prompt = usage.prompt_tokens || usage.input_tokens || 0;
|
|
182
|
+
const completion = usage.completion_tokens || usage.output_tokens || 0;
|
|
183
|
+
if (!prompt && !completion) return;
|
|
184
|
+
|
|
185
|
+
const now = new Date();
|
|
186
|
+
const dk = dateKey(now);
|
|
187
|
+
const mk = monthKey(now);
|
|
188
|
+
|
|
189
|
+
const prov = provider || 'unknown';
|
|
190
|
+
const mdl = model || 'unknown';
|
|
191
|
+
const keys = [
|
|
192
|
+
`p:${proxyId}`,
|
|
193
|
+
`p:${proxyId}:${prov}`,
|
|
194
|
+
`p:${proxyId}:${prov}:${mdl}`,
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
for (const period of ['daily', 'monthly']) {
|
|
198
|
+
const bucket = period === 'daily' ? dk : mk;
|
|
199
|
+
for (const key of keys) {
|
|
200
|
+
addToBuffer(period, bucket, key, prompt, completion, estimated);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ==================== 查询统计 ====================
|
|
206
|
+
|
|
207
|
+
function getStats(opts = {}) {
|
|
208
|
+
mergeIfNeeded();
|
|
209
|
+
const { range = 'daily', startDate, endDate, proxyId } = opts;
|
|
210
|
+
const stats = readStats();
|
|
211
|
+
const src = stats[range] || {};
|
|
212
|
+
|
|
213
|
+
const dates = Object.keys(src).filter(d => {
|
|
214
|
+
if (startDate && d < startDate) return false;
|
|
215
|
+
if (endDate && d > endDate) return false;
|
|
216
|
+
return true;
|
|
217
|
+
}).sort();
|
|
218
|
+
|
|
219
|
+
const summary = { prompt: 0, completion: 0, requests: 0, estimatedCount: 0 };
|
|
220
|
+
const byProvider = {};
|
|
221
|
+
const byModel = {};
|
|
222
|
+
|
|
223
|
+
for (const date of dates) {
|
|
224
|
+
const bucket = src[date];
|
|
225
|
+
for (const [key, val] of Object.entries(bucket)) {
|
|
226
|
+
if (!matchPrefix(key, proxyId)) continue;
|
|
227
|
+
|
|
228
|
+
summary.prompt += val.prompt;
|
|
229
|
+
summary.completion += val.completion;
|
|
230
|
+
summary.requests += val.requests;
|
|
231
|
+
if (val.estimated) summary.estimatedCount += val.requests;
|
|
232
|
+
|
|
233
|
+
const parts = key.split(':');
|
|
234
|
+
if (parts.length >= 3) {
|
|
235
|
+
const prov = parts[2];
|
|
236
|
+
if (!byProvider[prov]) byProvider[prov] = { prompt: 0, completion: 0, requests: 0, estimatedCount: 0 };
|
|
237
|
+
byProvider[prov].prompt += val.prompt;
|
|
238
|
+
byProvider[prov].completion += val.completion;
|
|
239
|
+
byProvider[prov].requests += val.requests;
|
|
240
|
+
if (val.estimated) byProvider[prov].estimatedCount += val.requests;
|
|
241
|
+
}
|
|
242
|
+
if (parts.length >= 4) {
|
|
243
|
+
const prov = parts[2];
|
|
244
|
+
const mdl = parts.slice(3).join(':');
|
|
245
|
+
const mk = prov + '/' + mdl;
|
|
246
|
+
if (!byModel[mk]) byModel[mk] = { provider: prov, model: mdl, prompt: 0, completion: 0, requests: 0, estimatedCount: 0 };
|
|
247
|
+
byModel[mk].prompt += val.prompt;
|
|
248
|
+
byModel[mk].completion += val.completion;
|
|
249
|
+
byModel[mk].requests += val.requests;
|
|
250
|
+
if (val.estimated) byModel[mk].estimatedCount += val.requests;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
range,
|
|
257
|
+
dates,
|
|
258
|
+
summary: {
|
|
259
|
+
...summary,
|
|
260
|
+
total: summary.prompt + summary.completion,
|
|
261
|
+
hasEstimated: summary.estimatedCount > 0,
|
|
262
|
+
},
|
|
263
|
+
byProvider: Object.entries(byProvider)
|
|
264
|
+
.map(([name, v]) => ({ name, ...v, total: v.prompt + v.completion, hasEstimated: v.estimatedCount > 0 }))
|
|
265
|
+
.sort((a, b) => b.total - a.total),
|
|
266
|
+
byModel: Object.values(byModel)
|
|
267
|
+
.map(v => ({ ...v, total: v.prompt + v.completion, hasEstimated: v.estimatedCount > 0 }))
|
|
268
|
+
.sort((a, b) => b.total - a.total),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function matchPrefix(key, proxyId) {
|
|
273
|
+
if (!proxyId) return true;
|
|
274
|
+
const prefix = 'p:' + proxyId;
|
|
275
|
+
if (key.length < prefix.length) return false;
|
|
276
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
277
|
+
if (key[i] !== prefix[i]) return false;
|
|
278
|
+
}
|
|
279
|
+
return key.length === prefix.length || key[prefix.length] === ':';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 进程退出时刷盘
|
|
283
|
+
process.on('exit', flush);
|
|
284
|
+
process.on('SIGINT', () => { flush(); process.exit(0); });
|
|
285
|
+
process.on('SIGTERM', () => { flush(); process.exit(0); });
|
|
286
|
+
|
|
287
|
+
module.exports = { recordUsage, getStats, flush };
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -211,6 +211,10 @@ function selectProvider(id) {
|
|
|
211
211
|
const models = provider?.models || [];
|
|
212
212
|
selectModel(models[0] || '');
|
|
213
213
|
updateModelAddState();
|
|
214
|
+
// 同步 Azure 字段
|
|
215
|
+
document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
|
|
216
|
+
document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
|
|
217
|
+
document.getElementById('azure-fields').style.display = protocol === 'openai' ? '' : 'none';
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
// ==================== Model 下拉框 ====================
|
|
@@ -345,8 +349,239 @@ function updateModelAddState() {
|
|
|
345
349
|
}
|
|
346
350
|
}
|
|
347
351
|
|
|
352
|
+
// ==================== 配置导入/导出 ====================
|
|
353
|
+
|
|
354
|
+
let importData = null;
|
|
355
|
+
|
|
356
|
+
async function exportConfig() {
|
|
357
|
+
try {
|
|
358
|
+
const res = await fetch('/api/config/export');
|
|
359
|
+
const data = await res.json();
|
|
360
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
361
|
+
const url = URL.createObjectURL(blob);
|
|
362
|
+
const a = document.createElement('a');
|
|
363
|
+
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
364
|
+
a.href = url;
|
|
365
|
+
a.download = `config-backup-${date}.json`;
|
|
366
|
+
a.click();
|
|
367
|
+
URL.revokeObjectURL(url);
|
|
368
|
+
showToast('配置已导出');
|
|
369
|
+
} catch (err) {
|
|
370
|
+
showToast('导出失败: ' + err.message, true);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function handleImportFile(e) {
|
|
375
|
+
const file = e.target.files[0];
|
|
376
|
+
if (!file) return;
|
|
377
|
+
e.target.value = '';
|
|
378
|
+
|
|
379
|
+
const reader = new FileReader();
|
|
380
|
+
reader.onload = () => {
|
|
381
|
+
try {
|
|
382
|
+
const data = JSON.parse(reader.result);
|
|
383
|
+
if (!Array.isArray(data.providers) || !Array.isArray(data.proxies)) {
|
|
384
|
+
showToast('配置格式错误:需要 providers 和 proxies 数组', true);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
importData = data;
|
|
388
|
+
document.getElementById('import-providers-count').textContent = data.providers.length;
|
|
389
|
+
document.getElementById('import-proxies-count').textContent = data.proxies.length;
|
|
390
|
+
document.getElementById('import-modal').classList.add('active');
|
|
391
|
+
} catch (err) {
|
|
392
|
+
showToast('文件解析失败: ' + err.message, true);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
reader.readAsText(file);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function closeImportModal() {
|
|
399
|
+
document.getElementById('import-modal').classList.remove('active');
|
|
400
|
+
importData = null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function confirmImport() {
|
|
404
|
+
if (!importData) return;
|
|
405
|
+
const mode = document.querySelector('input[name="import-mode"]:checked')?.value || 'merge';
|
|
406
|
+
|
|
407
|
+
if (mode === 'overwrite') {
|
|
408
|
+
const ok = await showConfirm('确认<strong>覆盖</strong>现有配置?此操作不可撤销。');
|
|
409
|
+
if (!ok) return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const res = await fetch('/api/config/import', {
|
|
414
|
+
method: 'POST',
|
|
415
|
+
headers: { 'Content-Type': 'application/json' },
|
|
416
|
+
body: JSON.stringify({ config: importData, mode }),
|
|
417
|
+
});
|
|
418
|
+
const result = await res.json();
|
|
419
|
+
|
|
420
|
+
if (!res.ok) {
|
|
421
|
+
showToast(result.error || '导入失败', true);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
closeImportModal();
|
|
426
|
+
await Promise.all([loadProxies(), loadProviders()]);
|
|
427
|
+
|
|
428
|
+
const added = result.added;
|
|
429
|
+
let msg = `导入成功(${mode === 'overwrite' ? '覆盖' : '合并'})`;
|
|
430
|
+
if (added) msg += `:新增 ${added.providers} 供应商、${added.proxies} 代理`;
|
|
431
|
+
|
|
432
|
+
const restart = await showConfirm(`${msg}。<br><br>运行中的代理需要重启才能应用变更,新增的代理需要手动启动。<br><br>是否立即重启所有代理?`);
|
|
433
|
+
if (restart) {
|
|
434
|
+
await restartAllProxies();
|
|
435
|
+
}
|
|
436
|
+
} catch (err) {
|
|
437
|
+
showToast('导入失败: ' + err.message, true);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function restartAllProxies() {
|
|
442
|
+
try {
|
|
443
|
+
for (const p of proxies) {
|
|
444
|
+
if (p.running) {
|
|
445
|
+
await fetch(`/api/proxies/${p.id}/stop`, { method: 'POST' });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
for (const p of proxies) {
|
|
449
|
+
await fetch(`/api/proxies/${p.id}/start`, { method: 'POST' });
|
|
450
|
+
}
|
|
451
|
+
await loadProxies();
|
|
452
|
+
showToast('所有代理已重启');
|
|
453
|
+
} catch (err) {
|
|
454
|
+
showToast('重启失败: ' + err.message, true);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
348
458
|
// ==================== 初始化 ====================
|
|
349
459
|
|
|
460
|
+
// ==================== Token 用量统计 ====================
|
|
461
|
+
|
|
462
|
+
let statsRange = 'daily';
|
|
463
|
+
let statsProxyId = '';
|
|
464
|
+
|
|
465
|
+
async function loadStats() {
|
|
466
|
+
try {
|
|
467
|
+
const params = new URLSearchParams({ range: statsRange });
|
|
468
|
+
if (statsProxyId) params.set('proxyId', statsProxyId);
|
|
469
|
+
const res = await fetch('/api/stats?' + params);
|
|
470
|
+
const data = await res.json();
|
|
471
|
+
renderStatsSummary(data.summary);
|
|
472
|
+
renderStatsBreakdown(data);
|
|
473
|
+
renderStatsProxyOptions(data.proxies || []);
|
|
474
|
+
} catch (err) {
|
|
475
|
+
console.error('加载统计失败:', err);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function renderStatsSummary(summary) {
|
|
480
|
+
document.getElementById('stats-total-tokens').textContent = formatTokens(summary.total);
|
|
481
|
+
document.getElementById('stats-prompt-tokens').textContent = formatTokens(summary.prompt);
|
|
482
|
+
document.getElementById('stats-completion-tokens').textContent = formatTokens(summary.completion);
|
|
483
|
+
document.getElementById('stats-total-requests').textContent = summary.requests.toLocaleString();
|
|
484
|
+
const badge = document.getElementById('stats-estimated-badge');
|
|
485
|
+
if (badge) badge.style.display = summary.hasEstimated ? 'inline' : 'none';
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function formatTokens(n) {
|
|
489
|
+
if (!n || n === 0) return '0';
|
|
490
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
491
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
492
|
+
return n.toLocaleString();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function renderStatsBreakdown(data) {
|
|
496
|
+
const container = document.getElementById('stats-breakdown');
|
|
497
|
+
const { byProvider, byModel, summary } = data;
|
|
498
|
+
|
|
499
|
+
if (!byProvider || byProvider.length === 0) {
|
|
500
|
+
container.innerHTML = '<div class="empty">暂无数据</div>';
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let html = '<table class="stats-table"><thead><tr>';
|
|
505
|
+
html += '<th>供应商</th><th>模型</th><th style="text-align:right">请求数</th>';
|
|
506
|
+
html += '<th style="text-align:right">输入 Token</th><th style="text-align:right">输出 Token</th>';
|
|
507
|
+
html += '<th style="text-align:right">合计</th>';
|
|
508
|
+
html += '</tr></thead><tbody>';
|
|
509
|
+
|
|
510
|
+
for (const item of byModel) {
|
|
511
|
+
const prefix = item.hasEstimated ? '~' : '';
|
|
512
|
+
html += '<tr>';
|
|
513
|
+
html += `<td class="provider-cell">${escapeHtml(item.provider)}</td>`;
|
|
514
|
+
html += `<td class="model-cell"><code>${escapeHtml(item.model)}</code></td>`;
|
|
515
|
+
html += `<td class="num">${item.requests.toLocaleString()}</td>`;
|
|
516
|
+
html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.prompt)}</td>`;
|
|
517
|
+
html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.completion)}</td>`;
|
|
518
|
+
html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.total)}</td>`;
|
|
519
|
+
html += '</tr>';
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
html += '</tbody>';
|
|
523
|
+
html += '<tfoot><tr>';
|
|
524
|
+
html += '<td colspan="2">合计</td>';
|
|
525
|
+
html += `<td class="num">${summary.requests.toLocaleString()}</td>`;
|
|
526
|
+
html += `<td class="num">${formatTokens(summary.prompt)}</td>`;
|
|
527
|
+
html += `<td class="num">${formatTokens(summary.completion)}</td>`;
|
|
528
|
+
html += `<td class="num">${formatTokens(summary.total)}</td>`;
|
|
529
|
+
html += '</tr></tfoot></table>';
|
|
530
|
+
|
|
531
|
+
container.innerHTML = html;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function renderStatsProxyOptions(proxyList) {
|
|
535
|
+
const container = document.getElementById('stats-proxy-dropdown-options');
|
|
536
|
+
container.innerHTML = `<div class="model-option${!statsProxyId ? ' selected' : ''}" data-proxy-id="">
|
|
537
|
+
<span class="model-option-name">全部代理</span>
|
|
538
|
+
</div>` + proxyList.map(p => `
|
|
539
|
+
<div class="model-option${p.id === statsProxyId ? ' selected' : ''}" data-proxy-id="${escapeHtml(p.id)}">
|
|
540
|
+
<span class="model-option-name">${escapeHtml(p.name)}</span>
|
|
541
|
+
${p.providerName ? `<span style="color:#64748b;font-size:12px;margin-left:4px">${escapeHtml(p.providerName)}</span>` : ''}
|
|
542
|
+
</div>
|
|
543
|
+
`).join('');
|
|
544
|
+
|
|
545
|
+
container.querySelectorAll('.model-option').forEach(opt => {
|
|
546
|
+
opt.addEventListener('click', () => {
|
|
547
|
+
statsProxyId = opt.dataset.proxyId;
|
|
548
|
+
document.getElementById('stats-proxy-dropdown-value').textContent =
|
|
549
|
+
statsProxyId ? (proxyList.find(p => p.id === statsProxyId)?.name || '全部代理') : '全部代理';
|
|
550
|
+
document.getElementById('stats-proxy-dropdown').classList.remove('open');
|
|
551
|
+
container.querySelectorAll('.model-option').forEach(o => o.classList.remove('selected'));
|
|
552
|
+
opt.classList.add('selected');
|
|
553
|
+
loadStats();
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function initStatsDropdown() {
|
|
559
|
+
const trigger = document.getElementById('stats-proxy-dropdown-trigger');
|
|
560
|
+
const dropdown = document.getElementById('stats-proxy-dropdown');
|
|
561
|
+
|
|
562
|
+
trigger.addEventListener('click', (e) => {
|
|
563
|
+
e.stopPropagation();
|
|
564
|
+
dropdown.classList.toggle('open');
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
document.addEventListener('click', (e) => {
|
|
568
|
+
if (!dropdown.contains(e.target)) {
|
|
569
|
+
dropdown.classList.remove('open');
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function initStatsRangeBtns() {
|
|
575
|
+
document.querySelectorAll('.stats-range-btn').forEach(btn => {
|
|
576
|
+
btn.addEventListener('click', () => {
|
|
577
|
+
document.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
|
|
578
|
+
btn.classList.add('active');
|
|
579
|
+
statsRange = btn.dataset.range;
|
|
580
|
+
loadStats();
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
350
585
|
function generateToken() {
|
|
351
586
|
const arr = new Uint8Array(24);
|
|
352
587
|
crypto.getRandomValues(arr);
|
|
@@ -385,9 +620,11 @@ function initSimpleDropdown(dropdownId, onChange) {
|
|
|
385
620
|
}
|
|
386
621
|
|
|
387
622
|
async function init() {
|
|
388
|
-
await Promise.all([loadProxies(), loadProviders()]);
|
|
623
|
+
await Promise.all([loadProxies(), loadProviders(), loadStats()]);
|
|
389
624
|
initProviderDropdown();
|
|
390
625
|
initModelDropdown();
|
|
626
|
+
initStatsDropdown();
|
|
627
|
+
initStatsRangeBtns();
|
|
391
628
|
initSimpleDropdown('auth-dropdown', (val) => {
|
|
392
629
|
const enabled = val === 'true';
|
|
393
630
|
document.getElementById('auth-token-group').style.display = enabled ? 'block' : 'none';
|
|
@@ -395,7 +632,9 @@ async function init() {
|
|
|
395
632
|
document.getElementById('proxy-auth-token').value = generateToken();
|
|
396
633
|
}
|
|
397
634
|
});
|
|
398
|
-
initSimpleDropdown('protocol-dropdown')
|
|
635
|
+
initSimpleDropdown('protocol-dropdown', (val) => {
|
|
636
|
+
document.getElementById('azure-fields').style.display = val === 'openai' ? '' : 'none';
|
|
637
|
+
});
|
|
399
638
|
}
|
|
400
639
|
|
|
401
640
|
// ==================== 代理地址复制 ====================
|
|
@@ -541,6 +780,11 @@ function openModal(id = null) {
|
|
|
541
780
|
selectProvider(p.providerId || '');
|
|
542
781
|
selectModel(p.defaultModel || '');
|
|
543
782
|
document.getElementById('target-key').placeholder = p.hasApiKey ? '已设置(留空则不修改)' : 'sk-...';
|
|
783
|
+
// Azure 字段从供应商配置读取
|
|
784
|
+
const provider = providers.find(pr => pr.id === p.providerId);
|
|
785
|
+
document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
|
|
786
|
+
document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
|
|
787
|
+
document.getElementById('azure-fields').style.display = p.protocol === 'openai' ? '' : 'none';
|
|
544
788
|
} else {
|
|
545
789
|
document.getElementById('proxy-id').value = '';
|
|
546
790
|
// 重置认证下拉框
|
|
@@ -552,6 +796,9 @@ function openModal(id = null) {
|
|
|
552
796
|
selectProvider('');
|
|
553
797
|
selectModel('');
|
|
554
798
|
document.getElementById('target-key').placeholder = 'sk-...';
|
|
799
|
+
document.getElementById('target-azure-deployment').value = '';
|
|
800
|
+
document.getElementById('target-azure-version').value = '';
|
|
801
|
+
document.getElementById('azure-fields').style.display = 'none';
|
|
555
802
|
}
|
|
556
803
|
|
|
557
804
|
updateModelAddState();
|
|
@@ -593,6 +840,10 @@ async function handleSubmit(e) {
|
|
|
593
840
|
const providerUpdates = {};
|
|
594
841
|
if (apiKey) providerUpdates.apiKey = apiKey;
|
|
595
842
|
if (protocol) providerUpdates.protocol = protocol;
|
|
843
|
+
const azureDeployment = document.getElementById('target-azure-deployment').value.trim();
|
|
844
|
+
const azureApiVersion = document.getElementById('target-azure-version').value.trim();
|
|
845
|
+
providerUpdates.azureDeployment = azureDeployment || '';
|
|
846
|
+
providerUpdates.azureApiVersion = azureApiVersion || '';
|
|
596
847
|
if (Object.keys(providerUpdates).length > 0) {
|
|
597
848
|
try {
|
|
598
849
|
const res = await fetch(`/api/providers/${providerId}`, {
|
package/public/index.html
CHANGED
|
@@ -24,10 +24,59 @@
|
|
|
24
24
|
</div>
|
|
25
25
|
</div>
|
|
26
26
|
|
|
27
|
+
<!-- Token 用量统计 -->
|
|
28
|
+
<section class="card stats-panel">
|
|
29
|
+
<div class="card-header">
|
|
30
|
+
<h2>Token 用量统计 <span class="stats-estimated-badge" id="stats-estimated-badge" style="display:none">含估算</span></h2>
|
|
31
|
+
<div class="stats-controls">
|
|
32
|
+
<div class="model-dropdown" id="stats-proxy-dropdown">
|
|
33
|
+
<div class="model-dropdown-trigger stats-filter-trigger" id="stats-proxy-dropdown-trigger">
|
|
34
|
+
<span id="stats-proxy-dropdown-value">全部代理</span>
|
|
35
|
+
<span class="model-dropdown-arrow">▾</span>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="model-dropdown-menu" id="stats-proxy-dropdown-menu">
|
|
38
|
+
<div class="model-dropdown-options" id="stats-proxy-dropdown-options"></div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="stats-range-btns">
|
|
42
|
+
<button class="btn btn-sm stats-range-btn active" data-range="daily">每日</button>
|
|
43
|
+
<button class="btn btn-sm stats-range-btn" data-range="monthly">每月</button>
|
|
44
|
+
<button class="btn btn-sm stats-range-btn" data-range="yearly">每年</button>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="stats-summary" id="stats-summary">
|
|
49
|
+
<div class="stats-summary-item">
|
|
50
|
+
<span class="stats-summary-value" id="stats-total-tokens">-</span>
|
|
51
|
+
<span class="stats-summary-label">总 Token</span>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="stats-summary-item">
|
|
54
|
+
<span class="stats-summary-value" id="stats-prompt-tokens">-</span>
|
|
55
|
+
<span class="stats-summary-label">输入 Token</span>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="stats-summary-item">
|
|
58
|
+
<span class="stats-summary-value" id="stats-completion-tokens">-</span>
|
|
59
|
+
<span class="stats-summary-label">输出 Token</span>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="stats-summary-item">
|
|
62
|
+
<span class="stats-summary-value" id="stats-total-requests">-</span>
|
|
63
|
+
<span class="stats-summary-label">请求数</span>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<div id="stats-breakdown" class="stats-breakdown">
|
|
67
|
+
<div class="empty">暂无数据</div>
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
|
|
27
71
|
<section class="card">
|
|
28
72
|
<div class="card-header">
|
|
29
73
|
<h2>代理列表</h2>
|
|
30
|
-
<
|
|
74
|
+
<div class="card-header-actions">
|
|
75
|
+
<button class="btn" onclick="exportConfig()">导出配置</button>
|
|
76
|
+
<button class="btn" onclick="document.getElementById('import-file').click()">导入配置</button>
|
|
77
|
+
<input type="file" id="import-file" accept=".json" style="display:none" onchange="handleImportFile(event)">
|
|
78
|
+
<button class="btn btn-primary" onclick="openModal()">+ 新建代理</button>
|
|
79
|
+
</div>
|
|
31
80
|
</div>
|
|
32
81
|
<div id="proxy-list" class="proxy-list">
|
|
33
82
|
<div class="empty">加载中...</div>
|
|
@@ -139,6 +188,16 @@
|
|
|
139
188
|
<input type="password" id="target-key" placeholder="sk-...">
|
|
140
189
|
</div>
|
|
141
190
|
</div>
|
|
191
|
+
<div class="form-row" id="azure-fields" style="display:none">
|
|
192
|
+
<div class="form-group">
|
|
193
|
+
<label>Azure Deployment</label>
|
|
194
|
+
<input type="text" id="target-azure-deployment" placeholder="gpt-4o">
|
|
195
|
+
</div>
|
|
196
|
+
<div class="form-group">
|
|
197
|
+
<label>Azure API Version</label>
|
|
198
|
+
<input type="text" id="target-azure-version" placeholder="2024-02-01">
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
142
201
|
</div>
|
|
143
202
|
</div>
|
|
144
203
|
|
|
@@ -163,6 +222,51 @@
|
|
|
163
222
|
</div>
|
|
164
223
|
</div>
|
|
165
224
|
|
|
225
|
+
<!-- 导入预览弹窗 -->
|
|
226
|
+
<div class="modal" id="import-modal">
|
|
227
|
+
<div class="modal-content" style="max-width:500px">
|
|
228
|
+
<div class="modal-header">
|
|
229
|
+
<h3>导入配置</h3>
|
|
230
|
+
<button class="btn-close" onclick="closeImportModal()">×</button>
|
|
231
|
+
</div>
|
|
232
|
+
<div class="import-preview" id="import-preview">
|
|
233
|
+
<div class="import-stats">
|
|
234
|
+
<div class="import-stat">
|
|
235
|
+
<span class="import-stat-value" id="import-providers-count">0</span>
|
|
236
|
+
<span class="import-stat-label">供应商</span>
|
|
237
|
+
</div>
|
|
238
|
+
<div class="import-stat">
|
|
239
|
+
<span class="import-stat-value" id="import-proxies-count">0</span>
|
|
240
|
+
<span class="import-stat-label">代理</span>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
<div class="import-mode">
|
|
244
|
+
<label>导入模式</label>
|
|
245
|
+
<div class="import-mode-options">
|
|
246
|
+
<label class="import-mode-option">
|
|
247
|
+
<input type="radio" name="import-mode" value="merge" checked>
|
|
248
|
+
<span>
|
|
249
|
+
<strong>合并</strong>
|
|
250
|
+
<small>按 ID 去重:新增导入项,同 ID 覆盖</small>
|
|
251
|
+
</span>
|
|
252
|
+
</label>
|
|
253
|
+
<label class="import-mode-option">
|
|
254
|
+
<input type="radio" name="import-mode" value="overwrite">
|
|
255
|
+
<span>
|
|
256
|
+
<strong>覆盖</strong>
|
|
257
|
+
<small>完全替换现有配置</small>
|
|
258
|
+
</span>
|
|
259
|
+
</label>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
<div class="modal-footer">
|
|
264
|
+
<button class="btn" onclick="closeImportModal()">取消</button>
|
|
265
|
+
<button class="btn btn-primary" onclick="confirmImport()">确认导入</button>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
166
270
|
<script src="app.js"></script>
|
|
167
271
|
</body>
|
|
168
272
|
</html>
|
package/public/style.css
CHANGED
|
@@ -843,6 +843,288 @@ form {
|
|
|
843
843
|
min-width: 90px;
|
|
844
844
|
}
|
|
845
845
|
|
|
846
|
+
/* Card header actions */
|
|
847
|
+
.card-header-actions {
|
|
848
|
+
display: flex;
|
|
849
|
+
gap: 8px;
|
|
850
|
+
align-items: center;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/* Import modal */
|
|
854
|
+
.import-preview {
|
|
855
|
+
padding: 24px 28px;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
.import-stats {
|
|
859
|
+
display: flex;
|
|
860
|
+
gap: 16px;
|
|
861
|
+
margin-bottom: 24px;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
.import-stat {
|
|
865
|
+
flex: 1;
|
|
866
|
+
background: rgba(6, 8, 15, 0.4);
|
|
867
|
+
border: 1px solid rgba(51, 65, 85, 0.3);
|
|
868
|
+
border-radius: 12px;
|
|
869
|
+
padding: 16px;
|
|
870
|
+
text-align: center;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
.import-stat-value {
|
|
874
|
+
display: block;
|
|
875
|
+
font-size: 1.8rem;
|
|
876
|
+
font-weight: 700;
|
|
877
|
+
color: #60a5fa;
|
|
878
|
+
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
.import-stat-label {
|
|
882
|
+
color: #64748b;
|
|
883
|
+
font-size: 0.8rem;
|
|
884
|
+
text-transform: uppercase;
|
|
885
|
+
letter-spacing: 0.06em;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
.import-mode label {
|
|
889
|
+
display: block;
|
|
890
|
+
margin-bottom: 10px;
|
|
891
|
+
color: #94a3b8;
|
|
892
|
+
font-size: 0.85rem;
|
|
893
|
+
font-weight: 500;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
.import-mode-options {
|
|
897
|
+
display: flex;
|
|
898
|
+
flex-direction: column;
|
|
899
|
+
gap: 10px;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
.import-mode-option {
|
|
903
|
+
display: flex;
|
|
904
|
+
align-items: flex-start;
|
|
905
|
+
gap: 12px;
|
|
906
|
+
padding: 14px 16px;
|
|
907
|
+
background: rgba(6, 8, 15, 0.4);
|
|
908
|
+
border: 1px solid rgba(51, 65, 85, 0.3);
|
|
909
|
+
border-radius: 10px;
|
|
910
|
+
cursor: pointer;
|
|
911
|
+
transition: all 0.2s;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
.import-mode-option:hover {
|
|
915
|
+
border-color: rgba(59, 130, 246, 0.3);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
.import-mode-option input[type="radio"] {
|
|
919
|
+
margin-top: 3px;
|
|
920
|
+
accent-color: #3b82f6;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
.import-mode-option strong {
|
|
924
|
+
display: block;
|
|
925
|
+
color: #e2e8f0;
|
|
926
|
+
font-size: 0.9rem;
|
|
927
|
+
margin-bottom: 2px;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
.import-mode-option small {
|
|
931
|
+
color: #64748b;
|
|
932
|
+
font-size: 0.8rem;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/* Stats Panel */
|
|
936
|
+
.stats-panel .card-header {
|
|
937
|
+
flex-wrap: wrap;
|
|
938
|
+
gap: 12px;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
.stats-estimated-badge {
|
|
942
|
+
display: inline-block;
|
|
943
|
+
padding: 2px 10px;
|
|
944
|
+
border-radius: 12px;
|
|
945
|
+
font-size: 0.7rem;
|
|
946
|
+
font-weight: 500;
|
|
947
|
+
background: rgba(251, 191, 36, 0.15);
|
|
948
|
+
color: #fbbf24;
|
|
949
|
+
border: 1px solid rgba(251, 191, 36, 0.2);
|
|
950
|
+
vertical-align: middle;
|
|
951
|
+
letter-spacing: 0.02em;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
.stats-controls {
|
|
955
|
+
display: flex;
|
|
956
|
+
gap: 12px;
|
|
957
|
+
align-items: center;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
.stats-controls .model-dropdown {
|
|
961
|
+
width: 180px;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.stats-filter-trigger {
|
|
965
|
+
padding: 7px 14px !important;
|
|
966
|
+
font-size: 0.85rem !important;
|
|
967
|
+
background: rgba(51, 65, 85, 0.3) !important;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
.stats-range-btns {
|
|
971
|
+
display: flex;
|
|
972
|
+
gap: 4px;
|
|
973
|
+
background: rgba(6, 8, 15, 0.4);
|
|
974
|
+
border-radius: 10px;
|
|
975
|
+
padding: 3px;
|
|
976
|
+
border: 1px solid rgba(51, 65, 85, 0.3);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.stats-range-btn {
|
|
980
|
+
border: none !important;
|
|
981
|
+
background: transparent !important;
|
|
982
|
+
box-shadow: none !important;
|
|
983
|
+
padding: 6px 16px !important;
|
|
984
|
+
border-radius: 8px !important;
|
|
985
|
+
color: #64748b !important;
|
|
986
|
+
font-size: 0.82rem !important;
|
|
987
|
+
transition: all 0.2s !important;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
.stats-range-btn:hover {
|
|
991
|
+
color: #94a3b8 !important;
|
|
992
|
+
background: rgba(51, 65, 85, 0.3) !important;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.stats-range-btn.active {
|
|
996
|
+
background: rgba(59, 130, 246, 0.2) !important;
|
|
997
|
+
color: #60a5fa !important;
|
|
998
|
+
box-shadow: 0 0 8px rgba(59, 130, 246, 0.15) !important;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
.stats-summary {
|
|
1002
|
+
display: grid;
|
|
1003
|
+
grid-template-columns: repeat(4, 1fr);
|
|
1004
|
+
gap: 16px;
|
|
1005
|
+
padding: 0 28px;
|
|
1006
|
+
margin-top: 8px;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
.stats-summary-item {
|
|
1010
|
+
background: rgba(6, 8, 15, 0.4);
|
|
1011
|
+
border: 1px solid rgba(51, 65, 85, 0.3);
|
|
1012
|
+
border-radius: 12px;
|
|
1013
|
+
padding: 18px 16px;
|
|
1014
|
+
text-align: center;
|
|
1015
|
+
transition: all 0.25s;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
.stats-summary-item:hover {
|
|
1019
|
+
border-color: rgba(59, 130, 246, 0.25);
|
|
1020
|
+
box-shadow: 0 0 16px rgba(59, 130, 246, 0.06);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
.stats-summary-value {
|
|
1024
|
+
display: block;
|
|
1025
|
+
font-size: 1.5rem;
|
|
1026
|
+
font-weight: 700;
|
|
1027
|
+
color: #60a5fa;
|
|
1028
|
+
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
|
1029
|
+
margin-bottom: 4px;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
.stats-summary-label {
|
|
1033
|
+
color: #64748b;
|
|
1034
|
+
font-size: 0.78rem;
|
|
1035
|
+
text-transform: uppercase;
|
|
1036
|
+
letter-spacing: 0.06em;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
.stats-breakdown {
|
|
1040
|
+
padding: 20px 28px 28px;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
.stats-table {
|
|
1044
|
+
width: 100%;
|
|
1045
|
+
border-collapse: separate;
|
|
1046
|
+
border-spacing: 0;
|
|
1047
|
+
font-size: 0.85rem;
|
|
1048
|
+
border-radius: 10px;
|
|
1049
|
+
overflow: hidden;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
.stats-table th,
|
|
1053
|
+
.stats-table td {
|
|
1054
|
+
text-align: left;
|
|
1055
|
+
padding: 10px 14px;
|
|
1056
|
+
border-bottom: 1px solid rgba(30, 41, 59, 0.6);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.stats-table th {
|
|
1060
|
+
color: #475569;
|
|
1061
|
+
font-weight: 600;
|
|
1062
|
+
text-transform: uppercase;
|
|
1063
|
+
font-size: 0.7rem;
|
|
1064
|
+
letter-spacing: 0.08em;
|
|
1065
|
+
background: rgba(15, 23, 42, 0.4);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
.stats-table td {
|
|
1069
|
+
color: #94a3b8;
|
|
1070
|
+
word-break: break-all;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
.stats-table tbody tr {
|
|
1074
|
+
transition: background 0.15s;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
.stats-table tbody tr:hover {
|
|
1078
|
+
background: rgba(59, 130, 246, 0.04);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
.stats-table tbody tr:last-child td {
|
|
1082
|
+
border-bottom: none;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
.stats-table td.num {
|
|
1086
|
+
text-align: right;
|
|
1087
|
+
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
|
1088
|
+
font-size: 0.82rem;
|
|
1089
|
+
color: #7dd3fc;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
.stats-table tfoot td {
|
|
1093
|
+
font-weight: 600;
|
|
1094
|
+
color: #e2e8f0;
|
|
1095
|
+
background: rgba(15, 23, 42, 0.3);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
.stats-table .provider-cell {
|
|
1099
|
+
color: #a78bfa;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
.stats-table .model-cell {
|
|
1103
|
+
color: #94a3b8;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
.num-estimated {
|
|
1107
|
+
color: #fbbf24;
|
|
1108
|
+
font-size: 0.75rem;
|
|
1109
|
+
margin-right: 2px;
|
|
1110
|
+
cursor: help;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
@media (max-width: 640px) {
|
|
1114
|
+
.stats-summary {
|
|
1115
|
+
grid-template-columns: repeat(2, 1fr);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
.stats-controls {
|
|
1119
|
+
flex-direction: column;
|
|
1120
|
+
align-items: stretch;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
.stats-controls .model-dropdown {
|
|
1124
|
+
width: 100%;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
846
1128
|
/* Responsive */
|
|
847
1129
|
@media (max-width: 640px) {
|
|
848
1130
|
.form-row {
|
package/server.js
CHANGED
|
@@ -121,6 +121,7 @@ async function init() {
|
|
|
121
121
|
const cors = require('cors');
|
|
122
122
|
const configStore = require('./lib/config-store');
|
|
123
123
|
const proxyManager = require('./lib/proxy-manager');
|
|
124
|
+
const statsStore = require('./lib/stats-store');
|
|
124
125
|
|
|
125
126
|
const app = express();
|
|
126
127
|
const PORT = process.env.ADMIN_PORT || 3000;
|
|
@@ -167,6 +168,8 @@ async function init() {
|
|
|
167
168
|
apiKey: provider.apiKey,
|
|
168
169
|
defaultModel: proxy.defaultModel,
|
|
169
170
|
models: provider.models,
|
|
171
|
+
azureDeployment: provider.azureDeployment || '',
|
|
172
|
+
azureApiVersion: provider.azureApiVersion || '',
|
|
170
173
|
};
|
|
171
174
|
}
|
|
172
175
|
|
|
@@ -194,7 +197,7 @@ async function init() {
|
|
|
194
197
|
});
|
|
195
198
|
|
|
196
199
|
app.post('/api/providers', (req, res) => {
|
|
197
|
-
const { name, url, protocol, apiKey, models } = req.body;
|
|
200
|
+
const { name, url, protocol, apiKey, models, azureDeployment, azureApiVersion } = req.body;
|
|
198
201
|
if (!name || !url) {
|
|
199
202
|
return res.status(400).json({ error: 'name and url are required' });
|
|
200
203
|
}
|
|
@@ -203,6 +206,8 @@ async function init() {
|
|
|
203
206
|
protocol: protocol || (/anthropic/i.test(url) ? 'anthropic' : 'openai'),
|
|
204
207
|
apiKey: apiKey || '',
|
|
205
208
|
models: models || [],
|
|
209
|
+
azureDeployment: azureDeployment || '',
|
|
210
|
+
azureApiVersion: azureApiVersion || '',
|
|
206
211
|
});
|
|
207
212
|
res.status(201).json(provider);
|
|
208
213
|
});
|
|
@@ -217,6 +222,8 @@ async function init() {
|
|
|
217
222
|
if (req.body.protocol !== undefined) updates.protocol = req.body.protocol;
|
|
218
223
|
if (req.body.apiKey !== undefined && req.body.apiKey !== '') updates.apiKey = req.body.apiKey;
|
|
219
224
|
if (req.body.models !== undefined) updates.models = req.body.models;
|
|
225
|
+
if (req.body.azureDeployment !== undefined) updates.azureDeployment = req.body.azureDeployment;
|
|
226
|
+
if (req.body.azureApiVersion !== undefined) updates.azureApiVersion = req.body.azureApiVersion;
|
|
220
227
|
|
|
221
228
|
const updated = configStore.updateProvider(req.params.id, updates);
|
|
222
229
|
|
|
@@ -415,6 +422,148 @@ async function init() {
|
|
|
415
422
|
});
|
|
416
423
|
});
|
|
417
424
|
|
|
425
|
+
// Token 用量统计
|
|
426
|
+
app.get('/api/stats', (req, res) => {
|
|
427
|
+
const { range, startDate, endDate, proxyId } = req.query;
|
|
428
|
+
const stats = statsStore.getStats({
|
|
429
|
+
range: range || 'daily',
|
|
430
|
+
startDate: startDate || undefined,
|
|
431
|
+
endDate: endDate || undefined,
|
|
432
|
+
proxyId: proxyId || undefined,
|
|
433
|
+
});
|
|
434
|
+
const proxies = configStore.getProxies().map(p => ({
|
|
435
|
+
id: p.id,
|
|
436
|
+
name: p.name,
|
|
437
|
+
providerName: configStore.getProviderById(p.providerId)?.name || '',
|
|
438
|
+
}));
|
|
439
|
+
res.json({ ...stats, proxies });
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// ==================== 配置导入/导出 ====================
|
|
443
|
+
|
|
444
|
+
app.get('/api/config/export', (req, res) => {
|
|
445
|
+
const providers = configStore.getProviders();
|
|
446
|
+
const proxies = configStore.getProxies().map(p => {
|
|
447
|
+
const provider = configStore.getProviderById(p.providerId);
|
|
448
|
+
return {
|
|
449
|
+
id: p.id,
|
|
450
|
+
name: p.name,
|
|
451
|
+
port: p.port,
|
|
452
|
+
requireAuth: p.requireAuth,
|
|
453
|
+
authToken: p.authToken,
|
|
454
|
+
providerId: p.providerId,
|
|
455
|
+
defaultModel: p.defaultModel || '',
|
|
456
|
+
providerName: provider?.name || '',
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
res.json({ providers, proxies, exportedAt: new Date().toISOString() });
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
app.post('/api/config/import', async (req, res) => {
|
|
463
|
+
const { config, mode } = req.body;
|
|
464
|
+
|
|
465
|
+
if (!config || !mode || !['overwrite', 'merge'].includes(mode)) {
|
|
466
|
+
return res.status(400).json({ error: '需要 config 和 mode(overwrite/merge)' });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// 校验结构
|
|
470
|
+
if (!Array.isArray(config.providers) || !Array.isArray(config.proxies)) {
|
|
471
|
+
return res.status(400).json({ error: '配置格式错误:需要 providers 和 proxies 数组' });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
for (const p of config.providers) {
|
|
475
|
+
if (!p.name || !p.url || !p.protocol) {
|
|
476
|
+
return res.status(400).json({ error: `供应商 "${p.name || '?'}" 缺少必要字段(name/url/protocol)` });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
for (const p of config.proxies) {
|
|
481
|
+
if (!p.name || !p.port || !p.providerId) {
|
|
482
|
+
return res.status(400).json({ error: `代理 "${p.name || '?'}" 缺少必要字段(name/port/providerId)` });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (mode === 'overwrite') {
|
|
487
|
+
// 覆盖模式:直接替换整个配置
|
|
488
|
+
const newConfig = {
|
|
489
|
+
providers: config.providers.map(p => ({
|
|
490
|
+
id: p.id,
|
|
491
|
+
name: p.name,
|
|
492
|
+
url: p.url,
|
|
493
|
+
protocol: p.protocol,
|
|
494
|
+
apiKey: p.apiKey || '',
|
|
495
|
+
models: Array.isArray(p.models) ? p.models : [],
|
|
496
|
+
})),
|
|
497
|
+
proxies: config.proxies.map(p => ({
|
|
498
|
+
id: p.id,
|
|
499
|
+
name: p.name,
|
|
500
|
+
port: p.port,
|
|
501
|
+
requireAuth: !!p.requireAuth,
|
|
502
|
+
authToken: p.authToken || null,
|
|
503
|
+
providerId: p.providerId,
|
|
504
|
+
defaultModel: p.defaultModel || '',
|
|
505
|
+
})),
|
|
506
|
+
};
|
|
507
|
+
configStore.saveConfig(newConfig);
|
|
508
|
+
return res.json({ success: true, mode, providers: newConfig.providers.length, proxies: newConfig.proxies.length });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// 合并模式:按 ID 去重
|
|
512
|
+
const existingProviders = configStore.getProviders();
|
|
513
|
+
const existingProxies = configStore.getProxies();
|
|
514
|
+
|
|
515
|
+
const providerMap = new Map(existingProviders.map(p => [p.id, p]));
|
|
516
|
+
for (const p of config.providers) {
|
|
517
|
+
providerMap.set(p.id, {
|
|
518
|
+
id: p.id,
|
|
519
|
+
name: p.name,
|
|
520
|
+
url: p.url,
|
|
521
|
+
protocol: p.protocol,
|
|
522
|
+
apiKey: p.apiKey || '',
|
|
523
|
+
models: Array.isArray(p.models) ? p.models : [],
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const proxyMap = new Map(existingProxies.map(p => [p.id, p]));
|
|
528
|
+
for (const p of config.proxies) {
|
|
529
|
+
// 检查端口冲突:导入的代理端口不能和现有代理或其他导入代理重复
|
|
530
|
+
const conflict = proxyMap.get(p.id)
|
|
531
|
+
? null // 同 ID 是覆盖,不算冲突
|
|
532
|
+
: Array.from(proxyMap.values()).find(ep => ep.port === p.port);
|
|
533
|
+
if (conflict) {
|
|
534
|
+
return res.status(409).json({
|
|
535
|
+
error: `端口 ${p.port} 已被代理「${conflict.name}」占用,无法导入代理「${p.name}」`,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
proxyMap.set(p.id, {
|
|
539
|
+
id: p.id,
|
|
540
|
+
name: p.name,
|
|
541
|
+
port: p.port,
|
|
542
|
+
requireAuth: !!p.requireAuth,
|
|
543
|
+
authToken: p.authToken || null,
|
|
544
|
+
providerId: p.providerId,
|
|
545
|
+
defaultModel: p.defaultModel || '',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const merged = {
|
|
550
|
+
providers: Array.from(providerMap.values()),
|
|
551
|
+
proxies: Array.from(proxyMap.values()),
|
|
552
|
+
};
|
|
553
|
+
configStore.saveConfig(merged);
|
|
554
|
+
|
|
555
|
+
res.json({
|
|
556
|
+
success: true,
|
|
557
|
+
mode,
|
|
558
|
+
providers: merged.providers.length,
|
|
559
|
+
proxies: merged.proxies.length,
|
|
560
|
+
added: {
|
|
561
|
+
providers: merged.providers.length - existingProviders.length,
|
|
562
|
+
proxies: merged.proxies.length - existingProxies.length,
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
418
567
|
// 前端首页
|
|
419
568
|
app.get('/', (req, res) => {
|
|
420
569
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|