protocol-proxy 2.8.0 → 2.8.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.
@@ -82,12 +82,15 @@ function convertMessage(msg, idMap) {
82
82
  // 处理 content 数组
83
83
  if (Array.isArray(msg.content)) {
84
84
  const textParts = [];
85
+ const thinkingParts = [];
85
86
  const toolResults = [];
86
87
  const toolUses = [];
87
88
 
88
89
  for (const block of msg.content) {
89
90
  if (block.type === 'text') {
90
91
  textParts.push(block.text);
92
+ } else if (block.type === 'thinking') {
93
+ thinkingParts.push(block.thinking);
91
94
  } else if (block.type === 'tool_result') {
92
95
  // 使用映射后的 OpenAI 格式 id
93
96
  const openaiId = idMap?.get(block.tool_use_id) || block.tool_use_id;
@@ -112,13 +115,15 @@ function convertMessage(msg, idMap) {
112
115
 
113
116
  // assistant 消息含 tool_use → 需要拆分为 assistant + tool
114
117
  if (msg.role === 'assistant' && toolUses.length > 0) {
115
- const result = [];
116
- result.push({
118
+ const assistantMsg = {
117
119
  role: 'assistant',
118
120
  content: textParts.join('') || '',
119
121
  tool_calls: toolUses,
120
- });
121
- return result;
122
+ };
123
+ if (thinkingParts.length > 0) {
124
+ assistantMsg.reasoning_content = thinkingParts.join('');
125
+ }
126
+ return [assistantMsg];
122
127
  }
123
128
 
124
129
  // user 消息含 tool_result → 拆分为多个 tool 消息
@@ -133,7 +138,11 @@ function convertMessage(msg, idMap) {
133
138
  }
134
139
 
135
140
  // 普通情况
136
- return { role: msg.role, content: textParts.join('') };
141
+ const out = { role: msg.role, content: textParts.join('') };
142
+ if (msg.role === 'assistant' && thinkingParts.length > 0) {
143
+ out.reasoning_content = thinkingParts.join('');
144
+ }
145
+ return out;
137
146
  }
138
147
 
139
148
  return { role: msg.role, content: msg.content };
@@ -8,6 +8,7 @@ const a2g = require('./converters/anthropic-to-gemini');
8
8
  const g2a = require('./converters/gemini-to-anthropic');
9
9
  const { recordUsage } = require('./stats-store');
10
10
  const logger = require('./logger');
11
+ const requestLog = require('./request-log');
11
12
 
12
13
  function createProxyApp(proxyConfigOrGetter) {
13
14
  const getProxyConfig = typeof proxyConfigOrGetter === 'function'
@@ -81,9 +82,13 @@ function createProxyApp(proxyConfigOrGetter) {
81
82
  function injectReasoningToMessages(messages) {
82
83
  if (!Array.isArray(messages)) return;
83
84
  for (const msg of messages) {
84
- if (msg.role === 'assistant' && msg.reasoning_content === undefined) {
85
+ if (msg.role === 'assistant' && (msg.reasoning_content === undefined || msg.reasoning_content === null)) {
85
86
  const reasoning = getReasoning(msg);
86
- msg.reasoning_content = reasoning || '';
87
+ if (reasoning) {
88
+ msg.reasoning_content = reasoning;
89
+ } else {
90
+ delete msg.reasoning_content;
91
+ }
87
92
  }
88
93
  }
89
94
  }
@@ -343,6 +348,7 @@ function createProxyApp(proxyConfigOrGetter) {
343
348
 
344
349
  async function handleRequest(req, res) {
345
350
  const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
351
+ const requestStart = Date.now();
346
352
  const proxyConfig = getProxyConfig();
347
353
  const inboundProtocol = detectInboundProtocol(req, req.body);
348
354
  const candidates = buildCandidates(proxyConfig);
@@ -353,6 +359,8 @@ function createProxyApp(proxyConfigOrGetter) {
353
359
 
354
360
  const isStream = req.body?.stream === true;
355
361
  const proxyId = proxyConfig.id || 'default';
362
+ const clientIP = req.ip || req.socket?.remoteAddress || '';
363
+ const proxyName = proxyConfig.name || '';
356
364
  const inboundModel = req.body?.model;
357
365
  const effectiveModel = proxyConfig.target?.defaultModel || inboundModel;
358
366
  const baseRequestBody = effectiveModel ? { ...req.body, model: effectiveModel } : { ...req.body };
@@ -448,6 +456,7 @@ function createProxyApp(proxyConfigOrGetter) {
448
456
 
449
457
  const maxKeyRetries = (candidate.apiKeys || []).filter(k => typeof k === 'object' ? k.enabled !== false : true).length || 1;
450
458
  let lastKeyError = null;
459
+ let keyLabel = '';
451
460
 
452
461
  for (let keyAttempt = 0; keyAttempt < maxKeyRetries; keyAttempt++) {
453
462
  const currentKey = selectKey(candidate.providerId, candidate.apiKeys || []);
@@ -471,7 +480,7 @@ function createProxyApp(proxyConfigOrGetter) {
471
480
  method: 'POST',
472
481
  headers: keyHeaders,
473
482
  body: JSON.stringify(targetBody),
474
- signal: AbortSignal.timeout(300000),
483
+ signal: AbortSignal.timeout(proxyConfig.timeout || 300000),
475
484
  });
476
485
 
477
486
  if (!fetchRes.ok) {
@@ -493,7 +502,7 @@ function createProxyApp(proxyConfigOrGetter) {
493
502
  recordSuccess(proxyId, candidate.providerId, Date.now() - startedAt);
494
503
  const keyEntry = (candidate.apiKeys || []).find(k => (typeof k === 'string' ? k : k.key) === currentKey);
495
504
  const alias = keyEntry && typeof keyEntry === 'object' ? keyEntry.alias : '';
496
- const keyLabel = alias ? `${alias}(…${currentKey.slice(-4)})` : (currentKey ? `…${currentKey.slice(-4)}` : '-');
505
+ keyLabel = alias ? `${alias}(…${currentKey.slice(-4)})` : (currentKey ? `…${currentKey.slice(-4)}` : '-');
497
506
  logger.log(`[${requestId}] ✓ ${candidate.providerName} | model=${candidateModel || '(default)'} key=${keyLabel} (${Date.now() - startedAt}ms)`);
498
507
 
499
508
  if (isStream) {
@@ -506,6 +515,7 @@ function createProxyApp(proxyConfigOrGetter) {
506
515
  const decoder = new TextDecoder();
507
516
  let streamUsage = null;
508
517
  let responseText = '';
518
+ let reasoningText = '';
509
519
  let toolCallCount = 0;
510
520
 
511
521
  req.on('close', () => {
@@ -525,6 +535,7 @@ function createProxyApp(proxyConfigOrGetter) {
525
535
  if (d.usage) streamUsage = d.usage;
526
536
  const delta = d.choices?.[0]?.delta;
527
537
  if (delta?.content) responseText += delta.content;
538
+ if (delta?.reasoning_content) reasoningText += delta.reasoning_content;
528
539
  if (delta?.tool_calls) {
529
540
  for (const tc of delta.tool_calls) {
530
541
  if (tc.function?.name) toolCallCount++;
@@ -541,8 +552,26 @@ function createProxyApp(proxyConfigOrGetter) {
541
552
  }
542
553
  }
543
554
 
555
+ // Cache reasoning_content from streaming response for future requests
556
+ if (reasoningText && responseText) {
557
+ const msg = { content: responseText, tool_calls: null };
558
+ setReasoning(msg, reasoningText);
559
+ }
560
+
544
561
  if (streamUsage) {
545
562
  recordUsage(proxyConfig.id, candidate.providerName, candidateModel, streamUsage, false);
563
+ requestLog.add({
564
+ id: requestId, proxyId, proxyName, method: req.method, path: req.path,
565
+ inboundProtocol, targetProtocol: candidate.protocol,
566
+ providerName: candidate.providerName, model: candidateModel || '',
567
+ status: 'success', upstreamStatusCode: null,
568
+ latencyMs: Date.now() - startedAt,
569
+ promptTokens: streamUsage.prompt_tokens || streamUsage.input_tokens || 0,
570
+ completionTokens: streamUsage.completion_tokens || streamUsage.output_tokens || 0,
571
+ totalTokens: (streamUsage.prompt_tokens || streamUsage.input_tokens || 0)
572
+ + (streamUsage.completion_tokens || streamUsage.output_tokens || 0),
573
+ isEstimated: false, stream: true, keyAlias: keyLabel, errorMessage: null, clientIP,
574
+ });
546
575
  } else if (responseText || toolCallCount > 0) {
547
576
  const inputTokens = estimateInputTokens(req.body);
548
577
  const outputTokens = estimateTokens(responseText) + toolCallCount * 15;
@@ -550,6 +579,16 @@ function createProxyApp(proxyConfigOrGetter) {
550
579
  prompt_tokens: inputTokens,
551
580
  completion_tokens: outputTokens,
552
581
  }, true);
582
+ requestLog.add({
583
+ id: requestId, proxyId, proxyName, method: req.method, path: req.path,
584
+ inboundProtocol, targetProtocol: candidate.protocol,
585
+ providerName: candidate.providerName, model: candidateModel || '',
586
+ status: 'success', upstreamStatusCode: null,
587
+ latencyMs: Date.now() - startedAt,
588
+ promptTokens: inputTokens, completionTokens: outputTokens,
589
+ totalTokens: inputTokens + outputTokens,
590
+ isEstimated: true, stream: true, keyAlias: keyLabel, errorMessage: null, clientIP,
591
+ });
553
592
  }
554
593
 
555
594
  if (sseConverter) {
@@ -559,6 +598,15 @@ function createProxyApp(proxyConfigOrGetter) {
559
598
  } catch (err) {
560
599
  recordFailure(proxyId, candidate.providerId);
561
600
  logger.error(`[${requestId}] Stream error:`, err.message);
601
+ requestLog.add({
602
+ id: requestId, proxyId, proxyName, method: req.method, path: req.path,
603
+ inboundProtocol, targetProtocol: candidate.protocol,
604
+ providerName: candidate.providerName, model: candidateModel || '',
605
+ status: 'failure', upstreamStatusCode: null,
606
+ latencyMs: Date.now() - startedAt,
607
+ promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
608
+ stream: true, keyAlias: keyLabel, errorMessage: err.message, clientIP,
609
+ });
562
610
  if (!res.writableEnded) {
563
611
  try {
564
612
  res.write(`data: ${JSON.stringify({ error: { message: err.message, type: 'proxy_error' } })}\n\n`);
@@ -574,6 +622,18 @@ function createProxyApp(proxyConfigOrGetter) {
574
622
  extractReasoningFromResponse(responseBody);
575
623
  extractAnthropicThinking(responseBody);
576
624
  recordUsage(proxyConfig.id, candidate.providerName, candidateModel, responseBody.usage);
625
+ requestLog.add({
626
+ id: requestId, proxyId, proxyName, method: req.method, path: req.path,
627
+ inboundProtocol, targetProtocol: candidate.protocol,
628
+ providerName: candidate.providerName, model: candidateModel || '',
629
+ status: 'success', upstreamStatusCode: fetchRes.status,
630
+ latencyMs: Date.now() - startedAt,
631
+ promptTokens: responseBody.usage?.prompt_tokens || responseBody.usage?.input_tokens || 0,
632
+ completionTokens: responseBody.usage?.completion_tokens || responseBody.usage?.output_tokens || 0,
633
+ totalTokens: (responseBody.usage?.prompt_tokens || responseBody.usage?.input_tokens || 0)
634
+ + (responseBody.usage?.completion_tokens || responseBody.usage?.output_tokens || 0),
635
+ isEstimated: false, stream: false, keyAlias: keyLabel, errorMessage: null, clientIP,
636
+ });
577
637
  const convertedBody = convertRes(responseBody);
578
638
  return res.json(convertedBody);
579
639
  } catch (err) {
@@ -584,6 +644,15 @@ function createProxyApp(proxyConfigOrGetter) {
584
644
  }
585
645
  recordFailure(proxyId, candidate.providerId);
586
646
  logger.error(`[${requestId}] ✗ ${candidate.providerName} | model=${candidateModel || '(default)'} - ${err.message}`);
647
+ requestLog.add({
648
+ id: requestId, proxyId, proxyName, method: req.method, path: req.path,
649
+ inboundProtocol, targetProtocol: candidate.protocol,
650
+ providerName: candidate.providerName, model: candidateModel || '',
651
+ status: 'failure', upstreamStatusCode: err?.status || null,
652
+ latencyMs: Date.now() - startedAt,
653
+ promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
654
+ stream: isStream, keyAlias: keyLabel, errorMessage: err.message, clientIP,
655
+ });
587
656
  if (err?.status && !isRetryableStatus(err.status)) {
588
657
  return res.status(err.status).json({ error: err.message });
589
658
  }
@@ -596,10 +665,27 @@ function createProxyApp(proxyConfigOrGetter) {
596
665
  if (lastKeyError) {
597
666
  recordFailure(proxyId, candidate.providerId);
598
667
  logger.error(`[${requestId}] ✗ ${candidate.providerName} | all keys rate-limited (429)`);
668
+ requestLog.add({
669
+ id: requestId, proxyId, proxyName, method: req.method, path: req.path,
670
+ inboundProtocol, targetProtocol: candidate.protocol,
671
+ providerName: candidate.providerName, model: candidateModel || '',
672
+ status: '429', upstreamStatusCode: 429,
673
+ latencyMs: Date.now() - startedAt,
674
+ promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
675
+ stream: isStream, keyAlias: keyLabel, errorMessage: 'All keys rate-limited', clientIP,
676
+ });
599
677
  }
600
678
  } // end candidate loop
601
679
 
602
680
  logger.error(`[${requestId}] 所有供应商均失败`);
681
+ requestLog.add({
682
+ id: requestId, proxyId, proxyName, method: req.method, path: req.path,
683
+ inboundProtocol, targetProtocol: '', providerName: 'N/A', model: effectiveModel || '',
684
+ status: 'failure', upstreamStatusCode: null,
685
+ latencyMs: Date.now() - requestStart,
686
+ promptTokens: 0, completionTokens: 0, totalTokens: 0, isEstimated: false,
687
+ stream: isStream, keyAlias: '', errorMessage: 'All providers failed', clientIP,
688
+ });
603
689
  return res.status(502).json({ error: 'All providers failed' });
604
690
  }
605
691
 
@@ -0,0 +1,27 @@
1
+ const MAX_ENTRIES = 1000;
2
+
3
+ const entries = [];
4
+ let listener = null;
5
+
6
+ function add(entry) {
7
+ entry.timestamp = entry.timestamp || new Date().toISOString();
8
+ entries.unshift(entry);
9
+ if (entries.length > MAX_ENTRIES) entries.pop();
10
+ if (listener) {
11
+ try { listener(entry); } catch { /* ignore */ }
12
+ }
13
+ }
14
+
15
+ function getAll(limit) {
16
+ return entries.slice(0, limit || MAX_ENTRIES);
17
+ }
18
+
19
+ function getCount() {
20
+ return entries.length;
21
+ }
22
+
23
+ function onEntry(callback) {
24
+ listener = callback;
25
+ }
26
+
27
+ module.exports = { add, getAll, getCount, onEntry, MAX_ENTRIES };
@@ -29,6 +29,7 @@ function addToBuffer(period, date, statsKey, prompt, completion, estimated) {
29
29
  function flush() {
30
30
  const stats = readStats();
31
31
  if (!dirty) return stats;
32
+ if (!stats.hourly) stats.hourly = {};
32
33
  if (!stats.daily) stats.daily = {};
33
34
  if (!stats.monthly) stats.monthly = {};
34
35
 
@@ -92,15 +93,31 @@ function yearKey(d) {
92
93
  return String(d.getFullYear());
93
94
  }
94
95
 
96
+ function hourKey(d) {
97
+ return dateKey(d) + '-' + String(d.getHours()).padStart(2, '0');
98
+ }
99
+
95
100
  // ==================== 分层合并 ====================
96
101
 
97
102
  function mergeIfNeeded() {
98
103
  const stats = flush();
99
104
  let changed = false;
100
105
 
106
+ const todayStr = dateKey(new Date());
101
107
  const currentMonth = monthKey(new Date()); // "2026-05"
102
108
  const currentYear = String(new Date().getFullYear());
103
109
 
110
+ // 小时级:清理昨天及更早的数据(daily 已有汇总,hourly 仅保留当天)
111
+ if (stats.hourly) {
112
+ const todayStart = todayStr + '-00';
113
+ for (const hk of Object.keys(stats.hourly)) {
114
+ if (hk < todayStart) {
115
+ delete stats.hourly[hk];
116
+ changed = true;
117
+ }
118
+ }
119
+ }
120
+
104
121
  // 日 → 月:非当月的日级数据合并为月级
105
122
  if (stats.daily) {
106
123
  const toMerge = {};
@@ -183,6 +200,7 @@ function recordUsage(proxyId, provider, model, usage, estimated = false) {
183
200
  if (!prompt && !completion) return;
184
201
 
185
202
  const now = new Date();
203
+ const hk = hourKey(now);
186
204
  const dk = dateKey(now);
187
205
  const mk = monthKey(now);
188
206
 
@@ -195,6 +213,7 @@ function recordUsage(proxyId, provider, model, usage, estimated = false) {
195
213
  ];
196
214
 
197
215
  for (const key of keys) {
216
+ addToBuffer('hourly', hk, key, prompt, completion, estimated);
198
217
  addToBuffer('daily', dk, key, prompt, completion, estimated);
199
218
  }
200
219
  }
@@ -208,9 +227,14 @@ function getStats(opts = {}) {
208
227
  const stats = readStats();
209
228
  const src = stats[range] || {};
210
229
 
230
+ // hourly keys are "YYYY-MM-DD-HH", need to pad day-level dates for comparison
231
+ const isHourly = range === 'hourly';
232
+ const filterStart = startDate ? (isHourly ? startDate + '-00' : startDate) : null;
233
+ const filterEnd = endDate ? (isHourly ? endDate + '-99' : endDate) : null;
234
+
211
235
  const dates = Object.keys(src).filter(d => {
212
- if (startDate && d < startDate) return false;
213
- if (endDate && d > endDate) return false;
236
+ if (filterStart && d < filterStart) return false;
237
+ if (filterEnd && d > filterEnd) return false;
214
238
  return true;
215
239
  }).sort();
216
240
 
@@ -0,0 +1,46 @@
1
+ const logger = require('./logger');
2
+
3
+ let wss = null;
4
+ let WebSocketServer = null;
5
+
6
+ function init(httpServer) {
7
+ try {
8
+ const ws = require('ws');
9
+ WebSocketServer = ws.WebSocketServer || ws.Server;
10
+ wss = new WebSocketServer({ server: httpServer });
11
+
12
+ wss.on('connection', (ws) => {
13
+ logger.log('[WS] Client connected');
14
+ try { ws.send(JSON.stringify({ type: 'connected', count: getClientCount() })); } catch { /* ignore */ }
15
+ ws.on('close', () => { logger.log('[WS] Client disconnected'); });
16
+ });
17
+
18
+ logger.log('[WS] WebSocket server initialized');
19
+ } catch (err) {
20
+ logger.error('[WS] Failed to initialize WebSocket server:', err.message);
21
+ wss = null;
22
+ }
23
+ }
24
+
25
+ function broadcast(data) {
26
+ if (!wss) return;
27
+ const msg = JSON.stringify(data);
28
+ for (const client of wss.clients) {
29
+ if (client.readyState === 1 /* OPEN */) {
30
+ try { client.send(msg); } catch { /* ignore */ }
31
+ }
32
+ }
33
+ }
34
+
35
+ function getClientCount() {
36
+ return wss ? wss.clients.size : 0;
37
+ }
38
+
39
+ function close() {
40
+ if (wss) {
41
+ try { wss.close(); } catch { /* ignore */ }
42
+ wss = null;
43
+ }
44
+ }
45
+
46
+ module.exports = { init, broadcast, getClientCount, close };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protocol-proxy",
3
- "version": "2.8.0",
3
+ "version": "2.8.2",
4
4
  "description": "OpenAI / Anthropic 协议转换透明代理",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -27,7 +27,8 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "cors": "^2.8.5",
30
- "express": "^4.19.2"
30
+ "express": "^4.19.2",
31
+ "ws": "^8.20.1"
31
32
  },
32
33
  "devDependencies": {
33
34
  "pkg": "^5.8.1"
@@ -48,4 +49,4 @@
48
49
  "engines": {
49
50
  "node": ">=20.0.0"
50
51
  }
51
- }
52
+ }