aicodeswitch 1.4.1 → 1.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.
- package/CLAUDE.md +1 -0
- package/dist/server/version-check.js +1 -2
- package/package.json +2 -2
- package/src/server/auth.ts +79 -0
- package/src/server/database.ts +809 -0
- package/src/server/main.ts +514 -0
- package/src/server/proxy-server.ts +1301 -0
- package/src/server/transformers/chunk-collector.ts +202 -0
- package/src/server/transformers/claude-openai.ts +261 -0
- package/src/server/transformers/openai-responses.ts +440 -0
- package/src/server/transformers/streaming.ts +775 -0
- package/src/server/version-check.ts +108 -0
- package/src/types/index.ts +217 -0
- package/src/ui/App.tsx +342 -0
- package/src/ui/api/client.ts +179 -0
- package/src/ui/components/JSONViewer.tsx +89 -0
- package/src/ui/constants/index.ts +4 -0
- package/src/ui/docs/vendors-recommand.md +13 -0
- package/src/ui/main.tsx +10 -0
- package/src/ui/pages/LogsPage.tsx +702 -0
- package/src/ui/pages/RoutesPage.tsx +552 -0
- package/src/ui/pages/SettingsPage.tsx +206 -0
- package/src/ui/pages/StatisticsPage.tsx +620 -0
- package/src/ui/pages/UsagePage.tsx +13 -0
- package/src/ui/pages/VendorsPage.tsx +490 -0
- package/src/ui/pages/WriteConfigPage.tsx +198 -0
- package/src/ui/styles/App.css +831 -0
- package/src/ui/styles/index.css +137 -0
|
@@ -0,0 +1,1301 @@
|
|
|
1
|
+
import express, { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import axios, { AxiosRequestConfig } from 'axios';
|
|
3
|
+
import { pipeline } from 'stream';
|
|
4
|
+
import { DatabaseManager } from './database';
|
|
5
|
+
import {
|
|
6
|
+
ClaudeToOpenAIResponsesEventTransform,
|
|
7
|
+
OpenAIResponsesToClaudeEventTransform,
|
|
8
|
+
OpenAIToClaudeEventTransform,
|
|
9
|
+
SSEParserTransform,
|
|
10
|
+
SSESerializerTransform,
|
|
11
|
+
} from './transformers/streaming';
|
|
12
|
+
import { SSEEventCollectorTransform } from './transformers/chunk-collector';
|
|
13
|
+
import {
|
|
14
|
+
extractTokenUsageFromClaudeUsage,
|
|
15
|
+
extractTokenUsageFromOpenAIUsage,
|
|
16
|
+
transformClaudeRequestToOpenAIChat,
|
|
17
|
+
transformOpenAIChatResponseToClaude,
|
|
18
|
+
} from './transformers/claude-openai';
|
|
19
|
+
import {
|
|
20
|
+
extractTokenUsageFromOpenAIResponsesUsage,
|
|
21
|
+
transformClaudeRequestToOpenAIResponses,
|
|
22
|
+
transformClaudeResponseToOpenAIResponses,
|
|
23
|
+
transformOpenAIResponsesRequestToClaude,
|
|
24
|
+
transformOpenAIResponsesRequestToOpenAIChat,
|
|
25
|
+
transformOpenAIResponsesToClaude,
|
|
26
|
+
} from './transformers/openai-responses';
|
|
27
|
+
import type { AppConfig, Rule, APIService, Route, SourceType, TokenUsage, ContentType } from '../types';
|
|
28
|
+
|
|
29
|
+
type ContentTypeDetector = {
|
|
30
|
+
type: ContentType;
|
|
31
|
+
match: (req: Request, body: any) => boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const SUPPORTED_TARGETS = ['claude-code', 'codex'];
|
|
35
|
+
|
|
36
|
+
export class ProxyServer {
|
|
37
|
+
private app: express.Application;
|
|
38
|
+
private dbManager: DatabaseManager;
|
|
39
|
+
private routes: Route[] = [];
|
|
40
|
+
private rules: Map<string, Rule[]> = new Map();
|
|
41
|
+
private services: Map<string, APIService> = new Map();
|
|
42
|
+
private config: AppConfig;
|
|
43
|
+
|
|
44
|
+
constructor(dbManager: DatabaseManager, app: express.Application) {
|
|
45
|
+
this.dbManager = dbManager;
|
|
46
|
+
this.config = dbManager.getConfig();
|
|
47
|
+
this.app = app;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private setupMiddleware() {
|
|
51
|
+
// Access logging middleware
|
|
52
|
+
this.app.use(async (req: Request, res: Response, next: NextFunction) => {
|
|
53
|
+
// Capture client info
|
|
54
|
+
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0] || req.socket.remoteAddress || '';
|
|
55
|
+
const userAgent = req.headers['user-agent'] || '';
|
|
56
|
+
|
|
57
|
+
const startTime = Date.now();
|
|
58
|
+
const originalSend = res.send.bind(res);
|
|
59
|
+
const originalJson = res.json.bind(res);
|
|
60
|
+
|
|
61
|
+
const accessLog = this.dbManager.addAccessLog({
|
|
62
|
+
timestamp: Date.now(),
|
|
63
|
+
method: req.method,
|
|
64
|
+
path: req.path,
|
|
65
|
+
clientIp,
|
|
66
|
+
userAgent,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
res.send = (data: any) => {
|
|
70
|
+
res.send = originalSend;
|
|
71
|
+
const responseTime = Date.now() - startTime;
|
|
72
|
+
accessLog.then((accessLogId) => {
|
|
73
|
+
this.dbManager.updateAccessLog(accessLogId, {
|
|
74
|
+
responseTime,
|
|
75
|
+
statusCode: res.statusCode,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
return originalSend(data);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
res.json = (data: any) => {
|
|
82
|
+
res.json = originalJson;
|
|
83
|
+
const responseTime = Date.now() - startTime;
|
|
84
|
+
accessLog.then((accessLogId) => {
|
|
85
|
+
this.dbManager.updateAccessLog(accessLogId, {
|
|
86
|
+
responseTime,
|
|
87
|
+
statusCode: res.statusCode,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
return originalJson(data);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
res.on('error', (err) => {
|
|
94
|
+
accessLog.then((accessLogId) => {
|
|
95
|
+
this.dbManager.updateAccessLog(accessLogId, {
|
|
96
|
+
statusCode: res.statusCode,
|
|
97
|
+
error: err.message,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
next();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Logging middleware (legacy RequestLog)
|
|
106
|
+
this.app.use(async (req: Request, res: Response, next: NextFunction) => {
|
|
107
|
+
const startTime = Date.now();
|
|
108
|
+
const originalSend = res.send.bind(res);
|
|
109
|
+
|
|
110
|
+
if (SUPPORTED_TARGETS.some(target => req.path.startsWith(`/${target}/`))) {
|
|
111
|
+
res.send = (data: any) => {
|
|
112
|
+
res.send = originalSend;
|
|
113
|
+
if (!res.locals.skipLog && this.config?.enableLogging && SUPPORTED_TARGETS.some(target => req.path.startsWith(`/${target}/`))) {
|
|
114
|
+
const responseTime = Date.now() - startTime;
|
|
115
|
+
this.dbManager.addLog({
|
|
116
|
+
timestamp: Date.now(),
|
|
117
|
+
method: req.method,
|
|
118
|
+
path: req.path,
|
|
119
|
+
headers: this.normalizeHeaders(req.headers),
|
|
120
|
+
body: req.body ? JSON.stringify(req.body) : undefined,
|
|
121
|
+
statusCode: res.statusCode,
|
|
122
|
+
responseTime,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return res.send(data);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
next();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Fixed route handlers
|
|
134
|
+
this.app.use('/claude-code/', this.createFixedRouteHandler('claude-code'));
|
|
135
|
+
this.app.use('/claude-code', this.createFixedRouteHandler('claude-code'));
|
|
136
|
+
this.app.use('/codex/', this.createFixedRouteHandler('codex'));
|
|
137
|
+
this.app.use('/codex', this.createFixedRouteHandler('codex'));
|
|
138
|
+
|
|
139
|
+
// Dynamic proxy middleware
|
|
140
|
+
this.app.use(async (req: Request, res: Response, next: NextFunction) => {
|
|
141
|
+
// 根路径 / 不应该被代理中间件处理,应该传递给静态文件服务
|
|
142
|
+
if (req.path === '/') {
|
|
143
|
+
return next();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const route = this.findMatchingRoute(req);
|
|
148
|
+
if (!route) {
|
|
149
|
+
return res.status(404).json({ error: 'No matching route found' });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 检查是否启用故障切换
|
|
153
|
+
const enableFailover = this.config?.enableFailover !== false; // 默认为 true
|
|
154
|
+
|
|
155
|
+
if (!enableFailover) {
|
|
156
|
+
// 故障切换已禁用,使用传统的单一规则匹配
|
|
157
|
+
const rule = await this.findMatchingRule(route.id, req);
|
|
158
|
+
if (!rule) {
|
|
159
|
+
return res.status(404).json({ error: 'No matching rule found' });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const service = this.services.get(rule.targetServiceId);
|
|
163
|
+
if (!service) {
|
|
164
|
+
return res.status(500).json({ error: 'Target service not configured' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await this.proxyRequest(req, res, route, rule, service);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 启用故障切换:获取所有候选规则
|
|
172
|
+
const allRules = this.getAllMatchingRules(route.id, req);
|
|
173
|
+
if (allRules.length === 0) {
|
|
174
|
+
return res.status(404).json({ error: 'No matching rule found' });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 尝试每个规则,直到成功或全部失败
|
|
178
|
+
let lastError: Error | null = null;
|
|
179
|
+
|
|
180
|
+
for (const rule of allRules) {
|
|
181
|
+
const service = this.services.get(rule.targetServiceId);
|
|
182
|
+
if (!service) continue;
|
|
183
|
+
|
|
184
|
+
// 检查黑名单
|
|
185
|
+
const isBlacklisted = await this.dbManager.isServiceBlacklisted(
|
|
186
|
+
service.id,
|
|
187
|
+
route.id,
|
|
188
|
+
rule.contentType
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (isBlacklisted) {
|
|
192
|
+
console.log(`Service ${service.name} is blacklisted, skipping...`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
// 尝试代理请求
|
|
198
|
+
await this.proxyRequest(req, res, route, rule, service);
|
|
199
|
+
return; // 成功,直接返回
|
|
200
|
+
} catch (error: any) {
|
|
201
|
+
console.error(`Service ${service.name} failed:`, error.message);
|
|
202
|
+
lastError = error;
|
|
203
|
+
|
|
204
|
+
// 判断是否应该加入黑名单 (4xx + 5xx)
|
|
205
|
+
const statusCode = error.response?.status || 500;
|
|
206
|
+
if (statusCode >= 400) {
|
|
207
|
+
await this.dbManager.addToBlacklist(
|
|
208
|
+
service.id,
|
|
209
|
+
route.id,
|
|
210
|
+
rule.contentType,
|
|
211
|
+
error.message,
|
|
212
|
+
statusCode
|
|
213
|
+
);
|
|
214
|
+
console.log(
|
|
215
|
+
`Service ${service.name} added to blacklist (${route.id}:${rule.contentType}:${service.id})`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 继续尝试下一个服务
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 所有服务都失败了
|
|
225
|
+
console.error('All services failed');
|
|
226
|
+
|
|
227
|
+
// 记录日志
|
|
228
|
+
if (this.config?.enableLogging && SUPPORTED_TARGETS.some(target => req.path.startsWith(`/${target}/`))) {
|
|
229
|
+
await this.dbManager.addLog({
|
|
230
|
+
timestamp: Date.now(),
|
|
231
|
+
method: req.method,
|
|
232
|
+
path: req.path,
|
|
233
|
+
headers: this.normalizeHeaders(req.headers),
|
|
234
|
+
body: req.body ? JSON.stringify(req.body) : undefined,
|
|
235
|
+
error: lastError?.message || 'All services failed',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 记录错误日志
|
|
240
|
+
await this.dbManager.addErrorLog({
|
|
241
|
+
timestamp: Date.now(),
|
|
242
|
+
method: req.method,
|
|
243
|
+
path: req.path,
|
|
244
|
+
statusCode: 503,
|
|
245
|
+
errorMessage: 'All services failed',
|
|
246
|
+
errorStack: lastError?.stack,
|
|
247
|
+
requestHeaders: this.normalizeHeaders(req.headers),
|
|
248
|
+
requestBody: req.body ? JSON.stringify(req.body) : undefined,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
res.status(503).json({
|
|
252
|
+
error: 'All services failed',
|
|
253
|
+
details: lastError?.message
|
|
254
|
+
});
|
|
255
|
+
} catch (error: any) {
|
|
256
|
+
console.error('Proxy error:', error);
|
|
257
|
+
if (this.config?.enableLogging && SUPPORTED_TARGETS.some(target => req.path.startsWith(`/${target}/`))) {
|
|
258
|
+
await this.dbManager.addLog({
|
|
259
|
+
timestamp: Date.now(),
|
|
260
|
+
method: req.method,
|
|
261
|
+
path: req.path,
|
|
262
|
+
headers: this.normalizeHeaders(req.headers),
|
|
263
|
+
body: req.body ? JSON.stringify(req.body) : undefined,
|
|
264
|
+
error: error.message,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// Add error log
|
|
268
|
+
await this.dbManager.addErrorLog({
|
|
269
|
+
timestamp: Date.now(),
|
|
270
|
+
method: req.method,
|
|
271
|
+
path: req.path,
|
|
272
|
+
statusCode: 500,
|
|
273
|
+
errorMessage: error.message,
|
|
274
|
+
errorStack: error.stack,
|
|
275
|
+
requestHeaders: this.normalizeHeaders(req.headers),
|
|
276
|
+
requestBody: req.body ? JSON.stringify(req.body) : undefined,
|
|
277
|
+
});
|
|
278
|
+
res.status(500).json({ error: error.message });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private createFixedRouteHandler(targetType: 'claude-code' | 'codex') {
|
|
284
|
+
return async (req: Request, res: Response) => {
|
|
285
|
+
try {
|
|
286
|
+
// 检查API Key验证
|
|
287
|
+
if (this.config.apiKey) {
|
|
288
|
+
const authHeader = req.headers.authorization;
|
|
289
|
+
const providedKey = authHeader?.replace('Bearer ', '');
|
|
290
|
+
if (!providedKey || providedKey !== this.config.apiKey) {
|
|
291
|
+
return res.status(401).json({ error: 'Invalid API key' });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const route = this.findRouteByTargetType(targetType);
|
|
296
|
+
if (!route) {
|
|
297
|
+
return res.status(404).json({ error: `No active route found for target type: ${targetType}` });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 检查是否启用故障切换
|
|
301
|
+
const enableFailover = this.config?.enableFailover !== false; // 默认为 true
|
|
302
|
+
|
|
303
|
+
if (!enableFailover) {
|
|
304
|
+
// 故障切换已禁用,使用传统的单一规则匹配
|
|
305
|
+
const rule = await this.findMatchingRule(route.id, req);
|
|
306
|
+
if (!rule) {
|
|
307
|
+
return res.status(404).json({ error: 'No matching rule found' });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const service = this.services.get(rule.targetServiceId);
|
|
311
|
+
if (!service) {
|
|
312
|
+
return res.status(500).json({ error: 'Target service not configured' });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await this.proxyRequest(req, res, route, rule, service);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 启用故障切换:获取所有候选规则
|
|
320
|
+
const allRules = this.getAllMatchingRules(route.id, req);
|
|
321
|
+
if (allRules.length === 0) {
|
|
322
|
+
return res.status(404).json({ error: 'No matching rule found' });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 尝试每个规则,直到成功或全部失败
|
|
326
|
+
let lastError: Error | null = null;
|
|
327
|
+
|
|
328
|
+
for (const rule of allRules) {
|
|
329
|
+
const service = this.services.get(rule.targetServiceId);
|
|
330
|
+
if (!service) continue;
|
|
331
|
+
|
|
332
|
+
// 检查黑名单
|
|
333
|
+
const isBlacklisted = await this.dbManager.isServiceBlacklisted(
|
|
334
|
+
service.id,
|
|
335
|
+
route.id,
|
|
336
|
+
rule.contentType
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
if (isBlacklisted) {
|
|
340
|
+
console.log(`Service ${service.name} is blacklisted, skipping...`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
// 尝试代理请求
|
|
346
|
+
await this.proxyRequest(req, res, route, rule, service);
|
|
347
|
+
return; // 成功,直接返回
|
|
348
|
+
} catch (error: any) {
|
|
349
|
+
console.error(`Service ${service.name} failed:`, error.message);
|
|
350
|
+
lastError = error;
|
|
351
|
+
|
|
352
|
+
// 判断是否应该加入黑名单 (4xx + 5xx)
|
|
353
|
+
const statusCode = error.response?.status || 500;
|
|
354
|
+
if (statusCode >= 400) {
|
|
355
|
+
await this.dbManager.addToBlacklist(
|
|
356
|
+
service.id,
|
|
357
|
+
route.id,
|
|
358
|
+
rule.contentType,
|
|
359
|
+
error.message,
|
|
360
|
+
statusCode
|
|
361
|
+
);
|
|
362
|
+
console.log(
|
|
363
|
+
`Service ${service.name} added to blacklist (${route.id}:${rule.contentType}:${service.id})`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 继续尝试下一个服务
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 所有服务都失败了
|
|
373
|
+
console.error('All services failed');
|
|
374
|
+
|
|
375
|
+
// 记录日志
|
|
376
|
+
if (this.config?.enableLogging && SUPPORTED_TARGETS.some(target => req.path.startsWith(`/${target}/`))) {
|
|
377
|
+
await this.dbManager.addLog({
|
|
378
|
+
timestamp: Date.now(),
|
|
379
|
+
method: req.method,
|
|
380
|
+
path: req.path,
|
|
381
|
+
headers: this.normalizeHeaders(req.headers),
|
|
382
|
+
body: req.body ? JSON.stringify(req.body) : undefined,
|
|
383
|
+
error: lastError?.message || 'All services failed',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 记录错误日志
|
|
388
|
+
await this.dbManager.addErrorLog({
|
|
389
|
+
timestamp: Date.now(),
|
|
390
|
+
method: req.method,
|
|
391
|
+
path: req.path,
|
|
392
|
+
statusCode: 503,
|
|
393
|
+
errorMessage: 'All services failed',
|
|
394
|
+
errorStack: lastError?.stack,
|
|
395
|
+
requestHeaders: this.normalizeHeaders(req.headers),
|
|
396
|
+
requestBody: req.body ? JSON.stringify(req.body) : undefined,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
res.status(503).json({
|
|
400
|
+
error: 'All services failed',
|
|
401
|
+
details: lastError?.message
|
|
402
|
+
});
|
|
403
|
+
} catch (error: any) {
|
|
404
|
+
console.error(`Fixed route error for ${targetType}:`, error);
|
|
405
|
+
if (this.config?.enableLogging && SUPPORTED_TARGETS.some(target => req.path.startsWith(`/${target}/`))) {
|
|
406
|
+
await this.dbManager.addLog({
|
|
407
|
+
timestamp: Date.now(),
|
|
408
|
+
method: req.method,
|
|
409
|
+
path: req.path,
|
|
410
|
+
headers: this.normalizeHeaders(req.headers),
|
|
411
|
+
body: req.body ? JSON.stringify(req.body) : undefined,
|
|
412
|
+
error: error.message,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
// Add error log
|
|
416
|
+
await this.dbManager.addErrorLog({
|
|
417
|
+
timestamp: Date.now(),
|
|
418
|
+
method: req.method,
|
|
419
|
+
path: req.path,
|
|
420
|
+
statusCode: 500,
|
|
421
|
+
errorMessage: error.message,
|
|
422
|
+
errorStack: error.stack,
|
|
423
|
+
requestHeaders: this.normalizeHeaders(req.headers),
|
|
424
|
+
requestBody: req.body ? JSON.stringify(req.body) : undefined,
|
|
425
|
+
});
|
|
426
|
+
res.status(500).json({ error: error.message });
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private findMatchingRoute(_req: Request): Route | undefined {
|
|
432
|
+
// Find active route based on targetType - for now, return the first active route
|
|
433
|
+
// This can be extended later based on specific routing logic
|
|
434
|
+
return this.routes.find(route => route.isActive);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private findRouteByTargetType(targetType: 'claude-code' | 'codex'): Route | undefined {
|
|
438
|
+
return this.routes.find(route => route.targetType === targetType && route.isActive);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private async findMatchingRule(routeId: string, req: Request): Promise<Rule | undefined> {
|
|
442
|
+
const rules = this.rules.get(routeId);
|
|
443
|
+
if (!rules) return undefined;
|
|
444
|
+
|
|
445
|
+
const body = req.body;
|
|
446
|
+
const requestModel = body?.model;
|
|
447
|
+
|
|
448
|
+
// 1. 首先查找 model-mapping 类型的规则,按 sortOrder 降序匹配
|
|
449
|
+
if (requestModel) {
|
|
450
|
+
const modelMappingRules = rules.filter(rule =>
|
|
451
|
+
rule.contentType === 'model-mapping' &&
|
|
452
|
+
rule.replacedModel &&
|
|
453
|
+
requestModel.includes(rule.replacedModel)
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// 过滤黑名单
|
|
457
|
+
for (const rule of modelMappingRules) {
|
|
458
|
+
const isBlacklisted = await this.dbManager.isServiceBlacklisted(
|
|
459
|
+
rule.targetServiceId,
|
|
460
|
+
routeId,
|
|
461
|
+
rule.contentType
|
|
462
|
+
);
|
|
463
|
+
if (!isBlacklisted) {
|
|
464
|
+
return rule;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// 2. 查找其他内容类型的规则
|
|
470
|
+
const contentType = this.determineContentType(req);
|
|
471
|
+
const contentTypeRules = rules.filter(rule => rule.contentType === contentType);
|
|
472
|
+
|
|
473
|
+
// 过滤黑名单
|
|
474
|
+
for (const rule of contentTypeRules) {
|
|
475
|
+
const isBlacklisted = await this.dbManager.isServiceBlacklisted(
|
|
476
|
+
rule.targetServiceId,
|
|
477
|
+
routeId,
|
|
478
|
+
contentType
|
|
479
|
+
);
|
|
480
|
+
if (!isBlacklisted) {
|
|
481
|
+
return rule;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// 3. 最后返回 default 规则
|
|
486
|
+
const defaultRules = rules.filter(rule => rule.contentType === 'default');
|
|
487
|
+
|
|
488
|
+
// 过滤黑名单
|
|
489
|
+
for (const rule of defaultRules) {
|
|
490
|
+
const isBlacklisted = await this.dbManager.isServiceBlacklisted(
|
|
491
|
+
rule.targetServiceId,
|
|
492
|
+
routeId,
|
|
493
|
+
'default'
|
|
494
|
+
);
|
|
495
|
+
if (!isBlacklisted) {
|
|
496
|
+
return rule;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private getAllMatchingRules(routeId: string, req: Request): Rule[] {
|
|
504
|
+
const rules = this.rules.get(routeId);
|
|
505
|
+
if (!rules) return [];
|
|
506
|
+
|
|
507
|
+
const body = req.body;
|
|
508
|
+
const requestModel = body?.model;
|
|
509
|
+
const candidates: Rule[] = [];
|
|
510
|
+
|
|
511
|
+
// 1. Model mapping rules
|
|
512
|
+
if (requestModel) {
|
|
513
|
+
const modelMappingRules = rules.filter(rule =>
|
|
514
|
+
rule.contentType === 'model-mapping' &&
|
|
515
|
+
rule.replacedModel &&
|
|
516
|
+
requestModel.includes(rule.replacedModel)
|
|
517
|
+
);
|
|
518
|
+
candidates.push(...modelMappingRules);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// 2. Content type specific rules
|
|
522
|
+
const contentType = this.determineContentType(req);
|
|
523
|
+
const contentTypeRules = rules.filter(rule => rule.contentType === contentType);
|
|
524
|
+
candidates.push(...contentTypeRules);
|
|
525
|
+
|
|
526
|
+
// 3. Default rules
|
|
527
|
+
const defaultRules = rules.filter(rule => rule.contentType === 'default');
|
|
528
|
+
candidates.push(...defaultRules);
|
|
529
|
+
|
|
530
|
+
return candidates;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private determineContentType(req: Request): ContentType {
|
|
534
|
+
const body = req.body;
|
|
535
|
+
if (!body) return 'default';
|
|
536
|
+
|
|
537
|
+
const explicitType = this.getExplicitContentType(req, body);
|
|
538
|
+
if (explicitType) {
|
|
539
|
+
return explicitType;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
for (const detector of this.getContentTypeDetectors()) {
|
|
543
|
+
if (detector.match(req, body)) {
|
|
544
|
+
return detector.type;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return 'default';
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private getContentTypeDetectors(): ContentTypeDetector[] {
|
|
552
|
+
return [
|
|
553
|
+
{
|
|
554
|
+
type: 'image-understanding',
|
|
555
|
+
match: (_req, body) => this.containsImageContent(body.messages) || this.containsImageContent(body.input),
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
type: 'thinking',
|
|
559
|
+
match: (_req, body) => this.hasThinkingSignal(body),
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
type: 'long-context',
|
|
563
|
+
match: (_req, body) => this.hasLongContextSignal(body),
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
type: 'background',
|
|
567
|
+
match: (_req, body) => this.hasBackgroundSignal(body),
|
|
568
|
+
},
|
|
569
|
+
];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private getExplicitContentType(req: Request, body: any): ContentType | null {
|
|
573
|
+
const headerKeys = ['x-aicodeswitch-content-type', 'x-content-type', 'x-request-type', 'x-object-type'];
|
|
574
|
+
const queryKeys = ['contentType', 'content_type', 'requestType', 'request_type', 'objectType', 'object_type'];
|
|
575
|
+
const bodyKeys = ['contentType', 'content_type', 'requestType', 'request_type', 'objectType', 'object_type', 'mode'];
|
|
576
|
+
|
|
577
|
+
for (const key of headerKeys) {
|
|
578
|
+
const raw = req.headers[key];
|
|
579
|
+
if (typeof raw === 'string') {
|
|
580
|
+
const normalized = this.normalizeContentType(raw);
|
|
581
|
+
if (normalized) return normalized;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
for (const key of queryKeys) {
|
|
586
|
+
const raw = req.query[key];
|
|
587
|
+
if (typeof raw === 'string') {
|
|
588
|
+
const normalized = this.normalizeContentType(raw);
|
|
589
|
+
if (normalized) return normalized;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
for (const key of bodyKeys) {
|
|
594
|
+
const raw = body?.[key];
|
|
595
|
+
if (typeof raw === 'string') {
|
|
596
|
+
const normalized = this.normalizeContentType(raw);
|
|
597
|
+
if (normalized) return normalized;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const metaCandidates = [
|
|
602
|
+
body?.metadata?.contentType,
|
|
603
|
+
body?.metadata?.content_type,
|
|
604
|
+
body?.metadata?.requestType,
|
|
605
|
+
body?.metadata?.request_type,
|
|
606
|
+
body?.meta?.contentType,
|
|
607
|
+
body?.meta?.content_type,
|
|
608
|
+
];
|
|
609
|
+
|
|
610
|
+
for (const raw of metaCandidates) {
|
|
611
|
+
if (typeof raw === 'string') {
|
|
612
|
+
const normalized = this.normalizeContentType(raw);
|
|
613
|
+
if (normalized) return normalized;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private normalizeContentType(raw: string): ContentType | null {
|
|
621
|
+
const normalized = raw.trim().toLowerCase();
|
|
622
|
+
const mapping: Record<string, ContentType> = {
|
|
623
|
+
default: 'default',
|
|
624
|
+
background: 'background',
|
|
625
|
+
bg: 'background',
|
|
626
|
+
thinking: 'thinking',
|
|
627
|
+
reasoning: 'thinking',
|
|
628
|
+
'long-context': 'long-context',
|
|
629
|
+
long_context: 'long-context',
|
|
630
|
+
long: 'long-context',
|
|
631
|
+
image: 'image-understanding',
|
|
632
|
+
image_understanding: 'image-understanding',
|
|
633
|
+
'image-understanding': 'image-understanding',
|
|
634
|
+
vision: 'image-understanding',
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
return mapping[normalized] || null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private containsImageContent(payload: any): boolean {
|
|
641
|
+
if (!payload) return false;
|
|
642
|
+
const messages = Array.isArray(payload) ? payload : [payload];
|
|
643
|
+
for (const message of messages) {
|
|
644
|
+
const content = message?.content ?? message;
|
|
645
|
+
if (Array.isArray(content)) {
|
|
646
|
+
for (const block of content) {
|
|
647
|
+
if (!block || typeof block !== 'object') continue;
|
|
648
|
+
const type = (block as any).type;
|
|
649
|
+
if (type === 'image' || type === 'image_url' || type === 'input_image') {
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
if ((block as any).image_url) {
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private hasThinkingSignal(body: any): boolean {
|
|
662
|
+
return Boolean(
|
|
663
|
+
body?.reasoning ||
|
|
664
|
+
body?.thinking ||
|
|
665
|
+
body?.reasoning_effort ||
|
|
666
|
+
body?.reasoning?.effort ||
|
|
667
|
+
body?.reasoning?.enabled
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private hasBackgroundSignal(body: any): boolean {
|
|
672
|
+
const candidates = [
|
|
673
|
+
body?.background,
|
|
674
|
+
body?.metadata?.background,
|
|
675
|
+
body?.meta?.background,
|
|
676
|
+
body?.priority,
|
|
677
|
+
body?.metadata?.priority,
|
|
678
|
+
body?.mode,
|
|
679
|
+
];
|
|
680
|
+
return candidates.some((value) => value === true || value === 'background');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private hasLongContextSignal(body: any): boolean {
|
|
684
|
+
const explicit = [
|
|
685
|
+
body?.long_context,
|
|
686
|
+
body?.longContext,
|
|
687
|
+
body?.metadata?.long_context,
|
|
688
|
+
body?.metadata?.longContext,
|
|
689
|
+
];
|
|
690
|
+
if (explicit.some((value) => value === true)) {
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const maxTokens = this.extractNumericField(body, [
|
|
695
|
+
'max_tokens',
|
|
696
|
+
'max_output_tokens',
|
|
697
|
+
'max_completion_tokens',
|
|
698
|
+
'max_context_tokens',
|
|
699
|
+
]);
|
|
700
|
+
if (maxTokens !== null && maxTokens >= 8000) {
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const contentLength = this.estimateTextLength(body);
|
|
705
|
+
return contentLength >= 12000;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private extractNumericField(body: any, fields: string[]): number | null {
|
|
709
|
+
for (const field of fields) {
|
|
710
|
+
const value = body?.[field];
|
|
711
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
712
|
+
return value;
|
|
713
|
+
}
|
|
714
|
+
if (typeof value === 'string') {
|
|
715
|
+
const parsed = Number(value);
|
|
716
|
+
if (Number.isFinite(parsed)) {
|
|
717
|
+
return parsed;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private estimateTextLength(body: any): number {
|
|
725
|
+
let length = 0;
|
|
726
|
+
const addText = (value?: string | null) => {
|
|
727
|
+
if (typeof value === 'string') {
|
|
728
|
+
length += value.length;
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
const addContent = (content: any) => {
|
|
732
|
+
if (typeof content === 'string' || content === null) {
|
|
733
|
+
addText(content);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (Array.isArray(content)) {
|
|
737
|
+
for (const part of content) {
|
|
738
|
+
if (typeof part === 'string') {
|
|
739
|
+
addText(part);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (part && typeof part === 'object') {
|
|
743
|
+
if (typeof part.text === 'string') {
|
|
744
|
+
addText(part.text);
|
|
745
|
+
}
|
|
746
|
+
if (typeof part.content === 'string') {
|
|
747
|
+
addText(part.content);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
if (Array.isArray(body?.messages)) {
|
|
755
|
+
for (const message of body.messages) {
|
|
756
|
+
addContent(message?.content);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (body?.input) {
|
|
761
|
+
if (typeof body.input === 'string') {
|
|
762
|
+
addText(body.input);
|
|
763
|
+
} else if (Array.isArray(body.input)) {
|
|
764
|
+
for (const message of body.input) {
|
|
765
|
+
if (typeof message === 'string') {
|
|
766
|
+
addText(message);
|
|
767
|
+
} else if (message && typeof message === 'object') {
|
|
768
|
+
addContent(message.content ?? message);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
} else if (body.input && typeof body.input === 'object') {
|
|
772
|
+
addContent(body.input.content ?? body.input);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
addContent(body?.system);
|
|
777
|
+
addText(body?.instructions);
|
|
778
|
+
addText(body?.prompt);
|
|
779
|
+
|
|
780
|
+
return length;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private isClaudeSource(sourceType: SourceType) {
|
|
784
|
+
return sourceType === 'claude-chat' || sourceType === 'claude-code';
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
private isOpenAIChatSource(sourceType: SourceType) {
|
|
788
|
+
return sourceType === 'openai-chat' || sourceType === 'openai-code' || sourceType === 'deepseek-chat';
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private isOpenAIResponsesSource(sourceType: SourceType) {
|
|
792
|
+
return sourceType === 'openai-responses';
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private applyModelOverride(body: any, rule: Rule) {
|
|
796
|
+
if (!rule.targetModel) return body;
|
|
797
|
+
if (body && typeof body === 'object') {
|
|
798
|
+
return { ...body, model: rule.targetModel };
|
|
799
|
+
}
|
|
800
|
+
return body;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private isStreamRequested(req: Request, body: any) {
|
|
804
|
+
const accept = typeof req.headers.accept === 'string' ? req.headers.accept : '';
|
|
805
|
+
return body?.stream === true || accept.includes('text/event-stream');
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private buildUpstreamHeaders(req: Request, service: APIService, sourceType: SourceType, streamRequested: boolean) {
|
|
809
|
+
const headers: Record<string, string> = {};
|
|
810
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
811
|
+
if (['host', 'connection', 'content-length', 'authorization'].includes(key.toLowerCase())) {
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if (typeof value === 'string') {
|
|
815
|
+
headers[key] = value;
|
|
816
|
+
} else if (Array.isArray(value)) {
|
|
817
|
+
headers[key] = value.join(', ');
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (streamRequested) {
|
|
822
|
+
headers.accept = 'text/event-stream';
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (this.isClaudeSource(sourceType)) {
|
|
826
|
+
headers['x-api-key'] = service.apiKey;
|
|
827
|
+
headers['anthropic-version'] = headers['anthropic-version'] || '2023-06-01';
|
|
828
|
+
} else {
|
|
829
|
+
delete headers['anthropic-version'];
|
|
830
|
+
delete headers['anthropic-beta'];
|
|
831
|
+
headers.authorization = `Bearer ${service.apiKey}`;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (!headers['content-type']) {
|
|
835
|
+
headers['content-type'] = 'application/json';
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return headers;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private copyResponseHeaders(responseHeaders: Record<string, any>, res: Response) {
|
|
842
|
+
Object.keys(responseHeaders).forEach((key) => {
|
|
843
|
+
if (!['content-encoding', 'transfer-encoding', 'connection', 'content-length'].includes(key.toLowerCase())) {
|
|
844
|
+
res.setHeader(key, responseHeaders[key]);
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
private normalizeHeaders(headers: Request['headers']) {
|
|
850
|
+
const normalized: Record<string, string> = {};
|
|
851
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
852
|
+
if (typeof value === 'string') {
|
|
853
|
+
normalized[key] = value;
|
|
854
|
+
} else if (Array.isArray(value)) {
|
|
855
|
+
normalized[key] = value.join(', ');
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return normalized;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private normalizeResponseHeaders(headers: Record<string, any>): Record<string, string> {
|
|
862
|
+
const normalized: Record<string, string> = {};
|
|
863
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
864
|
+
if (value !== null && value !== undefined) {
|
|
865
|
+
if (typeof value === 'string') {
|
|
866
|
+
normalized[key] = value;
|
|
867
|
+
} else if (Array.isArray(value)) {
|
|
868
|
+
normalized[key] = value.join(', ');
|
|
869
|
+
} else {
|
|
870
|
+
normalized[key] = String(value);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return normalized;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
private async readStreamBody(stream: NodeJS.ReadableStream): Promise<string> {
|
|
878
|
+
return new Promise((resolve, reject) => {
|
|
879
|
+
let data = '';
|
|
880
|
+
stream.on('data', (chunk) => {
|
|
881
|
+
data += chunk.toString();
|
|
882
|
+
});
|
|
883
|
+
stream.on('end', () => resolve(data));
|
|
884
|
+
stream.on('error', reject);
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private safeJsonParse(raw: string) {
|
|
889
|
+
try {
|
|
890
|
+
return JSON.parse(raw);
|
|
891
|
+
} catch {
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private extractTokenUsage(usage: any): TokenUsage | undefined {
|
|
897
|
+
if (!usage) return undefined;
|
|
898
|
+
if (typeof usage.input_tokens === 'number' && typeof usage.output_tokens === 'number' && usage.prompt_tokens === undefined) {
|
|
899
|
+
return extractTokenUsageFromOpenAIResponsesUsage(usage);
|
|
900
|
+
}
|
|
901
|
+
if (typeof usage.prompt_tokens === 'number' || typeof usage.completion_tokens === 'number') {
|
|
902
|
+
return extractTokenUsageFromOpenAIUsage(usage);
|
|
903
|
+
}
|
|
904
|
+
if (typeof usage.input_tokens === 'number' || typeof usage.output_tokens === 'number') {
|
|
905
|
+
return extractTokenUsageFromClaudeUsage(usage);
|
|
906
|
+
}
|
|
907
|
+
return undefined;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private async proxyRequest(req: Request, res: Response, route: Route, rule: Rule, service: APIService) {
|
|
911
|
+
res.locals.skipLog = true;
|
|
912
|
+
const startTime = Date.now();
|
|
913
|
+
const sourceType = (service.sourceType || 'openai-chat') as SourceType;
|
|
914
|
+
const targetType = route.targetType;
|
|
915
|
+
let requestBody: any = req.body || {};
|
|
916
|
+
let usageForLog: TokenUsage | undefined;
|
|
917
|
+
let logged = false;
|
|
918
|
+
|
|
919
|
+
// 用于收集响应数据的变量
|
|
920
|
+
let responseHeadersForLog: Record<string, string> | undefined;
|
|
921
|
+
let responseBodyForLog: string | undefined;
|
|
922
|
+
let streamChunksForLog: string[] | undefined;
|
|
923
|
+
|
|
924
|
+
const finalizeLog = async (statusCode: number, error?: string) => {
|
|
925
|
+
if (logged || !this.config?.enableLogging) return;
|
|
926
|
+
logged = true;
|
|
927
|
+
|
|
928
|
+
// 获取供应商信息
|
|
929
|
+
const vendors = this.dbManager.getVendors();
|
|
930
|
+
const vendor = vendors.find(v => v.id === service.vendorId);
|
|
931
|
+
|
|
932
|
+
// 从请求体中提取模型信息
|
|
933
|
+
const requestModel = req.body?.model;
|
|
934
|
+
|
|
935
|
+
await this.dbManager.addLog({
|
|
936
|
+
timestamp: Date.now(),
|
|
937
|
+
method: req.method,
|
|
938
|
+
path: req.path,
|
|
939
|
+
headers: this.normalizeHeaders(req.headers),
|
|
940
|
+
body: req.body ? JSON.stringify(req.body) : undefined,
|
|
941
|
+
statusCode,
|
|
942
|
+
responseTime: Date.now() - startTime,
|
|
943
|
+
targetProvider: service.name,
|
|
944
|
+
usage: usageForLog,
|
|
945
|
+
error,
|
|
946
|
+
|
|
947
|
+
// 新增字段
|
|
948
|
+
targetType,
|
|
949
|
+
targetServiceId: service.id,
|
|
950
|
+
targetServiceName: service.name,
|
|
951
|
+
targetModel: rule.targetModel,
|
|
952
|
+
vendorId: service.vendorId,
|
|
953
|
+
vendorName: vendor?.name,
|
|
954
|
+
requestModel,
|
|
955
|
+
responseHeaders: responseHeadersForLog,
|
|
956
|
+
responseBody: responseBodyForLog,
|
|
957
|
+
streamChunks: streamChunksForLog,
|
|
958
|
+
});
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
try {
|
|
962
|
+
if (targetType === 'claude-code') {
|
|
963
|
+
if (this.isClaudeSource(sourceType)) {
|
|
964
|
+
requestBody = this.applyModelOverride(requestBody, rule);
|
|
965
|
+
} else if (this.isOpenAIChatSource(sourceType)) {
|
|
966
|
+
requestBody = transformClaudeRequestToOpenAIChat(requestBody, rule.targetModel);
|
|
967
|
+
} else if (this.isOpenAIResponsesSource(sourceType)) {
|
|
968
|
+
requestBody = transformClaudeRequestToOpenAIResponses(requestBody, rule.targetModel);
|
|
969
|
+
} else {
|
|
970
|
+
res.status(400).json({ error: 'Unsupported source type for Claude Code.' });
|
|
971
|
+
await finalizeLog(400, 'Unsupported source type for Claude Code');
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
} else if (targetType === 'codex') {
|
|
975
|
+
if (this.isOpenAIResponsesSource(sourceType)) {
|
|
976
|
+
requestBody = this.applyModelOverride(requestBody, rule);
|
|
977
|
+
} else if (this.isOpenAIChatSource(sourceType)) {
|
|
978
|
+
requestBody = transformOpenAIResponsesRequestToOpenAIChat(requestBody, rule.targetModel);
|
|
979
|
+
} else if (this.isClaudeSource(sourceType)) {
|
|
980
|
+
requestBody = transformOpenAIResponsesRequestToClaude(requestBody, rule.targetModel);
|
|
981
|
+
} else {
|
|
982
|
+
res.status(400).json({ error: 'Codex requires an OpenAI Responses compatible source.' });
|
|
983
|
+
await finalizeLog(400, 'Unsupported source type for Codex');
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const streamRequested = this.isStreamRequested(req, requestBody);
|
|
989
|
+
|
|
990
|
+
// Build the full URL by appending the request path to the service API URL
|
|
991
|
+
let pathToAppend = req.path;
|
|
992
|
+
if (route.targetType === 'claude-code' && req.path.startsWith('/claude-code')) {
|
|
993
|
+
pathToAppend = req.path.slice('/claude-code'.length);
|
|
994
|
+
} else if (route.targetType === 'codex' && req.path.startsWith('/codex')) {
|
|
995
|
+
pathToAppend = req.path.slice('/codex'.length);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const config: AxiosRequestConfig = {
|
|
999
|
+
method: req.method as any,
|
|
1000
|
+
url: `${service.apiUrl}${pathToAppend}`,
|
|
1001
|
+
headers: this.buildUpstreamHeaders(req, service, sourceType, streamRequested),
|
|
1002
|
+
timeout: service.timeout || 30000,
|
|
1003
|
+
validateStatus: () => true,
|
|
1004
|
+
responseType: streamRequested ? 'stream' : 'json',
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
if (Object.keys(req.query).length > 0) {
|
|
1008
|
+
config.params = req.query;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) {
|
|
1012
|
+
config.data = requestBody;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const response = await axios(config);
|
|
1016
|
+
const responseHeaders = response.headers || {};
|
|
1017
|
+
const contentType = typeof responseHeaders['content-type'] === 'string' ? responseHeaders['content-type'] : '';
|
|
1018
|
+
const isEventStream = streamRequested && contentType.includes('text/event-stream');
|
|
1019
|
+
|
|
1020
|
+
if (isEventStream && response.data) {
|
|
1021
|
+
res.status(response.status);
|
|
1022
|
+
|
|
1023
|
+
if (targetType === 'claude-code' && this.isOpenAIChatSource(sourceType)) {
|
|
1024
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1025
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1026
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1027
|
+
|
|
1028
|
+
const parser = new SSEParserTransform();
|
|
1029
|
+
const eventCollector = new SSEEventCollectorTransform();
|
|
1030
|
+
const converter = new OpenAIToClaudeEventTransform({ model: requestBody?.model });
|
|
1031
|
+
const serializer = new SSESerializerTransform();
|
|
1032
|
+
|
|
1033
|
+
// 收集响应头
|
|
1034
|
+
responseHeadersForLog = this.normalizeResponseHeaders(responseHeaders);
|
|
1035
|
+
|
|
1036
|
+
res.on('finish', () => {
|
|
1037
|
+
const usage = converter.getUsage();
|
|
1038
|
+
if (usage) {
|
|
1039
|
+
usageForLog = extractTokenUsageFromClaudeUsage(usage);
|
|
1040
|
+
} else {
|
|
1041
|
+
// 尝试从event collector中提取usage
|
|
1042
|
+
const extractedUsage = eventCollector.extractUsage();
|
|
1043
|
+
if (extractedUsage) {
|
|
1044
|
+
usageForLog = this.extractTokenUsage(extractedUsage);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// 收集stream chunks(每个chunk是一个完整的SSE事件)
|
|
1048
|
+
streamChunksForLog = eventCollector.getChunks();
|
|
1049
|
+
void finalizeLog(res.statusCode);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
pipeline(response.data, parser, eventCollector, converter, serializer, res, (error) => {
|
|
1053
|
+
if (error) {
|
|
1054
|
+
void finalizeLog(500, error.message);
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (targetType === 'claude-code' && this.isOpenAIResponsesSource(sourceType)) {
|
|
1061
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1062
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1063
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1064
|
+
|
|
1065
|
+
const parser = new SSEParserTransform();
|
|
1066
|
+
const eventCollector = new SSEEventCollectorTransform();
|
|
1067
|
+
const converter = new OpenAIResponsesToClaudeEventTransform({ model: requestBody?.model });
|
|
1068
|
+
const serializer = new SSESerializerTransform();
|
|
1069
|
+
|
|
1070
|
+
responseHeadersForLog = this.normalizeResponseHeaders(responseHeaders);
|
|
1071
|
+
|
|
1072
|
+
res.on('finish', () => {
|
|
1073
|
+
const usage = converter.getUsage();
|
|
1074
|
+
if (usage) {
|
|
1075
|
+
usageForLog = extractTokenUsageFromClaudeUsage(usage);
|
|
1076
|
+
} else {
|
|
1077
|
+
// 尝试从event collector中提取usage
|
|
1078
|
+
const extractedUsage = eventCollector.extractUsage();
|
|
1079
|
+
if (extractedUsage) {
|
|
1080
|
+
usageForLog = this.extractTokenUsage(extractedUsage);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
streamChunksForLog = eventCollector.getChunks();
|
|
1084
|
+
void finalizeLog(res.statusCode);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
pipeline(response.data, parser, eventCollector, converter, serializer, res, (error) => {
|
|
1088
|
+
if (error) {
|
|
1089
|
+
void finalizeLog(500, error.message);
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (targetType === 'codex' && this.isClaudeSource(sourceType)) {
|
|
1096
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1097
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1098
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1099
|
+
|
|
1100
|
+
const parser = new SSEParserTransform();
|
|
1101
|
+
const eventCollector = new SSEEventCollectorTransform();
|
|
1102
|
+
const converter = new ClaudeToOpenAIResponsesEventTransform({ model: requestBody?.model });
|
|
1103
|
+
const serializer = new SSESerializerTransform();
|
|
1104
|
+
|
|
1105
|
+
responseHeadersForLog = this.normalizeResponseHeaders(responseHeaders);
|
|
1106
|
+
|
|
1107
|
+
res.on('finish', () => {
|
|
1108
|
+
const usage = converter.getUsage();
|
|
1109
|
+
if (usage) {
|
|
1110
|
+
usageForLog = extractTokenUsageFromClaudeUsage(usage);
|
|
1111
|
+
} else {
|
|
1112
|
+
// 尝试从event collector中提取usage
|
|
1113
|
+
const extractedUsage = eventCollector.extractUsage();
|
|
1114
|
+
if (extractedUsage) {
|
|
1115
|
+
usageForLog = this.extractTokenUsage(extractedUsage);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
streamChunksForLog = eventCollector.getChunks();
|
|
1119
|
+
void finalizeLog(res.statusCode);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
pipeline(response.data, parser, eventCollector, converter, serializer, res, (error) => {
|
|
1123
|
+
if (error) {
|
|
1124
|
+
void finalizeLog(500, error.message);
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (targetType === 'codex' && this.isOpenAIChatSource(sourceType)) {
|
|
1131
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1132
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1133
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1134
|
+
|
|
1135
|
+
const parser = new SSEParserTransform();
|
|
1136
|
+
const eventCollector = new SSEEventCollectorTransform();
|
|
1137
|
+
const toClaude = new OpenAIToClaudeEventTransform({ model: requestBody?.model });
|
|
1138
|
+
const toResponses = new ClaudeToOpenAIResponsesEventTransform({ model: requestBody?.model });
|
|
1139
|
+
const serializer = new SSESerializerTransform();
|
|
1140
|
+
|
|
1141
|
+
responseHeadersForLog = this.normalizeResponseHeaders(responseHeaders);
|
|
1142
|
+
|
|
1143
|
+
res.on('finish', () => {
|
|
1144
|
+
const usage = toResponses.getUsage();
|
|
1145
|
+
if (usage) {
|
|
1146
|
+
usageForLog = extractTokenUsageFromClaudeUsage(usage);
|
|
1147
|
+
} else {
|
|
1148
|
+
// 尝试从event collector中提取usage
|
|
1149
|
+
const extractedUsage = eventCollector.extractUsage();
|
|
1150
|
+
if (extractedUsage) {
|
|
1151
|
+
usageForLog = this.extractTokenUsage(extractedUsage);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
streamChunksForLog = eventCollector.getChunks();
|
|
1155
|
+
void finalizeLog(res.statusCode);
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
pipeline(response.data, parser, eventCollector, toClaude, toResponses, serializer, res, (error) => {
|
|
1159
|
+
if (error) {
|
|
1160
|
+
void finalizeLog(500, error.message);
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// 默认stream处理(无转换)
|
|
1167
|
+
const eventCollector = new SSEEventCollectorTransform();
|
|
1168
|
+
responseHeadersForLog = this.normalizeResponseHeaders(responseHeaders);
|
|
1169
|
+
|
|
1170
|
+
this.copyResponseHeaders(responseHeaders, res);
|
|
1171
|
+
res.on('finish', () => {
|
|
1172
|
+
streamChunksForLog = eventCollector.getChunks();
|
|
1173
|
+
// 尝试从event collector中提取usage信息
|
|
1174
|
+
const extractedUsage = eventCollector.extractUsage();
|
|
1175
|
+
if (extractedUsage) {
|
|
1176
|
+
usageForLog = this.extractTokenUsage(extractedUsage);
|
|
1177
|
+
}
|
|
1178
|
+
void finalizeLog(res.statusCode);
|
|
1179
|
+
});
|
|
1180
|
+
pipeline(response.data, eventCollector, res, (error) => {
|
|
1181
|
+
if (error) {
|
|
1182
|
+
void finalizeLog(500, error.message);
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
let responseData = response.data;
|
|
1189
|
+
if (streamRequested && response.data && typeof response.data.on === 'function' && !isEventStream) {
|
|
1190
|
+
const raw = await this.readStreamBody(response.data);
|
|
1191
|
+
responseData = this.safeJsonParse(raw) ?? raw;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// 收集响应头
|
|
1195
|
+
responseHeadersForLog = this.normalizeResponseHeaders(responseHeaders);
|
|
1196
|
+
|
|
1197
|
+
if (response.status >= 400) {
|
|
1198
|
+
usageForLog = this.extractTokenUsage(responseData?.usage);
|
|
1199
|
+
// 记录错误响应体
|
|
1200
|
+
responseBodyForLog = typeof responseData === 'string' ? responseData : JSON.stringify(responseData);
|
|
1201
|
+
this.copyResponseHeaders(responseHeaders, res);
|
|
1202
|
+
if (contentType.includes('application/json')) {
|
|
1203
|
+
res.status(response.status).json(responseData);
|
|
1204
|
+
} else {
|
|
1205
|
+
res.status(response.status).send(responseData);
|
|
1206
|
+
}
|
|
1207
|
+
await finalizeLog(res.statusCode);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (targetType === 'claude-code' && this.isOpenAIChatSource(sourceType)) {
|
|
1212
|
+
const converted = transformOpenAIChatResponseToClaude(responseData);
|
|
1213
|
+
usageForLog = extractTokenUsageFromOpenAIUsage(responseData?.usage);
|
|
1214
|
+
// 记录转换后的响应体
|
|
1215
|
+
responseBodyForLog = JSON.stringify(converted);
|
|
1216
|
+
res.status(response.status).json(converted);
|
|
1217
|
+
} else if (targetType === 'claude-code' && this.isOpenAIResponsesSource(sourceType)) {
|
|
1218
|
+
const converted = transformOpenAIResponsesToClaude(responseData);
|
|
1219
|
+
usageForLog = extractTokenUsageFromOpenAIResponsesUsage(responseData?.usage);
|
|
1220
|
+
responseBodyForLog = JSON.stringify(converted);
|
|
1221
|
+
res.status(response.status).json(converted);
|
|
1222
|
+
} else if (targetType === 'codex' && this.isClaudeSource(sourceType)) {
|
|
1223
|
+
const converted = transformClaudeResponseToOpenAIResponses(responseData);
|
|
1224
|
+
usageForLog = extractTokenUsageFromClaudeUsage(responseData?.usage);
|
|
1225
|
+
responseBodyForLog = JSON.stringify(converted);
|
|
1226
|
+
res.status(response.status).json(converted);
|
|
1227
|
+
} else if (targetType === 'codex' && this.isOpenAIChatSource(sourceType)) {
|
|
1228
|
+
const claudeResponse = transformOpenAIChatResponseToClaude(responseData);
|
|
1229
|
+
const converted = transformClaudeResponseToOpenAIResponses(claudeResponse);
|
|
1230
|
+
usageForLog = extractTokenUsageFromOpenAIUsage(responseData?.usage);
|
|
1231
|
+
responseBodyForLog = JSON.stringify(converted);
|
|
1232
|
+
res.status(response.status).json(converted);
|
|
1233
|
+
} else {
|
|
1234
|
+
usageForLog = this.extractTokenUsage(responseData?.usage);
|
|
1235
|
+
// 记录原始响应体
|
|
1236
|
+
responseBodyForLog = typeof responseData === 'string' ? responseData : JSON.stringify(responseData);
|
|
1237
|
+
this.copyResponseHeaders(responseHeaders, res);
|
|
1238
|
+
if (contentType.includes('application/json')) {
|
|
1239
|
+
res.status(response.status).json(responseData);
|
|
1240
|
+
} else {
|
|
1241
|
+
res.status(response.status).send(responseData);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
await finalizeLog(res.statusCode);
|
|
1246
|
+
} catch (error: any) {
|
|
1247
|
+
console.error('Proxy error:', error);
|
|
1248
|
+
await finalizeLog(500, error.message);
|
|
1249
|
+
|
|
1250
|
+
// 根据请求类型返回适当格式的错误响应
|
|
1251
|
+
const streamRequested = this.isStreamRequested(req, req.body || {});
|
|
1252
|
+
if (streamRequested && route.targetType === 'claude-code') {
|
|
1253
|
+
// 对于 Claude Code 的流式请求,返回 SSE 格式的错误响应
|
|
1254
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1255
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1256
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1257
|
+
res.status(500);
|
|
1258
|
+
|
|
1259
|
+
// 发送错误事件
|
|
1260
|
+
const errorEvent = `event: error\ndata: ${JSON.stringify({ error: error.message })}\n\n`;
|
|
1261
|
+
const doneEvent = `data: [DONE]\n\n`;
|
|
1262
|
+
res.write(errorEvent);
|
|
1263
|
+
res.write(doneEvent);
|
|
1264
|
+
res.end();
|
|
1265
|
+
} else {
|
|
1266
|
+
// 对于非流式请求,返回 JSON 格式的错误响应
|
|
1267
|
+
res.status(500).json({ error: error.message });
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
async reloadRoutes() {
|
|
1273
|
+
this.routes = this.dbManager.getRoutes().filter((g) => g.isActive);
|
|
1274
|
+
this.rules.clear();
|
|
1275
|
+
|
|
1276
|
+
for (const route of this.routes) {
|
|
1277
|
+
const routeRules = this.dbManager.getRules(route.id);
|
|
1278
|
+
// 确保按 sortOrder 降序排序(database 层已处理,但再次确保)
|
|
1279
|
+
const sortedRules = [...routeRules].sort((a, b) => (b.sortOrder || 0) - (a.sortOrder || 0));
|
|
1280
|
+
this.rules.set(route.id, sortedRules);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Load all services
|
|
1284
|
+
const allServices = this.dbManager.getAPIServices();
|
|
1285
|
+
this.services.clear();
|
|
1286
|
+
allServices.forEach((service) => {
|
|
1287
|
+
this.services.set(service.id, service);
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
console.log(`Loaded ${this.routes.length} active routes and ${this.services.size} services`);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async updateConfig(config: AppConfig) {
|
|
1294
|
+
this.config = config;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
async initialize() {
|
|
1298
|
+
this.setupMiddleware();
|
|
1299
|
+
await this.reloadRoutes();
|
|
1300
|
+
}
|
|
1301
|
+
}
|