protocol-proxy 2.4.0 → 2.5.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.
@@ -1,491 +1,636 @@
1
- const express = require('express');
2
- const { detectInboundProtocol } = require('./detector');
3
- const o2a = require('./converters/openai-to-anthropic');
4
- const a2o = require('./converters/anthropic-to-openai');
5
- const o2g = require('./converters/openai-to-gemini');
6
- const g2o = require('./converters/gemini-to-openai');
7
- const a2g = require('./converters/anthropic-to-gemini');
8
- const g2a = require('./converters/gemini-to-anthropic');
9
- const { recordUsage } = require('./stats-store');
10
- const logger = require('./logger');
11
-
12
- function createProxyApp(proxyConfigOrGetter) {
13
- const getProxyConfig = typeof proxyConfigOrGetter === 'function'
14
- ? proxyConfigOrGetter
15
- : () => proxyConfigOrGetter;
16
- const app = express();
17
- app.use(express.json({ limit: '50mb' }));
18
-
19
- const reasoningCache = new Map();
20
- const MAX_CACHE_SIZE = 100;
21
- const routeState = new Map();
22
- const FAILURE_THRESHOLD = 3;
23
- const OPEN_DURATION_MS = 60 * 1000;
24
-
25
- function getReasoningKey(msg) {
26
- const toolIds = msg.tool_calls?.map(t => t.id).join(',') || '';
27
- return msg.content + '|' + toolIds;
28
- }
29
-
30
- function setReasoning(msg, reasoning) {
31
- if (!msg?.content || !reasoning) return;
32
- const key = getReasoningKey(msg);
33
- if (reasoningCache.size >= MAX_CACHE_SIZE) {
34
- const firstKey = reasoningCache.keys().next().value;
35
- reasoningCache.delete(firstKey);
36
- }
37
- reasoningCache.set(key, reasoning);
38
- }
39
-
40
- function getReasoning(msg) {
41
- if (!msg?.content) return undefined;
42
- return reasoningCache.get(getReasoningKey(msg));
43
- }
44
-
45
- function estimateTokens(text) {
46
- if (!text) return 0;
47
- let tokens = 0;
48
- for (let i = 0; i < text.length; i++) {
49
- const code = text.charCodeAt(i);
50
- if (code >= 0x4E00 && code <= 0x9FFF) tokens += 1.5;
51
- else if (code >= 0x3000 && code <= 0x303F) tokens += 1;
52
- else tokens += 0.25;
53
- }
54
- return Math.ceil(tokens);
55
- }
56
-
57
- function estimateInputTokens(body) {
58
- if (!body?.messages) return 0;
59
- let text = '';
60
- for (const msg of body.messages) {
61
- if (typeof msg.content === 'string') {
62
- text += msg.content;
63
- } else if (Array.isArray(msg.content)) {
64
- for (const block of msg.content) {
65
- if (block.text) text += block.text;
66
- if (block.type === 'tool_result' && block.content) {
67
- text += typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
68
- }
69
- }
70
- }
71
- if (msg.tool_calls) {
72
- for (const tc of msg.tool_calls) {
73
- text += (tc.function?.arguments || '') + (tc.function?.name || '');
74
- }
75
- }
76
- }
77
- if (body.tools) text += JSON.stringify(body.tools);
78
- return estimateTokens(text);
79
- }
80
-
81
- function injectReasoningToMessages(messages) {
82
- if (!Array.isArray(messages)) return;
83
- for (const msg of messages) {
84
- if (msg.role === 'assistant' && msg.reasoning_content === undefined) {
85
- const reasoning = getReasoning(msg);
86
- msg.reasoning_content = reasoning || '';
87
- }
88
- }
89
- }
90
-
91
- function extractReasoningFromResponse(body) {
92
- const choice = body.choices?.[0];
93
- const message = choice?.message;
94
- if (message?.role === 'assistant' && message.reasoning_content) {
95
- setReasoning(message, message.reasoning_content);
96
- }
97
- }
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
-
223
- app.use((req, res, next) => {
224
- res.header('Access-Control-Allow-Origin', '*');
225
- res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
226
- res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Api-Key');
227
- if (req.method === 'OPTIONS') return res.sendStatus(200);
228
- next();
229
- });
230
-
231
- app.use((req, res, next) => {
232
- const proxyConfig = getProxyConfig();
233
- if (!proxyConfig.requireAuth || !proxyConfig.authToken) return next();
234
- const token = req.headers.authorization?.replace('Bearer ', '') || req.headers['x-api-key'];
235
- if (token !== proxyConfig.authToken) {
236
- return res.status(401).json({ error: 'Unauthorized' });
237
- }
238
- next();
239
- });
240
-
241
- app.post('/v1/chat/completions', handleRequest);
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
- });
249
-
250
- async function handleRequest(req, res) {
251
- const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
252
- const proxyConfig = getProxyConfig();
253
- const inboundProtocol = detectInboundProtocol(req, req.body);
254
- const candidates = buildCandidates(proxyConfig);
255
-
256
- if (candidates.length === 0) {
257
- return res.status(500).json({ error: 'Proxy target not configured' });
258
- }
259
-
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
- }
308
-
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 };
314
-
315
- // If candidate has a specific model override, apply it
316
- if (candidate.model) {
317
- targetBody.model = candidate.model;
318
- }
319
-
320
- const candidateModel = candidate.model || effectiveModel;
321
- logger.log(`[${requestId}] -> ${candidate.providerName} | model=${candidateModel || '(default)'}`);
322
-
323
- if (isStream && candidate.protocol === 'openai' && !isAzure) {
324
- targetBody.stream_options = { include_usage: true };
325
- }
326
-
327
- injectReasoningToMessages(targetBody.messages);
328
-
329
- const targetUrl = buildTargetUrl(candidate, req.path, isStream, candidateModel);
330
- const headers = {
331
- 'Content-Type': 'application/json',
332
- 'Accept': isStream ? 'text/event-stream' : 'application/json',
333
- };
334
-
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;
342
- headers['Anthropic-Version'] = '2023-06-01';
343
- headers['Authorization'] = `Bearer ${candidate.apiKey}`;
344
- }
345
-
346
- const startedAt = Date.now();
347
-
348
- try {
349
- const fetchRes = await fetch(targetUrl, {
350
- method: 'POST',
351
- headers,
352
- body: JSON.stringify(targetBody),
353
- signal: AbortSignal.timeout(300000),
354
- });
355
-
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
- }
401
- }
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);
422
- }
423
-
424
- if (sseConverter) {
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 */ }
435
- }
436
- } finally {
437
- res.end();
438
- }
439
- return;
440
- }
441
-
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 });
452
- }
453
- continue;
454
- }
455
- }
456
-
457
- logger.error(`[${requestId}] 所有供应商均失败`);
458
- return res.status(502).json({ error: 'All providers failed' });
459
- }
460
-
461
- return app;
462
- }
463
-
464
- function buildTargetUrl(target, originalPath, isStream, effectiveModel) {
465
- const base = target.providerUrl.replace(/\/$/, '');
466
- const hasV1Suffix = base.endsWith('/v1');
467
-
468
- if (target.protocol === 'openai') {
469
- if (target.azureDeployment) {
470
- const ver = target.azureApiVersion || '2024-02-01';
471
- return `${base}/openai/deployments/${target.azureDeployment}/chat/completions?api-version=${ver}`;
472
- }
473
- if (hasV1Suffix) return `${base}/chat/completions`;
474
- return `${base}/v1/chat/completions`;
475
- }
476
-
477
- if (target.protocol === 'anthropic') {
478
- if (hasV1Suffix) return `${base}/messages`;
479
- return `${base}/v1/messages`;
480
- }
481
-
482
- if (target.protocol === 'gemini') {
483
- const model = effectiveModel || 'gemini-pro';
484
- const action = isStream ? 'streamGenerateContent?alt=sse' : 'generateContent';
485
- return `${base}/v1beta/models/${model}:${action}`;
486
- }
487
-
488
- return base + originalPath;
489
- }
490
-
491
- module.exports = { createProxyApp };
1
+ const express = require('express');
2
+ const { detectInboundProtocol } = require('./detector');
3
+ const o2a = require('./converters/openai-to-anthropic');
4
+ const a2o = require('./converters/anthropic-to-openai');
5
+ const o2g = require('./converters/openai-to-gemini');
6
+ const g2o = require('./converters/gemini-to-openai');
7
+ const a2g = require('./converters/anthropic-to-gemini');
8
+ const g2a = require('./converters/gemini-to-anthropic');
9
+ const { recordUsage } = require('./stats-store');
10
+ const logger = require('./logger');
11
+
12
+ function createProxyApp(proxyConfigOrGetter) {
13
+ const getProxyConfig = typeof proxyConfigOrGetter === 'function'
14
+ ? proxyConfigOrGetter
15
+ : () => proxyConfigOrGetter;
16
+ const app = express();
17
+ app.use(express.json({ limit: '50mb' }));
18
+
19
+ const reasoningCache = new Map();
20
+ const MAX_CACHE_SIZE = 100;
21
+ const routeState = new Map();
22
+ const FAILURE_THRESHOLD = 3;
23
+ const OPEN_DURATION_MS = 60 * 1000;
24
+
25
+ function getReasoningKey(msg) {
26
+ const toolIds = msg.tool_calls?.map(t => t.id).join(',') || '';
27
+ return msg.content + '|' + toolIds;
28
+ }
29
+
30
+ function setReasoning(msg, reasoning) {
31
+ if (!msg?.content || !reasoning) return;
32
+ const key = getReasoningKey(msg);
33
+ if (reasoningCache.size >= MAX_CACHE_SIZE) {
34
+ const firstKey = reasoningCache.keys().next().value;
35
+ reasoningCache.delete(firstKey);
36
+ }
37
+ reasoningCache.set(key, reasoning);
38
+ }
39
+
40
+ function getReasoning(msg) {
41
+ if (!msg?.content) return undefined;
42
+ return reasoningCache.get(getReasoningKey(msg));
43
+ }
44
+
45
+ function estimateTokens(text) {
46
+ if (!text) return 0;
47
+ let tokens = 0;
48
+ for (let i = 0; i < text.length; i++) {
49
+ const code = text.charCodeAt(i);
50
+ if (code >= 0x4E00 && code <= 0x9FFF) tokens += 1.5;
51
+ else if (code >= 0x3000 && code <= 0x303F) tokens += 1;
52
+ else tokens += 0.25;
53
+ }
54
+ return Math.ceil(tokens);
55
+ }
56
+
57
+ function estimateInputTokens(body) {
58
+ if (!body?.messages) return 0;
59
+ let text = '';
60
+ for (const msg of body.messages) {
61
+ if (typeof msg.content === 'string') {
62
+ text += msg.content;
63
+ } else if (Array.isArray(msg.content)) {
64
+ for (const block of msg.content) {
65
+ if (block.text) text += block.text;
66
+ if (block.type === 'tool_result' && block.content) {
67
+ text += typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
68
+ }
69
+ }
70
+ }
71
+ if (msg.tool_calls) {
72
+ for (const tc of msg.tool_calls) {
73
+ text += (tc.function?.arguments || '') + (tc.function?.name || '');
74
+ }
75
+ }
76
+ }
77
+ if (body.tools) text += JSON.stringify(body.tools);
78
+ return estimateTokens(text);
79
+ }
80
+
81
+ function injectReasoningToMessages(messages) {
82
+ if (!Array.isArray(messages)) return;
83
+ for (const msg of messages) {
84
+ if (msg.role === 'assistant' && msg.reasoning_content === undefined) {
85
+ const reasoning = getReasoning(msg);
86
+ msg.reasoning_content = reasoning || '';
87
+ }
88
+ }
89
+ }
90
+
91
+ function extractReasoningFromResponse(body) {
92
+ const choice = body.choices?.[0];
93
+ const message = choice?.message;
94
+ if (message?.role === 'assistant' && message.reasoning_content) {
95
+ setReasoning(message, message.reasoning_content);
96
+ }
97
+ }
98
+
99
+ // Extract thinking blocks from Anthropic response and cache by assistant text content
100
+ function extractAnthropicThinking(body) {
101
+ const content = body.content;
102
+ if (!Array.isArray(content)) return;
103
+ const thinkingBlocks = content.filter(b => b.type === 'thinking');
104
+ if (thinkingBlocks.length === 0) return;
105
+ const textContent = content.filter(b => b.type === 'text').map(b => b.text).join('');
106
+ if (!textContent) return;
107
+ const msg = { content: textContent, tool_calls: null };
108
+ setReasoning(msg, thinkingBlocks);
109
+ }
110
+
111
+ // Inject cached thinking blocks into Anthropic-format assistant messages
112
+ function injectAnthropicThinking(messages) {
113
+ if (!Array.isArray(messages)) return;
114
+ for (const msg of messages) {
115
+ if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
116
+ const hasThinking = msg.content.some(b => b.type === 'thinking');
117
+ if (hasThinking) continue;
118
+ const textContent = msg.content.filter(b => b.type === 'text').map(b => b.text).join('');
119
+ if (!textContent) continue;
120
+ const cached = getReasoning({ content: textContent, tool_calls: null });
121
+ if (cached) {
122
+ msg.content = [...cached, ...msg.content];
123
+ }
124
+ }
125
+ }
126
+
127
+ function getRouteState(proxyId) {
128
+ if (!routeState.has(proxyId)) {
129
+ routeState.set(proxyId, { rrIndex: 0, metrics: new Map() });
130
+ }
131
+ return routeState.get(proxyId);
132
+ }
133
+
134
+ function getMetrics(proxyId, providerId) {
135
+ const state = getRouteState(proxyId);
136
+ if (!state.metrics.has(providerId)) {
137
+ state.metrics.set(providerId, {
138
+ successCount: 0,
139
+ failureCount: 0,
140
+ avgLatencyMs: null,
141
+ lastErrorAt: 0,
142
+ circuitOpenUntil: 0,
143
+ });
144
+ }
145
+ return state.metrics.get(providerId);
146
+ }
147
+
148
+ function isRetryableStatus(status) {
149
+ return status === 401
150
+ || status === 403
151
+ || status === 408
152
+ || status === 409
153
+ || status === 425
154
+ || status === 429
155
+ || status >= 500;
156
+ }
157
+
158
+ function isProviderAvailable(metrics) {
159
+ return !metrics.circuitOpenUntil || metrics.circuitOpenUntil <= Date.now();
160
+ }
161
+
162
+ function recordSuccess(proxyId, providerId, latencyMs) {
163
+ const metrics = getMetrics(proxyId, providerId);
164
+ metrics.successCount += 1;
165
+ metrics.lastErrorAt = 0;
166
+ metrics.failureCount = 0;
167
+ metrics.circuitOpenUntil = 0;
168
+ metrics.avgLatencyMs = metrics.avgLatencyMs == null
169
+ ? latencyMs
170
+ : Math.round(metrics.avgLatencyMs * 0.7 + latencyMs * 0.3);
171
+ }
172
+
173
+ function recordFailure(proxyId, providerId) {
174
+ const metrics = getMetrics(proxyId, providerId);
175
+ metrics.failureCount += 1;
176
+ metrics.lastErrorAt = Date.now();
177
+ if (metrics.failureCount >= FAILURE_THRESHOLD) {
178
+ metrics.circuitOpenUntil = Date.now() + OPEN_DURATION_MS;
179
+ }
180
+ }
181
+
182
+ // ==================== API Key 轮转 ====================
183
+
184
+ const keyPoolState = new Map();
185
+ const KEY_COOLDOWN_MS = 60 * 1000;
186
+
187
+ function getKeyState(providerId, apiKeys) {
188
+ if (!keyPoolState.has(providerId)) {
189
+ keyPoolState.set(providerId, {
190
+ keys: apiKeys || [],
191
+ index: 0,
192
+ cooldowns: new Map(), // key -> cooldownUntil timestamp
193
+ });
194
+ }
195
+ return keyPoolState.get(providerId);
196
+ }
197
+
198
+ function selectKey(providerId, apiKeys) {
199
+ if (!apiKeys || apiKeys.length === 0) return '';
200
+ // Filter out disabled keys (enabled defaults to true)
201
+ const enabledKeys = apiKeys.filter(k => (typeof k === 'object' ? k.enabled !== false : true));
202
+ if (enabledKeys.length === 0) return '';
203
+ // Normalize to string array (handle {key, alias} objects)
204
+ const keys = enabledKeys.map(k => typeof k === 'string' ? k : k.key);
205
+ if (keys.length === 1) return keys[0];
206
+
207
+ const state = getKeyState(providerId, keys);
208
+ // Sync keys in case they changed
209
+ state.keys = keys;
210
+ const now = Date.now();
211
+
212
+ // Clean expired cooldowns
213
+ for (const [key, until] of state.cooldowns) {
214
+ if (until <= now) state.cooldowns.delete(key);
215
+ }
216
+
217
+ // Try to find an available key starting from current index
218
+ for (let i = 0; i < keys.length; i++) {
219
+ const idx = (state.index + i) % keys.length;
220
+ const key = keys[idx];
221
+ if (!state.cooldowns.has(key)) {
222
+ state.index = (idx + 1) % keys.length;
223
+ return key;
224
+ }
225
+ }
226
+
227
+ // All keys on cooldown — pick the one with shortest remaining cooldown
228
+ let earliest = Infinity;
229
+ let bestKey = keys[0];
230
+ for (const [key, until] of state.cooldowns) {
231
+ if (keys.includes(key) && until < earliest) {
232
+ earliest = until;
233
+ bestKey = key;
234
+ }
235
+ }
236
+ state.index = (keys.indexOf(bestKey) + 1) % keys.length;
237
+ return bestKey;
238
+ }
239
+
240
+ function markKeyCooldown(providerId, key) {
241
+ const state = keyPoolState.get(providerId);
242
+ if (state) {
243
+ state.cooldowns.set(key, Date.now() + KEY_COOLDOWN_MS);
244
+ logger.log(`[KeyPool] ${providerId} key ${key.slice(0, 8)}... cooldown 60s`);
245
+ }
246
+ }
247
+
248
+ function buildCandidates(proxyConfig) {
249
+ const target = proxyConfig.target;
250
+ if (!target || !Array.isArray(target.providerPool) || target.providerPool.length === 0) return [];
251
+ const pool = target.providerPool;
252
+
253
+ const ordered = pool.map((item, index) => ({
254
+ ...item,
255
+ providerId: item.providerId || `provider-${index}`,
256
+ weight: Math.max(1, parseInt(item.weight, 10) || 1),
257
+ }));
258
+
259
+ const strategy = target.routingStrategy || 'primary_fallback';
260
+ const proxyId = proxyConfig.id || 'default';
261
+
262
+ const byHealth = ordered.filter(item => isProviderAvailable(getMetrics(proxyId, item.providerId)));
263
+ const healthy = byHealth.length > 0 ? byHealth : ordered;
264
+
265
+ if (strategy === 'weighted') {
266
+ // 加权随机选择第一个候选,剩余按权重排序作为 fallback
267
+ const totalWeight = healthy.reduce((sum, c) => sum + c.weight, 0);
268
+ let rand = Math.random() * totalWeight;
269
+ let picked = healthy.length - 1;
270
+ for (let i = 0; i < healthy.length; i++) {
271
+ rand -= healthy[i].weight;
272
+ if (rand <= 0) { picked = i; break; }
273
+ }
274
+ const first = healthy[picked];
275
+ const rest = healthy.filter((_, i) => i !== picked).sort((a, b) => b.weight - a.weight);
276
+ return [first, ...rest];
277
+ }
278
+
279
+ if (strategy === 'fastest') {
280
+ return healthy.slice().sort((a, b) => {
281
+ const am = getMetrics(proxyId, a.providerId).avgLatencyMs ?? Number.MAX_SAFE_INTEGER;
282
+ const bm = getMetrics(proxyId, b.providerId).avgLatencyMs ?? Number.MAX_SAFE_INTEGER;
283
+ return am - bm;
284
+ });
285
+ }
286
+
287
+ if (strategy === 'round_robin') {
288
+ const state = getRouteState(proxyId);
289
+ const start = state.rrIndex % healthy.length;
290
+ state.rrIndex = (state.rrIndex + 1) % healthy.length;
291
+ return healthy.slice(start).concat(healthy.slice(0, start));
292
+ }
293
+
294
+ return healthy;
295
+ }
296
+
297
+ function getRoutingHealth(proxyConfig) {
298
+ const proxyId = proxyConfig.id || 'default';
299
+ const target = proxyConfig.target || {};
300
+ const pool = Array.isArray(target.providerPool) && target.providerPool.length > 0
301
+ ? target.providerPool
302
+ : [];
303
+ return pool.map(item => {
304
+ const metrics = getMetrics(proxyId, item.providerId || 'primary');
305
+ return {
306
+ providerId: item.providerId || 'primary',
307
+ providerName: item.providerName || '',
308
+ successCount: metrics.successCount,
309
+ failureCount: metrics.failureCount,
310
+ avgLatencyMs: metrics.avgLatencyMs,
311
+ circuitOpenUntil: metrics.circuitOpenUntil,
312
+ available: isProviderAvailable(metrics),
313
+ };
314
+ });
315
+ }
316
+
317
+ app.use((req, res, next) => {
318
+ res.header('Access-Control-Allow-Origin', '*');
319
+ res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
320
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Api-Key');
321
+ if (req.method === 'OPTIONS') return res.sendStatus(200);
322
+ next();
323
+ });
324
+
325
+ app.use((req, res, next) => {
326
+ const proxyConfig = getProxyConfig();
327
+ if (!proxyConfig.requireAuth || !proxyConfig.authToken) return next();
328
+ const token = req.headers.authorization?.replace('Bearer ', '') || req.headers['x-api-key'];
329
+ if (token !== proxyConfig.authToken) {
330
+ return res.status(401).json({ error: 'Unauthorized' });
331
+ }
332
+ next();
333
+ });
334
+
335
+ app.post('/v1/chat/completions', handleRequest);
336
+ app.post('/v1/messages', handleRequest);
337
+ app.get('/_internal/routing-health', (req, res) => {
338
+ res.json({
339
+ proxy: getProxyConfig()?.id || null,
340
+ providers: getRoutingHealth(getProxyConfig() || {}),
341
+ });
342
+ });
343
+
344
+ async function handleRequest(req, res) {
345
+ const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
346
+ const proxyConfig = getProxyConfig();
347
+ const inboundProtocol = detectInboundProtocol(req, req.body);
348
+ const candidates = buildCandidates(proxyConfig);
349
+
350
+ if (candidates.length === 0) {
351
+ return res.status(500).json({ error: 'Proxy target not configured' });
352
+ }
353
+
354
+ const isStream = req.body?.stream === true;
355
+ const proxyId = proxyConfig.id || 'default';
356
+ const inboundModel = req.body?.model;
357
+ const effectiveModel = proxyConfig.target?.defaultModel || inboundModel;
358
+ const baseRequestBody = effectiveModel ? { ...req.body, model: effectiveModel } : { ...req.body };
359
+
360
+ // Inject cached reasoning for OpenAI inbound (OpenAI protocol lacks reasoning_content)
361
+ if (inboundProtocol === 'openai') {
362
+ injectReasoningToMessages(baseRequestBody.messages);
363
+ }
364
+
365
+ // Pre-build request templates for each protocol
366
+ const passthrough = (body, model) => ({ ...body, model: body.model || model });
367
+ const requestTemplates = {};
368
+ requestTemplates.openai = inboundProtocol === 'openai' ? passthrough(baseRequestBody, effectiveModel) :
369
+ inboundProtocol === 'anthropic' ? a2o.convertRequest(baseRequestBody, effectiveModel) :
370
+ inboundProtocol === 'gemini' ? g2o.convertRequest(baseRequestBody, effectiveModel) :
371
+ passthrough(baseRequestBody, effectiveModel);
372
+ requestTemplates.anthropic = inboundProtocol === 'anthropic' ? passthrough(baseRequestBody, effectiveModel) :
373
+ inboundProtocol === 'openai' ? o2a.convertRequest(baseRequestBody, effectiveModel) :
374
+ inboundProtocol === 'gemini' ? (() => { const r = g2a.convertRequest(baseRequestBody, effectiveModel); return { body: r, nameToId: r.nameToId }; })() :
375
+ passthrough(baseRequestBody, effectiveModel);
376
+ requestTemplates.gemini = inboundProtocol === 'gemini' ? passthrough(baseRequestBody, effectiveModel) :
377
+ inboundProtocol === 'openai' ? o2g.convertRequest(baseRequestBody, effectiveModel) :
378
+ inboundProtocol === 'anthropic' ? a2g.convertRequest(baseRequestBody, effectiveModel) :
379
+ passthrough(baseRequestBody, effectiveModel);
380
+
381
+ logger.log(`[${requestId}] ${(inboundProtocol || 'unknown').toUpperCase()} -> mixed | path=${req.path}`);
382
+
383
+ for (const candidate of candidates) {
384
+ const targetProtocol = candidate.protocol;
385
+ const isAzure = !!candidate.azureDeployment && /azure/i.test(candidate.providerUrl);
386
+
387
+ let convertRes;
388
+ let createSSEConv;
389
+ let nameToId = null;
390
+ let targetBody;
391
+
392
+ if (inboundProtocol === 'openai' && targetProtocol === 'anthropic') {
393
+ targetBody = { ...requestTemplates.anthropic };
394
+ convertRes = o2a.convertResponse;
395
+ createSSEConv = o2a.createSSEConverter;
396
+ } else if (inboundProtocol === 'anthropic' && targetProtocol === 'openai') {
397
+ targetBody = { ...requestTemplates.openai };
398
+ convertRes = a2o.convertResponse;
399
+ createSSEConv = a2o.createSSEConverter;
400
+ } else if (inboundProtocol === 'openai' && targetProtocol === 'gemini') {
401
+ targetBody = { ...requestTemplates.gemini };
402
+ convertRes = o2g.convertResponse;
403
+ createSSEConv = o2g.createSSEConverter;
404
+ } else if (inboundProtocol === 'gemini' && targetProtocol === 'openai') {
405
+ const result = g2o.convertRequest(baseRequestBody, effectiveModel);
406
+ nameToId = result.nameToId;
407
+ const { nameToId: _, ...bodyOnly } = result;
408
+ targetBody = bodyOnly;
409
+ convertRes = g2o.convertResponse;
410
+ createSSEConv = g2o.createSSEConverter;
411
+ } else if (inboundProtocol === 'anthropic' && targetProtocol === 'gemini') {
412
+ targetBody = { ...requestTemplates.gemini };
413
+ convertRes = a2g.convertResponse;
414
+ createSSEConv = a2g.createSSEConverter;
415
+ } else if (inboundProtocol === 'gemini' && targetProtocol === 'anthropic') {
416
+ const tpl = requestTemplates.anthropic;
417
+ targetBody = { ...tpl.body };
418
+ nameToId = tpl.nameToId;
419
+ convertRes = g2a.convertResponse;
420
+ createSSEConv = () => g2a.createSSEConverter(nameToId);
421
+ } else {
422
+ targetBody = { ...baseRequestBody };
423
+ convertRes = (body) => body;
424
+ createSSEConv = null;
425
+ }
426
+
427
+ // If candidate has a specific model override, apply it
428
+ if (candidate.model) {
429
+ targetBody.model = candidate.model;
430
+ }
431
+
432
+ const candidateModel = candidate.model || effectiveModel;
433
+ logger.log(`[${requestId}] -> ${candidate.providerName} (${targetProtocol}) | model=${candidateModel || '(default)'}`);
434
+
435
+ if (isStream && candidate.protocol === 'openai' && !isAzure) {
436
+ targetBody.stream_options = { include_usage: true };
437
+ }
438
+
439
+ const targetUrl = buildTargetUrl(candidate, req.path, isStream, candidateModel);
440
+ // Forward client headers (preserve anthropic-beta, user-agent, etc.)
441
+ const skipHeaders = new Set(['host', 'connection', 'content-length', 'content-type', 'accept', 'authorization', 'x-api-key', 'anthropic-version']);
442
+ const headers = {};
443
+ for (const [key, val] of Object.entries(req.headers)) {
444
+ if (!skipHeaders.has(key.toLowerCase())) headers[key] = val;
445
+ }
446
+ headers['Content-Type'] = 'application/json';
447
+ headers['Accept'] = isStream ? 'text/event-stream' : 'application/json';
448
+
449
+ const maxKeyRetries = (candidate.apiKeys || []).filter(k => typeof k === 'object' ? k.enabled !== false : true).length || 1;
450
+ let lastKeyError = null;
451
+
452
+ for (let keyAttempt = 0; keyAttempt < maxKeyRetries; keyAttempt++) {
453
+ const currentKey = selectKey(candidate.providerId, candidate.apiKeys || []);
454
+ const keyHeaders = { ...headers };
455
+
456
+ if (candidate.protocol === 'openai') {
457
+ if (isAzure) keyHeaders['api-key'] = currentKey;
458
+ else keyHeaders['Authorization'] = `Bearer ${currentKey}`;
459
+ } else if (candidate.protocol === 'gemini') {
460
+ keyHeaders['x-goog-api-key'] = currentKey;
461
+ } else if (candidate.protocol === 'anthropic') {
462
+ keyHeaders['X-Api-Key'] = currentKey;
463
+ keyHeaders['anthropic-version'] = keyHeaders['anthropic-version'] || '2023-06-01';
464
+ keyHeaders['Authorization'] = `Bearer ${currentKey}`;
465
+ }
466
+
467
+ const startedAt = Date.now();
468
+
469
+ try {
470
+ const fetchRes = await fetch(targetUrl, {
471
+ method: 'POST',
472
+ headers: keyHeaders,
473
+ body: JSON.stringify(targetBody),
474
+ signal: AbortSignal.timeout(300000),
475
+ });
476
+
477
+ if (!fetchRes.ok) {
478
+ const errBody = await fetchRes.text();
479
+ const error = Object.assign(new Error(errBody.slice(0, 500) || `HTTP ${fetchRes.status}`), { status: fetchRes.status });
480
+ // 429: mark key cooldown and retry with next key
481
+ if (fetchRes.status === 429 && maxKeyRetries > 1) {
482
+ markKeyCooldown(candidate.providerId, currentKey);
483
+ lastKeyError = error;
484
+ logger.log(`[${requestId}] 429 on key ${currentKey.slice(0, 8)}..., trying next key`);
485
+ continue;
486
+ }
487
+ if (isRetryableStatus(fetchRes.status)) {
488
+ throw error;
489
+ }
490
+ return res.status(fetchRes.status).json({ error: error.message });
491
+ }
492
+
493
+ recordSuccess(proxyId, candidate.providerId, Date.now() - startedAt);
494
+ const keyEntry = (candidate.apiKeys || []).find(k => (typeof k === 'string' ? k : k.key) === currentKey);
495
+ const alias = keyEntry && typeof keyEntry === 'object' ? keyEntry.alias : '';
496
+ const keyLabel = alias ? `${alias}(…${currentKey.slice(-4)})` : (currentKey ? `…${currentKey.slice(-4)}` : '-');
497
+ logger.log(`[${requestId}] ✓ ${candidate.providerName} | model=${candidateModel || '(default)'} key=${keyLabel} (${Date.now() - startedAt}ms)`);
498
+
499
+ if (isStream) {
500
+ res.setHeader('Content-Type', 'text/event-stream');
501
+ res.setHeader('Cache-Control', 'no-cache');
502
+ res.setHeader('Connection', 'keep-alive');
503
+
504
+ const sseConverter = createSSEConv ? createSSEConv(effectiveModel) : null;
505
+ const reader = fetchRes.body.getReader();
506
+ const decoder = new TextDecoder();
507
+ let streamUsage = null;
508
+ let responseText = '';
509
+ let toolCallCount = 0;
510
+
511
+ req.on('close', () => {
512
+ try { reader.cancel(); } catch { /* ignore */ }
513
+ });
514
+
515
+ try {
516
+ while (true) {
517
+ const { done, value } = await reader.read();
518
+ if (done) break;
519
+ const chunk = decoder.decode(value, { stream: true });
520
+ for (const line of chunk.split('\n')) {
521
+ const trimmed = line.trim();
522
+ if (!trimmed.startsWith('data:') || trimmed === 'data: [DONE]') continue;
523
+ try {
524
+ const d = JSON.parse(trimmed.slice(5).trim());
525
+ if (d.usage) streamUsage = d.usage;
526
+ const delta = d.choices?.[0]?.delta;
527
+ if (delta?.content) responseText += delta.content;
528
+ if (delta?.tool_calls) {
529
+ for (const tc of delta.tool_calls) {
530
+ if (tc.function?.name) toolCallCount++;
531
+ }
532
+ }
533
+ } catch { /* ignore */ }
534
+ }
535
+
536
+ if (sseConverter) {
537
+ const converted = sseConverter.convertChunk(chunk);
538
+ if (converted) res.write(converted);
539
+ } else {
540
+ res.write(chunk);
541
+ }
542
+ }
543
+
544
+ if (streamUsage) {
545
+ recordUsage(proxyConfig.id, candidate.providerName, candidateModel, streamUsage, false);
546
+ } else if (responseText || toolCallCount > 0) {
547
+ const inputTokens = estimateInputTokens(req.body);
548
+ const outputTokens = estimateTokens(responseText) + toolCallCount * 15;
549
+ recordUsage(proxyConfig.id, candidate.providerName, candidateModel, {
550
+ prompt_tokens: inputTokens,
551
+ completion_tokens: outputTokens,
552
+ }, true);
553
+ }
554
+
555
+ if (sseConverter) {
556
+ const flushed = sseConverter.flush();
557
+ if (flushed) res.write(flushed);
558
+ }
559
+ } catch (err) {
560
+ recordFailure(proxyId, candidate.providerId);
561
+ logger.error(`[${requestId}] Stream error:`, err.message);
562
+ if (!res.writableEnded) {
563
+ try {
564
+ res.write(`data: ${JSON.stringify({ error: { message: err.message, type: 'proxy_error' } })}\n\n`);
565
+ } catch { /* ignore */ }
566
+ }
567
+ } finally {
568
+ res.end();
569
+ }
570
+ return;
571
+ }
572
+
573
+ const responseBody = await fetchRes.json();
574
+ extractReasoningFromResponse(responseBody);
575
+ extractAnthropicThinking(responseBody);
576
+ recordUsage(proxyConfig.id, candidate.providerName, candidateModel, responseBody.usage);
577
+ const convertedBody = convertRes(responseBody);
578
+ return res.json(convertedBody);
579
+ } catch (err) {
580
+ // 429 already handled by key retry loop above
581
+ if (err?.status === 429 && maxKeyRetries > 1) {
582
+ lastKeyError = err;
583
+ continue; // retry with next key
584
+ }
585
+ recordFailure(proxyId, candidate.providerId);
586
+ logger.error(`[${requestId}] ✗ ${candidate.providerName} | model=${candidateModel || '(default)'} - ${err.message}`);
587
+ if (err?.status && !isRetryableStatus(err.status)) {
588
+ return res.status(err.status).json({ error: err.message });
589
+ }
590
+ break; // break key retry loop, continue to next candidate
591
+ }
592
+ break; // success, exit key retry loop
593
+ } // end key retry loop
594
+
595
+ // All keys exhausted with 429 — trigger circuit breaker
596
+ if (lastKeyError) {
597
+ recordFailure(proxyId, candidate.providerId);
598
+ logger.error(`[${requestId}] ✗ ${candidate.providerName} | all keys rate-limited (429)`);
599
+ }
600
+ } // end candidate loop
601
+
602
+ logger.error(`[${requestId}] 所有供应商均失败`);
603
+ return res.status(502).json({ error: 'All providers failed' });
604
+ }
605
+
606
+ return app;
607
+ }
608
+
609
+ function buildTargetUrl(target, originalPath, isStream, effectiveModel) {
610
+ const base = target.providerUrl.replace(/\/$/, '');
611
+ const hasV1Suffix = base.endsWith('/v1');
612
+
613
+ if (target.protocol === 'openai') {
614
+ if (target.azureDeployment) {
615
+ const ver = target.azureApiVersion || '2024-02-01';
616
+ return `${base}/openai/deployments/${target.azureDeployment}/chat/completions?api-version=${ver}`;
617
+ }
618
+ if (hasV1Suffix) return `${base}/chat/completions`;
619
+ return `${base}/v1/chat/completions`;
620
+ }
621
+
622
+ if (target.protocol === 'anthropic') {
623
+ if (hasV1Suffix) return `${base}/messages`;
624
+ return `${base}/v1/messages`;
625
+ }
626
+
627
+ if (target.protocol === 'gemini') {
628
+ const model = effectiveModel || 'gemini-pro';
629
+ const action = isStream ? 'streamGenerateContent?alt=sse' : 'generateContent';
630
+ return `${base}/v1beta/models/${model}:${action}`;
631
+ }
632
+
633
+ return base + originalPath;
634
+ }
635
+
636
+ module.exports = { createProxyApp };