protocol-proxy 2.8.3 → 2.9.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/conversation-store.js +108 -0
- package/lib/proxy-server.js +44 -5
- package/package.json +1 -1
- package/public/app.js +431 -66
- package/public/index.html +67 -6
- package/public/style.css +188 -0
- package/server.js +541 -52
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const DATA_DIR = path.join(os.homedir(), '.protocol-proxy', 'conversations');
|
|
6
|
+
|
|
7
|
+
// 内存缓存:conversationId → { id, proxyId, messages, createdAt, lastActivity }
|
|
8
|
+
let conversations = {};
|
|
9
|
+
|
|
10
|
+
// debounce 写入:500ms 内同一 conv 只写一次
|
|
11
|
+
const pendingWrites = new Map(); // convId → timer
|
|
12
|
+
|
|
13
|
+
function scheduleSave(conv) {
|
|
14
|
+
if (pendingWrites.has(conv.id)) clearTimeout(pendingWrites.get(conv.id));
|
|
15
|
+
pendingWrites.set(conv.id, setTimeout(() => {
|
|
16
|
+
pendingWrites.delete(conv.id);
|
|
17
|
+
fs.writeFile(path.join(DATA_DIR, conv.id + '.json'), JSON.stringify(conv), 'utf8', () => {});
|
|
18
|
+
}, 500));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function saveImmediate(conv) {
|
|
22
|
+
if (pendingWrites.has(conv.id)) {
|
|
23
|
+
clearTimeout(pendingWrites.get(conv.id));
|
|
24
|
+
pendingWrites.delete(conv.id);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
fs.writeFileSync(path.join(DATA_DIR, conv.id + '.json'), JSON.stringify(conv), 'utf8');
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function flushAll() {
|
|
32
|
+
for (const [id, timer] of pendingWrites) {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
const conv = conversations[id];
|
|
35
|
+
if (conv) {
|
|
36
|
+
try { fs.writeFileSync(path.join(DATA_DIR, id + '.json'), JSON.stringify(conv), 'utf8'); } catch {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
pendingWrites.clear();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function init() {
|
|
43
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
44
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const files = fs.readdirSync(DATA_DIR).filter(f => f.endsWith('.json'));
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
try {
|
|
50
|
+
const data = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), 'utf8'));
|
|
51
|
+
if (data.id) conversations[data.id] = data;
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function get(id) {
|
|
58
|
+
return conversations[id] || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function create(proxyId, maxConversations) {
|
|
62
|
+
// 超过最大会话数时删除最早的
|
|
63
|
+
if (maxConversations > 0) {
|
|
64
|
+
const all = list();
|
|
65
|
+
while (all.length >= maxConversations) {
|
|
66
|
+
const oldest = all.shift(); // list() 已按 lastActivity 升序
|
|
67
|
+
remove(oldest.id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const id = 'conv-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
71
|
+
const conv = { id, proxyId, messages: [], createdAt: Date.now(), lastActivity: Date.now() };
|
|
72
|
+
conversations[id] = conv;
|
|
73
|
+
saveImmediate(conv); // 新建立即写入
|
|
74
|
+
return conv;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function touch(conv) {
|
|
78
|
+
conv.lastActivity = Date.now();
|
|
79
|
+
scheduleSave(conv); // debounce 异步写入
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function remove(id) {
|
|
83
|
+
delete conversations[id];
|
|
84
|
+
if (pendingWrites.has(id)) { clearTimeout(pendingWrites.get(id)); pendingWrites.delete(id); }
|
|
85
|
+
fs.unlink(path.join(DATA_DIR, id + '.json'), () => {});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 返回会话列表(按 lastActivity 升序,不含 messages),用于前端展示
|
|
89
|
+
function list() {
|
|
90
|
+
return Object.values(conversations)
|
|
91
|
+
.map(c => ({
|
|
92
|
+
id: c.id,
|
|
93
|
+
proxyId: c.proxyId,
|
|
94
|
+
createdAt: c.createdAt,
|
|
95
|
+
lastActivity: c.lastActivity,
|
|
96
|
+
messageCount: (c.messages || []).length,
|
|
97
|
+
// 取第一条 user 消息作为标题预览
|
|
98
|
+
preview: ((c.messages || []).find(m => m.role === 'user')?.content || '').slice(0, 60),
|
|
99
|
+
}))
|
|
100
|
+
.sort((a, b) => a.lastActivity - b.lastActivity);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 进程退出时确保所有 debounce 中的数据落盘
|
|
104
|
+
process.on('exit', flushAll);
|
|
105
|
+
process.on('SIGINT', () => { flushAll(); process.exit(0); });
|
|
106
|
+
process.on('SIGTERM', () => { flushAll(); process.exit(0); });
|
|
107
|
+
|
|
108
|
+
module.exports = { init, get, create, touch, remove, list, flushAll };
|
package/lib/proxy-server.js
CHANGED
|
@@ -351,9 +351,13 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
351
351
|
const requestStart = Date.now();
|
|
352
352
|
const proxyConfig = getProxyConfig();
|
|
353
353
|
const inboundProtocol = detectInboundProtocol(req, req.body);
|
|
354
|
-
|
|
354
|
+
let candidates = buildCandidates(proxyConfig);
|
|
355
355
|
|
|
356
|
-
|
|
356
|
+
// 请求级供应商/模型覆盖(来自助手 chat 端点的自定义头)
|
|
357
|
+
const overrideProviderId = req.headers['x-pp-provider-id'];
|
|
358
|
+
const overrideModel = req.headers['x-pp-model'];
|
|
359
|
+
|
|
360
|
+
if (candidates.length === 0 && !overrideProviderId) {
|
|
357
361
|
return res.status(500).json({ error: 'Proxy target not configured' });
|
|
358
362
|
}
|
|
359
363
|
|
|
@@ -362,8 +366,43 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
362
366
|
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
|
363
367
|
const proxyName = proxyConfig.name || '';
|
|
364
368
|
const inboundModel = req.body?.model;
|
|
365
|
-
|
|
366
|
-
|
|
369
|
+
let effectiveModel = proxyConfig.target?.defaultModel || inboundModel;
|
|
370
|
+
|
|
371
|
+
// 模型覆盖:优先级高于 defaultModel
|
|
372
|
+
if (overrideModel) {
|
|
373
|
+
effectiveModel = overrideModel;
|
|
374
|
+
}
|
|
375
|
+
let baseRequestBody = effectiveModel ? { ...req.body, model: effectiveModel } : { ...req.body };
|
|
376
|
+
|
|
377
|
+
// 供应商覆盖:筛选或动态构建候选
|
|
378
|
+
if (overrideProviderId) {
|
|
379
|
+
const filtered = candidates.filter(c => c.providerId === overrideProviderId);
|
|
380
|
+
if (filtered.length > 0) {
|
|
381
|
+
candidates = filtered;
|
|
382
|
+
} else {
|
|
383
|
+
// 不在代理候选池中 → 用附加头动态构建临时候选
|
|
384
|
+
const providerUrl = req.headers['x-pp-provider-url'];
|
|
385
|
+
const providerProtocol = req.headers['x-pp-provider-protocol'];
|
|
386
|
+
const providerKeys = req.headers['x-pp-provider-keys'];
|
|
387
|
+
if (providerUrl && providerProtocol) {
|
|
388
|
+
const tempCandidate = {
|
|
389
|
+
providerId: overrideProviderId,
|
|
390
|
+
providerName: overrideProviderId,
|
|
391
|
+
providerUrl,
|
|
392
|
+
protocol: providerProtocol,
|
|
393
|
+
apiKeys: providerKeys ? JSON.parse(providerKeys) : [],
|
|
394
|
+
models: [],
|
|
395
|
+
azureDeployment: '',
|
|
396
|
+
azureApiVersion: '',
|
|
397
|
+
model: '',
|
|
398
|
+
weight: 1,
|
|
399
|
+
};
|
|
400
|
+
candidates = [tempCandidate];
|
|
401
|
+
} else {
|
|
402
|
+
return res.status(400).json({ error: '指定的供应商不在代理候选列表中,且缺少供应商配置' });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
367
406
|
|
|
368
407
|
// Inject cached reasoning for OpenAI inbound (OpenAI protocol lacks reasoning_content)
|
|
369
408
|
if (inboundProtocol === 'openai') {
|
|
@@ -446,7 +485,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
446
485
|
|
|
447
486
|
const targetUrl = buildTargetUrl(candidate, req.path, isStream, candidateModel);
|
|
448
487
|
// Forward client headers (preserve anthropic-beta, user-agent, etc.)
|
|
449
|
-
const skipHeaders = new Set(['host', 'connection', 'content-length', 'content-type', 'accept', 'authorization', 'x-api-key', 'anthropic-version']);
|
|
488
|
+
const skipHeaders = new Set(['host', 'connection', 'content-length', 'content-type', 'accept', 'authorization', 'x-api-key', 'anthropic-version', 'x-pp-provider-id', 'x-pp-model', 'x-pp-provider-url', 'x-pp-provider-protocol', 'x-pp-provider-keys']);
|
|
450
489
|
const headers = {};
|
|
451
490
|
for (const [key, val] of Object.entries(req.headers)) {
|
|
452
491
|
if (!skipHeaders.has(key.toLowerCase())) headers[key] = val;
|