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.
- package/lib/config-store.js +8 -5
- package/lib/converters/anthropic-to-openai.js +8 -2
- package/lib/converters/openai-to-anthropic.js +7 -1
- package/lib/converters/sse-helpers.js +0 -30
- package/lib/detector.js +2 -2
- package/lib/proxy-manager.js +1 -1
- package/lib/proxy-server.js +20 -9
- package/package.json +3 -3
- package/public/app.js +20 -6
- package/public/index.html +2 -1
- package/server.js +38 -6
package/lib/config-store.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 ||
|
|
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 ||
|
|
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 {
|
|
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
|
|
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
|
|
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
package/lib/proxy-manager.js
CHANGED
|
@@ -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
|
|
package/lib/proxy-server.js
CHANGED
|
@@ -15,24 +15,31 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
15
15
|
const reasoningCache = new Map();
|
|
16
16
|
const MAX_CACHE_SIZE = 100;
|
|
17
17
|
|
|
18
|
-
function
|
|
19
|
-
|
|
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(
|
|
30
|
+
reasoningCache.set(key, reasoning);
|
|
25
31
|
}
|
|
26
32
|
|
|
27
|
-
function getReasoning(
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
43
|
+
"node20-win-x64"
|
|
44
44
|
],
|
|
45
45
|
"outputPath": "dist",
|
|
46
46
|
"options": [
|
|
47
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
|