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/lib/config-store.js +36 -1
- package/lib/logger.js +58 -0
- package/lib/proxy-manager.js +4 -0
- package/lib/proxy-server.js +312 -178
- package/lib/stats-store.js +3 -5
- package/package.json +51 -51
- package/public/app.js +272 -22
- package/public/index.html +316 -277
- package/public/style.css +159 -4
- package/server.js +120 -28
package/lib/proxy-server.js
CHANGED
|
@@ -7,6 +7,7 @@ const g2o = require('./converters/gemini-to-openai');
|
|
|
7
7
|
const a2g = require('./converters/anthropic-to-gemini');
|
|
8
8
|
const g2a = require('./converters/gemini-to-anthropic');
|
|
9
9
|
const { recordUsage } = require('./stats-store');
|
|
10
|
+
const logger = require('./logger');
|
|
10
11
|
|
|
11
12
|
function createProxyApp(proxyConfigOrGetter) {
|
|
12
13
|
const getProxyConfig = typeof proxyConfigOrGetter === 'function'
|
|
@@ -15,10 +16,11 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
15
16
|
const app = express();
|
|
16
17
|
app.use(express.json({ limit: '50mb' }));
|
|
17
18
|
|
|
18
|
-
// reasoning_content 缓存(用于 DeepSeek 等 reasoning model)
|
|
19
|
-
// key: assistant message content, value: reasoning_content
|
|
20
19
|
const reasoningCache = new Map();
|
|
21
20
|
const MAX_CACHE_SIZE = 100;
|
|
21
|
+
const routeState = new Map();
|
|
22
|
+
const FAILURE_THRESHOLD = 3;
|
|
23
|
+
const OPEN_DURATION_MS = 60 * 1000;
|
|
22
24
|
|
|
23
25
|
function getReasoningKey(msg) {
|
|
24
26
|
const toolIds = msg.tool_calls?.map(t => t.id).join(',') || '';
|
|
@@ -45,11 +47,8 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
45
47
|
let tokens = 0;
|
|
46
48
|
for (let i = 0; i < text.length; i++) {
|
|
47
49
|
const code = text.charCodeAt(i);
|
|
48
|
-
// CJK 字符 ~1.5 token/字
|
|
49
50
|
if (code >= 0x4E00 && code <= 0x9FFF) tokens += 1.5;
|
|
50
|
-
// 全角标点等 ~1 token
|
|
51
51
|
else if (code >= 0x3000 && code <= 0x303F) tokens += 1;
|
|
52
|
-
// 其他(ASCII 字母、数字、标点、空格)~0.25 token
|
|
53
52
|
else tokens += 0.25;
|
|
54
53
|
}
|
|
55
54
|
return Math.ceil(tokens);
|
|
@@ -75,9 +74,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
}
|
|
78
|
-
if (body.tools)
|
|
79
|
-
text += JSON.stringify(body.tools);
|
|
80
|
-
}
|
|
77
|
+
if (body.tools) text += JSON.stringify(body.tools);
|
|
81
78
|
return estimateTokens(text);
|
|
82
79
|
}
|
|
83
80
|
|
|
@@ -86,7 +83,6 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
86
83
|
for (const msg of messages) {
|
|
87
84
|
if (msg.role === 'assistant' && msg.reasoning_content === undefined) {
|
|
88
85
|
const reasoning = getReasoning(msg);
|
|
89
|
-
// DeepSeek 等 reasoning model 要求 assistant message 必须包含 reasoning_content 字段
|
|
90
86
|
msg.reasoning_content = reasoning || '';
|
|
91
87
|
}
|
|
92
88
|
}
|
|
@@ -100,6 +96,130 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
100
96
|
}
|
|
101
97
|
}
|
|
102
98
|
|
|
99
|
+
function getRouteState(proxyId) {
|
|
100
|
+
if (!routeState.has(proxyId)) {
|
|
101
|
+
routeState.set(proxyId, { rrIndex: 0, metrics: new Map() });
|
|
102
|
+
}
|
|
103
|
+
return routeState.get(proxyId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getMetrics(proxyId, providerId) {
|
|
107
|
+
const state = getRouteState(proxyId);
|
|
108
|
+
if (!state.metrics.has(providerId)) {
|
|
109
|
+
state.metrics.set(providerId, {
|
|
110
|
+
successCount: 0,
|
|
111
|
+
failureCount: 0,
|
|
112
|
+
avgLatencyMs: null,
|
|
113
|
+
lastErrorAt: 0,
|
|
114
|
+
circuitOpenUntil: 0,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return state.metrics.get(providerId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isRetryableStatus(status) {
|
|
121
|
+
return status === 401
|
|
122
|
+
|| status === 403
|
|
123
|
+
|| status === 408
|
|
124
|
+
|| status === 409
|
|
125
|
+
|| status === 425
|
|
126
|
+
|| status === 429
|
|
127
|
+
|| status >= 500;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isProviderAvailable(metrics) {
|
|
131
|
+
return !metrics.circuitOpenUntil || metrics.circuitOpenUntil <= Date.now();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function recordSuccess(proxyId, providerId, latencyMs) {
|
|
135
|
+
const metrics = getMetrics(proxyId, providerId);
|
|
136
|
+
metrics.successCount += 1;
|
|
137
|
+
metrics.lastErrorAt = 0;
|
|
138
|
+
metrics.failureCount = 0;
|
|
139
|
+
metrics.circuitOpenUntil = 0;
|
|
140
|
+
metrics.avgLatencyMs = metrics.avgLatencyMs == null
|
|
141
|
+
? latencyMs
|
|
142
|
+
: Math.round(metrics.avgLatencyMs * 0.7 + latencyMs * 0.3);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function recordFailure(proxyId, providerId) {
|
|
146
|
+
const metrics = getMetrics(proxyId, providerId);
|
|
147
|
+
metrics.failureCount += 1;
|
|
148
|
+
metrics.lastErrorAt = Date.now();
|
|
149
|
+
if (metrics.failureCount >= FAILURE_THRESHOLD) {
|
|
150
|
+
metrics.circuitOpenUntil = Date.now() + OPEN_DURATION_MS;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildCandidates(proxyConfig) {
|
|
155
|
+
const target = proxyConfig.target;
|
|
156
|
+
if (!target || !Array.isArray(target.providerPool) || target.providerPool.length === 0) return [];
|
|
157
|
+
const pool = target.providerPool;
|
|
158
|
+
|
|
159
|
+
const ordered = pool.map((item, index) => ({
|
|
160
|
+
...item,
|
|
161
|
+
providerId: item.providerId || `provider-${index}`,
|
|
162
|
+
weight: Math.max(1, parseInt(item.weight, 10) || 1),
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
const strategy = target.routingStrategy || 'primary_fallback';
|
|
166
|
+
const proxyId = proxyConfig.id || 'default';
|
|
167
|
+
|
|
168
|
+
const byHealth = ordered.filter(item => isProviderAvailable(getMetrics(proxyId, item.providerId)));
|
|
169
|
+
const healthy = byHealth.length > 0 ? byHealth : ordered;
|
|
170
|
+
|
|
171
|
+
if (strategy === 'weighted') {
|
|
172
|
+
// 加权随机选择第一个候选,剩余按权重排序作为 fallback
|
|
173
|
+
const totalWeight = healthy.reduce((sum, c) => sum + c.weight, 0);
|
|
174
|
+
let rand = Math.random() * totalWeight;
|
|
175
|
+
let picked = healthy.length - 1;
|
|
176
|
+
for (let i = 0; i < healthy.length; i++) {
|
|
177
|
+
rand -= healthy[i].weight;
|
|
178
|
+
if (rand <= 0) { picked = i; break; }
|
|
179
|
+
}
|
|
180
|
+
const first = healthy[picked];
|
|
181
|
+
const rest = healthy.filter((_, i) => i !== picked).sort((a, b) => b.weight - a.weight);
|
|
182
|
+
return [first, ...rest];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (strategy === 'fastest') {
|
|
186
|
+
return healthy.slice().sort((a, b) => {
|
|
187
|
+
const am = getMetrics(proxyId, a.providerId).avgLatencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
188
|
+
const bm = getMetrics(proxyId, b.providerId).avgLatencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
189
|
+
return am - bm;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (strategy === 'round_robin') {
|
|
194
|
+
const state = getRouteState(proxyId);
|
|
195
|
+
const start = state.rrIndex % healthy.length;
|
|
196
|
+
state.rrIndex = (state.rrIndex + 1) % healthy.length;
|
|
197
|
+
return healthy.slice(start).concat(healthy.slice(0, start));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return healthy;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getRoutingHealth(proxyConfig) {
|
|
204
|
+
const proxyId = proxyConfig.id || 'default';
|
|
205
|
+
const target = proxyConfig.target || {};
|
|
206
|
+
const pool = Array.isArray(target.providerPool) && target.providerPool.length > 0
|
|
207
|
+
? target.providerPool
|
|
208
|
+
: [];
|
|
209
|
+
return pool.map(item => {
|
|
210
|
+
const metrics = getMetrics(proxyId, item.providerId || 'primary');
|
|
211
|
+
return {
|
|
212
|
+
providerId: item.providerId || 'primary',
|
|
213
|
+
providerName: item.providerName || '',
|
|
214
|
+
successCount: metrics.successCount,
|
|
215
|
+
failureCount: metrics.failureCount,
|
|
216
|
+
avgLatencyMs: metrics.avgLatencyMs,
|
|
217
|
+
circuitOpenUntil: metrics.circuitOpenUntil,
|
|
218
|
+
available: isProviderAvailable(metrics),
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
103
223
|
app.use((req, res, next) => {
|
|
104
224
|
res.header('Access-Control-Allow-Origin', '*');
|
|
105
225
|
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
@@ -110,10 +230,7 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
110
230
|
|
|
111
231
|
app.use((req, res, next) => {
|
|
112
232
|
const proxyConfig = getProxyConfig();
|
|
113
|
-
if (!proxyConfig.requireAuth || !proxyConfig.authToken)
|
|
114
|
-
return next();
|
|
115
|
-
}
|
|
116
|
-
|
|
233
|
+
if (!proxyConfig.requireAuth || !proxyConfig.authToken) return next();
|
|
117
234
|
const token = req.headers.authorization?.replace('Bearer ', '') || req.headers['x-api-key'];
|
|
118
235
|
if (token !== proxyConfig.authToken) {
|
|
119
236
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
@@ -123,204 +240,222 @@ function createProxyApp(proxyConfigOrGetter) {
|
|
|
123
240
|
|
|
124
241
|
app.post('/v1/chat/completions', handleRequest);
|
|
125
242
|
app.post('/v1/messages', handleRequest);
|
|
243
|
+
app.get('/_internal/routing-health', (req, res) => {
|
|
244
|
+
res.json({
|
|
245
|
+
proxy: getProxyConfig()?.id || null,
|
|
246
|
+
providers: getRoutingHealth(getProxyConfig() || {}),
|
|
247
|
+
});
|
|
248
|
+
});
|
|
126
249
|
|
|
127
250
|
async function handleRequest(req, res) {
|
|
128
251
|
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const target = proxyConfig.target;
|
|
252
|
+
const proxyConfig = getProxyConfig();
|
|
253
|
+
const inboundProtocol = detectInboundProtocol(req, req.body);
|
|
254
|
+
const candidates = buildCandidates(proxyConfig);
|
|
133
255
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
256
|
+
if (candidates.length === 0) {
|
|
257
|
+
return res.status(500).json({ error: 'Proxy target not configured' });
|
|
258
|
+
}
|
|
137
259
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
260
|
+
const targetProtocol = candidates[0].protocol;
|
|
261
|
+
const isStream = req.body?.stream === true;
|
|
262
|
+
const proxyId = proxyConfig.id || 'default';
|
|
263
|
+
const inboundModel = req.body?.model;
|
|
264
|
+
const effectiveModel = proxyConfig.target?.defaultModel || inboundModel;
|
|
265
|
+
const baseRequestBody = effectiveModel ? { ...req.body, model: effectiveModel } : { ...req.body };
|
|
266
|
+
|
|
267
|
+
logger.log(`[${requestId}] ${(inboundProtocol || 'unknown').toUpperCase()} -> ${targetProtocol.toUpperCase()} | path=${req.path}`);
|
|
268
|
+
|
|
269
|
+
let convertReq;
|
|
270
|
+
let convertRes;
|
|
271
|
+
let createSSEConv;
|
|
272
|
+
let nameToId = null;
|
|
273
|
+
|
|
274
|
+
if (inboundProtocol === 'openai' && targetProtocol === 'anthropic') {
|
|
275
|
+
convertReq = o2a.convertRequest;
|
|
276
|
+
convertRes = o2a.convertResponse;
|
|
277
|
+
createSSEConv = o2a.createSSEConverter;
|
|
278
|
+
} else if (inboundProtocol === 'anthropic' && targetProtocol === 'openai') {
|
|
279
|
+
convertReq = a2o.convertRequest;
|
|
280
|
+
convertRes = a2o.convertResponse;
|
|
281
|
+
createSSEConv = a2o.createSSEConverter;
|
|
282
|
+
} else if (inboundProtocol === 'openai' && targetProtocol === 'gemini') {
|
|
283
|
+
convertReq = o2g.convertRequest;
|
|
284
|
+
convertRes = o2g.convertResponse;
|
|
285
|
+
createSSEConv = o2g.createSSEConverter;
|
|
286
|
+
} else if (inboundProtocol === 'gemini' && targetProtocol === 'openai') {
|
|
287
|
+
convertReq = g2o.convertRequest;
|
|
288
|
+
convertRes = g2o.convertResponse;
|
|
289
|
+
createSSEConv = g2o.createSSEConverter;
|
|
290
|
+
} else if (inboundProtocol === 'anthropic' && targetProtocol === 'gemini') {
|
|
291
|
+
convertReq = a2g.convertRequest;
|
|
292
|
+
convertRes = a2g.convertResponse;
|
|
293
|
+
createSSEConv = a2g.createSSEConverter;
|
|
294
|
+
} else if (inboundProtocol === 'gemini' && targetProtocol === 'anthropic') {
|
|
295
|
+
convertReq = (body, model) => {
|
|
296
|
+
const result = g2a.convertRequest(body, model);
|
|
297
|
+
nameToId = result.nameToId;
|
|
298
|
+
const { nameToId: _, ...bodyOnly } = result;
|
|
299
|
+
return bodyOnly;
|
|
300
|
+
};
|
|
301
|
+
convertRes = g2a.convertResponse;
|
|
302
|
+
createSSEConv = () => g2a.createSSEConverter(nameToId);
|
|
303
|
+
} else {
|
|
304
|
+
convertReq = (body, model) => ({ ...body, model: body.model || model });
|
|
305
|
+
convertRes = (body) => body;
|
|
306
|
+
createSSEConv = null;
|
|
307
|
+
}
|
|
180
308
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
309
|
+
const requestTemplate = convertReq(baseRequestBody, effectiveModel);
|
|
310
|
+
|
|
311
|
+
for (const candidate of candidates) {
|
|
312
|
+
const isAzure = !!candidate.azureDeployment && /azure/i.test(candidate.providerUrl);
|
|
313
|
+
const targetBody = { ...requestTemplate };
|
|
187
314
|
|
|
188
|
-
|
|
315
|
+
// If candidate has a specific model override, apply it
|
|
316
|
+
if (candidate.model) {
|
|
317
|
+
targetBody.model = candidate.model;
|
|
318
|
+
}
|
|
189
319
|
|
|
190
|
-
const
|
|
320
|
+
const candidateModel = candidate.model || effectiveModel;
|
|
321
|
+
logger.log(`[${requestId}] -> ${candidate.providerName} | model=${candidateModel || '(default)'}`);
|
|
191
322
|
|
|
192
|
-
|
|
193
|
-
if (isStream && targetProtocol === 'openai' && !isAzure) {
|
|
323
|
+
if (isStream && candidate.protocol === 'openai' && !isAzure) {
|
|
194
324
|
targetBody.stream_options = { include_usage: true };
|
|
195
325
|
}
|
|
196
326
|
|
|
197
|
-
// 注入 reasoning_content(针对 DeepSeek 等 reasoning model)
|
|
198
327
|
injectReasoningToMessages(targetBody.messages);
|
|
199
328
|
|
|
200
|
-
|
|
201
|
-
const targetUrl = buildTargetUrl(target, req.path, isStream, effectiveModel);
|
|
202
|
-
console.log(`[${requestId}] 🔗 ${targetUrl} | model=${effectiveModel}`);
|
|
203
|
-
|
|
204
|
-
// 构建请求头
|
|
329
|
+
const targetUrl = buildTargetUrl(candidate, req.path, isStream, candidateModel);
|
|
205
330
|
const headers = {
|
|
206
331
|
'Content-Type': 'application/json',
|
|
207
332
|
'Accept': isStream ? 'text/event-stream' : 'application/json',
|
|
208
333
|
};
|
|
209
334
|
|
|
210
|
-
if (
|
|
211
|
-
if (isAzure)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
headers['x-goog-api-key'] = target.apiKey;
|
|
218
|
-
} else if (targetProtocol === 'anthropic') {
|
|
219
|
-
headers['X-Api-Key'] = target.apiKey;
|
|
335
|
+
if (candidate.protocol === 'openai') {
|
|
336
|
+
if (isAzure) headers['api-key'] = candidate.apiKey;
|
|
337
|
+
else headers['Authorization'] = `Bearer ${candidate.apiKey}`;
|
|
338
|
+
} else if (candidate.protocol === 'gemini') {
|
|
339
|
+
headers['x-goog-api-key'] = candidate.apiKey;
|
|
340
|
+
} else if (candidate.protocol === 'anthropic') {
|
|
341
|
+
headers['X-Api-Key'] = candidate.apiKey;
|
|
220
342
|
headers['Anthropic-Version'] = '2023-06-01';
|
|
221
|
-
headers['Authorization'] = `Bearer ${
|
|
343
|
+
headers['Authorization'] = `Bearer ${candidate.apiKey}`;
|
|
222
344
|
}
|
|
223
345
|
|
|
224
|
-
const
|
|
225
|
-
method: 'POST',
|
|
226
|
-
headers,
|
|
227
|
-
body: JSON.stringify(targetBody),
|
|
228
|
-
signal: AbortSignal.timeout(300000),
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
if (!fetchRes.ok) {
|
|
232
|
-
const errBody = await fetchRes.text();
|
|
233
|
-
console.log(`[${requestId}] ❌ Target error: HTTP ${fetchRes.status} | ${errBody.slice(0, 500)}`);
|
|
234
|
-
res.status(fetchRes.status);
|
|
235
|
-
res.set('Content-Type', fetchRes.headers.get('content-type') || 'application/json');
|
|
236
|
-
return res.send(errBody);
|
|
237
|
-
}
|
|
346
|
+
const startedAt = Date.now();
|
|
238
347
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const sseConverter = createSSEConv ? createSSEConv(effectiveModel) : null;
|
|
246
|
-
const reader = fetchRes.body.getReader();
|
|
247
|
-
const decoder = new TextDecoder();
|
|
248
|
-
let streamUsage = null;
|
|
249
|
-
let responseText = '';
|
|
250
|
-
let toolCallCount = 0;
|
|
251
|
-
|
|
252
|
-
req.on('close', () => {
|
|
253
|
-
try { reader.cancel(); } catch (err) { /* ignore */ }
|
|
348
|
+
try {
|
|
349
|
+
const fetchRes = await fetch(targetUrl, {
|
|
350
|
+
method: 'POST',
|
|
351
|
+
headers,
|
|
352
|
+
body: JSON.stringify(targetBody),
|
|
353
|
+
signal: AbortSignal.timeout(300000),
|
|
254
354
|
});
|
|
255
355
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
356
|
+
if (!fetchRes.ok) {
|
|
357
|
+
const errBody = await fetchRes.text();
|
|
358
|
+
const error = Object.assign(new Error(errBody.slice(0, 500) || `HTTP ${fetchRes.status}`), { status: fetchRes.status });
|
|
359
|
+
if (isRetryableStatus(fetchRes.status)) {
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
return res.status(fetchRes.status).json({ error: error.message });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
recordSuccess(proxyId, candidate.providerId, Date.now() - startedAt);
|
|
366
|
+
logger.log(`[${requestId}] ✓ ${candidate.providerName} | model=${candidateModel || '(default)'} (${Date.now() - startedAt}ms)`);
|
|
367
|
+
|
|
368
|
+
if (isStream) {
|
|
369
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
370
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
371
|
+
res.setHeader('Connection', 'keep-alive');
|
|
372
|
+
|
|
373
|
+
const sseConverter = createSSEConv ? createSSEConv(effectiveModel) : null;
|
|
374
|
+
const reader = fetchRes.body.getReader();
|
|
375
|
+
const decoder = new TextDecoder();
|
|
376
|
+
let streamUsage = null;
|
|
377
|
+
let responseText = '';
|
|
378
|
+
let toolCallCount = 0;
|
|
379
|
+
|
|
380
|
+
req.on('close', () => {
|
|
381
|
+
try { reader.cancel(); } catch { /* ignore */ }
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
while (true) {
|
|
386
|
+
const { done, value } = await reader.read();
|
|
387
|
+
if (done) break;
|
|
388
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
389
|
+
for (const line of chunk.split('\n')) {
|
|
390
|
+
const trimmed = line.trim();
|
|
391
|
+
if (!trimmed.startsWith('data:') || trimmed === 'data: [DONE]') continue;
|
|
392
|
+
try {
|
|
393
|
+
const d = JSON.parse(trimmed.slice(5).trim());
|
|
394
|
+
if (d.usage) streamUsage = d.usage;
|
|
395
|
+
const delta = d.choices?.[0]?.delta;
|
|
396
|
+
if (delta?.content) responseText += delta.content;
|
|
397
|
+
if (delta?.tool_calls) {
|
|
398
|
+
for (const tc of delta.tool_calls) {
|
|
399
|
+
if (tc.function?.name) toolCallCount++;
|
|
400
|
+
}
|
|
274
401
|
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
402
|
+
} catch { /* ignore */ }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (sseConverter) {
|
|
406
|
+
const converted = sseConverter.convertChunk(chunk);
|
|
407
|
+
if (converted) res.write(converted);
|
|
408
|
+
} else {
|
|
409
|
+
res.write(chunk);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (streamUsage) {
|
|
414
|
+
recordUsage(proxyConfig.id, candidate.providerName, candidateModel, streamUsage, false);
|
|
415
|
+
} else if (responseText || toolCallCount > 0) {
|
|
416
|
+
const inputTokens = estimateInputTokens(req.body);
|
|
417
|
+
const outputTokens = estimateTokens(responseText) + toolCallCount * 15;
|
|
418
|
+
recordUsage(proxyConfig.id, candidate.providerName, candidateModel, {
|
|
419
|
+
prompt_tokens: inputTokens,
|
|
420
|
+
completion_tokens: outputTokens,
|
|
421
|
+
}, true);
|
|
277
422
|
}
|
|
423
|
+
|
|
278
424
|
if (sseConverter) {
|
|
279
|
-
const
|
|
280
|
-
if (
|
|
281
|
-
}
|
|
282
|
-
|
|
425
|
+
const flushed = sseConverter.flush();
|
|
426
|
+
if (flushed) res.write(flushed);
|
|
427
|
+
}
|
|
428
|
+
} catch (err) {
|
|
429
|
+
recordFailure(proxyId, candidate.providerId);
|
|
430
|
+
logger.error(`[${requestId}] Stream error:`, err.message);
|
|
431
|
+
if (!res.writableEnded) {
|
|
432
|
+
try {
|
|
433
|
+
res.write(`data: ${JSON.stringify({ error: { message: err.message, type: 'proxy_error' } })}\n\n`);
|
|
434
|
+
} catch { /* ignore */ }
|
|
283
435
|
}
|
|
436
|
+
} finally {
|
|
437
|
+
res.end();
|
|
284
438
|
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
285
441
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
if (sseConverter) {
|
|
298
|
-
const flushed = sseConverter.flush();
|
|
299
|
-
if (flushed) res.write(flushed);
|
|
300
|
-
}
|
|
301
|
-
} catch (err) {
|
|
302
|
-
console.error(`[${requestId}] Stream error:`, err.message);
|
|
303
|
-
if (!res.writableEnded) {
|
|
304
|
-
try {
|
|
305
|
-
res.write(`data: ${JSON.stringify({ error: { message: err.message, type: 'proxy_error' } })}\n\n`);
|
|
306
|
-
} catch { /* ignore */ }
|
|
307
|
-
}
|
|
308
|
-
} finally {
|
|
309
|
-
res.end();
|
|
442
|
+
const responseBody = await fetchRes.json();
|
|
443
|
+
extractReasoningFromResponse(responseBody);
|
|
444
|
+
recordUsage(proxyConfig.id, candidate.providerName, candidateModel, responseBody.usage);
|
|
445
|
+
const convertedBody = convertRes(responseBody);
|
|
446
|
+
return res.json(convertedBody);
|
|
447
|
+
} catch (err) {
|
|
448
|
+
recordFailure(proxyId, candidate.providerId);
|
|
449
|
+
logger.error(`[${requestId}] ✗ ${candidate.providerName} | model=${candidateModel || '(default)'} - ${err.message}`);
|
|
450
|
+
if (err?.status && !isRetryableStatus(err.status)) {
|
|
451
|
+
return res.status(err.status).json({ error: err.message });
|
|
310
452
|
}
|
|
311
|
-
|
|
453
|
+
continue;
|
|
312
454
|
}
|
|
313
|
-
|
|
314
|
-
const responseBody = await fetchRes.json();
|
|
315
|
-
extractReasoningFromResponse(responseBody);
|
|
316
|
-
recordUsage(proxyConfig.id, proxyConfig.target?.providerName, req.body?.model, responseBody.usage);
|
|
317
|
-
const convertedBody = convertRes(responseBody);
|
|
318
|
-
res.json(convertedBody);
|
|
319
|
-
|
|
320
|
-
} catch (err) {
|
|
321
|
-
console.error(`[${requestId}] ❌ Proxy error:`, err.message);
|
|
322
|
-
res.status(500).json({ error: 'Proxy error', message: err.message });
|
|
323
455
|
}
|
|
456
|
+
|
|
457
|
+
logger.error(`[${requestId}] 所有供应商均失败`);
|
|
458
|
+
return res.status(502).json({ error: 'All providers failed' });
|
|
324
459
|
}
|
|
325
460
|
|
|
326
461
|
return app;
|
|
@@ -331,7 +466,6 @@ function buildTargetUrl(target, originalPath, isStream, effectiveModel) {
|
|
|
331
466
|
const hasV1Suffix = base.endsWith('/v1');
|
|
332
467
|
|
|
333
468
|
if (target.protocol === 'openai') {
|
|
334
|
-
// Azure OpenAI
|
|
335
469
|
if (target.azureDeployment) {
|
|
336
470
|
const ver = target.azureApiVersion || '2024-02-01';
|
|
337
471
|
return `${base}/openai/deployments/${target.azureDeployment}/chat/completions?api-version=${ver}`;
|
package/lib/stats-store.js
CHANGED
|
@@ -194,14 +194,12 @@ function recordUsage(proxyId, provider, model, usage, estimated = false) {
|
|
|
194
194
|
`p:${proxyId}:${prov}:${mdl}`,
|
|
195
195
|
];
|
|
196
196
|
|
|
197
|
-
for (const
|
|
198
|
-
|
|
199
|
-
for (const key of keys) {
|
|
200
|
-
addToBuffer(period, bucket, key, prompt, completion, estimated);
|
|
201
|
-
}
|
|
197
|
+
for (const key of keys) {
|
|
198
|
+
addToBuffer('daily', dk, key, prompt, completion, estimated);
|
|
202
199
|
}
|
|
203
200
|
}
|
|
204
201
|
|
|
202
|
+
|
|
205
203
|
// ==================== 查询统计 ====================
|
|
206
204
|
|
|
207
205
|
function getStats(opts = {}) {
|