protocol-proxy 2.3.4 → 2.4.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,51 +1,51 @@
1
- {
2
- "name": "protocol-proxy",
3
- "version": "2.3.4",
4
- "description": "OpenAI / Anthropic 协议转换透明代理",
5
- "main": "server.js",
6
- "bin": {
7
- "protocol-proxy": "server.js"
8
- },
9
- "files": [
10
- "server.js",
11
- "lib/",
12
- "public/",
13
- "config/",
14
- "README.md"
15
- ],
16
- "keywords": [
17
- "proxy",
18
- "openai",
19
- "anthropic",
20
- "api",
21
- "protocol-converter"
22
- ],
23
- "scripts": {
24
- "start": "node server.js",
25
- "dev": "node --watch server.js",
26
- "build": "pkg . --out-path=dist"
27
- },
28
- "dependencies": {
29
- "cors": "^2.8.5",
30
- "express": "^4.19.2"
31
- },
32
- "devDependencies": {
33
- "pkg": "^5.8.1"
34
- },
35
- "pkg": {
36
- "scripts": [
37
- "lib/**/*.js"
38
- ],
39
- "assets": [
40
- "public/**/*"
41
- ],
42
- "targets": [
43
- "node20-win-x64"
44
- ],
45
- "outputPath": "dist",
46
- "options": []
47
- },
48
- "engines": {
49
- "node": ">=20.0.0"
50
- }
51
- }
1
+ {
2
+ "name": "protocol-proxy",
3
+ "version": "2.4.0",
4
+ "description": "OpenAI / Anthropic 协议转换透明代理",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "protocol-proxy": "server.js"
8
+ },
9
+ "files": [
10
+ "server.js",
11
+ "lib/",
12
+ "public/",
13
+ "config/",
14
+ "README.md"
15
+ ],
16
+ "keywords": [
17
+ "proxy",
18
+ "openai",
19
+ "anthropic",
20
+ "api",
21
+ "protocol-converter"
22
+ ],
23
+ "scripts": {
24
+ "start": "node server.js",
25
+ "dev": "node --watch server.js",
26
+ "build": "pkg . --out-path=dist"
27
+ },
28
+ "dependencies": {
29
+ "cors": "^2.8.5",
30
+ "express": "^4.19.2"
31
+ },
32
+ "devDependencies": {
33
+ "pkg": "^5.8.1"
34
+ },
35
+ "pkg": {
36
+ "scripts": [
37
+ "lib/**/*.js"
38
+ ],
39
+ "assets": [
40
+ "public/**/*"
41
+ ],
42
+ "targets": [
43
+ "node20-win-x64"
44
+ ],
45
+ "outputPath": "dist",
46
+ "options": []
47
+ },
48
+ "engines": {
49
+ "node": ">=20.0.0"
50
+ }
51
+ }
package/public/app.js CHANGED
@@ -1,6 +1,11 @@
1
- let proxies = [];
1
+ let proxies = [];
2
2
  let providers = [];
3
3
  let editingId = null;
4
+ let editingProviderId = null;
5
+ let importData = null;
6
+ let statsRange = 'daily';
7
+ let statsProxyId = '';
8
+ let providerPoolItems = [];
4
9
 
5
10
  // ==================== 数据加载 ====================
6
11
 
@@ -33,6 +38,193 @@ function updateStats() {
33
38
  proxies.filter(p => p.running).length;
34
39
  }
35
40
 
