protocol-proxy 2.1.5 → 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/converters/anthropic-to-openai.js +2 -3
- 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
|
@@ -122,14 +122,13 @@ function convertMessage(msg, idMap) {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
// user 消息含 tool_result → 拆分为多个 tool 消息
|
|
125
|
+
// OpenAI 要求 tool 消息紧跟 assistant tool_calls,user text 放在 tool 之后
|
|
125
126
|
if (msg.role === 'user' && toolResults.length > 0) {
|
|
126
127
|
const result = [];
|
|
127
|
-
|
|
128
|
-
// 但通常 tool_result 消息不会有额外的 user text
|
|
128
|
+
result.push(...toolResults);
|
|
129
129
|
if (textParts.length > 0) {
|
|
130
130
|
result.push({ role: 'user', content: textParts.join('') });
|
|
131
131
|
}
|
|
132
|
-
result.push(...toolResults);
|
|
133
132
|
return result;
|
|
134
133
|
}
|
|
135
134
|
|
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 };
|