protocol-proxy 2.5.0 → 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protocol-proxy",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "OpenAI / Anthropic 协议转换透明代理",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -380,6 +380,42 @@ function renderProviderOptions() {
380
380
  });
381
381
  }
382
382
 
383
+ function attachMaskedKeyClick(span) {
384
+ span.style.cursor = 'pointer';
385
+ span.title = '点击修改 API Key';
386
+ span.addEventListener('click', () => {
387
+ const row = span.closest('.api-key-entry');
388
+ const group = span.parentElement;
389
+ const input = document.createElement('input');
390
+ input.type = 'password';
391
+ input.className = 'api-key-input';
392
+ input.placeholder = '输入新的 API Key...';
393
+ group.replaceChild(input, span);
394
+ input.focus();
395
+ input.addEventListener('blur', async () => {
396
+ const val = input.value.trim();
397
+ if (!val) {
398
+ restoreMaskedSpan(group, input, row);
399
+ return;
400
+ }
401
+ if (await showConfirm('确认修改此 API Key?<br>取消将恢复为 ****', '确认修改')) {
402
+ row.dataset.masked = 'false';
403
+ } else {
404
+ row.dataset.masked = 'true';
405
+ restoreMaskedSpan(group, input, row);
406
+ }
407
+ });
408
+ });
409
+ }
410
+
411
+ function restoreMaskedSpan(group, input, row) {
412
+ const restored = document.createElement('span');
413
+ restored.className = 'api-key-display';
414
+ restored.textContent = '****';
415
+ group.replaceChild(restored, input);
416
+ attachMaskedKeyClick(restored);
417
+ }
418
+
383
419
  function renderApiKeys(provider) {
384
420
  const container = document.getElementById('api-keys-list');
385
421
  if (!container) return;
@@ -407,6 +443,10 @@ function renderApiKeys(provider) {
407
443
  </div>
408
444
  `).join('');
409
445
 
446
+ container.querySelectorAll('.api-key-display').forEach(span => {
447
+ attachMaskedKeyClick(span);
448
+ });
449
+
410
450
  container.querySelectorAll('.api-key-remove').forEach(btn => {
411
451
  btn.addEventListener('click', () => {
412
452
  btn.closest('.api-key-entry').remove();
@@ -453,7 +493,12 @@ function initApiKeyAddBtn() {
453
493
  });
454
494
  }
455
495
 
456
- function selectProvider(id) {
496
+ function hasUnsavedMaskedKeyEdits() {
497
+ return !!document.querySelector('#api-keys-list .api-key-entry[data-index][data-masked="false"]');
498
+ }
499
+
500
+ async function selectProvider(id) {
501
+ if (hasUnsavedMaskedKeyEdits() && !await showConfirm('当前有未保存的 API Key 修改,切换供应商将丢失,确认切换?', '确认切换')) return;
457
502
  const provider = providers.find(p => p.id === id);
458
503
  document.getElementById('provider-id').value = id || '';
459
504
  const protocol = provider ? provider.protocol : '';
@@ -542,6 +587,59 @@ function initModelDropdown() {
542
587
  });
543
588
  }
544
589
 
590
+ async function importModels() {
591
+ const providerId = document.getElementById('provider-id').value;
592
+ if (!providerId) {
593
+ showToast('请先选择供应商', true);
594
+ return;
595
+ }
596
+ const btn = document.getElementById('model-import-btn');
597
+ btn.disabled = true;
598
+ btn.textContent = '导入中...';
599
+ try {
600
+ const apiKeys = collectApiKeys();
601
+ const res = await fetch(`/api/providers/${providerId}/available-models`, {
602
+ method: 'POST',
603
+ headers: { 'Content-Type': 'application/json' },
604
+ body: JSON.stringify({ apiKeys }),
605
+ });
606
+ const data = await res.json();
607
+ if (!data.models || data.models.length === 0) {
608
+ showToast(data.message || '未获取到模型', true);
609
+ return;
610
+ }
611
+ const provider = providers.find(p => p.id === providerId);
612
+ const existing = new Set(provider?.models || []);
613
+ const newModels = data.models.filter(m => !existing.has(m));
614
+ if (newModels.length === 0) {
615
+ showToast(`已全部存在,共 ${data.models.length} 个模型`);
616
+ // 即使没有新模型,也尝试自动选择第一个
617
+ if (!document.getElementById('target-model').value && data.models.length > 0) {
618
+ selectModel(data.models[0]);
619
+ }
620
+ return;
621
+ }
622
+ const merged = [...(provider?.models || []), ...newModels];
623
+ await fetch(`/api/providers/${providerId}`, {
624
+ method: 'PUT',
625
+ headers: { 'Content-Type': 'application/json' },
626
+ body: JSON.stringify({ models: merged }),
627
+ });
628
+ await loadProviders();
629
+ renderModelOptions();
630
+ // 自动选择默认模型
631
+ if (!document.getElementById('target-model').value) {
632
+ selectModel(newModels[0] || data.models[0]);
633
+ }
634
+ showToast(`已导入 ${newModels.length} 个新模型(共 ${data.models.length} 个)`);
635
+ } catch (err) {
636
+ showToast('导入失败: ' + err.message, true);
637
+ } finally {
638
+ btn.disabled = false;
639
+ btn.textContent = '自动导入';
640
+ }
641
+ }
642
+
545
643
  function renderModelOptions() {
546
644
  const container = document.getElementById('model-dropdown-options');
547
645
  const providerId = document.getElementById('provider-id').value;
@@ -958,14 +1056,14 @@ function copyProxyUrl(port, btn) {
958
1056
  });
959
1057
  }
960
1058
 
961
- function showConfirm(text) {
1059
+ function showConfirm(text, okText = '删除') {
962
1060
  return new Promise(resolve => {
963
1061
  const modal = document.getElementById('confirm-modal');
964
1062
  document.getElementById('confirm-text').innerHTML = text;
965
- modal.classList.add('active');
966
-
967
1063
  const okBtn = document.getElementById('confirm-ok');
968
1064
  const cancelBtn = document.getElementById('confirm-cancel');
1065
+ okBtn.textContent = okText;
1066
+ modal.classList.add('active');
969
1067
 
970
1068
  function cleanup(result) {
971
1069
  modal.classList.remove('active');
@@ -1156,6 +1254,140 @@ function closeModal() {
1156
1254
  editingProviderId = null;
1157
1255
  }
1158
1256
 
1257
+ function showTestResultModal(data) {
1258
+ const modal = document.getElementById('test-result-modal');
1259
+ const icon = document.getElementById('test-result-icon');
1260
+ const summary = document.getElementById('test-result-summary');
1261
+ const list = document.getElementById('test-result-list');
1262
+ const closeBtn = document.getElementById('test-result-close');
1263
+
1264
+ if (data.failed === 0) {
1265
+ icon.textContent = '✓';
1266
+ icon.style.background = 'rgba(6, 78, 59, 0.4)';
1267
+ icon.style.color = '#34d399';
1268
+ icon.style.borderColor = 'rgba(52, 211, 153, 0.15)';
1269
+ summary.innerHTML = `<strong>${data.total}</strong> 条 API Key 全部测试通过`;
1270
+ } else if (data.passed === 0) {
1271
+ icon.textContent = '✗';
1272
+ icon.style.background = 'rgba(127, 29, 29, 0.4)';
1273
+ icon.style.color = '#f87171';
1274
+ icon.style.borderColor = 'rgba(248, 113, 113, 0.15)';
1275
+ summary.innerHTML = `<strong>${data.total}</strong> 条 API Key 全部测试失败`;
1276
+ } else {
1277
+ icon.textContent = '!';
1278
+ icon.style.background = 'rgba(69, 26, 3, 0.4)';
1279
+ icon.style.color = '#fbbf24';
1280
+ icon.style.borderColor = 'rgba(251, 191, 36, 0.15)';
1281
+ summary.innerHTML = `<strong>${data.passed}</strong> 条通过,<strong>${data.failed}</strong> 条失败`;
1282
+ }
1283
+
1284
+ list.innerHTML = data.results.map(r => `
1285
+ <div class="test-result-item ${r.ok ? 'test-ok' : 'test-fail'}">
1286
+ <div class="test-result-row">
1287
+ <span class="test-result-status">${r.ok ? '✓' : '✗'}</span>
1288
+ <span class="test-result-alias">${escapeHtml(r.alias || `Key #${r.index + 1}`)}</span>
1289
+ ${r.latencyMs != null ? `<span class="test-result-latency">${r.latencyMs}ms</span>` : ''}
1290
+ </div>
1291
+ ${r.message ? `<div class="test-result-error">${escapeHtml(r.message)}</div>` : ''}
1292
+ </div>
1293
+ `).join('');
1294
+
1295
+ modal.classList.add('active');
1296
+ closeBtn.onclick = () => modal.classList.remove('active');
1297
+ }
1298
+
1299
+ function clearKeyErrors() {
1300
+ document.querySelectorAll('#api-keys-list .api-key-entry').forEach(row => {
1301
+ row.querySelector('.api-key-input')?.style.removeProperty('border-color');
1302
+ row.querySelector('.api-key-display')?.style.removeProperty('border-color');
1303
+ row.querySelector('.api-key-error')?.remove();
1304
+ });
1305
+ }
1306
+
1307
+ function markKeyErrors(data) {
1308
+ const rows = document.querySelectorAll('#api-keys-list .api-key-entry');
1309
+ for (const r of data.results) {
1310
+ if (!r.ok) {
1311
+ const row = rows[r.index];
1312
+ if (row) {
1313
+ const el = row.querySelector('.api-key-input') || row.querySelector('.api-key-display');
1314
+ if (el) el.style.borderColor = '#ef4444';
1315
+ if (r.message) {
1316
+ const errDiv = document.createElement('div');
1317
+ errDiv.className = 'api-key-error';
1318
+ errDiv.textContent = r.message;
1319
+ // Insert after the API Key form-group
1320
+ const keyGroup = row.querySelectorAll('.form-group')[1];
1321
+ if (keyGroup) keyGroup.appendChild(errDiv);
1322
+ }
1323
+ }
1324
+ }
1325
+ }
1326
+ }
1327
+
1328
+ async function testConnection() {
1329
+ const providerId = document.getElementById('provider-id').value;
1330
+ if (!providerId) {
1331
+ showToast('请先选择供应商', true);
1332
+ return;
1333
+ }
1334
+ const protocol = document.getElementById('target-protocol').value;
1335
+ const apiKeys = collectApiKeys();
1336
+ const model = document.getElementById('target-model').value.trim() || '';
1337
+ const btn = document.getElementById('test-connection-btn');
1338
+ btn.disabled = true;
1339
+ btn.textContent = '测试中...';
1340
+ clearKeyErrors();
1341
+ try {
1342
+ const res = await fetch(`/api/providers/${providerId}/test`, {
1343
+ method: 'POST',
1344
+ headers: { 'Content-Type': 'application/json' },
1345
+ body: JSON.stringify({ protocol, apiKeys, model }),
1346
+ });
1347
+ const data = await res.json();
1348
+ if (!data.results || data.results.length === 0) {
1349
+ showToast(data.message || '没有可用的 API Key', true);
1350
+ return;
1351
+ }
1352
+ markKeyErrors(data);
1353
+ showTestResultModal(data);
1354
+ } catch (err) {
1355
+ showToast('测试请求失败: ' + err.message, true);
1356
+ } finally {
1357
+ btn.disabled = false;
1358
+ btn.textContent = '测试连接';
1359
+ }
1360
+ }
1361
+
1362
+ async function autoTestForSave() {
1363
+ const providerId = document.getElementById('provider-id').value;
1364
+ if (!providerId) return true;
1365
+ const protocol = document.getElementById('target-protocol').value;
1366
+ const apiKeys = collectApiKeys();
1367
+ const model = document.getElementById('target-model').value.trim() || '';
1368
+ clearKeyErrors();
1369
+ try {
1370
+ const res = await fetch(`/api/providers/${providerId}/test`, {
1371
+ method: 'POST',
1372
+ headers: { 'Content-Type': 'application/json' },
1373
+ body: JSON.stringify({ protocol, apiKeys, model }),
1374
+ });
1375
+ const data = await res.json();
1376
+ if (!data.results || data.results.length === 0) return true;
1377
+ if (data.failed === 0) {
1378
+ showToast(`${data.total} 条 API Key 测试通过`);
1379
+ return true;
1380
+ }
1381
+ markKeyErrors(data);
1382
+ return await showConfirm(
1383
+ `${data.passed} 条通过,${data.failed} 条失败。<br><br>是否仍然保存?`,
1384
+ '仍然保存'
1385
+ );
1386
+ } catch (err) {
1387
+ return true;
1388
+ }
1389
+ }
1390
+
1159
1391
  async function handleSubmit(e) {
1160
1392
  e.preventDefault();
1161
1393
 
@@ -1165,6 +1397,21 @@ async function handleSubmit(e) {
1165
1397
  return;
1166
1398
  }
1167
1399
 
1400
+ // 保存前自动测试:如果有 API Key 被修改,先测试连接
1401
+ const hasModifiedKeys = !!document.querySelector('#api-keys-list .api-key-entry[data-masked="false"], #api-keys-list .api-key-entry[data-new="true"]');
1402
+ if (hasModifiedKeys) {
1403
+ const saveBtn = document.querySelector('.modal-footer .btn-primary');
1404
+ saveBtn.disabled = true;
1405
+ saveBtn.textContent = '测试中...';
1406
+ try {
1407
+ const canProceed = await autoTestForSave();
1408
+ if (!canProceed) return;
1409
+ } finally {
1410
+ saveBtn.disabled = false;
1411
+ saveBtn.textContent = '保存';
1412
+ }
1413
+ }
1414
+
1168
1415
  const port = parseInt(document.getElementById('proxy-port').value);
1169
1416
 
1170
1417
  const conflict = proxies.find(p => p.id !== editingId && p.port === port);
package/public/index.html CHANGED
@@ -184,6 +184,7 @@
184
184
  <div class="model-add-section" id="model-add-section">
185
185
  <input type="text" class="model-add-input" id="model-add-input" placeholder="输入模型名称">
186
186
  <button type="button" class="btn btn-primary btn-sm" id="model-add-btn">添加</button>
187
+ <button type="button" class="btn btn-sm" id="model-import-btn" onclick="importModels()">自动导入</button>
187
188
  </div>
188
189
  </div>
189
190
  </div>
@@ -252,6 +253,7 @@
252
253
 
253
254
  <div class="modal-footer">
254
255
  <button type="button" class="btn" onclick="closeModal()">取消</button>
256
+ <button type="button" class="btn" id="test-connection-btn" onclick="testConnection()">测试连接</button>
255
257
  <button type="submit" class="btn btn-primary">保存</button>
256
258
  </div>
257
259
  </form>
@@ -271,6 +273,18 @@
271
273
  </div>
272
274
  </div>
273
275
 
276
+ <!-- 测试结果弹窗 -->
277
+ <div class="modal confirm-modal" id="test-result-modal">
278
+ <div class="confirm-box test-result-box">
279
+ <div class="confirm-icon" id="test-result-icon">!</div>
280
+ <p class="confirm-text" id="test-result-summary"></p>
281
+ <div class="test-result-list" id="test-result-list"></div>
282
+ <div class="confirm-actions">
283
+ <button class="btn btn-primary" id="test-result-close">知道了</button>
284
+ </div>
285
+ </div>
286
+ </div>
287
+
274
288
  <!-- 导入预览弹窗 -->
275
289
  <div class="modal" id="import-modal">
276
290
  <div class="modal-content" style="max-width:500px">
package/public/style.css CHANGED
@@ -611,8 +611,9 @@ form {
611
611
  /* Toast */
612
612
  .toast {
613
613
  position: fixed;
614
- bottom: 28px;
615
- right: 28px;
614
+ top: 50%;
615
+ left: 50%;
616
+ transform: translate(-50%, -50%);
616
617
  padding: 12px 24px;
617
618
  background: linear-gradient(135deg, #22c55e, #16a34a);
618
619
  color: white;
@@ -626,8 +627,8 @@ form {
626
627
  }
627
628
 
628
629
  @keyframes toast-in {
629
- from { opacity: 0; transform: translateY(16px) scale(0.95); }
630
- to { opacity: 1; transform: translateY(0) scale(1); }
630
+ from { opacity: 0; transform: translate(-50%, -50%) scale(0.95); }
631
+ to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
631
632
  }
632
633
 
633
634
  @keyframes toast-out {
@@ -793,6 +794,10 @@ form {
793
794
  }
794
795
 
795
796
  /* Confirm modal */
797
+ .confirm-modal {
798
+ align-items: center;
799
+ }
800
+
796
801
  .confirm-modal .confirm-box {
797
802
  background: rgba(15, 23, 42, 0.8);
798
803
  backdrop-filter: blur(24px);
@@ -1277,6 +1282,13 @@ form {
1277
1282
  color: #64748b;
1278
1283
  font-size: 0.9rem;
1279
1284
  letter-spacing: 2px;
1285
+ cursor: pointer;
1286
+ transition: border-color 0.2s, color 0.2s;
1287
+ }
1288
+
1289
+ .api-key-display:hover {
1290
+ border-color: rgba(99, 102, 241, 0.5);
1291
+ color: #94a3b8;
1280
1292
  }
1281
1293
 
1282
1294
  .api-key-remove {
@@ -1446,3 +1458,103 @@ form {
1446
1458
  background: rgba(127, 29, 29, 0.35);
1447
1459
  border-color: rgba(248, 113, 113, 0.8);
1448
1460
  }
1461
+
1462
+ /* Inline API Key error */
1463
+ .api-key-error {
1464
+ margin-top: 6px;
1465
+ padding: 6px 10px;
1466
+ background: rgba(127, 29, 29, 0.15);
1467
+ border: 1px solid rgba(248, 113, 113, 0.2);
1468
+ border-radius: 6px;
1469
+ color: #fca5a5;
1470
+ font-size: 0.78rem;
1471
+ line-height: 1.4;
1472
+ }
1473
+
1474
+ /* Test result modal items */
1475
+ .test-result-box {
1476
+ max-width: 460px !important;
1477
+ }
1478
+
1479
+ .test-result-list {
1480
+ display: flex;
1481
+ flex-direction: column;
1482
+ gap: 8px;
1483
+ margin-bottom: 20px;
1484
+ max-height: 300px;
1485
+ overflow-y: auto;
1486
+ }
1487
+
1488
+ .test-result-item {
1489
+ padding: 10px 14px;
1490
+ border-radius: 10px;
1491
+ background: rgba(6, 8, 15, 0.4);
1492
+ border: 1px solid rgba(51, 65, 85, 0.3);
1493
+ }
1494
+
1495
+ .test-result-item.test-ok {
1496
+ border-color: rgba(52, 211, 153, 0.2);
1497
+ }
1498
+
1499
+ .test-result-item.test-fail {
1500
+ border-color: rgba(248, 113, 113, 0.3);
1501
+ background: rgba(127, 29, 29, 0.1);
1502
+ }
1503
+
1504
+ .test-result-row {
1505
+ display: flex;
1506
+ align-items: center;
1507
+ gap: 10px;
1508
+ }
1509
+
1510
+ .test-result-status {
1511
+ font-size: 1rem;
1512
+ font-weight: 700;
1513
+ width: 20px;
1514
+ text-align: center;
1515
+ }
1516
+
1517
+ .test-ok .test-result-status {
1518
+ color: #34d399;
1519
+ }
1520
+
1521
+ .test-fail .test-result-status {
1522
+ color: #f87171;
1523
+ }
1524
+
1525
+ .test-result-alias {
1526
+ flex: 1;
1527
+ color: #e2e8f0;
1528
+ font-size: 0.9rem;
1529
+ font-weight: 500;
1530
+ }
1531
+
1532
+ .test-result-latency {
1533
+ color: #60a5fa;
1534
+ font-size: 0.82rem;
1535
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
1536
+ }
1537
+
1538
+ .test-result-error {
1539
+ margin-top: 6px;
1540
+ padding: 5px 10px;
1541
+ color: #fca5a5;
1542
+ font-size: 0.78rem;
1543
+ background: rgba(127, 29, 29, 0.15);
1544
+ border-radius: 6px;
1545
+ border: 1px solid rgba(248, 113, 113, 0.1);
1546
+ }
1547
+
1548
+ /* test-result-list scrollbar */
1549
+ .test-result-list::-webkit-scrollbar {
1550
+ width: 6px;
1551
+ }
1552
+
1553
+ .test-result-list::-webkit-scrollbar-track {
1554
+ background: transparent;
1555
+ }
1556
+
1557
+ .test-result-list::-webkit-scrollbar-thumb {
1558
+ background: rgba(71, 85, 105, 0.5);
1559
+ border-radius: 3px;
1560
+ }
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' });