41
+ function parseProviderPool(value) {
42
+ const text = (value || '').trim();
43
+ if (!text) return [];
44
+ const seen = new Set();
45
+ const items = [];
46
+ for (const part of text.split(/[\n,]/)) {
47
+ const token = part.trim();
48
+ if (!token) continue;
49
+ const [providerIdRaw, modelRaw, weightRaw] = token.split(':');
50
+ const providerId = (providerIdRaw || '').trim();
51
+ if (!providerId) continue;
52
+ const model = (modelRaw || '').trim();
53
+ const key = `${providerId}\0${model}`;
54
+ if (seen.has(key)) continue;
55
+ seen.add(key);
56
+ items.push({ providerId, model, weight: Math.max(1, parseInt((weightRaw || '1').trim(), 10) || 1) });
57
+ }
58
+ return items;
59
+ }
60
+
61
+ function formatProviderPool(pool) {
62
+ if (!Array.isArray(pool) || pool.length === 0) return '';
63
+ return pool.map(item => {
64
+ const w = Math.max(1, parseInt(item.weight, 10) || 1);
65
+ return item.model ? `${item.providerId}:${item.model}:${w}` : `${item.providerId}::${w}`;
66
+ }).join(', ');
67
+ }
68
+
69
+ function syncSimpleDropdown(dropdownId, value, hiddenInputId) {
70
+ const dropdown = document.getElementById(dropdownId);
71
+ if (!dropdown) return value;
72
+ const hiddenInput = document.getElementById(hiddenInputId || dropdownId.replace('-dropdown', ''));
73
+ const valueEl = dropdown.querySelector('[id$="-dropdown-value"]');
74
+ const options = Array.from(dropdown.querySelectorAll('.model-option'));
75
+ const nextValue = options.some(opt => opt.dataset.value === value)
76
+ ? value
77
+ : (options[0]?.dataset.value || '');
78
+ options.forEach(opt => opt.classList.toggle('selected', opt.dataset.value === nextValue));
79
+ if (hiddenInput) hiddenInput.value = nextValue;
80
+ const selected = options.find(opt => opt.dataset.value === nextValue);
81
+ if (valueEl && selected) {
82
+ valueEl.textContent = selected.querySelector('.model-option-name')?.textContent || valueEl.textContent;
83
+ }
84
+ return nextValue;
85
+ }
86
+
87
+ function syncProviderPoolState(items) {
88
+ providerPoolItems = Array.isArray(items)
89
+ ? items
90
+ .filter(item => item && item.providerId)
91
+ .map(item => ({
92
+ providerId: item.providerId,
93
+ model: typeof item.model === 'string' ? item.model : '',
94
+ weight: Math.max(1, parseInt(item.weight, 10) || 1),
95
+ }))
96
+ : [];
97
+ renderProviderPoolEditor();
98
+ }
99
+
100
+ function addProviderToPool(providerId, model) {
101
+ if (!providerId) return;
102
+ const m = model || '';
103
+ if (providerPoolItems.some(item => item.providerId === providerId && (item.model || '') === m)) return;
104
+ providerPoolItems = [...providerPoolItems, { providerId, model: m, weight: 1 }];
105
+ renderProviderPoolEditor();
106
+ }
107
+
108
+ function removeProviderFromPool(providerId, model) {
109
+ const m = model || '';
110
+ providerPoolItems = providerPoolItems.filter(item => !(item.providerId === providerId && (item.model || '') === m));
111
+ renderProviderPoolEditor();
112
+ }
113
+
114
+ function updateProviderPoolWeight(providerId, model, weight) {
115
+ const m = model || '';
116
+ providerPoolItems = providerPoolItems.map(item => (
117
+ item.providerId === providerId && (item.model || '') === m
118
+ ? { ...item, weight: Math.max(1, parseInt(weight, 10) || 1) }
119
+ : item
120
+ ));
121
+ }
122
+
123
+ function renderProviderPoolEditor() {
124
+ const container = document.getElementById('provider-pool-list');
125
+ const select = document.getElementById('provider-pool-dropdown-options');
126
+ const valueEl = document.getElementById('provider-pool-dropdown-value');
127
+ const dropdown = document.getElementById('provider-pool-dropdown');
128
+ if (!container || !select || !valueEl || !dropdown) return;
129
+
130
+ const primaryId = document.getElementById('provider-id').value;
131
+ const defaultModel = document.getElementById('target-model').value;
132
+ // All providers available (including primary, for different models)
133
+ const available = providers.filter(p => p.id);
134
+
135
+ // Build dropdown: show providers, each expandable to models
136
+ select.innerHTML = available.length === 0
137
+ ? '<div class="model-option"><span class="model-option-name">暂无可添加供应商</span></div>'
138
+ : available.map(p => {
139
+ const models = p.models || [];
140
+ const isPrimary = p.id === primaryId;
141
+ // Filter out already-added provider+model combos
142
+ const usedModels = new Set(
143
+ providerPoolItems
144
+ .filter(item => item.providerId === p.id)
145
+ .map(item => item.model || '')
146
+ );
147
+ // For primary provider, also exclude its default model (already in use)
148
+ if (isPrimary && defaultModel) usedModels.add(defaultModel);
149
+ const availModels = models.filter(m => !usedModels.has(m));
150
+ // "any model" not available for primary (already covered by defaultModel)
151
+ const anyModelUsed = usedModels.has('');
152
+ const showAnyModel = !isPrimary && !anyModelUsed;
153
+ return `
154
+ <div class="pool-provider-group" data-pool-provider="${escapeHtml(p.id)}">
155
+ <div class="model-option pool-provider-trigger" data-pool-provider-id="${escapeHtml(p.id)}">
156
+ <span class="model-option-name">${escapeHtml(p.name)}</span>
157
+ ${p.url ? `<span style="color:#64748b;font-size:12px;margin-left:4px">${escapeHtml(p.url)}</span>` : ''}
158
+ <span class="pool-provider-arrow">&#9656;</span>
159
+ </div>
160
+ <div class="pool-model-sublist" data-pool-models-for="${escapeHtml(p.id)}">
161
+ ${showAnyModel ? `<div class="model-option pool-model-option" data-pool-provider-id="${escapeHtml(p.id)}" data-pool-model=""><span class="model-option-name">不指定模型(使用请求模型)</span></div>` : ''}
162
+ ${availModels.map(m => `<div class="model-option pool-model-option" data-pool-provider-id="${escapeHtml(p.id)}" data-pool-model="${escapeHtml(m)}"><span class="model-option-name">${escapeHtml(m)}</span></div>`).join('')}
163
+ ${availModels.length === 0 && !showAnyModel ? '<div class="model-option"><span class="model-option-name">该供应商所有模型已添加</span></div>' : ''}
164
+ </div>
165
+ </div>
166
+ `;
167
+ }).join('');
168
+
169
+ valueEl.textContent = available.length === 0 ? '暂无可添加供应商' : '从供应商列表添加';
170
+
171
+ // Provider click → toggle model sub-list
172
+ select.querySelectorAll('.pool-provider-trigger').forEach(trigger => {
173
+ trigger.addEventListener('click', (e) => {
174
+ e.stopPropagation();
175
+ const group = trigger.closest('.pool-provider-group');
176
+ const wasOpen = group.classList.contains('open');
177
+ // Close all other sub-lists
178
+ select.querySelectorAll('.pool-provider-group').forEach(g => g.classList.remove('open'));
179
+ if (!wasOpen) group.classList.add('open');
180
+ });
181
+ });
182
+
183
+ // Model click → add to pool
184
+ select.querySelectorAll('.pool-model-option').forEach(opt => {
185
+ opt.addEventListener('click', () => {
186
+ addProviderToPool(opt.dataset.poolProviderId, opt.dataset.poolModel || '');
187
+ dropdown.classList.remove('open');
188
+ select.querySelectorAll('.pool-provider-group').forEach(g => g.classList.remove('open'));
189
+ });
190
+ });
191
+
192
+ // Render pool items
193
+ container.innerHTML = providerPoolItems.length === 0
194
+ ? '<div class="provider-pool-empty">暂无备选供应商,使用上方下拉框添加</div>'
195
+ : providerPoolItems.map(item => {
196
+ const provider = providers.find(p => p.id === item.providerId);
197
+ const modelLabel = item.model || '使用请求模型';
198
+ return `
199
+ <div class="provider-pool-item">
200
+ <div class="provider-pool-main">
201
+ <div class="provider-pool-name">${escapeHtml(provider?.name || item.providerId)}</div>
202
+ <div class="provider-pool-meta">${escapeHtml(provider?.url || '')}</div>
203
+ </div>
204
+ <div class="provider-pool-model">
205
+ <label>模型</label>
206
+ <span class="provider-pool-model-value">${escapeHtml(modelLabel)}</span>
207
+ </div>
208
+ <div class="provider-pool-weight">
209
+ <label>权重</label>
210
+ <input type="number" min="1" step="1" value="${Math.max(1, parseInt(item.weight, 10) || 1)}" data-weight-provider="${escapeHtml(item.providerId)}" data-weight-model="${escapeHtml(item.model || '')}">
211
+ </div>
212
+ <button type="button" class="provider-pool-remove" data-remove-provider="${escapeHtml(item.providerId)}" data-remove-model="${escapeHtml(item.model || '')}">移除</button>
213
+ </div>
214
+ `;
215
+ }).join('');
216
+
217
+ container.querySelectorAll('[data-weight-provider]').forEach(input => {
218
+ const handler = () => updateProviderPoolWeight(input.dataset.weightProvider, input.dataset.weightModel, input.value);
219
+ input.addEventListener('change', handler);
220
+ input.addEventListener('input', handler);
221
+ });
222
+
223
+ container.querySelectorAll('[data-remove-provider]').forEach(btn => {
224
+ btn.addEventListener('click', () => removeProviderFromPool(btn.dataset.removeProvider, btn.dataset.removeModel));
225
+ });
226
+ }
227
+
36
228
  // ==================== 供应商下拉框 ====================
