iflow-local-proxy 1.0.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/LICENSE +21 -0
- package/README.md +149 -0
- package/bin/cli.js +69 -0
- package/config.yaml +56 -0
- package/package.json +43 -0
- package/src/anthropic/adapter.js +457 -0
- package/src/auth/credentials.js +239 -0
- package/src/auth/oauth-cli.js +47 -0
- package/src/auth/oauth.js +127 -0
- package/src/config.js +76 -0
- package/src/iflow/client.js +102 -0
- package/src/iflow/models.js +69 -0
- package/src/iflow/signature.js +42 -0
- package/src/index.js +774 -0
- package/src/protection/circuit-breaker.js +207 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { loadConfig, findConfigPath } from './config.js';
|
|
6
|
+
import { loadCredential, refreshOAuthToken, refreshCookieApiKey, saveCredential, resolveAuthDir } from './auth/credentials.js';
|
|
7
|
+
import { chatCompletions, chatCompletionsStream, listModels, ProxyError } from './iflow/client.js';
|
|
8
|
+
import { mergeWithKnownModels, validateModel } from './iflow/models.js';
|
|
9
|
+
import { anthropicToOpenAI, openAIToAnthropic, anthropicError, AnthropicStreamAdapter } from './anthropic/adapter.js';
|
|
10
|
+
import { CircuitBreaker, RequestThrottler } from './protection/circuit-breaker.js';
|
|
11
|
+
import { startOAuthSession } from './auth/oauth.js';
|
|
12
|
+
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = path.dirname(__filename);
|
|
15
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
16
|
+
|
|
17
|
+
const app = express();
|
|
18
|
+
app.use(express.json({ limit: '10mb' }));
|
|
19
|
+
|
|
20
|
+
// ========================
|
|
21
|
+
// 加载配置
|
|
22
|
+
// ========================
|
|
23
|
+
|
|
24
|
+
const configPath = findConfigPath(path.join(PKG_ROOT, 'config.yaml'));
|
|
25
|
+
const config = loadConfig(configPath);
|
|
26
|
+
const authDir = config['auth-dir'];
|
|
27
|
+
|
|
28
|
+
// 单账号模式: 只加载一个凭据
|
|
29
|
+
let credential = loadCredential(authDir);
|
|
30
|
+
|
|
31
|
+
// ========================
|
|
32
|
+
// 防封保护模块
|
|
33
|
+
// ========================
|
|
34
|
+
|
|
35
|
+
const protectionConfig = config.protection || {};
|
|
36
|
+
const cbConfig = protectionConfig['circuit-breaker'] || {};
|
|
37
|
+
|
|
38
|
+
const circuitBreaker = new CircuitBreaker({
|
|
39
|
+
enabled: cbConfig.enabled !== false,
|
|
40
|
+
pauseDuration: cbConfig['pause-duration'] || 3600,
|
|
41
|
+
maxConsecutive434: cbConfig['max-consecutive-434'] || 3,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const throttler = new RequestThrottler({
|
|
45
|
+
minInterval: protectionConfig['min-request-interval'] || 2000,
|
|
46
|
+
jitterMax: protectionConfig['jitter-max'] || 1000,
|
|
47
|
+
maxPerMinute: protectionConfig['max-requests-per-minute'] || 20,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const modelWhitelist = protectionConfig['model-whitelist'] || [];
|
|
51
|
+
|
|
52
|
+
// ========================
|
|
53
|
+
// 快捷方法
|
|
54
|
+
// ========================
|
|
55
|
+
|
|
56
|
+
function getApiKey() {
|
|
57
|
+
return credential?.data?.api_key || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getAccountName() {
|
|
61
|
+
return credential?.data?.email || 'none';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ========================
|
|
65
|
+
// 统计
|
|
66
|
+
// ========================
|
|
67
|
+
|
|
68
|
+
const STATS_DIR = path.join(resolveAuthDir(authDir), 'data');
|
|
69
|
+
const STATS_FILE = path.join(STATS_DIR, 'stats.json');
|
|
70
|
+
|
|
71
|
+
function loadPersistentStats() {
|
|
72
|
+
try {
|
|
73
|
+
if (fs.existsSync(STATS_FILE)) {
|
|
74
|
+
const saved = JSON.parse(fs.readFileSync(STATS_FILE, 'utf-8'));
|
|
75
|
+
return {
|
|
76
|
+
totalRequests: saved.totalRequests || 0,
|
|
77
|
+
totalErrors: saved.totalErrors || 0,
|
|
78
|
+
totalTokens: saved.totalTokens || { input: 0, output: 0 },
|
|
79
|
+
startTime: Date.now(),
|
|
80
|
+
requestsByModel: saved.requestsByModel || {},
|
|
81
|
+
recentRequests: (saved.recentRequests || []).filter(r => Date.now() - r.timestamp < 7 * 24 * 3600 * 1000),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
return { totalRequests: 0, totalErrors: 0, totalTokens: { input: 0, output: 0 }, startTime: Date.now(), requestsByModel: {}, recentRequests: [] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function saveStatsToDisk() {
|
|
89
|
+
try {
|
|
90
|
+
if (!fs.existsSync(STATS_DIR)) fs.mkdirSync(STATS_DIR, { recursive: true });
|
|
91
|
+
const toSave = {
|
|
92
|
+
totalRequests: stats.totalRequests,
|
|
93
|
+
totalErrors: stats.totalErrors,
|
|
94
|
+
totalTokens: stats.totalTokens,
|
|
95
|
+
requestsByModel: stats.requestsByModel,
|
|
96
|
+
recentRequests: stats.recentRequests.slice(-1000),
|
|
97
|
+
};
|
|
98
|
+
fs.writeFileSync(STATS_FILE, JSON.stringify(toSave, null, 2));
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.warn('[warn] 保存统计失败:', err.message);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const stats = loadPersistentStats();
|
|
105
|
+
setInterval(saveStatsToDisk, 60000);
|
|
106
|
+
process.on('exit', saveStatsToDisk);
|
|
107
|
+
process.on('SIGINT', () => { saveStatsToDisk(); process.exit(0); });
|
|
108
|
+
|
|
109
|
+
if (credential) {
|
|
110
|
+
console.log(`[info] 已加载账号: ${credential.data.email} (${credential.filename})`);
|
|
111
|
+
} else {
|
|
112
|
+
console.warn('[warn] 未找到凭据,请通过 API 添加账号后才能使用');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 定时刷新凭据(每1小时)
|
|
116
|
+
setInterval(async () => {
|
|
117
|
+
if (!credential) return;
|
|
118
|
+
try {
|
|
119
|
+
let success = false;
|
|
120
|
+
if (credential.data.cookie) {
|
|
121
|
+
success = await refreshCookieApiKey(credential, authDir);
|
|
122
|
+
} else if (credential.data.refresh_token) {
|
|
123
|
+
success = await refreshOAuthToken(credential, authDir);
|
|
124
|
+
}
|
|
125
|
+
if (success) {
|
|
126
|
+
credential = loadCredential(authDir);
|
|
127
|
+
console.log('[info] 凭据刷新成功');
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error(`[error] 凭据刷新异常: ${err.message}`);
|
|
131
|
+
}
|
|
132
|
+
}, 3600 * 1000);
|
|
133
|
+
|
|
134
|
+
// 启动时刷新即将过期的凭据
|
|
135
|
+
if (credential) {
|
|
136
|
+
(async () => {
|
|
137
|
+
try {
|
|
138
|
+
const expired = new Date(credential.data.expired).getTime();
|
|
139
|
+
const leadTime = 47 * 3600 * 1000;
|
|
140
|
+
if (!isNaN(expired) && Date.now() + leadTime > expired) {
|
|
141
|
+
let success = false;
|
|
142
|
+
if (credential.data.cookie) success = await refreshCookieApiKey(credential, authDir);
|
|
143
|
+
else if (credential.data.refresh_token) success = await refreshOAuthToken(credential, authDir);
|
|
144
|
+
if (success) credential = loadCredential(authDir);
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
})();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ========================
|
|
151
|
+
// 模型列表缓存
|
|
152
|
+
// ========================
|
|
153
|
+
|
|
154
|
+
let modelCache = { data: [], timestamp: 0 };
|
|
155
|
+
const MODEL_CACHE_TTL = 10 * 60 * 1000;
|
|
156
|
+
|
|
157
|
+
async function getModels() {
|
|
158
|
+
if (Date.now() - modelCache.timestamp < MODEL_CACHE_TTL && modelCache.data.length > 0) {
|
|
159
|
+
return modelCache.data;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const allModels = new Map();
|
|
163
|
+
const apiKey = getApiKey();
|
|
164
|
+
if (apiKey) {
|
|
165
|
+
try {
|
|
166
|
+
const resp = await listModels(apiKey);
|
|
167
|
+
if (resp?.data) {
|
|
168
|
+
for (const model of resp.data) {
|
|
169
|
+
allModels.set(model.id, model);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const models = mergeWithKnownModels(Array.from(allModels.values()));
|
|
176
|
+
modelCache = { data: models, timestamp: Date.now() };
|
|
177
|
+
console.log(`[info] 模型列表已更新: ${models.length} 个模型`);
|
|
178
|
+
return models;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
getModels().catch(() => {});
|
|
182
|
+
|
|
183
|
+
// ========================
|
|
184
|
+
// 核心中间件: 防封保护 + 账号检查
|
|
185
|
+
// ========================
|
|
186
|
+
|
|
187
|
+
async function protectionMiddleware(req, res, next) {
|
|
188
|
+
// 0. 检查是否有可用账号
|
|
189
|
+
if (!credential || !getApiKey()) {
|
|
190
|
+
return res.status(503).json({
|
|
191
|
+
error: {
|
|
192
|
+
message: '未配置账号。请先通过 POST /api/account/set-key 或 POST /api/login/oauth 添加凭据',
|
|
193
|
+
type: 'no_account',
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 1. 熔断器检查
|
|
199
|
+
const cbCheck = circuitBreaker.check();
|
|
200
|
+
if (!cbCheck.allowed) {
|
|
201
|
+
console.warn(`[protection] 请求被熔断器拒绝: ${cbCheck.reason}`);
|
|
202
|
+
if (cbCheck.retryAfter) res.set('Retry-After', String(cbCheck.retryAfter));
|
|
203
|
+
return res.status(503).json({
|
|
204
|
+
error: {
|
|
205
|
+
message: `🛡️ 防封保护: ${cbCheck.reason}`,
|
|
206
|
+
type: 'circuit_breaker',
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 2. 模型白名单检查
|
|
212
|
+
const model = req.body?.model;
|
|
213
|
+
if (model) {
|
|
214
|
+
const { allowed, suggestion } = validateModel(model, modelWhitelist);
|
|
215
|
+
if (!allowed) {
|
|
216
|
+
const msg = suggestion
|
|
217
|
+
? `模型 "${model}" 不在白名单中。你是否想用 "${suggestion}"?`
|
|
218
|
+
: `模型 "${model}" 不在白名单中。可用模型: ${modelWhitelist.join(', ')}`;
|
|
219
|
+
console.warn(`[protection] 模型被白名单拦截: ${model}`);
|
|
220
|
+
return res.status(400).json({
|
|
221
|
+
error: { message: `🛡️ 防封保护: ${msg}`, type: 'model_not_allowed' },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 3. 请求节流 (等待或拒绝)
|
|
227
|
+
const throttleResult = await throttler.waitForSlot();
|
|
228
|
+
if (!throttleResult.allowed) {
|
|
229
|
+
if (throttleResult.retryAfter) res.set('Retry-After', String(throttleResult.retryAfter));
|
|
230
|
+
return res.status(429).json({
|
|
231
|
+
error: {
|
|
232
|
+
message: `🛡️ 防封保护: ${throttleResult.reason}`,
|
|
233
|
+
type: 'throttled',
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (throttleResult.waited > 100) {
|
|
239
|
+
console.log(`[protection] 请求已等待 ${throttleResult.waited}ms(节流保护)`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
next();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ========================
|
|
246
|
+
// 路由: GET /
|
|
247
|
+
// ========================
|
|
248
|
+
|
|
249
|
+
app.get('/', (req, res) => {
|
|
250
|
+
res.json({
|
|
251
|
+
name: 'iflow-local-proxy',
|
|
252
|
+
version: '1.0.0',
|
|
253
|
+
mode: 'local-only (single-account)',
|
|
254
|
+
status: circuitBreaker.getStatus().state,
|
|
255
|
+
account: credential ? {
|
|
256
|
+
email: credential.data.email,
|
|
257
|
+
apiKeyPrefix: credential.data.api_key ? credential.data.api_key.substring(0, 12) + '...' : '',
|
|
258
|
+
expired: credential.data.expired,
|
|
259
|
+
} : null,
|
|
260
|
+
protection: {
|
|
261
|
+
circuitBreaker: circuitBreaker.getStatus(),
|
|
262
|
+
throttler: throttler.getStatus(),
|
|
263
|
+
modelWhitelist: modelWhitelist.length > 0 ? modelWhitelist : 'disabled',
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ========================
|
|
269
|
+
// 路由: GET /v1/models
|
|
270
|
+
// ========================
|
|
271
|
+
|
|
272
|
+
app.get('/v1/models', async (req, res) => {
|
|
273
|
+
try {
|
|
274
|
+
const models = await getModels();
|
|
275
|
+
const filtered = modelWhitelist.length > 0
|
|
276
|
+
? models.filter(m => modelWhitelist.includes(m.id))
|
|
277
|
+
: models;
|
|
278
|
+
res.json({ object: 'list', data: filtered });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
res.status(500).json({ error: { message: err.message, type: 'server_error' } });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ========================
|
|
285
|
+
// 路由: POST /v1/messages/count_tokens (Anthropic)
|
|
286
|
+
// ========================
|
|
287
|
+
|
|
288
|
+
app.post('/v1/messages/count_tokens', (req, res) => {
|
|
289
|
+
const body = req.body;
|
|
290
|
+
let totalChars = 0;
|
|
291
|
+
|
|
292
|
+
if (body.system) {
|
|
293
|
+
if (typeof body.system === 'string') totalChars += body.system.length;
|
|
294
|
+
else if (Array.isArray(body.system)) {
|
|
295
|
+
for (const block of body.system) {
|
|
296
|
+
if (block.type === 'text') totalChars += (block.text || '').length;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const msg of body.messages || []) {
|
|
302
|
+
if (typeof msg.content === 'string') totalChars += msg.content.length;
|
|
303
|
+
else if (Array.isArray(msg.content)) {
|
|
304
|
+
for (const block of msg.content) {
|
|
305
|
+
if (block.type === 'text') totalChars += (block.text || '').length;
|
|
306
|
+
else if (block.type === 'thinking') totalChars += (block.thinking || '').length;
|
|
307
|
+
else if (block.type === 'tool_use') totalChars += JSON.stringify(block.input || {}).length;
|
|
308
|
+
else if (block.type === 'tool_result') {
|
|
309
|
+
if (typeof block.content === 'string') totalChars += block.content.length;
|
|
310
|
+
else if (Array.isArray(block.content)) {
|
|
311
|
+
for (const b of block.content) { if (b.type === 'text') totalChars += (b.text || '').length; }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (body.tools?.length) totalChars += JSON.stringify(body.tools).length;
|
|
319
|
+
|
|
320
|
+
res.json({ input_tokens: Math.ceil(totalChars / 3) });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ========================
|
|
324
|
+
// 路由: POST /v1/messages (Anthropic)
|
|
325
|
+
// ========================
|
|
326
|
+
|
|
327
|
+
app.post('/v1/messages', protectionMiddleware, async (req, res) => {
|
|
328
|
+
const anthropicReq = req.body;
|
|
329
|
+
const model = anthropicReq.model;
|
|
330
|
+
const isStream = anthropicReq.stream === true;
|
|
331
|
+
const apiKey = getApiKey();
|
|
332
|
+
|
|
333
|
+
console.log(`[info] Anthropic ${isStream ? 'Stream' : 'Non-stream'} | model=${model} | account=${getAccountName()}`);
|
|
334
|
+
|
|
335
|
+
stats.totalRequests++;
|
|
336
|
+
stats.requestsByModel[model] = (stats.requestsByModel[model] || 0) + 1;
|
|
337
|
+
const requestRecord = { timestamp: Date.now(), model, inputTokens: 0, outputTokens: 0 };
|
|
338
|
+
stats.recentRequests.push(requestRecord);
|
|
339
|
+
if (stats.recentRequests.length > 2000) stats.recentRequests = stats.recentRequests.slice(-1000);
|
|
340
|
+
|
|
341
|
+
let openaiBody;
|
|
342
|
+
try {
|
|
343
|
+
openaiBody = anthropicToOpenAI(anthropicReq);
|
|
344
|
+
} catch (err) {
|
|
345
|
+
return res.status(400).json(anthropicError(400, `Request conversion error: ${err.message}`));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
if (isStream) {
|
|
350
|
+
await handleAnthropicStream(apiKey, openaiBody, model, res, requestRecord);
|
|
351
|
+
} else {
|
|
352
|
+
await handleAnthropicNonStream(apiKey, openaiBody, model, res, requestRecord);
|
|
353
|
+
}
|
|
354
|
+
circuitBreaker.onSuccess();
|
|
355
|
+
} catch (err) {
|
|
356
|
+
handleUpstreamError(err, model);
|
|
357
|
+
handleAnthropicError(err, res);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
async function handleAnthropicNonStream(apiKey, openaiBody, model, res, requestRecord) {
|
|
362
|
+
openaiBody.stream = false;
|
|
363
|
+
const result = await chatCompletions(apiKey, openaiBody);
|
|
364
|
+
const anthropicResp = openAIToAnthropic(result, model);
|
|
365
|
+
if (result.usage && requestRecord) {
|
|
366
|
+
requestRecord.inputTokens = result.usage.prompt_tokens || 0;
|
|
367
|
+
requestRecord.outputTokens = result.usage.completion_tokens || 0;
|
|
368
|
+
stats.totalTokens.input += requestRecord.inputTokens;
|
|
369
|
+
stats.totalTokens.output += requestRecord.outputTokens;
|
|
370
|
+
}
|
|
371
|
+
res.json(anthropicResp);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function handleAnthropicStream(apiKey, openaiBody, model, res, requestRecord) {
|
|
375
|
+
openaiBody.stream = true;
|
|
376
|
+
const upstream = await chatCompletionsStream(apiKey, openaiBody);
|
|
377
|
+
|
|
378
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
379
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
380
|
+
res.setHeader('Connection', 'keep-alive');
|
|
381
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
382
|
+
|
|
383
|
+
const adapter = new AnthropicStreamAdapter(res, model);
|
|
384
|
+
const reader = upstream.body.getReader();
|
|
385
|
+
const decoder = new TextDecoder();
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
while (true) {
|
|
389
|
+
const { done, value } = await reader.read();
|
|
390
|
+
if (done) break;
|
|
391
|
+
adapter.feed(decoder.decode(value, { stream: true }));
|
|
392
|
+
}
|
|
393
|
+
adapter.flush();
|
|
394
|
+
} catch (err) {
|
|
395
|
+
console.error(`[error] Anthropic 流式传输中断: ${err.message}`);
|
|
396
|
+
} finally {
|
|
397
|
+
if (requestRecord) {
|
|
398
|
+
requestRecord.inputTokens = adapter.inputTokens || 0;
|
|
399
|
+
requestRecord.outputTokens = adapter.outputTokens || 0;
|
|
400
|
+
stats.totalTokens.input += requestRecord.inputTokens;
|
|
401
|
+
stats.totalTokens.output += requestRecord.outputTokens;
|
|
402
|
+
}
|
|
403
|
+
res.end();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function handleAnthropicError(err, res) {
|
|
408
|
+
if (res.headersSent) return;
|
|
409
|
+
if (err instanceof ProxyError) {
|
|
410
|
+
const status = err.status || 500;
|
|
411
|
+
res.status(status).json(anthropicError(status, err.body || err.message));
|
|
412
|
+
} else {
|
|
413
|
+
res.status(500).json(anthropicError(500, 'Internal server error'));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ========================
|
|
418
|
+
// 路由: POST /v1/chat/completions (OpenAI)
|
|
419
|
+
// ========================
|
|
420
|
+
|
|
421
|
+
app.post('/v1/chat/completions', protectionMiddleware, async (req, res) => {
|
|
422
|
+
const body = req.body;
|
|
423
|
+
const model = body.model;
|
|
424
|
+
const isStream = body.stream === true;
|
|
425
|
+
const apiKey = getApiKey();
|
|
426
|
+
|
|
427
|
+
console.log(`[info] OpenAI ${isStream ? 'Stream' : 'Non-stream'} | model=${model} | account=${getAccountName()}`);
|
|
428
|
+
|
|
429
|
+
stats.totalRequests++;
|
|
430
|
+
stats.requestsByModel[model] = (stats.requestsByModel[model] || 0) + 1;
|
|
431
|
+
const requestRecord = { timestamp: Date.now(), model, inputTokens: 0, outputTokens: 0 };
|
|
432
|
+
stats.recentRequests.push(requestRecord);
|
|
433
|
+
if (stats.recentRequests.length > 2000) stats.recentRequests = stats.recentRequests.slice(-1000);
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
if (isStream) {
|
|
437
|
+
await handleStream(apiKey, body, model, res, requestRecord);
|
|
438
|
+
} else {
|
|
439
|
+
await handleNonStream(apiKey, body, model, res, requestRecord);
|
|
440
|
+
}
|
|
441
|
+
circuitBreaker.onSuccess();
|
|
442
|
+
} catch (err) {
|
|
443
|
+
handleUpstreamError(err, model);
|
|
444
|
+
handleError(err, res);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
async function handleNonStream(apiKey, body, model, res, requestRecord) {
|
|
449
|
+
const result = await chatCompletions(apiKey, body);
|
|
450
|
+
if (result.usage && requestRecord) {
|
|
451
|
+
requestRecord.inputTokens = result.usage.prompt_tokens || 0;
|
|
452
|
+
requestRecord.outputTokens = result.usage.completion_tokens || 0;
|
|
453
|
+
stats.totalTokens.input += requestRecord.inputTokens;
|
|
454
|
+
stats.totalTokens.output += requestRecord.outputTokens;
|
|
455
|
+
}
|
|
456
|
+
res.json(result);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function handleStream(apiKey, body, model, res, requestRecord) {
|
|
460
|
+
const upstream = await chatCompletionsStream(apiKey, body);
|
|
461
|
+
|
|
462
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
463
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
464
|
+
res.setHeader('Connection', 'keep-alive');
|
|
465
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
466
|
+
|
|
467
|
+
const reader = upstream.body.getReader();
|
|
468
|
+
const decoder = new TextDecoder();
|
|
469
|
+
let lastUsage = null;
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
while (true) {
|
|
473
|
+
const { done, value } = await reader.read();
|
|
474
|
+
if (done) break;
|
|
475
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
476
|
+
res.write(chunk);
|
|
477
|
+
|
|
478
|
+
const lines = chunk.split('\n');
|
|
479
|
+
for (const line of lines) {
|
|
480
|
+
const trimmed = line.startsWith('data:') ? line.slice(5).trim() : null;
|
|
481
|
+
if (!trimmed || trimmed === '[DONE]') continue;
|
|
482
|
+
try {
|
|
483
|
+
const parsed = JSON.parse(trimmed);
|
|
484
|
+
if (parsed.usage) lastUsage = parsed.usage;
|
|
485
|
+
} catch {}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
} catch (err) {
|
|
489
|
+
console.error(`[error] 流式传输中断: ${err.message}`);
|
|
490
|
+
} finally {
|
|
491
|
+
if (lastUsage && requestRecord) {
|
|
492
|
+
requestRecord.inputTokens = lastUsage.prompt_tokens || 0;
|
|
493
|
+
requestRecord.outputTokens = lastUsage.completion_tokens || 0;
|
|
494
|
+
stats.totalTokens.input += requestRecord.inputTokens;
|
|
495
|
+
stats.totalTokens.output += requestRecord.outputTokens;
|
|
496
|
+
}
|
|
497
|
+
res.end();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ========================
|
|
502
|
+
// 上游错误处理 + 熔断器联动
|
|
503
|
+
// ========================
|
|
504
|
+
|
|
505
|
+
function handleUpstreamError(err, model) {
|
|
506
|
+
stats.totalErrors++;
|
|
507
|
+
if (err instanceof ProxyError) {
|
|
508
|
+
console.error(`[error] iFlow 上游错误: ${err.status} | model=${model} | account=${getAccountName()}`);
|
|
509
|
+
|
|
510
|
+
// 434 = AK blocked → 触发熔断器
|
|
511
|
+
if (err.status === 434) {
|
|
512
|
+
console.error(`[ALERT] ⚠️ 收到 434 (AK blocked)!账号: ${getAccountName()}`);
|
|
513
|
+
circuitBreaker.on434(getAccountName());
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function handleError(err, res) {
|
|
519
|
+
if (res.headersSent) return;
|
|
520
|
+
if (err instanceof ProxyError) {
|
|
521
|
+
try {
|
|
522
|
+
const body = JSON.parse(err.body);
|
|
523
|
+
res.status(err.status).json(body);
|
|
524
|
+
} catch {
|
|
525
|
+
res.status(err.status).json({ error: { message: err.body, type: 'upstream_error' } });
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
res.status(500).json({ error: { message: 'Internal server error', type: 'server_error' } });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ========================
|
|
533
|
+
// 管理 API
|
|
534
|
+
// ========================
|
|
535
|
+
|
|
536
|
+
// 状态概览
|
|
537
|
+
app.get('/api/status', (req, res) => {
|
|
538
|
+
const uptime = Math.floor((Date.now() - stats.startTime) / 1000);
|
|
539
|
+
res.json({
|
|
540
|
+
uptime,
|
|
541
|
+
mode: 'local-only (single-account)',
|
|
542
|
+
totalRequests: stats.totalRequests,
|
|
543
|
+
totalErrors: stats.totalErrors,
|
|
544
|
+
totalTokens: stats.totalTokens,
|
|
545
|
+
protection: {
|
|
546
|
+
circuitBreaker: circuitBreaker.getStatus(),
|
|
547
|
+
throttler: throttler.getStatus(),
|
|
548
|
+
modelWhitelist: modelWhitelist.length > 0 ? `${modelWhitelist.length} models` : 'disabled',
|
|
549
|
+
},
|
|
550
|
+
account: credential ? {
|
|
551
|
+
filename: credential.filename,
|
|
552
|
+
email: credential.data.email,
|
|
553
|
+
type: credential.data.cookie ? 'cookie' : (credential.data.refresh_token ? 'oauth' : 'api-key'),
|
|
554
|
+
expired: credential.data.expired,
|
|
555
|
+
lastRefresh: credential.data.last_refresh,
|
|
556
|
+
apiKeyPrefix: credential.data.api_key ? credential.data.api_key.substring(0, 12) + '...' : '',
|
|
557
|
+
} : null,
|
|
558
|
+
requestsByModel: stats.requestsByModel,
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// 熔断器管理
|
|
563
|
+
app.get('/api/circuit-breaker', (req, res) => {
|
|
564
|
+
res.json(circuitBreaker.getStatus());
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
app.post('/api/circuit-breaker/reset', (req, res) => {
|
|
568
|
+
circuitBreaker.reset();
|
|
569
|
+
res.json({ success: true, message: '熔断器已重置', status: circuitBreaker.getStatus() });
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// OAuth 登录 (会覆盖当前账号)
|
|
573
|
+
const oauthSessions = new Map();
|
|
574
|
+
|
|
575
|
+
app.post('/api/login/oauth', async (req, res) => {
|
|
576
|
+
try {
|
|
577
|
+
const { sessionId, authUrl, session, promise } = await startOAuthSession(authDir);
|
|
578
|
+
oauthSessions.set(sessionId, { session, promise });
|
|
579
|
+
|
|
580
|
+
promise.then(() => {
|
|
581
|
+
// 清除其他凭据文件,只保留最新的
|
|
582
|
+
const dir = resolveAuthDir(authDir);
|
|
583
|
+
const files = fs.readdirSync(dir)
|
|
584
|
+
.filter(f => f.startsWith('iflow-') && f.endsWith('.json'))
|
|
585
|
+
.sort()
|
|
586
|
+
.reverse();
|
|
587
|
+
// 保留最新的那个,删除其余
|
|
588
|
+
for (let i = 1; i < files.length; i++) {
|
|
589
|
+
try { fs.unlinkSync(path.join(dir, files[i])); } catch {}
|
|
590
|
+
}
|
|
591
|
+
credential = loadCredential(authDir);
|
|
592
|
+
setTimeout(() => oauthSessions.delete(sessionId), 30000);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
res.json({ sessionId, authUrl, message: '请在浏览器中打开 authUrl 完成登录(将覆盖当前账号)' });
|
|
596
|
+
} catch (err) {
|
|
597
|
+
if (err.code === 'EADDRINUSE') {
|
|
598
|
+
return res.status(409).json({ error: '回调端口 11451 被占用' });
|
|
599
|
+
}
|
|
600
|
+
res.status(500).json({ error: err.message });
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
app.get('/api/login/oauth/:sessionId', (req, res) => {
|
|
605
|
+
const entry = oauthSessions.get(req.params.sessionId);
|
|
606
|
+
if (!entry) return res.json({ status: 'not_found' });
|
|
607
|
+
res.json({ status: entry.session.status, result: entry.session.result });
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// 设置 API Key (覆盖当前账号)
|
|
611
|
+
app.post('/api/account/set-key', (req, res) => {
|
|
612
|
+
const { apiKey, name } = req.body;
|
|
613
|
+
if (!apiKey) return res.status(400).json({ error: '需要提供 apiKey' });
|
|
614
|
+
|
|
615
|
+
// 清除旧凭据文件
|
|
616
|
+
const dir = resolveAuthDir(authDir);
|
|
617
|
+
if (fs.existsSync(dir)) {
|
|
618
|
+
const oldFiles = fs.readdirSync(dir).filter(f => f.startsWith('iflow-') && f.endsWith('.json'));
|
|
619
|
+
for (const f of oldFiles) {
|
|
620
|
+
try { fs.unlinkSync(path.join(dir, f)); } catch {}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
625
|
+
const filename = `iflow-${name || 'account'}-${timestamp}.json`;
|
|
626
|
+
saveCredential(authDir, filename, {
|
|
627
|
+
access_token: '', refresh_token: '',
|
|
628
|
+
last_refresh: new Date().toISOString(),
|
|
629
|
+
expired: new Date(Date.now() + 365 * 24 * 3600 * 1000).toISOString(),
|
|
630
|
+
api_key: apiKey,
|
|
631
|
+
email: name || 'account',
|
|
632
|
+
token_type: '', scope: '', cookie: '',
|
|
633
|
+
type: 'iflow', disabled: false,
|
|
634
|
+
});
|
|
635
|
+
credential = loadCredential(authDir);
|
|
636
|
+
|
|
637
|
+
// 同时重置熔断器(新 Key 应该是干净的)
|
|
638
|
+
circuitBreaker.reset();
|
|
639
|
+
|
|
640
|
+
console.log(`[info] API Key 已设置: ${name || 'account'} -> ${filename}`);
|
|
641
|
+
res.json({ success: true, filename, message: '已设置新 API Key,熔断器已重置' });
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Cookie 登录 (覆盖当前账号)
|
|
645
|
+
app.post('/api/account/set-cookie', async (req, res) => {
|
|
646
|
+
const { cookie, name } = req.body;
|
|
647
|
+
if (!cookie) return res.status(400).json({ error: '请输入 Cookie' });
|
|
648
|
+
|
|
649
|
+
const match = cookie.match(/BXAuth=([^;]+)/);
|
|
650
|
+
if (!match) return res.status(400).json({ error: 'Cookie 中未找到 BXAuth 字段' });
|
|
651
|
+
const cleanCookie = `BXAuth=${match[1]};`;
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
const apiResp = await fetch('https://platform.iflow.cn/api/openapi/apikey', {
|
|
655
|
+
method: 'POST',
|
|
656
|
+
headers: { 'Content-Type': 'application/json', 'Cookie': cleanCookie },
|
|
657
|
+
body: JSON.stringify({ name: name || 'iflow-proxy' }),
|
|
658
|
+
});
|
|
659
|
+
if (!apiResp.ok) return res.status(apiResp.status).json({ error: `API Key 获取失败: ${apiResp.status}` });
|
|
660
|
+
|
|
661
|
+
const result = await apiResp.json();
|
|
662
|
+
const apiKey = result.data?.apiKey;
|
|
663
|
+
if (!apiKey) return res.status(500).json({ error: '返回数据中未找到 apiKey' });
|
|
664
|
+
|
|
665
|
+
// 清除旧凭据
|
|
666
|
+
const dir = resolveAuthDir(authDir);
|
|
667
|
+
if (fs.existsSync(dir)) {
|
|
668
|
+
const oldFiles = fs.readdirSync(dir).filter(f => f.startsWith('iflow-') && f.endsWith('.json'));
|
|
669
|
+
for (const f of oldFiles) {
|
|
670
|
+
try { fs.unlinkSync(path.join(dir, f)); } catch {}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
675
|
+
const filename = `iflow-${name || 'cookie'}-${timestamp}.json`;
|
|
676
|
+
saveCredential(authDir, filename, {
|
|
677
|
+
access_token: '', refresh_token: '',
|
|
678
|
+
last_refresh: new Date().toISOString(),
|
|
679
|
+
expired: new Date(Date.now() + 48 * 3600 * 1000).toISOString(),
|
|
680
|
+
api_key: apiKey, email: name || 'cookie-user',
|
|
681
|
+
token_type: '', scope: '', cookie: cleanCookie,
|
|
682
|
+
type: 'iflow', disabled: false,
|
|
683
|
+
});
|
|
684
|
+
credential = loadCredential(authDir);
|
|
685
|
+
circuitBreaker.reset();
|
|
686
|
+
res.json({ success: true, email: name || 'cookie-user', filename });
|
|
687
|
+
} catch (err) {
|
|
688
|
+
res.status(500).json({ error: err.message });
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// 手动刷新当前凭据
|
|
693
|
+
app.post('/api/account/refresh', async (req, res) => {
|
|
694
|
+
if (!credential) return res.status(404).json({ error: '未找到账号' });
|
|
695
|
+
|
|
696
|
+
let success = false;
|
|
697
|
+
if (credential.data.cookie) {
|
|
698
|
+
success = await refreshCookieApiKey(credential, authDir);
|
|
699
|
+
} else if (credential.data.refresh_token) {
|
|
700
|
+
success = await refreshOAuthToken(credential, authDir);
|
|
701
|
+
} else {
|
|
702
|
+
return res.status(400).json({ error: '该账号没有可用的刷新方式(纯 API Key 无法刷新)' });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (success) {
|
|
706
|
+
credential = loadCredential(authDir);
|
|
707
|
+
res.json({ success: true, email: credential?.data.email, expired: credential?.data.expired });
|
|
708
|
+
} else {
|
|
709
|
+
res.status(500).json({ error: '刷新失败' });
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// 查看当前账号
|
|
714
|
+
app.get('/api/account', (req, res) => {
|
|
715
|
+
if (!credential) return res.json({ account: null });
|
|
716
|
+
res.json({
|
|
717
|
+
account: {
|
|
718
|
+
filename: credential.filename,
|
|
719
|
+
email: credential.data.email,
|
|
720
|
+
type: credential.data.cookie ? 'cookie' : (credential.data.refresh_token ? 'oauth' : 'api-key'),
|
|
721
|
+
expired: credential.data.expired,
|
|
722
|
+
lastRefresh: credential.data.last_refresh,
|
|
723
|
+
apiKeyPrefix: credential.data.api_key ? credential.data.api_key.substring(0, 12) + '...' : '',
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// 删除当前账号
|
|
729
|
+
app.delete('/api/account', (req, res) => {
|
|
730
|
+
if (!credential) return res.status(404).json({ error: '无账号' });
|
|
731
|
+
const dir = resolveAuthDir(authDir);
|
|
732
|
+
try {
|
|
733
|
+
fs.unlinkSync(path.join(dir, credential.filename));
|
|
734
|
+
} catch {}
|
|
735
|
+
credential = null;
|
|
736
|
+
res.json({ success: true, message: '账号已删除' });
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// ========================
|
|
740
|
+
// 启动服务 (仅绑定 127.0.0.1)
|
|
741
|
+
// ========================
|
|
742
|
+
|
|
743
|
+
const port = parseInt(process.env.IFLOW_PORT, 10) || config.port || 8318;
|
|
744
|
+
const HOST = '127.0.0.1';
|
|
745
|
+
|
|
746
|
+
app.listen(port, HOST, () => {
|
|
747
|
+
console.log('');
|
|
748
|
+
console.log('╔══════════════════════════════════════════════════════════╗');
|
|
749
|
+
console.log('║ iFlow Local Proxy - 本地安全反代 ║');
|
|
750
|
+
console.log('║ 单账号模式 ║');
|
|
751
|
+
console.log('╠══════════════════════════════════════════════════════════╣');
|
|
752
|
+
console.log(`║ 地址: http://${HOST}:${port} ║`);
|
|
753
|
+
console.log('║ 模式: 仅本地 (127.0.0.1) · 单账号 ║');
|
|
754
|
+
console.log('╠══════════════════════════════════════════════════════════╣');
|
|
755
|
+
console.log('║ 防封保护: ║');
|
|
756
|
+
console.log(`║ ├─ 熔断器: ${circuitBreaker.enabled ? '✅' : '❌'} (434 自动熔断) ║`);
|
|
757
|
+
console.log(`║ ├─ 请求间隔: ${throttler.minInterval}ms + ${throttler.jitterMax}ms 抖动 ║`);
|
|
758
|
+
console.log(`║ ├─ 频率上限: ${throttler.maxPerMinute} 次/分钟 ║`);
|
|
759
|
+
console.log(`║ └─ 模型白名单: ${modelWhitelist.length > 0 ? modelWhitelist.length + ' 个模型' : '未启用'} ║`);
|
|
760
|
+
console.log('╠══════════════════════════════════════════════════════════╣');
|
|
761
|
+
console.log(`║ 账号: ${credential ? credential.data.email : '⚠️ 未配置'}${' '.repeat(Math.max(0, 43 - (credential ? credential.data.email.length : 7)))}║`);
|
|
762
|
+
console.log('╠══════════════════════════════════════════════════════════╣');
|
|
763
|
+
console.log('║ 端点: ║');
|
|
764
|
+
console.log('║ ├─ GET /v1/models ║');
|
|
765
|
+
console.log('║ ├─ POST /v1/chat/completions (OpenAI) ║');
|
|
766
|
+
console.log('║ ├─ POST /v1/messages (Anthropic) ║');
|
|
767
|
+
console.log('║ ├─ POST /api/account/set-key (设置 API Key) ║');
|
|
768
|
+
console.log('║ ├─ GET /api/status (状态) ║');
|
|
769
|
+
console.log('║ └─ POST /api/circuit-breaker/reset (重置熔断器) ║');
|
|
770
|
+
console.log('╚══════════════════════════════════════════════════════════╝');
|
|
771
|
+
console.log('');
|
|
772
|
+
console.log(`[info] 配置文件: ${configPath}`);
|
|
773
|
+
console.log(`[info] 凭据目录: ${resolveAuthDir(authDir)}`);
|
|
774
|
+
});
|