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.
@@ -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
- try {
130
- const proxyConfig = getProxyConfig();
131
- const inboundProtocol = detectInboundProtocol(req, req.body);
132
- const target = proxyConfig.target;
252
+ const proxyConfig = getProxyConfig();
253
+ const inboundProtocol = detectInboundProtocol(req, req.body);
254
+ const candidates = buildCandidates(proxyConfig);
133
255
 
134
- if (!target) {
135
- return res.status(500).json({ error: 'Proxy target not configured' });
136
- }
256
+ if (candidates.length === 0) {
257
+ return res.status(500).json({ error: 'Proxy target not configured' });
258
+ }
137
259
 
138
- const targetProtocol = target.protocol;
139
- const isStream = req.body?.stream === true;
140
-
141
- console.log(`[${requestId}] ⬅️ ${(inboundProtocol || 'unknown').toUpperCase()} → ${targetProtocol.toUpperCase()} | path=${req.path}`);
142
-
143
- // 决定转换方向
144
- let convertReq, convertRes, createSSEConv, nameToId = null;
145
- if (inboundProtocol === 'openai' && targetProtocol === 'anthropic') {
146
- convertReq = o2a.convertRequest;
147
- convertRes = o2a.convertResponse;
148
- createSSEConv = o2a.createSSEConverter;
149
- } else if (inboundProtocol === 'anthropic' && targetProtocol === 'openai') {
150
- convertReq = a2o.convertRequest;
151
- convertRes = a2o.convertResponse;
152
- createSSEConv = a2o.createSSEConverter;
153
- } else if (inboundProtocol === 'openai' && targetProtocol === 'gemini') {
154
- convertReq = o2g.convertRequest;
155
- convertRes = o2g.convertResponse;
156
- createSSEConv = o2g.createSSEConverter;
157
- } else if (inboundProtocol === 'gemini' && targetProtocol === 'openai') {
158
- convertReq = g2o.convertRequest;
159
- convertRes = g2o.convertResponse;
160
- createSSEConv = g2o.createSSEConverter;
161
- } else if (inboundProtocol === 'anthropic' && targetProtocol === 'gemini') {
162
- convertReq = a2g.convertRequest;
163
- convertRes = a2g.convertResponse;
164
- createSSEConv = a2g.createSSEConverter;
165
- } else if (inboundProtocol === 'gemini' && targetProtocol === 'anthropic') {
166
- // g2a.convertRequest 返回 { ...body, nameToId },需要提取映射
167
- convertReq = (body, model) => {
168
- const result = g2a.convertRequest(body, model);
169
- nameToId = result.nameToId;
170
- const { nameToId: _, ...bodyOnly } = result;
171
- return bodyOnly;
172
- };
173
- convertRes = g2a.convertResponse;
174
- createSSEConv = (model) => g2a.createSSEConverter(nameToId);
175
- } else {
176
- convertReq = (body, model) => ({ ...body, model: body.model || model });
177
- convertRes = (body) => body;
178
- createSSEConv = null;
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
- // 如果请求没有 model,注入默认 model
182
- const inboundModel = req.body?.model;
183
- const effectiveModel = target.defaultModel || inboundModel;
184
- if (effectiveModel) {
185
- req.body = { ...req.body, model: effectiveModel };
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
- const targetBody = convertReq(req.body, effectiveModel);
315
+ // If candidate has a specific model override, apply it
316
+ if (candidate.model) {
317
+ targetBody.model = candidate.model;
318
+ }
189
319
 
190
- const isAzure = !!target.azureDeployment && /azure/i.test(target.providerUrl);
320
+ const candidateModel = candidate.model || effectiveModel;
321
+ logger.log(`[${requestId}] -> ${candidate.providerName} | model=${candidateModel || '(default)'}`);
191
322
 
192
- // 流式请求时注入 stream_options 以获取 usage 统计(Azure 不支持)
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
- // 构建目标 URL
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 (targetProtocol === 'openai') {
211
- if (isAzure) {
212
- headers['api-key'] = target.apiKey;
213
- } else {
214
- headers['Authorization'] = `Bearer ${target.apiKey}`;
215
- }
216
- } else if (targetProtocol === 'gemini') {
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 ${target.apiKey}`;
343
+ headers['Authorization'] = `Bearer ${candidate.apiKey}`;
222
344
  }
223
345
 
224
- const fetchRes = await fetch(targetUrl, {
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
- // 流式响应(以客户端请求意图为准,不依赖上游 Content-Type)
240
- if (isStream) {
241
- res.setHeader('Content-Type', 'text/event-stream');
242
- res.setHeader('Cache-Control', 'no-cache');
243
- res.setHeader('Connection', 'keep-alive');
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
- try {
257
- while (true) {
258
- const { done, value } = await reader.read();
259
- if (done) break;
260
- const chunk = decoder.decode(value, { stream: true });
261
- // 从流中提取 usage 和响应内容
262
- const lines = chunk.split('\n');
263
- for (const line of lines) {
264
- const trimmed = line.trim();
265
- if (!trimmed.startsWith('data:') || trimmed === 'data: [DONE]') continue;
266
- try {
267
- const d = JSON.parse(trimmed.slice(5).trim());
268
- if (d.usage) streamUsage = d.usage;
269
- const delta = d.choices?.[0]?.delta;
270
- if (delta?.content) responseText += delta.content;
271
- if (delta?.tool_calls) {
272
- for (const tc of delta.tool_calls) {
273
- if (tc.function?.name) toolCallCount++;
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
- } catch { /* ignore */ }
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 converted = sseConverter.convertChunk(chunk);
280
- if (converted) res.write(converted);
281
- } else {
282
- res.write(chunk);
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
- if (streamUsage) {
287
- recordUsage(proxyConfig.id, proxyConfig.target?.providerName, req.body?.model, streamUsage, false);
288
- } else if (responseText || toolCallCount > 0) {
289
- // 上游未返回 usage,从响应内容估算
290
- const inputTokens = estimateInputTokens(req.body);
291
- const outputTokens = estimateTokens(responseText) + toolCallCount * 15;
292
- recordUsage(proxyConfig.id, proxyConfig.target?.providerName, req.body?.model, {
293
- prompt_tokens: inputTokens,
294
- completion_tokens: outputTokens,
295
- }, true);
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
- return;
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}`;
@@ -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 period of ['daily', 'monthly']) {
198
- const bucket = period === 'daily' ? dk : mk;
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 = {}) {