protocol-proxy 2.5.1 → 2.7.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 +83 -2
- package/package.json +1 -1
- package/public/app.js +436 -1
- package/public/index.html +60 -0
- package/public/style.css +483 -69
- package/server.js +245 -0
package/server.js
CHANGED
|
@@ -329,6 +329,169 @@ async function init() {
|
|
|
329
329
|
res.json(updated);
|
|
330
330
|
});
|
|
331
331
|
|
|
332
|
+
app.post('/api/providers/:id/test', async (req, res) => {
|
|
333
|
+
const provider = configStore.getProviderById(req.params.id);
|
|
334
|
+
if (!provider) return res.status(404).json({ error: 'Provider not found' });
|
|
335
|
+
|
|
336
|
+
const existingKeys = provider.apiKeys || [];
|
|
337
|
+
const reqKeys = Array.isArray(req.body.apiKeys) ? req.body.apiKeys : [];
|
|
338
|
+
const resolved = reqKeys
|
|
339
|
+
.map((k, i) => {
|
|
340
|
+
if (k && typeof k === 'object' && k.masked && typeof k.index === 'number') {
|
|
341
|
+
const ex = existingKeys[k.index];
|
|
342
|
+
return ex ? { key: ex.key, alias: k.alias || ex.alias || '', domIndex: i } : null;
|
|
343
|
+
}
|
|
344
|
+
if (k && typeof k === 'object' && typeof k.key === 'string' && k.key.trim()) {
|
|
345
|
+
return { key: k.key.trim(), alias: k.alias || '', domIndex: i };
|
|
346
|
+
}
|
|
347
|
+
if (typeof k === 'string' && k.trim()) return { key: k.trim(), alias: '', domIndex: i };
|
|
348
|
+
return null;
|
|
349
|
+
})
|
|
350
|
+
.filter(Boolean);
|
|
351
|
+
|
|
352
|
+
if (resolved.length === 0) {
|
|
353
|
+
return res.json({ ok: false, message: '没有可用的 API Key', results: [] });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const protocol = req.body.protocol || provider.protocol || 'openai';
|
|
357
|
+
const base = provider.url.replace(/\/$/, '');
|
|
358
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
359
|
+
const isAzure = protocol === 'openai' && !!provider.azureDeployment;
|
|
360
|
+
|
|
361
|
+
function buildTestOpts(key) {
|
|
362
|
+
if (protocol === 'openai') {
|
|
363
|
+
if (isAzure) {
|
|
364
|
+
const ver = provider.azureApiVersion || '2024-02-01';
|
|
365
|
+
return {
|
|
366
|
+
url: `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`,
|
|
367
|
+
opts: { headers: { 'api-key': key } },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
url: hasV1Suffix ? `${base}/models` : `${base}/v1/models`,
|
|
372
|
+
opts: { headers: { 'Authorization': `Bearer ${key}` } },
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
if (protocol === 'anthropic') {
|
|
376
|
+
const testModel = req.body.model || 'claude-3-haiku-20240307';
|
|
377
|
+
return {
|
|
378
|
+
url: hasV1Suffix ? `${base}/messages` : `${base}/v1/messages`,
|
|
379
|
+
opts: {
|
|
380
|
+
method: 'POST',
|
|
381
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01' },
|
|
382
|
+
body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (protocol === 'gemini') {
|
|
387
|
+
return { url: `${base}/v1beta/models?key=${key}`, opts: {} };
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (protocol !== 'openai' && protocol !== 'anthropic' && protocol !== 'gemini') {
|
|
393
|
+
return res.json({ ok: false, message: `不支持的协议: ${protocol}`, results: [] });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const results = await Promise.all(resolved.map(async entry => {
|
|
397
|
+
const { url: testUrl, opts: fetchOpts } = buildTestOpts(entry.key);
|
|
398
|
+
try {
|
|
399
|
+
const startedAt = Date.now();
|
|
400
|
+
const fetchRes = await fetch(testUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
|
|
401
|
+
const latencyMs = Date.now() - startedAt;
|
|
402
|
+
if (!fetchRes.ok) {
|
|
403
|
+
const errText = await fetchRes.text().catch(() => '');
|
|
404
|
+
const hint = fetchRes.status === 401 || fetchRes.status === 403
|
|
405
|
+
? 'API Key 无效或无权限'
|
|
406
|
+
: `HTTP ${fetchRes.status}: ${errText.slice(0, 200) || '未知错误'}`;
|
|
407
|
+
return { ok: false, alias: entry.alias, index: entry.domIndex, message: hint, latencyMs };
|
|
408
|
+
}
|
|
409
|
+
return { ok: true, alias: entry.alias, index: entry.domIndex, latencyMs };
|
|
410
|
+
} catch (err) {
|
|
411
|
+
const msg = err.name === 'TimeoutError' ? '连接超时 (15s)' : `连接失败: ${err.message}`;
|
|
412
|
+
return { ok: false, alias: entry.alias, index: entry.domIndex, message: msg };
|
|
413
|
+
}
|
|
414
|
+
}));
|
|
415
|
+
|
|
416
|
+
const passed = results.filter(r => r.ok).length;
|
|
417
|
+
const failed = results.length - passed;
|
|
418
|
+
res.json({ ok: failed === 0, passed, failed, total: results.length, results });
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
app.post('/api/providers/:id/available-models', async (req, res) => {
|
|
422
|
+
const provider = configStore.getProviderById(req.params.id);
|
|
423
|
+
if (!provider) return res.status(404).json({ error: 'Provider not found' });
|
|
424
|
+
|
|
425
|
+
// Support unsaved API keys from form
|
|
426
|
+
let keys;
|
|
427
|
+
const reqKeys = Array.isArray(req.body?.apiKeys) ? req.body.apiKeys : [];
|
|
428
|
+
if (reqKeys.length > 0) {
|
|
429
|
+
const existingKeys = provider.apiKeys || [];
|
|
430
|
+
keys = reqKeys
|
|
431
|
+
.map(k => {
|
|
432
|
+
if (k && typeof k === 'object' && k.masked && typeof k.index === 'number') {
|
|
433
|
+
return existingKeys[k.index]?.key || null;
|
|
434
|
+
}
|
|
435
|
+
if (k && typeof k === 'object' && typeof k.key === 'string' && k.key.trim()) {
|
|
436
|
+
return k.key.trim();
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
})
|
|
440
|
+
.filter(Boolean);
|
|
441
|
+
} else {
|
|
442
|
+
keys = (provider.apiKeys || []).map(k => k.key).filter(Boolean);
|
|
443
|
+
}
|
|
444
|
+
if (keys.length === 0) return res.json({ models: [], message: '没有可用的 API Key' });
|
|
445
|
+
|
|
446
|
+
const protocol = provider.protocol || 'openai';
|
|
447
|
+
const base = provider.url.replace(/\/$/, '');
|
|
448
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
449
|
+
const key = keys[0];
|
|
450
|
+
const isAzure = protocol === 'openai' && !!provider.azureDeployment;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
let fetchUrl, fetchOpts;
|
|
454
|
+
if (protocol === 'openai') {
|
|
455
|
+
if (isAzure) {
|
|
456
|
+
const ver = provider.azureApiVersion || '2024-02-01';
|
|
457
|
+
fetchUrl = `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`;
|
|
458
|
+
fetchOpts = { headers: { 'api-key': key } };
|
|
459
|
+
} else {
|
|
460
|
+
fetchUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
461
|
+
fetchOpts = { headers: { 'Authorization': `Bearer ${key}` } };
|
|
462
|
+
}
|
|
463
|
+
} else if (protocol === 'gemini') {
|
|
464
|
+
fetchUrl = `${base}/v1beta/models?key=${key}`;
|
|
465
|
+
fetchOpts = {};
|
|
466
|
+
} else if (protocol === 'anthropic') {
|
|
467
|
+
fetchUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
468
|
+
fetchOpts = { headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' } };
|
|
469
|
+
} else {
|
|
470
|
+
return res.json({ models: [], message: `不支持的协议: ${protocol}` });
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const fetchRes = await fetch(fetchUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
|
|
474
|
+
if (!fetchRes.ok) {
|
|
475
|
+
const hint = fetchRes.status === 404 ? '该供应商不支持模型列表接口' : `获取失败: HTTP ${fetchRes.status}`;
|
|
476
|
+
return res.json({ models: [], message: hint });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const data = await fetchRes.json().catch(() => null);
|
|
480
|
+
let models = [];
|
|
481
|
+
if (Array.isArray(data?.data)) {
|
|
482
|
+
// OpenAI 格式(含第三方 Anthropic 兼容供应商)
|
|
483
|
+
models = data.data.map(m => m.id || m.name).filter(Boolean).sort();
|
|
484
|
+
} else if (Array.isArray(data?.models)) {
|
|
485
|
+
// Gemini 格式
|
|
486
|
+
models = data.models.map(m => (m.name || m.id)?.replace('models/', '')).filter(Boolean).sort();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
res.json({ models });
|
|
490
|
+
} catch (err) {
|
|
491
|
+
res.json({ models: [], message: `获取失败: ${err.message}` });
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
332
495
|
app.delete('/api/providers/:id', (req, res) => {
|
|
333
496
|
const existing = configStore.getProviderById(req.params.id);
|
|
334
497
|
if (!existing) return res.status(404).json({ error: 'Provider not found' });
|
|
@@ -388,6 +551,7 @@ async function init() {
|
|
|
388
551
|
|
|
389
552
|
// 创建代理
|
|
390
553
|
app.post('/api/proxies', async (req, res) => {
|
|
554
|
+
configStore.saveSnapshot('create-proxy');
|
|
391
555
|
const { name, port, requireAuth, authToken, providerId, defaultModel, routingStrategy, providerPool, providerWeight } = req.body;
|
|
392
556
|
|
|
393
557
|
if (!name || !port || !providerId) {
|
|
@@ -429,6 +593,7 @@ async function init() {
|
|
|
429
593
|
|
|
430
594
|
// 更新代理
|
|
431
595
|
app.put('/api/proxies/:id', async (req, res) => {
|
|
596
|
+
configStore.saveSnapshot('update-proxy');
|
|
432
597
|
const existing = configStore.getProxyById(req.params.id);
|
|
433
598
|
if (!existing) return res.status(404).json({ error: 'Proxy not found' });
|
|
434
599
|
|
|
@@ -477,6 +642,7 @@ async function init() {
|
|
|
477
642
|
|
|
478
643
|
// 删除代理
|
|
479
644
|
app.delete('/api/proxies/:id', async (req, res) => {
|
|
645
|
+
configStore.saveSnapshot('delete-proxy');
|
|
480
646
|
const existing = configStore.getProxyById(req.params.id);
|
|
481
647
|
if (!existing) return res.status(404).json({ error: 'Proxy not found' });
|
|
482
648
|
|
|
@@ -503,6 +669,36 @@ async function init() {
|
|
|
503
669
|
res.json({ success: true, running: false });
|
|
504
670
|
});
|
|
505
671
|
|
|
672
|
+
// 批量启动所有代理
|
|
673
|
+
app.post('/api/proxies/start-all', async (req, res) => {
|
|
674
|
+
const proxies = configStore.getProxies();
|
|
675
|
+
const results = [];
|
|
676
|
+
for (const proxy of proxies) {
|
|
677
|
+
if (proxyManager.isRunning(proxy.id)) {
|
|
678
|
+
results.push({ id: proxy.id, name: proxy.name, skipped: true });
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
await startProxyWithProvider(proxy);
|
|
683
|
+
results.push({ id: proxy.id, name: proxy.name, success: true });
|
|
684
|
+
} catch (err) {
|
|
685
|
+
results.push({ id: proxy.id, name: proxy.name, success: false, error: err.message });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
res.json({ results });
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// 批量停止所有代理
|
|
692
|
+
app.post('/api/proxies/stop-all', async (req, res) => {
|
|
693
|
+
const running = proxyManager.getRunningPorts();
|
|
694
|
+
const results = [];
|
|
695
|
+
for (const r of running) {
|
|
696
|
+
await proxyManager.stopProxy(r.id);
|
|
697
|
+
results.push({ id: r.id, name: r.name, success: true });
|
|
698
|
+
}
|
|
699
|
+
res.json({ results });
|
|
700
|
+
});
|
|
701
|
+
|
|
506
702
|
// 获取运行状态
|
|
507
703
|
app.get('/api/status', (req, res) => {
|
|
508
704
|
res.json({
|
|
@@ -524,6 +720,22 @@ async function init() {
|
|
|
524
720
|
});
|
|
525
721
|
});
|
|
526
722
|
|
|
723
|
+
// 设置
|
|
724
|
+
app.get('/api/settings', (req, res) => {
|
|
725
|
+
res.json(configStore.getSettings());
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
app.put('/api/settings', (req, res) => {
|
|
729
|
+
const settings = req.body;
|
|
730
|
+
if (!settings || typeof settings !== 'object') {
|
|
731
|
+
return res.status(400).json({ error: '需要 settings 对象' });
|
|
732
|
+
}
|
|
733
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
734
|
+
configStore.setSetting(key, value);
|
|
735
|
+
}
|
|
736
|
+
res.json(configStore.getSettings());
|
|
737
|
+
});
|
|
738
|
+
|
|
527
739
|
// Token 用量统计
|
|
528
740
|
app.get('/api/stats', (req, res) => {
|
|
529
741
|
const { range, startDate, endDate, proxyId } = req.query;
|
|
@@ -541,6 +753,22 @@ async function init() {
|
|
|
541
753
|
res.json({ ...stats, proxies });
|
|
542
754
|
});
|
|
543
755
|
|
|
756
|
+
// 日志查看
|
|
757
|
+
app.get('/api/logs', (req, res) => {
|
|
758
|
+
const lines = Math.min(parseInt(req.query.lines) || 200, 2000);
|
|
759
|
+
try {
|
|
760
|
+
if (!fs.existsSync(logger.LOG_FILE)) {
|
|
761
|
+
return res.json({ lines: [] });
|
|
762
|
+
}
|
|
763
|
+
const content = fs.readFileSync(logger.LOG_FILE, 'utf8');
|
|
764
|
+
const allLines = content.split('\n').filter(l => l.trim());
|
|
765
|
+
const tail = allLines.slice(-lines);
|
|
766
|
+
res.json({ lines: tail, total: allLines.length });
|
|
767
|
+
} catch (err) {
|
|
768
|
+
res.json({ lines: [], error: err.message });
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
|
|
544
772
|
// ==================== 配置导入/导出 ====================
|
|
545
773
|
|
|
546
774
|
app.get('/api/config/export', (req, res) => {
|
|
@@ -570,6 +798,8 @@ async function init() {
|
|
|
570
798
|
return res.status(400).json({ error: '需要 config 和 mode(overwrite/merge)' });
|
|
571
799
|
}
|
|
572
800
|
|
|
801
|
+
configStore.saveSnapshot('import-' + mode);
|
|
802
|
+
|
|
573
803
|
// 校验结构
|
|
574
804
|
if (!Array.isArray(config.providers) || !Array.isArray(config.proxies)) {
|
|
575
805
|
return res.status(400).json({ error: '配置格式错误:需要 providers 和 proxies 数组' });
|
|
@@ -674,6 +904,21 @@ async function init() {
|
|
|
674
904
|
});
|
|
675
905
|
});
|
|
676
906
|
|
|
907
|
+
// ==================== 配置版本历史 ====================
|
|
908
|
+
|
|
909
|
+
app.get('/api/config/history', (req, res) => {
|
|
910
|
+
const snapshots = configStore.getSnapshots();
|
|
911
|
+
res.json({ snapshots });
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
app.post('/api/config/rollback', async (req, res) => {
|
|
915
|
+
const { file } = req.body;
|
|
916
|
+
if (!file) return res.status(400).json({ error: '需要指定快照文件' });
|
|
917
|
+
const result = configStore.restoreSnapshot(file);
|
|
918
|
+
if (result.error) return res.status(400).json({ error: result.error });
|
|
919
|
+
res.json({ success: true });
|
|
920
|
+
});
|
|
921
|
+
|
|
677
922
|
// 前端首页
|
|
678
923
|
app.get('/', (req, res) => {
|
|
679
924
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|