protocol-proxy 2.1.6 → 2.3.2
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/converters/anthropic-to-gemini.js +253 -0
- package/lib/converters/gemini-to-anthropic.js +275 -0
- package/lib/converters/gemini-to-openai.js +238 -0
- package/lib/converters/openai-to-gemini.js +284 -0
- package/lib/detector.js +4 -0
- package/lib/proxy-server.js +140 -10
- package/lib/stats-store.js +285 -0
- package/package.json +2 -3
- package/public/app.js +273 -2
- package/public/index.html +110 -1
- package/public/style.css +324 -0
- package/server.js +154 -1
package/lib/proxy-server.js
CHANGED
|
@@ -2,6 +2,11 @@ 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 o2g = require('./converters/openai-to-gemini');
|
|
6
|
+
const g2o = require('./converters/gemini-to-openai');
|
|
7
|
+
const a2g = require('./converters/anthropic-to-gemini');
|
|
8
|
+
const g2a = require('./converters/gemini-to-anthropic');
|
|
9
|
+
const { recordUsage } = require('./stats-store');
|
|
5
10
|
|
|
6
11
|
function createProxyApp(proxyConfigOrGetter) {
|
|
7
12
|
const getProxyConfig = typeof proxyConfigOrGetter === 'function'
|
|
@@ -35,6 +40,47 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
35
40
|
return reasoningCache.get(getReasoningKey(msg));
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
function estimateTokens(text) {
|
|
44
|
+
if (!text) return 0;
|
|
45
|
+
let tokens = 0;
|
|
46
|
+
for (let i = 0; i < text.length; i++) {
|
|
47
|
+
const code = text.charCodeAt(i);
|
|
48
|
+
// CJK 字符 ~1.5 token/字
|
|
49
|
+
if (code >= 0x4E00 && code <= 0x9FFF) tokens += 1.5;
|
|
50
|
+
// 全角标点等 ~1 token
|
|
51
|
+
else if (code >= 0x3000 && code <= 0x303F) tokens += 1;
|
|
52
|
+
// 其他(ASCII 字母、数字、标点、空格)~0.25 token
|
|
53
|
+
else tokens += 0.25;
|
|
54
|
+
}
|
|
55
|
+
return Math.ceil(tokens);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function estimateInputTokens(body) {
|
|
59
|
+
if (!body?.messages) return 0;
|
|
60
|
+
let text = '';
|
|
61
|
+
for (const msg of body.messages) {
|
|
62
|
+
if (typeof msg.content === 'string') {
|
|
63
|
+
text += msg.content;
|
|
64
|
+
} else if (Array.isArray(msg.content)) {
|
|
65
|
+
for (const block of msg.content) {
|
|
66
|
+
if (block.text) text += block.text;
|
|
67
|
+
if (block.type === 'tool_result' && block.content) {
|
|
68
|
+
text += typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (msg.tool_calls) {
|
|
73
|
+
for (const tc of msg.tool_calls) {
|
|
74
|
+
text += (tc.function?.arguments || '') + (tc.function?.name || '');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (body.tools) {
|
|
79
|
+
text += JSON.stringify(body.tools);
|
|
80
|
+
}
|
|
81
|
+
return estimateTokens(text);
|
|
82
|
+
}
|
|
83
|
+
|
|
38
84
|
function injectReasoningToMessages(messages) {
|
|
39
85
|
if (!Array.isArray(messages)) return;
|
|
40
86
|
for (const msg of messages) {
|
|
@@ -95,7 +141,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
95
141
|
console.log(`[${requestId}] ⬅️ ${(inboundProtocol || 'unknown').toUpperCase()} → ${targetProtocol.toUpperCase()} | path=${req.path}`);
|
|
96
142
|
|
|
97
143
|
// 决定转换方向
|
|
98
|
-
let convertReq, convertRes, createSSEConv;
|
|
144
|
+
let convertReq, convertRes, createSSEConv, nameToId = null;
|
|
99
145
|
if (inboundProtocol === 'openai' && targetProtocol === 'anthropic') {
|
|
100
146
|
convertReq = o2a.convertRequest;
|
|
101
147
|
convertRes = o2a.convertResponse;
|
|
@@ -104,6 +150,28 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
104
150
|
convertReq = a2o.convertRequest;
|
|
105
151
|
convertRes = a2o.convertResponse;
|
|
106
152
|
createSSEConv = a2o.createSSEConverter;
|
|
153
|
+
} else if (inboundProtocol === 'openai' && targetProtocol === 'gemini') {
|
|
154
|
+
convertReq = o2g.convertRequest;
|
|
155
|
+
convertRes = o2g.convertResponse;
|
|
156
|
+
createSSEConv = o2g.createSSEConverter;
|
|
157
|
+
} else if (inboundProtocol === 'gemini' && targetProtocol === 'openai') {
|
|
158
|
+
convertReq = g2o.convertRequest;
|
|
159
|
+
convertRes = g2o.convertResponse;
|
|
160
|
+
createSSEConv = g2o.createSSEConverter;
|
|
161
|
+
} else if (inboundProtocol === 'anthropic' && targetProtocol === 'gemini') {
|
|
162
|
+
convertReq = a2g.convertRequest;
|
|
163
|
+
convertRes = a2g.convertResponse;
|
|
164
|
+
createSSEConv = a2g.createSSEConverter;
|
|
165
|
+
} else if (inboundProtocol === 'gemini' && targetProtocol === 'anthropic') {
|
|
166
|
+
// g2a.convertRequest 返回 { ...body, nameToId },需要提取映射
|
|
167
|
+
convertReq = (body, model) => {
|
|
168
|
+
const result = g2a.convertRequest(body, model);
|
|
169
|
+
nameToId = result.nameToId;
|
|
170
|
+
const { nameToId: _, ...bodyOnly } = result;
|
|
171
|
+
return bodyOnly;
|
|
172
|
+
};
|
|
173
|
+
convertRes = g2a.convertResponse;
|
|
174
|
+
createSSEConv = (model) => g2a.createSSEConverter(nameToId);
|
|
107
175
|
} else {
|
|
108
176
|
convertReq = (body, model) => ({ ...body, model: body.model || model });
|
|
109
177
|
convertRes = (body) => body;
|
|
@@ -119,11 +187,18 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
119
187
|
|
|
120
188
|
const targetBody = convertReq(req.body, effectiveModel);
|
|
121
189
|
|
|
190
|
+
const isAzure = !!target.azureDeployment && /azure/i.test(target.providerUrl);
|
|
191
|
+
|
|
192
|
+
// 流式请求时注入 stream_options 以获取 usage 统计(Azure 不支持)
|
|
193
|
+
if (isStream && targetProtocol === 'openai' && !isAzure) {
|
|
194
|
+
targetBody.stream_options = { include_usage: true };
|
|
195
|
+
}
|
|
196
|
+
|
|
122
197
|
// 注入 reasoning_content(针对 DeepSeek 等 reasoning model)
|
|
123
198
|
injectReasoningToMessages(targetBody.messages);
|
|
124
199
|
|
|
125
200
|
// 构建目标 URL
|
|
126
|
-
const targetUrl = buildTargetUrl(target.
|
|
201
|
+
const targetUrl = buildTargetUrl(target, req.path, isStream, effectiveModel);
|
|
127
202
|
console.log(`[${requestId}] 🔗 ${targetUrl} | model=${effectiveModel}`);
|
|
128
203
|
|
|
129
204
|
// 构建请求头
|
|
@@ -133,7 +208,13 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
133
208
|
};
|
|
134
209
|
|
|
135
210
|
if (targetProtocol === 'openai') {
|
|
136
|
-
|
|
211
|
+
if (isAzure) {
|
|
212
|
+
headers['api-key'] = target.apiKey;
|
|
213
|
+
} else {
|
|
214
|
+
headers['Authorization'] = `Bearer ${target.apiKey}`;
|
|
215
|
+
}
|
|
216
|
+
} else if (targetProtocol === 'gemini') {
|
|
217
|
+
headers['x-goog-api-key'] = target.apiKey;
|
|
137
218
|
} else if (targetProtocol === 'anthropic') {
|
|
138
219
|
headers['X-Api-Key'] = target.apiKey;
|
|
139
220
|
headers['Anthropic-Version'] = '2023-06-01';
|
|
@@ -144,7 +225,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
144
225
|
method: 'POST',
|
|
145
226
|
headers,
|
|
146
227
|
body: JSON.stringify(targetBody),
|
|
147
|
-
signal: AbortSignal.timeout(
|
|
228
|
+
signal: AbortSignal.timeout(300000),
|
|
148
229
|
});
|
|
149
230
|
|
|
150
231
|
if (!fetchRes.ok) {
|
|
@@ -155,8 +236,8 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
155
236
|
return res.send(errBody);
|
|
156
237
|
}
|
|
157
238
|
|
|
158
|
-
//
|
|
159
|
-
if (isStream
|
|
239
|
+
// 流式响应(以客户端请求意图为准,不依赖上游 Content-Type)
|
|
240
|
+
if (isStream) {
|
|
160
241
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
161
242
|
res.setHeader('Cache-Control', 'no-cache');
|
|
162
243
|
res.setHeader('Connection', 'keep-alive');
|
|
@@ -164,6 +245,9 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
164
245
|
const sseConverter = createSSEConv ? createSSEConv(effectiveModel) : null;
|
|
165
246
|
const reader = fetchRes.body.getReader();
|
|
166
247
|
const decoder = new TextDecoder();
|
|
248
|
+
let streamUsage = null;
|
|
249
|
+
let responseText = '';
|
|
250
|
+
let toolCallCount = 0;
|
|
167
251
|
|
|
168
252
|
req.on('close', () => {
|
|
169
253
|
try { reader.cancel(); } catch (err) { /* ignore */ }
|
|
@@ -174,6 +258,23 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
174
258
|
const { done, value } = await reader.read();
|
|
175
259
|
if (done) break;
|
|
176
260
|
const chunk = decoder.decode(value, { stream: true });
|
|
261
|
+
// 从流中提取 usage 和响应内容
|
|
262
|
+
const lines = chunk.split('\n');
|
|
263
|
+
for (const line of lines) {
|
|
264
|
+
const trimmed = line.trim();
|
|
265
|
+
if (!trimmed.startsWith('data:') || trimmed === 'data: [DONE]') continue;
|
|
266
|
+
try {
|
|
267
|
+
const d = JSON.parse(trimmed.slice(5).trim());
|
|
268
|
+
if (d.usage) streamUsage = d.usage;
|
|
269
|
+
const delta = d.choices?.[0]?.delta;
|
|
270
|
+
if (delta?.content) responseText += delta.content;
|
|
271
|
+
if (delta?.tool_calls) {
|
|
272
|
+
for (const tc of delta.tool_calls) {
|
|
273
|
+
if (tc.function?.name) toolCallCount++;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} catch { /* ignore */ }
|
|
277
|
+
}
|
|
177
278
|
if (sseConverter) {
|
|
178
279
|
const converted = sseConverter.convertChunk(chunk);
|
|
179
280
|
if (converted) res.write(converted);
|
|
@@ -181,12 +282,29 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
181
282
|
res.write(chunk);
|
|
182
283
|
}
|
|
183
284
|
}
|
|
285
|
+
|
|
286
|
+
if (streamUsage) {
|
|
287
|
+
recordUsage(proxyConfig.id, proxyConfig.target?.providerName, req.body?.model, streamUsage, false);
|
|
288
|
+
} else if (responseText || toolCallCount > 0) {
|
|
289
|
+
// 上游未返回 usage,从响应内容估算
|
|
290
|
+
const inputTokens = estimateInputTokens(req.body);
|
|
291
|
+
const outputTokens = estimateTokens(responseText) + toolCallCount * 15;
|
|
292
|
+
recordUsage(proxyConfig.id, proxyConfig.target?.providerName, req.body?.model, {
|
|
293
|
+
prompt_tokens: inputTokens,
|
|
294
|
+
completion_tokens: outputTokens,
|
|
295
|
+
}, true);
|
|
296
|
+
}
|
|
184
297
|
if (sseConverter) {
|
|
185
298
|
const flushed = sseConverter.flush();
|
|
186
299
|
if (flushed) res.write(flushed);
|
|
187
300
|
}
|
|
188
301
|
} catch (err) {
|
|
189
302
|
console.error(`[${requestId}] Stream error:`, err.message);
|
|
303
|
+
if (!res.writableEnded) {
|
|
304
|
+
try {
|
|
305
|
+
res.write(`data: ${JSON.stringify({ error: { message: err.message, type: 'proxy_error' } })}\n\n`);
|
|
306
|
+
} catch { /* ignore */ }
|
|
307
|
+
}
|
|
190
308
|
} finally {
|
|
191
309
|
res.end();
|
|
192
310
|
}
|
|
@@ -195,6 +313,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
195
313
|
|
|
196
314
|
const responseBody = await fetchRes.json();
|
|
197
315
|
extractReasoningFromResponse(responseBody);
|
|
316
|
+
recordUsage(proxyConfig.id, proxyConfig.target?.providerName, req.body?.model, responseBody.usage);
|
|
198
317
|
const convertedBody = convertRes(responseBody);
|
|
199
318
|
res.json(convertedBody);
|
|
200
319
|
|
|
@@ -207,20 +326,31 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
207
326
|
return app;
|
|
208
327
|
}
|
|
209
328
|
|
|
210
|
-
function buildTargetUrl(
|
|
211
|
-
const base = providerUrl.replace(/\/$/, '');
|
|
329
|
+
function buildTargetUrl(target, originalPath, isStream, effectiveModel) {
|
|
330
|
+
const base = target.providerUrl.replace(/\/$/, '');
|
|
212
331
|
const hasV1Suffix = base.endsWith('/v1');
|
|
213
332
|
|
|
214
|
-
if (
|
|
333
|
+
if (target.protocol === 'openai') {
|
|
334
|
+
// Azure OpenAI
|
|
335
|
+
if (target.azureDeployment) {
|
|
336
|
+
const ver = target.azureApiVersion || '2024-02-01';
|
|
337
|
+
return `${base}/openai/deployments/${target.azureDeployment}/chat/completions?api-version=${ver}`;
|
|
338
|
+
}
|
|
215
339
|
if (hasV1Suffix) return `${base}/chat/completions`;
|
|
216
340
|
return `${base}/v1/chat/completions`;
|
|
217
341
|
}
|
|
218
342
|
|
|
219
|
-
if (
|
|
343
|
+
if (target.protocol === 'anthropic') {
|
|
220
344
|
if (hasV1Suffix) return `${base}/messages`;
|
|
221
345
|
return `${base}/v1/messages`;
|
|
222
346
|
}
|
|
223
347
|
|
|
348
|
+
if (target.protocol === 'gemini') {
|
|
349
|
+
const model = effectiveModel || 'gemini-pro';
|
|
350
|
+
const action = isStream ? 'streamGenerateContent?alt=sse' : 'generateContent';
|
|
351
|
+
return `${base}/v1beta/models/${model}:${action}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
224
354
|
return base + originalPath;
|
|
225
355
|
}
|
|
226
356
|
|
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
const parts = key.split(':');
|
|
229
|
+
// 只从 model 层(parts.length >= 4)聚合,避免三层重复计数
|
|
230
|
+
if (parts.length >= 4) {
|
|
231
|
+
const prov = parts[2];
|
|
232
|
+
const mdl = parts.slice(3).join(':');
|
|
233
|
+
|
|
234
|
+
summary.prompt += val.prompt;
|
|
235
|
+
summary.completion += val.completion;
|
|
236
|
+
summary.requests += val.requests;
|
|
237
|
+
if (val.estimated) summary.estimatedCount += val.requests;
|
|
238
|
+
|
|
239
|
+
if (!byProvider[prov]) byProvider[prov] = { prompt: 0, completion: 0, requests: 0, estimatedCount: 0 };
|
|
240
|
+
byProvider[prov].prompt += val.prompt;
|
|
241
|
+
byProvider[prov].completion += val.completion;
|
|
242
|
+
byProvider[prov].requests += val.requests;
|
|
243
|
+
if (val.estimated) byProvider[prov].estimatedCount += val.requests;
|
|
244
|
+
|
|
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
|
+
// 进程退出时刷盘(信号处理统一由 server.js 管理)
|
|
283
|
+
process.on('exit', flush);
|
|
284
|
+
|
|
285
|
+
module.exports = { recordUsage, getStats, flush };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "protocol-proxy",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.2",
|
|
4
4
|
"description": "OpenAI / Anthropic 协议转换透明代理",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"cors": "^2.8.5",
|
|
30
|
-
"express": "^4.19.2"
|
|
31
|
-
"puppeteer-core": "^24.43.0"
|
|
30
|
+
"express": "^4.19.2"
|
|
32
31
|
},
|
|
33
32
|
"devDependencies": {
|
|
34
33
|
"pkg": "^5.8.1"
|