protocol-proxy 2.0.6 → 2.1.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.
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
+ const crypto = require('crypto');
4
5
 
5
6
  const CONFIG_PATH = path.join(os.homedir(), '.protocol-proxy', 'proxies.json');
6
7
 
@@ -15,7 +16,7 @@ function migrateOldConfig() {
15
16
  const dir = path.dirname(CONFIG_PATH);
16
17
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
17
18
  fs.copyFileSync(OLD_CONFIG_PATH, CONFIG_PATH);
18
- } catch {}
19
+ } catch (err) { console.error('[Config] 迁移旧配置失败:', err.message); }
19
20
  }
20
21
  migrateOldConfig();
21
22
 
@@ -33,7 +34,7 @@ function migrateTargetToProvider(config) {
33
34
  if (!proxy.providerId) {
34
35
  const t = proxy.target;
35
36
  const provider = {
36
- id: 'provider-' + Date.now(),
37
+ id: crypto.randomUUID(),
37
38
  name: t.providerName || t.providerUrl,
38
39
  url: t.providerUrl,
39
40
  protocol: t.protocol || 'openai',
@@ -119,7 +120,9 @@ function saveConfig(config) {
119
120
  if (!fs.existsSync(dir)) {
120
121
  fs.mkdirSync(dir, { recursive: true });
121
122
  }
122
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(normalizedConfig, null, 2), 'utf-8');
123
+ const tmpPath = CONFIG_PATH + '.tmp';
124
+ fs.writeFileSync(tmpPath, JSON.stringify(normalizedConfig, null, 2), 'utf-8');
125
+ fs.renameSync(tmpPath, CONFIG_PATH);
123
126
  configCache = normalizedConfig;
124
127
  return true;
125
128
  } catch (err) {
@@ -141,7 +144,7 @@ function getProviderById(id) {
141
144
  function addProvider(provider) {
142
145
  const config = loadConfig();
143
146
  config.providers = config.providers || [];
144
- provider.id = provider.id || 'provider-' + Date.now();
147
+ provider.id = provider.id || crypto.randomUUID();
145
148
  provider.models = normalizeModels(provider.models);
146
149
  config.providers.push(provider);
147
150
  saveConfig(config);
@@ -182,7 +185,7 @@ function getProxyById(id) {
182
185
  function addProxy(proxy) {
183
186
  const config = loadConfig();
184
187
  config.proxies = config.proxies || [];
185
- proxy.id = proxy.id || 'proxy-' + Date.now();
188
+ proxy.id = proxy.id || crypto.randomUUID();
186
189
  config.proxies.push(proxy);
187
190
  saveConfig(config);
188
191
  return proxy;
@@ -2,7 +2,7 @@
2
2
  * Anthropic → OpenAI 协议转换
3
3
  */
4
4
 
5
- const { encodeOpenAIEvent, encodeOpenAIDone, encodeAnthropicEvent } = require('./sse-helpers');
5
+ const { encodeAnthropicEvent } = require('./sse-helpers');
6
6
 
7
7
  // ==================== 请求转换 ====================
8
8
 
@@ -175,11 +175,17 @@ function convertResponse(openaiBody) {
175
175
  // tool_calls → tool_use
176
176
  if (message.tool_calls) {
177
177
  for (const tc of message.tool_calls) {
178
+ let input = {};
179
+ try {
180
+ input = tc.function?.arguments ? JSON.parse(tc.function.arguments) : {};
181
+ } catch {
182
+ input = {};
183
+ }
178
184
  content.push({
179
185
  type: 'tool_use',
180
186
  id: tc.id,
181
187
  name: tc.function?.name,
182
- input: tc.function?.arguments ? JSON.parse(tc.function.arguments) : {},
188
+ input,
183
189
  });
184
190
  }
185
191
  }
@@ -76,11 +76,17 @@ function convertMessage(msg) {
76
76
  content.push({ type: 'text', text: msg.content });
77
77
  }
78
78
  for (const tc of msg.tool_calls) {
79
+ let input = {};
80
+ try {
81
+ input = tc.function?.arguments ? JSON.parse(tc.function.arguments) : {};
82
+ } catch {
83
+ input = {};
84
+ }
79
85
  content.push({
80
86
  type: 'tool_use',
81
87
  id: tc.id,
82
88
  name: tc.function?.name,
83
- input: tc.function?.arguments ? JSON.parse(tc.function.arguments) : {},
89
+ input,
84
90
  });
85
91
  }
86
92
  return { role: 'assistant', content };
@@ -2,35 +2,6 @@
2
2
  * SSE 解析与编码辅助函数
3
3
  */
4
4
 
5
- function parseSSELines(buffer) {
6
- const lines = buffer.split('\n');
7
- const events = [];
8
- let currentEvent = { event: null, data: null };
9
-
10
- for (const line of lines) {
11
- const trimmed = line.trim();
12
- if (trimmed === '') {
13
- // 空行表示一个事件结束
14
- if (currentEvent.data !== null) {
15
- events.push(currentEvent);
16
- }
17
- currentEvent = { event: null, data: null };
18
- continue;
19
- }
20
- if (trimmed.startsWith('event:')) {
21
- currentEvent.event = trimmed.slice(6).trim();
22
- } else if (trimmed.startsWith('data:')) {
23
- const data = trimmed.slice(5).trim();
24
- currentEvent.data = currentEvent.data === null ? data : currentEvent.data + '\n' + data;
25
- }
26
- }
27
-
28
- // 如果最后一行没有空行结束,保留未完成的事件
29
- const remainder = currentEvent.data !== null ? currentEvent : null;
30
-
31
- return { events, remainder };
32
- }
33
-
34
5
  function encodeOpenAIEvent(obj) {
35
6
  return `data: ${JSON.stringify(obj)}\n\n`;
36
7
  }
@@ -44,7 +15,6 @@ function encodeAnthropicEvent(eventName, obj) {
44
15
  }
45
16
 
46
17
  module.exports = {
47
- parseSSELines,
48
18
  encodeOpenAIEvent,
49
19
  encodeOpenAIDone,
50
20
  encodeAnthropicEvent,
package/lib/detector.js CHANGED
@@ -31,8 +31,8 @@ function detectInboundProtocol(req, body) {
31
31
  }
32
32
  }
33
33
 
34
- // 无法确定时,默认 openai
35
- return 'openai';
34
+ // 无法确定时,返回 null,由调用方决定是否透传
35
+ return null;
36
36
  }
37
37
 
38
38
  module.exports = { detectInboundProtocol };
@@ -38,10 +38,10 @@ class ProxyManager {
38
38
 
39
39
  return new Promise((resolve) => {
40
40
  entry.server.close(() => {
41
+ this.servers.delete(id);
41
42
  console.log(`[Proxy] ${entry.config.name} stopped on port ${entry.config.port}`);
42
43
  resolve(true);
43
44
  });
44
- this.servers.delete(id);
45
45
  });
46
46
  }
47
47
 
@@ -15,24 +15,31 @@ function createProxyApp(proxyConfigOrGetter) {
15
15
  const reasoningCache = new Map();
16
16
  const MAX_CACHE_SIZE = 100;
17
17
 
18
- function setReasoning(content, reasoning) {
19
- if (!content || !reasoning) return;
18
+ function getReasoningKey(msg) {
19
+ const toolIds = msg.tool_calls?.map(t => t.id).join(',') || '';
20
+ return msg.content + '|' + toolIds;
21
+ }
22
+
23
+ function setReasoning(msg, reasoning) {
24
+ if (!msg?.content || !reasoning) return;
25
+ const key = getReasoningKey(msg);
20
26
  if (reasoningCache.size >= MAX_CACHE_SIZE) {
21
27
  const firstKey = reasoningCache.keys().next().value;
22
28
  reasoningCache.delete(firstKey);
23
29
  }
24
- reasoningCache.set(content, reasoning);
30
+ reasoningCache.set(key, reasoning);
25
31
  }
26
32
 
27
- function getReasoning(content) {
28
- return reasoningCache.get(content);
33
+ function getReasoning(msg) {
34
+ if (!msg?.content) return undefined;
35
+ return reasoningCache.get(getReasoningKey(msg));
29
36
  }
30
37
 
31
38
  function injectReasoningToMessages(messages) {
32
39
  if (!Array.isArray(messages)) return;
33
40
  for (const msg of messages) {
34
- if (msg.role === 'assistant') {
35
- const reasoning = getReasoning(msg.content);
41
+ if (msg.role === 'assistant' && msg.reasoning_content === undefined) {
42
+ const reasoning = getReasoning(msg);
36
43
  // DeepSeek 等 reasoning model 要求 assistant message 必须包含 reasoning_content 字段
37
44
  msg.reasoning_content = reasoning || '';
38
45
  }
@@ -43,7 +50,7 @@ function createProxyApp(proxyConfigOrGetter) {
43
50
  const choice = body.choices?.[0];
44
51
  const message = choice?.message;
45
52
  if (message?.role === 'assistant' && message.reasoning_content) {
46
- setReasoning(message.content, message.reasoning_content);
53
+ setReasoning(message, message.reasoning_content);
47
54
  }
48
55
  }
49
56
 
@@ -85,7 +92,7 @@ function createProxyApp(proxyConfigOrGetter) {
85
92
  const targetProtocol = target.protocol;
86
93
  const isStream = req.body?.stream === true;
87
94
 
88
- console.log(`[${requestId}] ⬅️ ${inboundProtocol.toUpperCase()} → ${targetProtocol.toUpperCase()} | path=${req.path}`);
95
+ console.log(`[${requestId}] ⬅️ ${(inboundProtocol || 'unknown').toUpperCase()} → ${targetProtocol.toUpperCase()} | path=${req.path}`);
89
96
 
90
97
  // 决定转换方向
91
98
  let convertReq, convertRes, createSSEConv;
@@ -158,6 +165,10 @@ function createProxyApp(proxyConfigOrGetter) {
158
165
  const reader = fetchRes.body.getReader();
159
166
  const decoder = new TextDecoder();
160
167
 
168
+ req.on('close', () => {
169
+ try { reader.cancel(); } catch (err) { /* ignore */ }
170
+ });
171
+
161
172
  try {
162
173
  while (true) {
163
174
  const { done, value } = await reader.read();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protocol-proxy",
3
- "version": "2.0.6",
3
+ "version": "2.1.0",
4
4
  "description": "OpenAI / Anthropic 协议转换透明代理",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -40,11 +40,11 @@
40
40
  "public/**/*"
41
41
  ],
42
42
  "targets": [
43
- "node18-win-x64"
43
+ "node20-win-x64"
44
44
  ],
45
45
  "outputPath": "dist",
46
46
  "options": [
47
- "--experimental-fetch"
47
+
48
48
  ]
49
49
  },
50
50
  "engines": {
package/public/app.js CHANGED
@@ -40,6 +40,7 @@ function initProviderDropdown() {
40
40
  const dropdown = document.getElementById('provider-dropdown');
41
41
  const addNameInput = document.getElementById('provider-add-name');
42
42
  const addUrlInput = document.getElementById('provider-add-url');
43
+ const addKeyInput = document.getElementById('provider-add-key');
43
44
  const addBtn = document.getElementById('provider-add-btn');
44
45
 
45
46
  trigger.addEventListener('click', (e) => {
@@ -49,6 +50,7 @@ function initProviderDropdown() {
49
50
  editingProviderId = null;
50
51
  addNameInput.value = '';
51
52
  addUrlInput.value = '';
53
+ addKeyInput.value = '';
52
54
  addUrlInput.disabled = false;
53
55
  addBtn.textContent = '添加';
54
56
  renderProviderOptions();
@@ -80,10 +82,13 @@ function initProviderDropdown() {
80
82
  });
81
83
  } else {
82
84
  // 新增模式
85
+ const body = { name, url };
86
+ const key = addKeyInput.value.trim();
87
+ if (key) body.apiKey = key;
83
88
  res = await fetch('/api/providers', {
84
89
  method: 'POST',
85
90
  headers: { 'Content-Type': 'application/json' },
86
- body: JSON.stringify({ name, url }),
91
+ body: JSON.stringify(body),
87
92
  });
88
93
  }
89
94
  if (!res.ok) {
@@ -199,7 +204,9 @@ function selectProvider(id) {
199
204
  document.getElementById('provider-dropdown-value').textContent = provider
200
205
  ? (provider.name !== provider.url ? `${provider.name} - ${provider.url}` : provider.url)
201
206
  : '选择供应商...';
202
- renderModelOptions();
207
+ // 切换供应商后模型自动选为该供应商模型列表的第一个
208
+ const models = provider?.models || [];
209
+ selectModel(models[0] || '');
203
210
  updateModelAddState();
204
211
  // 加载供应商的 API Key
205
212
  if (id) {
@@ -520,6 +527,7 @@ function closeModal() {
520
527
  document.getElementById('model-dropdown').classList.remove('open');
521
528
  document.getElementById('provider-dropdown').classList.remove('open');
522
529
  editingId = null;
530
+ editingProviderId = null;
523
531
  }
524
532
 
525
533
  async function handleSubmit(e) {
@@ -549,13 +557,19 @@ async function handleSubmit(e) {
549
557
  if (protocol) providerUpdates.protocol = protocol;
550
558
  if (Object.keys(providerUpdates).length > 0) {
551
559
  try {
552
- await fetch(`/api/providers/${providerId}`, {
560
+ const res = await fetch(`/api/providers/${providerId}`, {
553
561
  method: 'PUT',
554
562
  headers: { 'Content-Type': 'application/json' },
555
563
  body: JSON.stringify(providerUpdates),
556
564
  });
565
+ if (!res.ok) {
566
+ const err = await res.json();
567
+ showToast('供应商配置保存失败: ' + (err.error || '未知错误'), true);
568
+ }
557
569
  await loadProviders();
558
- } catch {}
570
+ } catch (err) {
571
+ showToast('供应商配置保存失败: ' + err.message, true);
572
+ }
559
573
  }
560
574
 
561
575
  const payload = {
@@ -599,7 +613,7 @@ async function startProxy(id) {
599
613
  await fetch(`/api/proxies/${id}/start`, { method: 'POST' });
600
614
  await loadProxies();
601
615
  } catch (err) {
602
- alert('启动失败: ' + err.message);
616
+ showToast('启动失败: ' + err.message, true);
603
617
  }
604
618
  }
605
619
 
@@ -608,7 +622,7 @@ async function stopProxy(id) {
608
622
  await fetch(`/api/proxies/${id}/stop`, { method: 'POST' });
609
623
  await loadProxies();
610
624
  } catch (err) {
611
- alert('停止失败: ' + err.message);
625
+ showToast('停止失败: ' + err.message, true);
612
626
  }
613
627
  }
614
628
 
package/public/index.html CHANGED
@@ -84,7 +84,8 @@
84
84
  <div class="model-dropdown-options" id="provider-dropdown-options"></div>
85
85
  <div class="model-add-section">
86
86
  <input type="text" class="model-add-input" id="provider-add-name" placeholder="供应商名称">
87
- <input type="url" class="model-add-input" id="provider-add-url" placeholder="https://api.example.com">
87
+ <input type="text" class="model-add-input" id="provider-add-url" placeholder="https://api.example.com">
88
+ <input type="password" class="model-add-input" id="provider-add-key" placeholder="API Key (可选)">
88
89
  <button type="button" class="btn btn-primary btn-sm" id="provider-add-btn">添加</button>
89
90
  </div>
90
91
  </div>
package/server.js CHANGED
@@ -10,7 +10,9 @@ const PID_FILE = path.join(os.tmpdir(), 'protocol-proxy.pid');
10
10
  const pkg = require('./package.json');
11
11
 
12
12
  function writePid() {
13
- try { fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
13
+ try { fs.writeFileSync(PID_FILE, String(process.pid)); } catch (err) {
14
+ console.error('[PID] 写入失败:', err.message);
15
+ }
14
16
  }
15
17
 
16
18
  function readPid() {
@@ -18,7 +20,9 @@ function readPid() {
18
20
  }
19
21
 
20
22
  function removePid() {
21
- try { fs.unlinkSync(PID_FILE); } catch {}
23
+ try { fs.unlinkSync(PID_FILE); } catch (err) {
24
+ console.error('[PID] 删除失败:', err.message);
25
+ }
22
26
  }
23
27
 
24
28
  function isProcessAlive(pid) {
@@ -136,6 +140,17 @@ async function init() {
136
140
 
137
141
  app.use(cors());
138
142
  app.use(express.json());
143
+
144
+ // 访问日志
145
+ app.use((req, res, next) => {
146
+ const start = Date.now();
147
+ res.on('finish', () => {
148
+ const duration = Date.now() - start;
149
+ console.log(`[HTTP] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`);
150
+ });
151
+ next();
152
+ });
153
+
139
154
  app.use(express.static(path.join(__dirname, 'public')));
140
155
 
141
156
  // ==================== 辅助函数 ====================
@@ -383,6 +398,19 @@ async function init() {
383
398
  });
384
399
  });
385
400
 
401
+ // 健康检查
402
+ app.get('/api/health', (req, res) => {
403
+ res.json({
404
+ status: 'ok',
405
+ version: pkg.version,
406
+ uptime: process.uptime(),
407
+ proxies: {
408
+ total: configStore.getProxies().length,
409
+ running: proxyManager.getRunningPorts().length,
410
+ },
411
+ });
412
+ });
413
+
386
414
  // 前端首页
387
415
  app.get('/', (req, res) => {
388
416
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
@@ -393,13 +421,13 @@ async function init() {
393
421
 
394
422
  // 启动所有已配置的代理
395
423
  const proxies = configStore.getProxies();
396
- for (const proxy of proxies) {
424
+ await Promise.all(proxies.map(async (proxy) => {
397
425
  try {
398
426
  await startProxyWithProvider(proxy);
399
427
  } catch (err) {
400
428
  console.error(`[Init] Failed to start proxy ${proxy.name}:`, err.message);
401
429
  }
402
- }
430
+ }));
403
431
 
404
432
  app.listen(PORT, () => {
405
433
  const adminUrl = `http://localhost:${PORT}`;
@@ -416,7 +444,9 @@ process.on('SIGINT', async () => {
416
444
  try {
417
445
  const proxyManager = require('./lib/proxy-manager');
418
446
  await proxyManager.stopAll();
419
- } catch {}
447
+ } catch (err) {
448
+ console.error('[Shutdown] stopAll error:', err.message);
449
+ }
420
450
  process.exit(0);
421
451
  });
422
452
 
@@ -425,7 +455,9 @@ process.on('SIGTERM', async () => {
425
455
  try {
426
456
  const proxyManager = require('./lib/proxy-manager');
427
457
  await proxyManager.stopAll();
428
- } catch {}
458
+ } catch (err) {
459
+ console.error('[Shutdown] stopAll error:', err.message);
460
+ }
429
461
  process.exit(0);
430
462
  });
431
463