37
229
 
38
230
  function initProviderDropdown() {
@@ -114,8 +306,6 @@ function initProviderDropdown() {
114
306
  });
115
307
  }
116
308
 
117
- let editingProviderId = null;
118
-
119
309
  function renderProviderOptions() {
120
310
  const container = document.getElementById('provider-dropdown-options');
121
311
  const currentId = document.getElementById('provider-id').value;
@@ -217,6 +407,10 @@ function selectProvider(id) {
217
407
  document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
218
408
  document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
219
409
  document.getElementById('azure-fields').style.display = protocol === 'openai' ? '' : 'none';
410
+ // Only remove pool entries matching this provider's default model (allow other models)
411
+ const currentModel = models[0] || '';
412
+ providerPoolItems = providerPoolItems.filter(item => !(item.providerId === id && (!item.model || item.model === currentModel)));
413
+ renderProviderPoolEditor();
220
414
  }
221
415
 
222
416
  // ==================== Model 下拉框 ====================
@@ -353,8 +547,6 @@ function updateModelAddState() {
353
547
 
354
548
  // ==================== 配置导入/导出 ====================
355
549
 
356
- let importData = null;
357
-
358
550
  async function exportConfig() {
359
551
  try {
360
552
  const res = await fetch('/api/config/export');
@@ -466,9 +658,6 @@ async function restartAllProxies() {
466
658
 
467
659
  // ==================== Token 用量统计 ====================
468
660
 
469
- let statsRange = 'daily';
470
- let statsProxyId = '';
471
-
472
661
  async function loadStats() {
473
662
  try {
474
663
  const params = new URLSearchParams({ range: statsRange });
@@ -605,11 +794,11 @@ function generateToken() {
605
794
  return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
606
795
  }
607
796
 
608
- function initSimpleDropdown(dropdownId, onChange) {
797
+ function initSimpleDropdown(dropdownId, onChange, hiddenInputId) {
609
798
  const dropdown = document.getElementById(dropdownId);
610
799
  const trigger = dropdown.querySelector('.model-dropdown-trigger');
611
800
  const valueEl = dropdown.querySelector('[id$="-dropdown-value"]');
612
- const hiddenInput = document.getElementById(dropdownId.replace('-dropdown', ''));
801
+ const hiddenInput = document.getElementById(hiddenInputId || dropdownId.replace('-dropdown', ''));
613
802
  const opts = dropdown.querySelectorAll('.model-option');
614
803
 
615
804
  trigger.addEventListener('click', (e) => {
@@ -625,23 +814,39 @@ function initSimpleDropdown(dropdownId, onChange) {
625
814
 
626
815
  opts.forEach(opt => {
627
816
  opt.addEventListener('click', () => {
628
- opts.forEach(o => o.classList.remove('selected'));
629
- opt.classList.add('selected');
630
- const val = opt.dataset.value;
631
- if (hiddenInput) hiddenInput.value = val;
632
- valueEl.textContent = opt.querySelector('.model-option-name').textContent;
817
+ const val = syncSimpleDropdown(dropdownId, opt.dataset.value, hiddenInput?.id);
633
818
  onChange?.(val);
634
819
  dropdown.classList.remove('open');
635
820
  });
636
821
  });
822
+
823
+ syncSimpleDropdown(dropdownId, hiddenInput?.value || opts[0]?.dataset.value || '', hiddenInput?.id);
824
+ }
825
+
826
+ function initProviderPoolDropdown() {
827
+ const dropdown = document.getElementById('provider-pool-dropdown');
828
+ const trigger = document.getElementById('provider-pool-dropdown-trigger');
829
+ if (!dropdown || !trigger) return;
830
+
831
+ trigger.addEventListener('click', (e) => {
832
+ e.stopPropagation();
833
+ renderProviderPoolEditor();
834
+ dropdown.classList.toggle('open');
835
+ });
836
+
837
+ document.addEventListener('click', (e) => {
838
+ if (!dropdown.contains(e.target)) dropdown.classList.remove('open');
839
+ });
637
840
  }
638
841
 
639
842
  async function init() {
640
843
  await Promise.all([loadProxies(), loadProviders(), loadStats()]);
844
+ renderProxies();
641
845
  initProviderDropdown();
642
846
  initModelDropdown();
643
847
  initStatsDropdown();
644
848
  initStatsRangeBtns();
849
+ initProviderPoolDropdown();
645
850
  initSimpleDropdown('auth-dropdown', (val) => {
646
851
  const enabled = val === 'true';
647
852
  document.getElementById('auth-token-group').style.display = enabled ? 'block' : 'none';
@@ -651,7 +856,11 @@ async function init() {
651
856
  });
652
857
  initSimpleDropdown('protocol-dropdown', (val) => {
653
858
  document.getElementById('azure-fields').style.display = val === 'openai' ? '' : 'none';
654
- });
859
+ }, 'target-protocol');
860
+ initSimpleDropdown('routing-dropdown', (val) => {
861
+ document.getElementById('routing-strategy').value = val;
862
+ }, 'routing-strategy');
863
+ renderProviderPoolEditor();
655
864
  // 初始状态:根据当前协议值决定 Azure 字段显示
656
865
  const initProto = document.getElementById('target-protocol').value;
657
866
  document.getElementById('azure-fields').style.display = initProto === 'openai' ? '' : 'none';
@@ -711,6 +920,13 @@ function showToast(msg, isError) {
711
920
 
712
921
  // ==================== 渲染代理列表 ====================
713
922
 
923
+ const ROUTING_LABELS = {
924
+ primary_fallback: '主备切换',
925
+ round_robin: '轮询',
926
+ weighted: '加权',
927
+ fastest: '最快优先',
928
+ };
929
+
714
930
  function renderProxies() {
715
931
  const container = document.getElementById('proxy-list');
716
932
  if (proxies.length === 0) {
@@ -719,6 +935,27 @@ function renderProxies() {
719
935
  }
720
936
 
721
937
  container.innerHTML = proxies.map(p => {
938
+ // Build unified provider rows: primary first, then pool entries
939
+ const primaryRow = {
940
+ name: p.providerName || p.providerUrl || '-',
941
+ tag: '',
942
+ protocol: p.protocol || '-',
943
+ model: p.defaultModel || '-',
944
+ weight: Math.max(1, parseInt(p.providerWeight, 10) || 1),
945
+ };
946
+ const poolRows = (p.providerPool || []).map(item => {
947
+ const prov = providers.find(pr => pr.id === item.providerId);
948
+ return {
949
+ name: prov?.name || item.providerId,
950
+ tag: '备选',
951
+ protocol: prov?.protocol || p.protocol || '-',
952
+ model: item.model || '-',
953
+ weight: Math.max(1, parseInt(item.weight, 10) || 1),
954
+ };
955
+ });
956
+ const allRows = [primaryRow, ...poolRows];
957
+ const strategy = ROUTING_LABELS[p.routingStrategy] || p.routingStrategy;
958
+
722
959
  return `
723
960
  <div class="proxy-item">
724
961
  <div class="proxy-header">
@@ -728,6 +965,7 @@ function renderProxies() {
728
965
  ${p.running ? '运行中' : '已停止'}
729
966
  </span>
730
967
  </div>
968
+ <span class="proxy-routing-badge">${escapeHtml(strategy)}</span>
731
969
  </div>
732
970
  <div class="proxy-meta">
733
971
  <span>端口: <strong>${p.port}</strong></span>
@@ -745,19 +983,22 @@ function renderProxies() {
745
983
  <tr>
746
984
  <th>供应商</th>
747
985
  <th>协议</th>
748
- <th>默认 Model</th>
986
+ <th>模型</th>
987
+ <th>权重</th>
749
988
  </tr>
750
989
  </thead>
751
990
  <tbody>
991
+ ${allRows.map(r => `
752
992
  <tr>
753
- <td>${escapeHtml(p.providerName || p.providerUrl || '-')}</td>
993
+ <td>${escapeHtml(r.name)}${r.tag ? `<span class="provider-tag">${r.tag}</span>` : ''}</td>
754
994
  <td>
755
- <span class="badge" style="background:${p.protocol==='openai'?'#0c4a6e':'#581c87'};color:${p.protocol==='openai'?'#7dd3fc':'#e9d5ff'}">
756
- ${p.protocol || '-'}
995
+ <span class="badge" style="background:${r.protocol==='openai'?'#0c4a6e':r.protocol==='anthropic'?'#581c87':'#064e3b'};color:${r.protocol==='openai'?'#7dd3fc':r.protocol==='anthropic'?'#e9d5ff':'#6ee7b7'}">
996
+ ${r.protocol}
757
997
  </span>
758
998
  </td>
759
- <td><code>${escapeHtml(p.defaultModel) || '-'}</code></td>
760
- </tr>
999
+ <td><code>${escapeHtml(r.model)}</code></td>
1000
+ <td>${r.weight}</td>
1001
+ </tr>`).join('')}
761
1002
  </tbody>
762
1003
  </table>
763
1004
  <div class="proxy-actions">
@@ -805,6 +1046,9 @@ function openModal(id = null) {
805
1046
  document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
806
1047
  document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
807
1048
  document.getElementById('azure-fields').style.display = p.protocol === 'openai' ? '' : 'none';
1049
+ document.getElementById('provider-weight').value = Math.max(1, parseInt(p.providerWeight, 10) || 1);
1050
+ syncSimpleDropdown('routing-dropdown', p.routingStrategy || 'primary_fallback', 'routing-strategy');
1051
+ syncProviderPoolState(p.providerPool || []);
808
1052
  } else {
809
1053
  document.getElementById('proxy-id').value = '';
810
1054
  // 重置认证下拉框
@@ -819,6 +1063,9 @@ function openModal(id = null) {
819
1063
  document.getElementById('target-azure-deployment').value = '';
820
1064
  document.getElementById('target-azure-version').value = '';
821
1065
  document.getElementById('azure-fields').style.display = 'none';
1066
+ document.getElementById('provider-weight').value = 1;
1067
+ syncSimpleDropdown('routing-dropdown', 'primary_fallback', 'routing-strategy');
1068
+ syncProviderPoolState([]);
822
1069
  }
823
1070
 
824
1071
  updateModelAddState();
@@ -888,6 +1135,9 @@ async function handleSubmit(e) {
888
1135
  authToken: document.getElementById('proxy-auth-token').value.trim() || null,
889
1136
  providerId,
890
1137
  defaultModel,
1138
+ providerWeight: Math.max(1, parseInt(document.getElementById('provider-weight').value, 10) || 1),
1139
+ routingStrategy: document.getElementById('routing-strategy').value || 'primary_fallback',
1140
+ providerPool: providerPoolItems,
891
1141
  };
892
1142
 
893
1143
  try {