@lightharu/krouter 1.8.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 +679 -0
- package/README.md +238 -0
- package/dist-web/assets/index-CM4-0adf.css +1 -0
- package/dist-web/assets/index-DCslvfUR.js +139 -0
- package/dist-web/favicon.svg +9 -0
- package/dist-web/icon.svg +9 -0
- package/dist-web/index.html +19 -0
- package/out-server/main/kiroAuthSync.js +249 -0
- package/out-server/main/kproxy/certManager.js +262 -0
- package/out-server/main/kproxy/index.js +254 -0
- package/out-server/main/kproxy/mitmProxy.js +475 -0
- package/out-server/main/kproxy/types.js +23 -0
- package/out-server/main/proxy/accountPool.js +543 -0
- package/out-server/main/proxy/clientConfig.js +596 -0
- package/out-server/main/proxy/index.js +25 -0
- package/out-server/main/proxy/kiroApi.js +1996 -0
- package/out-server/main/proxy/logger.js +407 -0
- package/out-server/main/proxy/modelCatalog.js +75 -0
- package/out-server/main/proxy/promptCacheTracker.js +301 -0
- package/out-server/main/proxy/proxyServer.js +3543 -0
- package/out-server/main/proxy/selfSignedCert.js +179 -0
- package/out-server/main/proxy/systemProxy.js +250 -0
- package/out-server/main/proxy/tokenCounter.js +164 -0
- package/out-server/main/proxy/toolNameRegistry.js +57 -0
- package/out-server/main/proxy/translator.js +1084 -0
- package/out-server/main/proxy/types.js +3 -0
- package/out-server/main/registration/browser-identity.js +184 -0
- package/out-server/main/registration/chainProxy.js +349 -0
- package/out-server/main/registration/config.js +58 -0
- package/out-server/main/registration/email-service.js +801 -0
- package/out-server/main/registration/fingerprint.js +352 -0
- package/out-server/main/registration/http-utils.js +148 -0
- package/out-server/main/registration/jwe.js +74 -0
- package/out-server/main/registration/names.js +142 -0
- package/out-server/main/registration/proton-mail-window.js +339 -0
- package/out-server/main/registration/registrar.js +1715 -0
- package/out-server/main/registration/tlsClientPool.js +70 -0
- package/out-server/main/registration/xxtea.js +161 -0
- package/out-server/main/runtimePaths.js +19 -0
- package/out-server/main/utils/redact.js +95 -0
- package/out-server/server/index.js +1272 -0
- package/out-server/server/services/accountExtras.js +105 -0
- package/out-server/server/services/accountProfileHydration.js +95 -0
- package/out-server/server/services/authFlows.js +509 -0
- package/out-server/server/services/dashboardTunnel.js +315 -0
- package/out-server/server/services/diagnostics.js +326 -0
- package/out-server/server/services/kiroAccounts.js +431 -0
- package/out-server/server/services/kiroSettings.js +260 -0
- package/out-server/server/services/kproxyRuntime.js +264 -0
- package/out-server/server/services/localKiroCredentials.js +320 -0
- package/out-server/server/services/machineIdRuntime.js +327 -0
- package/out-server/server/services/protonBrowserRuntime.js +724 -0
- package/out-server/server/services/proxyRuntime.js +523 -0
- package/out-server/server/services/registrationRuntime.js +106 -0
- package/out-server/server/store.js +266 -0
- package/package.json +113 -0
- package/resources/tls-client-xgo-1.14.0-windows-amd64.dll +0 -0
- package/scripts/kiro-manager-cli.cjs +3 -0
- package/scripts/krouter-cli.cjs +509 -0
- package/src/renderer/src/assets/krouter-logo.svg +11 -0
- package/src/renderer/src/assets/krouter-mark.svg +9 -0
|
@@ -0,0 +1,3543 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ProxyServer = void 0;
|
|
7
|
+
// Kiro Proxy HTTP/HTTPS 服务器
|
|
8
|
+
const http_1 = __importDefault(require("http"));
|
|
9
|
+
const https_1 = __importDefault(require("https"));
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
12
|
+
const uuid_1 = require("uuid");
|
|
13
|
+
const accountPool_1 = require("./accountPool");
|
|
14
|
+
const kiroApi_1 = require("./kiroApi");
|
|
15
|
+
const logger_1 = require("./logger");
|
|
16
|
+
const kproxy_1 = require("../kproxy");
|
|
17
|
+
const translator_1 = require("./translator");
|
|
18
|
+
const toolNameRegistry_1 = require("./toolNameRegistry");
|
|
19
|
+
const promptCacheTracker_1 = require("./promptCacheTracker");
|
|
20
|
+
const runtimePaths_1 = require("../runtimePaths");
|
|
21
|
+
const modelCatalog_1 = require("./modelCatalog");
|
|
22
|
+
function modelDisplayName(id, modelName) {
|
|
23
|
+
if (modelName?.trim())
|
|
24
|
+
return modelName;
|
|
25
|
+
return id
|
|
26
|
+
.split('-')
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map(part => part === 'gpt' ? 'GPT' : part === 'ai' ? 'AI' : part[0]?.toUpperCase() + part.slice(1))
|
|
29
|
+
.join(' ');
|
|
30
|
+
}
|
|
31
|
+
function modelFamily(id) {
|
|
32
|
+
const lower = id.toLowerCase();
|
|
33
|
+
if (lower.includes('opus'))
|
|
34
|
+
return 'claude-opus';
|
|
35
|
+
if (lower.includes('sonnet'))
|
|
36
|
+
return 'claude-sonnet';
|
|
37
|
+
if (lower.includes('haiku'))
|
|
38
|
+
return 'claude-haiku';
|
|
39
|
+
if (lower.includes('gpt-4o'))
|
|
40
|
+
return 'gpt-4o';
|
|
41
|
+
if (lower.includes('gpt-4'))
|
|
42
|
+
return 'gpt-4';
|
|
43
|
+
if (lower.includes('gpt-3.5'))
|
|
44
|
+
return 'gpt-3.5';
|
|
45
|
+
if (lower.includes('glm'))
|
|
46
|
+
return 'glm';
|
|
47
|
+
if (lower === 'auto')
|
|
48
|
+
return 'auto';
|
|
49
|
+
return lower.split(/[.-]/).slice(0, 2).join('-') || lower;
|
|
50
|
+
}
|
|
51
|
+
function modelOutputLimit(id, output) {
|
|
52
|
+
if (typeof output === 'number' && output > 0)
|
|
53
|
+
return output;
|
|
54
|
+
const lower = id.toLowerCase();
|
|
55
|
+
if (lower.includes('haiku') || lower.includes('gpt-3.5'))
|
|
56
|
+
return 8192;
|
|
57
|
+
return 32000;
|
|
58
|
+
}
|
|
59
|
+
function modelInputModalities(inputTypes) {
|
|
60
|
+
const values = new Set(['text']);
|
|
61
|
+
for (const item of inputTypes ?? []) {
|
|
62
|
+
const lower = item.toLowerCase();
|
|
63
|
+
if (lower.includes('image'))
|
|
64
|
+
values.add('image');
|
|
65
|
+
if (lower.includes('pdf') || lower.includes('document') || lower.includes('file'))
|
|
66
|
+
values.add('pdf');
|
|
67
|
+
if (lower.includes('audio'))
|
|
68
|
+
values.add('audio');
|
|
69
|
+
if (lower.includes('video'))
|
|
70
|
+
values.add('video');
|
|
71
|
+
}
|
|
72
|
+
return Array.from(values);
|
|
73
|
+
}
|
|
74
|
+
function modelCapabilityMap(modalities) {
|
|
75
|
+
return {
|
|
76
|
+
text: modalities.includes('text'),
|
|
77
|
+
audio: modalities.includes('audio'),
|
|
78
|
+
image: modalities.includes('image'),
|
|
79
|
+
video: modalities.includes('video'),
|
|
80
|
+
pdf: modalities.includes('pdf')
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function extractThinkingEfforts(schema) {
|
|
84
|
+
if (!schema)
|
|
85
|
+
return undefined;
|
|
86
|
+
const props = schema.properties;
|
|
87
|
+
if (!props?.thinking)
|
|
88
|
+
return undefined;
|
|
89
|
+
const thinking = props.thinking;
|
|
90
|
+
const thinkingProps = thinking.properties;
|
|
91
|
+
const typeField = thinkingProps?.type;
|
|
92
|
+
const enumValues = typeField?.enum;
|
|
93
|
+
if (enumValues?.includes('adaptive') || enumValues?.includes('disabled')) {
|
|
94
|
+
const effortField = props.output_config?.properties;
|
|
95
|
+
const effortEnum = effortField?.effort?.enum;
|
|
96
|
+
return effortEnum || undefined;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
function buildClientModel(input) {
|
|
101
|
+
const name = modelDisplayName(input.id, input.modelName);
|
|
102
|
+
const inputModalities = modelInputModalities(input.supportedInputTypes);
|
|
103
|
+
const outputModalities = ['text'];
|
|
104
|
+
const output = modelOutputLimit(input.id, input.maxOutputTokens);
|
|
105
|
+
const context = typeof input.maxInputTokens === 'number' && input.maxInputTokens > 0 ? input.maxInputTokens : 200000;
|
|
106
|
+
const reasoning = false;
|
|
107
|
+
const interleaved = false;
|
|
108
|
+
return {
|
|
109
|
+
id: input.id,
|
|
110
|
+
object: 'model',
|
|
111
|
+
created: input.created,
|
|
112
|
+
owned_by: input.ownedBy,
|
|
113
|
+
name,
|
|
114
|
+
description: input.description || name,
|
|
115
|
+
model_name: input.modelName || name,
|
|
116
|
+
family: modelFamily(input.id),
|
|
117
|
+
release_date: '',
|
|
118
|
+
attachment: inputModalities.some(item => item !== 'text'),
|
|
119
|
+
reasoning,
|
|
120
|
+
temperature: true,
|
|
121
|
+
tool_call: true,
|
|
122
|
+
interleaved,
|
|
123
|
+
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
124
|
+
limit: {
|
|
125
|
+
context,
|
|
126
|
+
...(typeof input.maxInputTokens === 'number' && input.maxInputTokens > 0 ? { input: input.maxInputTokens } : {}),
|
|
127
|
+
output
|
|
128
|
+
},
|
|
129
|
+
modalities: { input: inputModalities, output: outputModalities },
|
|
130
|
+
capabilities: {
|
|
131
|
+
temperature: true,
|
|
132
|
+
reasoning,
|
|
133
|
+
attachment: inputModalities.some(item => item !== 'text'),
|
|
134
|
+
toolcall: true,
|
|
135
|
+
input: modelCapabilityMap(inputModalities),
|
|
136
|
+
output: modelCapabilityMap(outputModalities),
|
|
137
|
+
interleaved
|
|
138
|
+
},
|
|
139
|
+
context_length: context,
|
|
140
|
+
max_tokens: output,
|
|
141
|
+
...(typeof input.maxInputTokens === 'number' && input.maxInputTokens > 0 ? { max_input_tokens: input.maxInputTokens } : {}),
|
|
142
|
+
max_output_tokens: output,
|
|
143
|
+
inputTypes: input.supportedInputTypes,
|
|
144
|
+
rateMultiplier: input.rateMultiplier,
|
|
145
|
+
rateUnit: input.rateUnit,
|
|
146
|
+
supportsThinking: !!input.additionalModelRequestFieldsSchema?.properties?.thinking,
|
|
147
|
+
thinkingEfforts: extractThinkingEfforts(input.additionalModelRequestFieldsSchema),
|
|
148
|
+
supportsPromptCaching: input.promptCaching?.supportsPromptCaching || false,
|
|
149
|
+
modelProvider: input.modelProvider || undefined,
|
|
150
|
+
permission: [],
|
|
151
|
+
root: input.id,
|
|
152
|
+
parent: null
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// 请求体超限错误(统一识别用,触发 413 响应)
|
|
156
|
+
function buildKiroPresetClientModel(preset, created) {
|
|
157
|
+
return buildClientModel({
|
|
158
|
+
id: preset.id,
|
|
159
|
+
created,
|
|
160
|
+
ownedBy: 'kiro-api',
|
|
161
|
+
description: preset.description,
|
|
162
|
+
modelName: preset.name,
|
|
163
|
+
supportedInputTypes: preset.inputTypes,
|
|
164
|
+
maxInputTokens: preset.maxInputTokens,
|
|
165
|
+
maxOutputTokens: preset.maxOutputTokens,
|
|
166
|
+
modelProvider: preset.modelProvider
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function buildKiroPresetModel(preset) {
|
|
170
|
+
return {
|
|
171
|
+
modelId: preset.id,
|
|
172
|
+
modelName: preset.name,
|
|
173
|
+
description: preset.description,
|
|
174
|
+
supportedInputTypes: preset.inputTypes,
|
|
175
|
+
tokenLimits: {
|
|
176
|
+
maxInputTokens: preset.maxInputTokens,
|
|
177
|
+
maxOutputTokens: preset.maxOutputTokens
|
|
178
|
+
},
|
|
179
|
+
modelProvider: preset.modelProvider
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
class BodyTooLargeError extends Error {
|
|
183
|
+
received;
|
|
184
|
+
limit;
|
|
185
|
+
constructor(received, limit) {
|
|
186
|
+
super(`Request body too large: ${received} bytes exceeds limit of ${limit} bytes`);
|
|
187
|
+
this.received = received;
|
|
188
|
+
this.limit = limit;
|
|
189
|
+
this.name = 'BodyTooLargeError';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
class ProxyServer {
|
|
193
|
+
server = null;
|
|
194
|
+
fallbackServer = null; // HTTPS 启用时同时监听 HTTP(可选)
|
|
195
|
+
accountPool;
|
|
196
|
+
config;
|
|
197
|
+
stats;
|
|
198
|
+
sessionStats;
|
|
199
|
+
events;
|
|
200
|
+
refreshingTokens = new Set(); // 防止并发刷新
|
|
201
|
+
isHttps = false;
|
|
202
|
+
isStopping = false;
|
|
203
|
+
activeRequests = new Set();
|
|
204
|
+
sockets = new Set();
|
|
205
|
+
/** P1-7 按 API Key/IP 的滑动窗口限流(每分钟桶) */
|
|
206
|
+
rateLimitBuckets = new Map();
|
|
207
|
+
/** P1-8 会话粘性:session hint → accountId 的映射(10 分钟 TTL) */
|
|
208
|
+
sessionAffinity = new Map();
|
|
209
|
+
modelLoadBalanceState = new Map();
|
|
210
|
+
accountModelCapabilityCache = new Map();
|
|
211
|
+
modelPaceQueues = new Map();
|
|
212
|
+
modelPaceReadyAt = new Map();
|
|
213
|
+
MODEL_CAPABILITY_CACHE_TTL = 5 * 60 * 1000;
|
|
214
|
+
OPUS_MODEL_PACE_MS = 10 * 1000;
|
|
215
|
+
/** P2-17 审计日志(最近 200 条) */
|
|
216
|
+
auditLog = [];
|
|
217
|
+
/** Webhook 触发回调(由外部注入,避免 main → renderer 循环依赖) */
|
|
218
|
+
webhookTrigger;
|
|
219
|
+
/** 定期清理 timer */
|
|
220
|
+
cleanupTimer = null;
|
|
221
|
+
/**
|
|
222
|
+
* 从请求中提取 session hint,用于稳定 conversationId
|
|
223
|
+
* 优先级 1:显式稳定 ID(header)
|
|
224
|
+
* 优先级 2:请求体中的会话相关字段(body)
|
|
225
|
+
* 优先级 3:返回 undefined(由 kiroApi 用 history fingerprint 兜底)
|
|
226
|
+
*/
|
|
227
|
+
static extractSessionHint(req, body) {
|
|
228
|
+
const b = (body && typeof body === 'object' ? body : {});
|
|
229
|
+
const h = req.headers;
|
|
230
|
+
// 优先级 1:显式稳定 header
|
|
231
|
+
const headerHint = h['x-claude-code-session-id'] ||
|
|
232
|
+
h['x-opencode-session'] ||
|
|
233
|
+
h['x-session-affinity'] ||
|
|
234
|
+
h['x-conversation-id'];
|
|
235
|
+
if (headerHint)
|
|
236
|
+
return headerHint;
|
|
237
|
+
// 优先级 2:body 中可靠的会话字段
|
|
238
|
+
const bodyHint = b.prompt_cache_key ||
|
|
239
|
+
b.promptCacheKey ||
|
|
240
|
+
b.conversation_id ||
|
|
241
|
+
b.conversationId ||
|
|
242
|
+
b.thread_id ||
|
|
243
|
+
b.threadId ||
|
|
244
|
+
b.session_id ||
|
|
245
|
+
b.sessionId;
|
|
246
|
+
if (bodyHint)
|
|
247
|
+
return bodyHint;
|
|
248
|
+
// 优先级 2.5:metadata 中的 session/conversation
|
|
249
|
+
const metadata = b.metadata;
|
|
250
|
+
if (metadata) {
|
|
251
|
+
const metaHint = metadata.session_id ||
|
|
252
|
+
metadata.conversation_id;
|
|
253
|
+
if (metaHint)
|
|
254
|
+
return metaHint;
|
|
255
|
+
}
|
|
256
|
+
// 优先级 3:无显式 ID,返回 undefined(kiroApi 用 history fingerprint 兜底)
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
constructor(config = {}, events = {}) {
|
|
260
|
+
this.config = {
|
|
261
|
+
enabled: false,
|
|
262
|
+
port: 5580,
|
|
263
|
+
host: '127.0.0.1',
|
|
264
|
+
enableMultiAccount: true,
|
|
265
|
+
selectedAccountIds: [],
|
|
266
|
+
logRequests: true,
|
|
267
|
+
maxConcurrent: 10,
|
|
268
|
+
maxRetries: 3,
|
|
269
|
+
retryDelayMs: 1000,
|
|
270
|
+
tokenRefreshBeforeExpiry: 300, // 5分钟提前刷新
|
|
271
|
+
autoStart: false, // 是否自动启动
|
|
272
|
+
clientDrivenToolExecution: true,
|
|
273
|
+
accountSelectionStrategy: 'round-robin',
|
|
274
|
+
sessionAffinityEnabled: false,
|
|
275
|
+
...config
|
|
276
|
+
};
|
|
277
|
+
this.normalizeAccountBalancingConfig();
|
|
278
|
+
this.accountPool = new accountPool_1.AccountPool();
|
|
279
|
+
this.accountPool.setStrategy(this.config.accountSelectionStrategy || 'round-robin');
|
|
280
|
+
this.stats = {
|
|
281
|
+
totalRequests: 0,
|
|
282
|
+
successRequests: 0,
|
|
283
|
+
failedRequests: 0,
|
|
284
|
+
totalTokens: 0,
|
|
285
|
+
totalCredits: 0,
|
|
286
|
+
inputTokens: 0,
|
|
287
|
+
outputTokens: 0,
|
|
288
|
+
cacheReadTokens: 0,
|
|
289
|
+
cacheWriteTokens: 0,
|
|
290
|
+
reasoningTokens: 0,
|
|
291
|
+
startTime: Date.now(),
|
|
292
|
+
accountStats: new Map(),
|
|
293
|
+
endpointStats: new Map(),
|
|
294
|
+
modelStats: new Map(),
|
|
295
|
+
recentRequests: []
|
|
296
|
+
};
|
|
297
|
+
this.sessionStats = {
|
|
298
|
+
totalRequests: 0,
|
|
299
|
+
successRequests: 0,
|
|
300
|
+
failedRequests: 0,
|
|
301
|
+
startTime: 0
|
|
302
|
+
};
|
|
303
|
+
this.events = events;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* 检测当前绑定地址是否会暴露到本机以外
|
|
307
|
+
* 0.0.0.0 / :: / 网卡地址 → true;127.0.0.1 / ::1 / localhost → false
|
|
308
|
+
*/
|
|
309
|
+
isBindingExternal(host) {
|
|
310
|
+
if (!host)
|
|
311
|
+
return false;
|
|
312
|
+
const h = host.toLowerCase().trim();
|
|
313
|
+
return h === '0.0.0.0' || h === '::' || h === '*' || (h !== '127.0.0.1' && h !== '::1' && h !== 'localhost');
|
|
314
|
+
}
|
|
315
|
+
// 启动服务器
|
|
316
|
+
async start() {
|
|
317
|
+
if (this.server) {
|
|
318
|
+
console.log('[ProxyServer] Server already running');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// P0-2 安全护栏:外网绑定 + 无 API Key → 拒绝启动(用户可以显式 allowExternalWithoutApiKey 解除)
|
|
322
|
+
if (this.isBindingExternal(this.config.host)) {
|
|
323
|
+
const hasAnyKey = (this.config.apiKeys?.some(k => k.enabled && k.key) ?? false) || !!this.config.apiKey;
|
|
324
|
+
if (!hasAnyKey && !this.config.allowExternalWithoutApiKey) {
|
|
325
|
+
const err = new Error(`[Security] Refused to start: host=${this.config.host} exposes to network but no API Key configured. ` +
|
|
326
|
+
`Set at least one API Key, or change host to 127.0.0.1, or set allowExternalWithoutApiKey=true (NOT RECOMMENDED).`);
|
|
327
|
+
console.error('[ProxyServer]', err.message);
|
|
328
|
+
this.events.onError?.(err);
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
if (!hasAnyKey) {
|
|
332
|
+
console.warn(`[ProxyServer] [Security] WARNING: binding to ${this.config.host} without API Key (allowExternalWithoutApiKey=true). This exposes your accounts to the network!`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return new Promise((resolve, reject) => {
|
|
336
|
+
this.isStopping = false;
|
|
337
|
+
const requestHandler = (req, res) => this.handleRequest(req, res);
|
|
338
|
+
// 检查是否启用 TLS
|
|
339
|
+
if (this.config.tls?.enabled) {
|
|
340
|
+
try {
|
|
341
|
+
const tlsOptions = this.getTlsOptions();
|
|
342
|
+
this.server = https_1.default.createServer(tlsOptions, requestHandler);
|
|
343
|
+
this.isHttps = true;
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
reject(new Error(`TLS configuration error: ${error.message}`));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
this.server = http_1.default.createServer(requestHandler);
|
|
352
|
+
this.isHttps = false;
|
|
353
|
+
}
|
|
354
|
+
this.server.on('error', (error) => {
|
|
355
|
+
if (error.code === 'EADDRINUSE') {
|
|
356
|
+
console.error(`[ProxyServer] Port ${this.config.port} is already in use`);
|
|
357
|
+
reject(new Error(`Port ${this.config.port} is already in use`));
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
console.error('[ProxyServer] Server error:', error);
|
|
361
|
+
reject(error);
|
|
362
|
+
}
|
|
363
|
+
this.events.onError?.(error);
|
|
364
|
+
});
|
|
365
|
+
this.server.on('connection', (socket) => {
|
|
366
|
+
this.sockets.add(socket);
|
|
367
|
+
socket.on('close', () => this.sockets.delete(socket));
|
|
368
|
+
// P1-10 backpressure 监控:socket 写入缓冲区超过 1MB 时记录警告
|
|
369
|
+
socket.on('drain', () => {
|
|
370
|
+
if (socket.writableLength > 0) {
|
|
371
|
+
logger_1.proxyLogger.debug('ProxyServer', `Socket drain: bufferedLen=${socket.writableLength}`);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
// 服务器关闭时尝试自动重启
|
|
376
|
+
this.server.on('close', () => {
|
|
377
|
+
if (!this.isStopping && this.config.autoStart && this.config.enabled) {
|
|
378
|
+
console.log('[ProxyServer] Server closed unexpectedly, attempting restart in 3s...');
|
|
379
|
+
setTimeout(() => {
|
|
380
|
+
if (!this.isStopping && this.config.autoStart && !this.isRunning()) {
|
|
381
|
+
console.log('[ProxyServer] Auto-restarting...');
|
|
382
|
+
this.start().catch(err => {
|
|
383
|
+
console.error('[ProxyServer] Auto-restart failed:', err);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}, 3000);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
// P1-11 keep-alive / headers 空闲超时(避免长连接占用资源)
|
|
390
|
+
const keepAliveMs = this.config.keepAliveTimeoutMs ?? 65_000;
|
|
391
|
+
const headersMs = this.config.headersTimeoutMs ?? 60_000;
|
|
392
|
+
this.server.keepAliveTimeout = keepAliveMs;
|
|
393
|
+
this.server.headersTimeout = Math.max(headersMs, keepAliveMs + 1000); // headers 必须 > keepAlive,否则 Node 会 warn
|
|
394
|
+
this.server.requestTimeout = 0; // 流式响应可能很长,禁用 request 总超时
|
|
395
|
+
// 启动定期清理(每 5 分钟)
|
|
396
|
+
if (this.cleanupTimer)
|
|
397
|
+
clearInterval(this.cleanupTimer);
|
|
398
|
+
this.cleanupTimer = setInterval(() => this.cleanupExpiredCaches(), 5 * 60_000);
|
|
399
|
+
// 让 timer 在 Node 退出时不阻塞
|
|
400
|
+
this.cleanupTimer.unref?.();
|
|
401
|
+
const protocol = this.isHttps ? 'https' : 'http';
|
|
402
|
+
this.server.listen(this.config.port, this.config.host, () => {
|
|
403
|
+
logger_1.proxyLogger.info('ProxyServer', `Started on ${protocol}://${this.config.host}:${this.config.port} (keepAlive=${keepAliveMs}ms)`);
|
|
404
|
+
this.stats.startTime = Date.now();
|
|
405
|
+
// 重置会话统计
|
|
406
|
+
this.sessionStats = {
|
|
407
|
+
totalRequests: 0,
|
|
408
|
+
successRequests: 0,
|
|
409
|
+
failedRequests: 0,
|
|
410
|
+
startTime: Date.now()
|
|
411
|
+
};
|
|
412
|
+
this.events.onStatusChange?.(true, this.config.port);
|
|
413
|
+
resolve();
|
|
414
|
+
});
|
|
415
|
+
// D4 启用 TLS 时同时监听 HTTP fallback 端口(如果配置了 fallbackPort)
|
|
416
|
+
if (this.isHttps && this.config.fallbackPort && this.config.fallbackPort !== this.config.port) {
|
|
417
|
+
const fallback = http_1.default.createServer(requestHandler);
|
|
418
|
+
fallback.keepAliveTimeout = keepAliveMs;
|
|
419
|
+
fallback.headersTimeout = Math.max(headersMs, keepAliveMs + 1000);
|
|
420
|
+
fallback.requestTimeout = 0;
|
|
421
|
+
fallback.on('connection', (socket) => {
|
|
422
|
+
this.sockets.add(socket);
|
|
423
|
+
socket.on('close', () => this.sockets.delete(socket));
|
|
424
|
+
});
|
|
425
|
+
fallback.on('error', (err) => logger_1.proxyLogger.warn('ProxyServer', `Fallback HTTP error: ${err.message}`));
|
|
426
|
+
fallback.listen(this.config.fallbackPort, this.config.host, () => {
|
|
427
|
+
logger_1.proxyLogger.info('ProxyServer', `Fallback HTTP listening on http://${this.config.host}:${this.config.fallbackPort}`);
|
|
428
|
+
});
|
|
429
|
+
this.fallbackServer = fallback;
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
// 获取 TLS 配置选项
|
|
434
|
+
// P1-13 当 tls.enabled 但未提供 cert/key 时,自动生成自签证书
|
|
435
|
+
getTlsOptions() {
|
|
436
|
+
const tls = this.config.tls;
|
|
437
|
+
let cert;
|
|
438
|
+
let key;
|
|
439
|
+
// 优先使用直接提供的 PEM 内容
|
|
440
|
+
if (tls.cert && tls.key) {
|
|
441
|
+
cert = tls.cert;
|
|
442
|
+
key = tls.key;
|
|
443
|
+
}
|
|
444
|
+
else if (tls.certPath && tls.keyPath) {
|
|
445
|
+
// 从文件读取
|
|
446
|
+
cert = fs_1.default.readFileSync(tls.certPath, 'utf8');
|
|
447
|
+
key = fs_1.default.readFileSync(tls.keyPath, 'utf8');
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
// 自动生成自签证书(位于 userData/proxy-tls/)
|
|
451
|
+
try {
|
|
452
|
+
const { ensureProxySelfSignedCert } = require('./selfSignedCert');
|
|
453
|
+
const hostnames = [this.config.host || '127.0.0.1'];
|
|
454
|
+
const result = ensureProxySelfSignedCert((0, runtimePaths_1.getRuntimeUserDataPath)(), hostnames);
|
|
455
|
+
logger_1.proxyLogger.info('ProxyServer', `Using self-signed TLS cert (SAN=${result.altNames.join(',')}, fingerprint=${result.fingerprint.slice(0, 19)}...)`);
|
|
456
|
+
cert = result.cert;
|
|
457
|
+
key = result.key;
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
throw new Error(`TLS enabled but no certificate/key provided and auto-generation failed: ${err.message}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return { cert, key };
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* 获取(或生成)反代自签证书信息(供 UI 显示/导出 PEM)
|
|
467
|
+
*/
|
|
468
|
+
getSelfSignedCertInfo() {
|
|
469
|
+
try {
|
|
470
|
+
const { ensureProxySelfSignedCert } = require('./selfSignedCert');
|
|
471
|
+
return ensureProxySelfSignedCert((0, runtimePaths_1.getRuntimeUserDataPath)(), [this.config.host || '127.0.0.1']);
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
logger_1.proxyLogger.warn('ProxyServer', `getSelfSignedCertInfo failed: ${err.message}`);
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/** 强制重新生成自签证书(用户在 UI 上点"重新生成") */
|
|
479
|
+
regenerateSelfSignedCert() {
|
|
480
|
+
try {
|
|
481
|
+
const { ensureProxySelfSignedCert } = require('./selfSignedCert');
|
|
482
|
+
this.appendAuditLog('regenerate_self_signed_cert', { host: this.config.host });
|
|
483
|
+
return ensureProxySelfSignedCert((0, runtimePaths_1.getRuntimeUserDataPath)(), [this.config.host || '127.0.0.1'], true);
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
logger_1.proxyLogger.warn('ProxyServer', `regenerateSelfSignedCert failed: ${err.message}`);
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* 优雅停止服务器
|
|
492
|
+
* - 立刻拒绝新连接(server.close)
|
|
493
|
+
* - 给正在进行中的请求 5 秒完成;超时后强制 destroy socket
|
|
494
|
+
* - 同时停 fallback HTTP 服务器
|
|
495
|
+
*/
|
|
496
|
+
async stop(gracefulMs = 5000) {
|
|
497
|
+
if (!this.server) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
this.isStopping = true;
|
|
501
|
+
const main = this.server;
|
|
502
|
+
const fallback = this.fallbackServer;
|
|
503
|
+
return new Promise((resolve) => {
|
|
504
|
+
let done = false;
|
|
505
|
+
const finish = () => {
|
|
506
|
+
if (done)
|
|
507
|
+
return;
|
|
508
|
+
done = true;
|
|
509
|
+
logger_1.proxyLogger.info('ProxyServer', 'Stopped');
|
|
510
|
+
this.server = null;
|
|
511
|
+
this.fallbackServer = null;
|
|
512
|
+
this.isStopping = false;
|
|
513
|
+
this.activeRequests.clear();
|
|
514
|
+
this.sockets.clear();
|
|
515
|
+
if (this.cleanupTimer) {
|
|
516
|
+
clearInterval(this.cleanupTimer);
|
|
517
|
+
this.cleanupTimer = null;
|
|
518
|
+
}
|
|
519
|
+
this.events.onStatusChange?.(false, this.config.port);
|
|
520
|
+
resolve();
|
|
521
|
+
};
|
|
522
|
+
// 先停止接受新连接
|
|
523
|
+
main.close(() => {
|
|
524
|
+
fallback?.close(() => finish()) || finish();
|
|
525
|
+
});
|
|
526
|
+
fallback?.close();
|
|
527
|
+
// P1-14 优雅停止:给正在进行中的请求时间完成,超时再强制
|
|
528
|
+
this.activeRequests.forEach(controller => {
|
|
529
|
+
// 给客户端一个明确的 stop 信号,但不立即中断已发送的响应流
|
|
530
|
+
try {
|
|
531
|
+
controller.abort(new Error('Proxy server stopped'));
|
|
532
|
+
}
|
|
533
|
+
catch { /* ignore */ }
|
|
534
|
+
});
|
|
535
|
+
// 超时强制 destroy
|
|
536
|
+
setTimeout(() => {
|
|
537
|
+
this.sockets.forEach(socket => { try {
|
|
538
|
+
socket.destroy();
|
|
539
|
+
}
|
|
540
|
+
catch { /* ignore */ } });
|
|
541
|
+
finish();
|
|
542
|
+
}, Math.max(0, gracefulMs));
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
// 更新配置
|
|
546
|
+
// P2-18 检测到 port/host/tls 变更时,标记 needsRestart=true,UI 可读取并提示
|
|
547
|
+
_needsRestart = false;
|
|
548
|
+
updateConfig(config) {
|
|
549
|
+
// 标记需要重启的字段
|
|
550
|
+
const restartTriggerFields = ['port', 'host', 'tls', 'fallbackPort'];
|
|
551
|
+
const willRestart = restartTriggerFields.some(k => k in config && JSON.stringify(this.config[k]) !== JSON.stringify(config[k]));
|
|
552
|
+
if (willRestart && this.isRunning()) {
|
|
553
|
+
this._needsRestart = true;
|
|
554
|
+
logger_1.proxyLogger.warn('ProxyServer', `Config change requires restart: ${restartTriggerFields.filter(k => k in config).join(', ')}`);
|
|
555
|
+
}
|
|
556
|
+
this.appendAuditLog('config_changed', { fields: Object.keys(config), needsRestart: willRestart });
|
|
557
|
+
if (config.modelMappings) {
|
|
558
|
+
this.modelLoadBalanceState.clear();
|
|
559
|
+
}
|
|
560
|
+
this.config = { ...this.config, ...config };
|
|
561
|
+
this.normalizeAccountBalancingConfig();
|
|
562
|
+
// 同步账号选择策略到 accountPool
|
|
563
|
+
this.accountPool.setStrategy(this.config.accountSelectionStrategy || 'round-robin');
|
|
564
|
+
}
|
|
565
|
+
normalizeAccountBalancingConfig() {
|
|
566
|
+
const strategy = this.config.accountSelectionStrategy || 'round-robin';
|
|
567
|
+
this.config.accountSelectionStrategy = strategy;
|
|
568
|
+
if (this.config.enableMultiAccount && strategy !== 'sticky') {
|
|
569
|
+
this.config.sessionAffinityEnabled = false;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
isSessionAffinityActive() {
|
|
573
|
+
return Boolean(this.config.sessionAffinityEnabled &&
|
|
574
|
+
(this.config.accountSelectionStrategy || 'round-robin') === 'sticky');
|
|
575
|
+
}
|
|
576
|
+
/** UI 可用此判断是否需提示用户重启 */
|
|
577
|
+
needsRestart() {
|
|
578
|
+
return this._needsRestart;
|
|
579
|
+
}
|
|
580
|
+
/** 重启后调用清除 needsRestart 标记 */
|
|
581
|
+
async restartServer() {
|
|
582
|
+
if (!this.isRunning()) {
|
|
583
|
+
await this.start();
|
|
584
|
+
this._needsRestart = false;
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
await this.stop();
|
|
588
|
+
await this.start();
|
|
589
|
+
this._needsRestart = false;
|
|
590
|
+
}
|
|
591
|
+
// 获取配置
|
|
592
|
+
getConfig() {
|
|
593
|
+
return { ...this.config };
|
|
594
|
+
}
|
|
595
|
+
validateCacheControl(cacheControl) {
|
|
596
|
+
if (!cacheControl)
|
|
597
|
+
return;
|
|
598
|
+
if (cacheControl.type !== 'ephemeral') {
|
|
599
|
+
throw new Error(`Unsupported cache_control type: ${cacheControl.type}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
validateClaudeContentBlocks(blocks) {
|
|
603
|
+
blocks.forEach(block => {
|
|
604
|
+
this.validateCacheControl(block.cache_control);
|
|
605
|
+
if (Array.isArray(block.content)) {
|
|
606
|
+
this.validateClaudeContentBlocks(block.content);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
validateOpenAICacheControls(request) {
|
|
611
|
+
request.messages.forEach(message => {
|
|
612
|
+
this.validateCacheControl(message.cache_control);
|
|
613
|
+
if (Array.isArray(message.content)) {
|
|
614
|
+
message.content.forEach(part => this.validateCacheControl(part.cache_control));
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
request.tools?.forEach(tool => this.validateCacheControl(tool.cache_control));
|
|
618
|
+
}
|
|
619
|
+
validateClaudeCacheControls(request) {
|
|
620
|
+
if (Array.isArray(request.system)) {
|
|
621
|
+
request.system.forEach(block => this.validateCacheControl(block.cache_control));
|
|
622
|
+
}
|
|
623
|
+
request.messages.forEach(message => {
|
|
624
|
+
this.validateCacheControl(message.cache_control);
|
|
625
|
+
if (Array.isArray(message.content)) {
|
|
626
|
+
this.validateClaudeContentBlocks(message.content);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
request.tools?.forEach(tool => this.validateCacheControl(tool.cache_control));
|
|
630
|
+
}
|
|
631
|
+
async downloadImageDataUrl(url, signal) {
|
|
632
|
+
const controller = new AbortController();
|
|
633
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
634
|
+
const abort = () => controller.abort(this.getAbortError(signal));
|
|
635
|
+
try {
|
|
636
|
+
if (signal?.aborted)
|
|
637
|
+
throw this.getAbortError(signal);
|
|
638
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
639
|
+
const agent = (() => {
|
|
640
|
+
const { getSystemProxy, safeCreateProxyAgent } = require('./systemProxy');
|
|
641
|
+
const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
|
|
642
|
+
const envAgent = safeCreateProxyAgent(envProxy);
|
|
643
|
+
if (envAgent)
|
|
644
|
+
return envAgent;
|
|
645
|
+
return safeCreateProxyAgent(getSystemProxy());
|
|
646
|
+
})();
|
|
647
|
+
const { fetch: undiciFetch } = require('undici');
|
|
648
|
+
const response = agent
|
|
649
|
+
? await undiciFetch(url, { signal: controller.signal, dispatcher: agent })
|
|
650
|
+
: await fetch(url, { signal: controller.signal });
|
|
651
|
+
if (!response.ok) {
|
|
652
|
+
throw new Error(`Failed to download image: HTTP ${response.status}`);
|
|
653
|
+
}
|
|
654
|
+
const contentType = response.headers.get('content-type')?.split(';')[0]?.toLowerCase();
|
|
655
|
+
if (!contentType || !['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(contentType)) {
|
|
656
|
+
throw new Error(`Unsupported image content-type: ${contentType || 'unknown'}`);
|
|
657
|
+
}
|
|
658
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
659
|
+
if (arrayBuffer.byteLength > 10 * 1024 * 1024) {
|
|
660
|
+
throw new Error('Image exceeds 10MB limit');
|
|
661
|
+
}
|
|
662
|
+
return `data:${contentType};base64,${Buffer.from(arrayBuffer).toString('base64')}`;
|
|
663
|
+
}
|
|
664
|
+
finally {
|
|
665
|
+
clearTimeout(timeout);
|
|
666
|
+
signal?.removeEventListener('abort', abort);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async resolveOpenAIHttpImages(request, signal) {
|
|
670
|
+
await Promise.all(request.messages.map(async (message) => {
|
|
671
|
+
if (!Array.isArray(message.content))
|
|
672
|
+
return;
|
|
673
|
+
await Promise.all(message.content.map(async (part) => {
|
|
674
|
+
if (part.type !== 'image_url' || !part.image_url?.url.startsWith('http'))
|
|
675
|
+
return;
|
|
676
|
+
part.image_url.url = await this.downloadImageDataUrl(part.image_url.url, signal);
|
|
677
|
+
}));
|
|
678
|
+
}));
|
|
679
|
+
return request;
|
|
680
|
+
}
|
|
681
|
+
async resolveClaudeHttpImages(request, signal) {
|
|
682
|
+
await Promise.all(request.messages.map(async (message) => {
|
|
683
|
+
if (!Array.isArray(message.content))
|
|
684
|
+
return;
|
|
685
|
+
await Promise.all(message.content.map(async (block) => {
|
|
686
|
+
if (block.type !== 'image' || block.source?.type !== 'url')
|
|
687
|
+
return;
|
|
688
|
+
const dataUrl = await this.downloadImageDataUrl(block.source.url, signal);
|
|
689
|
+
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
|
690
|
+
if (!match) {
|
|
691
|
+
throw new Error('Downloaded image produced invalid data URL');
|
|
692
|
+
}
|
|
693
|
+
block.source = { type: 'base64', media_type: match[1], data: match[2] };
|
|
694
|
+
}));
|
|
695
|
+
}));
|
|
696
|
+
return request;
|
|
697
|
+
}
|
|
698
|
+
prepareOpenAIRequest(request) {
|
|
699
|
+
this.validateOpenAICacheControls(request);
|
|
700
|
+
if (this.config.disableTools || request.tool_choice === 'none') {
|
|
701
|
+
return { ...request, tools: undefined, tool_choice: undefined };
|
|
702
|
+
}
|
|
703
|
+
if (request.tool_choice && typeof request.tool_choice === 'object' && request.tool_choice.type === 'function' && !request.tool_choice.function?.name) {
|
|
704
|
+
throw new Error('tool_choice function requires a tool name');
|
|
705
|
+
}
|
|
706
|
+
if (request.tool_choice && typeof request.tool_choice === 'object' && request.tool_choice.function?.name) {
|
|
707
|
+
const selectedToolName = request.tool_choice.function.name;
|
|
708
|
+
if (!request.tools?.some(tool => tool.function.name === selectedToolName)) {
|
|
709
|
+
throw new Error(`tool_choice references unknown tool: ${selectedToolName}`);
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
...request,
|
|
713
|
+
tools: request.tools?.filter(tool => tool.function.name === selectedToolName)
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
return request;
|
|
717
|
+
}
|
|
718
|
+
prepareClaudeRequest(request) {
|
|
719
|
+
this.validateClaudeCacheControls(request);
|
|
720
|
+
if (this.config.disableTools || request.tool_choice?.type === 'none') {
|
|
721
|
+
return { ...request, tools: undefined, tool_choice: undefined };
|
|
722
|
+
}
|
|
723
|
+
if (request.tool_choice?.type === 'tool' && !request.tool_choice.name) {
|
|
724
|
+
throw new Error('tool_choice tool requires a tool name');
|
|
725
|
+
}
|
|
726
|
+
if (request.tool_choice?.name) {
|
|
727
|
+
const selectedToolName = request.tool_choice.name;
|
|
728
|
+
if (!request.tools?.some(tool => tool.name === selectedToolName)) {
|
|
729
|
+
throw new Error(`tool_choice references unknown tool: ${selectedToolName}`);
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
...request,
|
|
733
|
+
tools: request.tools?.filter(tool => tool.name === selectedToolName)
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return request;
|
|
737
|
+
}
|
|
738
|
+
// 获取统计信息
|
|
739
|
+
getStats() {
|
|
740
|
+
const poolStats = this.accountPool.getStats();
|
|
741
|
+
// 返回可序列化的统计信息(Map 对象在 IPC 中无法正确序列化)
|
|
742
|
+
return {
|
|
743
|
+
totalRequests: this.stats.totalRequests,
|
|
744
|
+
successRequests: this.stats.successRequests,
|
|
745
|
+
failedRequests: this.stats.failedRequests,
|
|
746
|
+
totalTokens: this.stats.totalTokens,
|
|
747
|
+
totalCredits: this.stats.totalCredits,
|
|
748
|
+
inputTokens: this.stats.inputTokens,
|
|
749
|
+
outputTokens: this.stats.outputTokens,
|
|
750
|
+
cacheReadTokens: this.stats.cacheReadTokens,
|
|
751
|
+
cacheWriteTokens: this.stats.cacheWriteTokens,
|
|
752
|
+
reasoningTokens: this.stats.reasoningTokens,
|
|
753
|
+
startTime: this.stats.startTime,
|
|
754
|
+
accountStats: poolStats.accounts,
|
|
755
|
+
endpointStats: this.stats.endpointStats,
|
|
756
|
+
modelStats: this.stats.modelStats,
|
|
757
|
+
recentRequests: this.stats.recentRequests
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
// 获取账号池
|
|
761
|
+
getAccountPool() {
|
|
762
|
+
return this.accountPool;
|
|
763
|
+
}
|
|
764
|
+
// 设置初始累计 credits(用于从持久化存储恢复)
|
|
765
|
+
setTotalCredits(credits) {
|
|
766
|
+
this.stats.totalCredits = credits;
|
|
767
|
+
}
|
|
768
|
+
// 重置累计 credits
|
|
769
|
+
resetTotalCredits() {
|
|
770
|
+
this.stats.totalCredits = 0;
|
|
771
|
+
this.events.onCreditsUpdate?.(0);
|
|
772
|
+
}
|
|
773
|
+
// 设置初始累计 tokens(用于从持久化存储恢复)
|
|
774
|
+
setTotalTokens(inputTokens, outputTokens) {
|
|
775
|
+
this.stats.inputTokens = inputTokens;
|
|
776
|
+
this.stats.outputTokens = outputTokens;
|
|
777
|
+
this.stats.totalTokens = inputTokens + outputTokens;
|
|
778
|
+
}
|
|
779
|
+
// 重置累计 tokens
|
|
780
|
+
resetTotalTokens() {
|
|
781
|
+
this.stats.inputTokens = 0;
|
|
782
|
+
this.stats.outputTokens = 0;
|
|
783
|
+
this.stats.totalTokens = 0;
|
|
784
|
+
}
|
|
785
|
+
// 设置请求统计(用于从持久化存储恢复)
|
|
786
|
+
setRequestStats(totalRequests, successRequests, failedRequests) {
|
|
787
|
+
this.stats.totalRequests = totalRequests;
|
|
788
|
+
this.stats.successRequests = successRequests;
|
|
789
|
+
this.stats.failedRequests = failedRequests;
|
|
790
|
+
}
|
|
791
|
+
// 重置请求统计
|
|
792
|
+
resetRequestStats() {
|
|
793
|
+
this.stats.totalRequests = 0;
|
|
794
|
+
this.stats.successRequests = 0;
|
|
795
|
+
this.stats.failedRequests = 0;
|
|
796
|
+
this.notifyRequestStatsUpdate();
|
|
797
|
+
}
|
|
798
|
+
// 通知请求统计更新
|
|
799
|
+
notifyRequestStatsUpdate() {
|
|
800
|
+
this.events.onRequestStatsUpdate?.(this.stats.totalRequests, this.stats.successRequests, this.stats.failedRequests);
|
|
801
|
+
}
|
|
802
|
+
// 记录请求成功
|
|
803
|
+
recordRequestSuccess() {
|
|
804
|
+
this.stats.successRequests++;
|
|
805
|
+
this.sessionStats.successRequests++;
|
|
806
|
+
this.notifyRequestStatsUpdate();
|
|
807
|
+
}
|
|
808
|
+
// 记录请求失败
|
|
809
|
+
recordRequestFailed() {
|
|
810
|
+
this.stats.failedRequests++;
|
|
811
|
+
this.sessionStats.failedRequests++;
|
|
812
|
+
this.notifyRequestStatsUpdate();
|
|
813
|
+
}
|
|
814
|
+
// 记录新请求
|
|
815
|
+
recordNewRequest() {
|
|
816
|
+
this.stats.totalRequests++;
|
|
817
|
+
this.sessionStats.totalRequests++;
|
|
818
|
+
this.notifyRequestStatsUpdate();
|
|
819
|
+
}
|
|
820
|
+
// 获取会话统计(当前服务运行期间的统计)
|
|
821
|
+
getSessionStats() {
|
|
822
|
+
return { ...this.sessionStats };
|
|
823
|
+
}
|
|
824
|
+
// 是否运行中
|
|
825
|
+
isRunning() {
|
|
826
|
+
return this.server !== null;
|
|
827
|
+
}
|
|
828
|
+
getAbortError(signal) {
|
|
829
|
+
if (signal?.reason instanceof Error)
|
|
830
|
+
return signal.reason;
|
|
831
|
+
if (signal?.reason)
|
|
832
|
+
return new Error(String(signal.reason));
|
|
833
|
+
return new Error('Request aborted');
|
|
834
|
+
}
|
|
835
|
+
isAbortError(error, signal) {
|
|
836
|
+
return signal?.aborted === true
|
|
837
|
+
|| (error instanceof Error && (error.message.includes('Client disconnected') || error.message.includes('Proxy server stopped')));
|
|
838
|
+
}
|
|
839
|
+
throwIfAborted(signal) {
|
|
840
|
+
if (signal?.aborted)
|
|
841
|
+
throw this.getAbortError(signal);
|
|
842
|
+
}
|
|
843
|
+
throwIfResponseClosed(res, signal) {
|
|
844
|
+
this.throwIfAborted(signal);
|
|
845
|
+
if (res.writableEnded || res.destroyed)
|
|
846
|
+
throw new Error('Client disconnected');
|
|
847
|
+
}
|
|
848
|
+
isResponseClosed(res) {
|
|
849
|
+
return res.writableEnded || res.destroyed;
|
|
850
|
+
}
|
|
851
|
+
// 检测错误消息中是否包含账号被长期封禁的特征
|
|
852
|
+
// 返回 { reason, message } 表示需要标记 suspended;返回 null 表示非封禁错误
|
|
853
|
+
// 覆盖:
|
|
854
|
+
// - Kiro 后端 HTTP 403 + body: { reason: "TEMPORARILY_SUSPENDED", message: "..." }
|
|
855
|
+
// - CodeWhisperer AccountSuspendedException
|
|
856
|
+
// - 423 Locked
|
|
857
|
+
detectSuspendedError(errMsg) {
|
|
858
|
+
if (!errMsg)
|
|
859
|
+
return null;
|
|
860
|
+
// 1) 显式 reason: "TEMPORARILY_SUSPENDED" (Kiro 风控)
|
|
861
|
+
const reasonMatch = errMsg.match(/"reason"\s*:\s*"(TEMPORARILY_SUSPENDED|ACCOUNT_SUSPENDED|PERMANENTLY_SUSPENDED)"/i);
|
|
862
|
+
if (reasonMatch) {
|
|
863
|
+
// 尝试提取 message 字段
|
|
864
|
+
const msgMatch = errMsg.match(/"message"\s*:\s*"([^"]+)"/);
|
|
865
|
+
return { reason: reasonMatch[1].toUpperCase(), message: msgMatch?.[1] || errMsg };
|
|
866
|
+
}
|
|
867
|
+
// 2) 文本特征 "temporarily suspended" / "user id is ... suspended"
|
|
868
|
+
if (/User\s+ID\s+is\s+(temporarily\s+)?suspended/i.test(errMsg) || /temporarily\s+suspended/i.test(errMsg)) {
|
|
869
|
+
const msgMatch = errMsg.match(/"message"\s*:\s*"([^"]+)"/);
|
|
870
|
+
return { reason: 'TEMPORARILY_SUSPENDED', message: msgMatch?.[1] || errMsg };
|
|
871
|
+
}
|
|
872
|
+
// 3) AccountSuspendedException (CodeWhisperer)
|
|
873
|
+
if (errMsg.includes('AccountSuspendedException') || errMsg.includes('Account suspended')) {
|
|
874
|
+
const msgMatch = errMsg.match(/"message"\s*:\s*"([^"]+)"/);
|
|
875
|
+
return { reason: 'AccountSuspendedException', message: msgMatch?.[1] || errMsg };
|
|
876
|
+
}
|
|
877
|
+
// 4) HTTP 423 Locked
|
|
878
|
+
if (/\b423\b/.test(errMsg) && /locked|suspended/i.test(errMsg)) {
|
|
879
|
+
return { reason: 'ACCOUNT_LOCKED', message: errMsg };
|
|
880
|
+
}
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
waitForRetry(ms, signal) {
|
|
884
|
+
this.throwIfAborted(signal);
|
|
885
|
+
return new Promise((resolve, reject) => {
|
|
886
|
+
const timeout = setTimeout(() => {
|
|
887
|
+
signal?.removeEventListener('abort', abort);
|
|
888
|
+
resolve();
|
|
889
|
+
}, ms);
|
|
890
|
+
const abort = () => {
|
|
891
|
+
clearTimeout(timeout);
|
|
892
|
+
reject(this.getAbortError(signal));
|
|
893
|
+
};
|
|
894
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
getPacedModelGroup(modelId) {
|
|
898
|
+
if (!modelId)
|
|
899
|
+
return null;
|
|
900
|
+
const normalized = (0, modelCatalog_1.normalizeKiroModelIdForCompare)(modelId);
|
|
901
|
+
if (normalized.includes('opus'))
|
|
902
|
+
return 'opus';
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
async runWithModelPacing(account, modelId, signal, work) {
|
|
906
|
+
const group = this.getPacedModelGroup(modelId);
|
|
907
|
+
if (!group)
|
|
908
|
+
return await work();
|
|
909
|
+
const key = `${account.id}:${group}`;
|
|
910
|
+
const paceMs = group === 'opus' ? this.OPUS_MODEL_PACE_MS : 0;
|
|
911
|
+
const previous = this.modelPaceQueues.get(key) || Promise.resolve();
|
|
912
|
+
const queued = previous.catch(() => undefined).then(async () => {
|
|
913
|
+
this.throwIfAborted(signal);
|
|
914
|
+
const waitMs = Math.max(0, (this.modelPaceReadyAt.get(key) || 0) - Date.now());
|
|
915
|
+
if (waitMs > 0) {
|
|
916
|
+
logger_1.proxyLogger.info('ProxyServer', `Pacing ${modelId || group} on ${account.email || account.id.slice(0, 8)} for ${waitMs}ms`);
|
|
917
|
+
await this.waitForRetry(waitMs, signal);
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
return await work();
|
|
921
|
+
}
|
|
922
|
+
finally {
|
|
923
|
+
this.modelPaceReadyAt.set(key, Date.now() + paceMs);
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
const marker = queued.then(() => undefined, () => undefined);
|
|
927
|
+
this.modelPaceQueues.set(key, marker);
|
|
928
|
+
try {
|
|
929
|
+
return await queued;
|
|
930
|
+
}
|
|
931
|
+
finally {
|
|
932
|
+
if (this.modelPaceQueues.get(key) === marker)
|
|
933
|
+
this.modelPaceQueues.delete(key);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
async abortable(promise, signal) {
|
|
937
|
+
this.throwIfAborted(signal);
|
|
938
|
+
if (!signal)
|
|
939
|
+
return promise;
|
|
940
|
+
return await Promise.race([
|
|
941
|
+
promise,
|
|
942
|
+
new Promise((_, reject) => {
|
|
943
|
+
const abort = () => reject(this.getAbortError(signal));
|
|
944
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
945
|
+
promise.then(() => signal.removeEventListener('abort', abort), () => signal.removeEventListener('abort', abort));
|
|
946
|
+
})
|
|
947
|
+
]);
|
|
948
|
+
}
|
|
949
|
+
// 清除模型缓存,强制下次请求重新获取
|
|
950
|
+
clearModelCache() {
|
|
951
|
+
this.modelCache = null;
|
|
952
|
+
console.log('[ProxyServer] Model cache cleared');
|
|
953
|
+
}
|
|
954
|
+
// 获取可用模型列表
|
|
955
|
+
static mapKiroModelToApi(m) {
|
|
956
|
+
return {
|
|
957
|
+
id: m.modelId,
|
|
958
|
+
name: m.modelName,
|
|
959
|
+
description: m.description,
|
|
960
|
+
inputTypes: m.supportedInputTypes,
|
|
961
|
+
maxInputTokens: m.tokenLimits?.maxInputTokens,
|
|
962
|
+
maxOutputTokens: m.tokenLimits?.maxOutputTokens,
|
|
963
|
+
rateMultiplier: m.rateMultiplier,
|
|
964
|
+
rateUnit: m.rateUnit,
|
|
965
|
+
supportsThinking: !!m.additionalModelRequestFieldsSchema?.properties?.thinking,
|
|
966
|
+
thinkingEfforts: extractThinkingEfforts(m.additionalModelRequestFieldsSchema),
|
|
967
|
+
supportsPromptCaching: m.promptCaching?.supportsPromptCaching || false,
|
|
968
|
+
modelProvider: m.modelProvider || undefined
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
async getAvailableModels(signal) {
|
|
972
|
+
const now = Date.now();
|
|
973
|
+
let kiroModels;
|
|
974
|
+
let fromCache = false;
|
|
975
|
+
if (this.modelCache && (now - this.modelCache.timestamp) < this.MODEL_CACHE_TTL) {
|
|
976
|
+
kiroModels = this.modelCache.models;
|
|
977
|
+
fromCache = true;
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
this.throwIfAborted(signal);
|
|
981
|
+
const account = await this.getAvailableAccount(signal);
|
|
982
|
+
this.throwIfAborted(signal);
|
|
983
|
+
if (!account) {
|
|
984
|
+
return { models: [], fromCache: false };
|
|
985
|
+
}
|
|
986
|
+
try {
|
|
987
|
+
kiroModels = await (0, kiroApi_1.fetchKiroModels)(account, signal);
|
|
988
|
+
if (kiroModels.length > 0) {
|
|
989
|
+
this.modelCache = { models: kiroModels, timestamp: now };
|
|
990
|
+
// 同步到 kiroApi 的 ctx cache, 供 token 裁剪逻辑使用
|
|
991
|
+
for (const m of kiroModels) {
|
|
992
|
+
if (m.tokenLimits?.maxInputTokens) {
|
|
993
|
+
(0, kiroApi_1.setModelContextWindow)(m.modelId, m.tokenLimits.maxInputTokens);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
catch (error) {
|
|
999
|
+
if (this.isAbortError(error, signal))
|
|
1000
|
+
throw error;
|
|
1001
|
+
console.error('[ProxyServer] Failed to fetch models:', error);
|
|
1002
|
+
return { models: [], fromCache: false };
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
// 合并隐藏模型(与 /v1/models 端点一致)
|
|
1006
|
+
const modelIds = new Set(kiroModels.map(m => m.modelId));
|
|
1007
|
+
const presetModels = modelCatalog_1.KIRO_PROXY_MODEL_PRESETS.map(buildKiroPresetModel);
|
|
1008
|
+
const hiddenModels = [
|
|
1009
|
+
{ modelId: 'claude-3.7-sonnet', modelName: 'Claude 3.7 Sonnet', description: 'Claude 3.7 Sonnet (hidden)', supportedInputTypes: ['TEXT', 'IMAGE'], tokenLimits: { maxInputTokens: 200000, maxOutputTokens: 64000 } },
|
|
1010
|
+
{ modelId: 'simple-task', modelName: 'Simple Task', description: 'Kiro fast model (routes to Haiku)', supportedInputTypes: ['TEXT'], tokenLimits: { maxInputTokens: 200000, maxOutputTokens: 4096 } },
|
|
1011
|
+
{ modelId: 'CLAUDE_SONNET_4_20250514_V1_0', modelName: 'Claude Sonnet 4 (CW)', description: 'CodeWhisperer internal ID', supportedInputTypes: ['TEXT', 'IMAGE'], tokenLimits: { maxInputTokens: 200000, maxOutputTokens: 64000 } },
|
|
1012
|
+
{ modelId: 'CLAUDE_HAIKU_4_5_20251001_V1_0', modelName: 'Claude Haiku 4.5 (CW)', description: 'CodeWhisperer internal ID', supportedInputTypes: ['TEXT', 'IMAGE'], tokenLimits: { maxInputTokens: 200000, maxOutputTokens: 64000 } },
|
|
1013
|
+
{ modelId: 'CLAUDE_3_7_SONNET_20250219_V1_0', modelName: 'Claude 3.7 Sonnet (CW)', description: 'CodeWhisperer internal ID', supportedInputTypes: ['TEXT', 'IMAGE'], tokenLimits: { maxInputTokens: 200000, maxOutputTokens: 64000 } }
|
|
1014
|
+
];
|
|
1015
|
+
const merged = [...kiroModels];
|
|
1016
|
+
for (const model of [...presetModels, ...hiddenModels]) {
|
|
1017
|
+
if (!modelIds.has(model.modelId)) {
|
|
1018
|
+
modelIds.add(model.modelId);
|
|
1019
|
+
merged.push(model);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return { models: merged.map(ProxyServer.mapKiroModelToApi), fromCache };
|
|
1023
|
+
}
|
|
1024
|
+
// 检查 Token 是否需要刷新
|
|
1025
|
+
isTokenExpiringSoon(account) {
|
|
1026
|
+
if (!account.expiresAt)
|
|
1027
|
+
return false;
|
|
1028
|
+
const refreshBeforeMs = (this.config.tokenRefreshBeforeExpiry || 300) * 1000;
|
|
1029
|
+
return Date.now() + refreshBeforeMs >= account.expiresAt;
|
|
1030
|
+
}
|
|
1031
|
+
// 刷新 Token
|
|
1032
|
+
async refreshToken(account, signal) {
|
|
1033
|
+
this.throwIfAborted(signal);
|
|
1034
|
+
if (!this.events.onTokenRefresh) {
|
|
1035
|
+
console.warn('[ProxyServer] No token refresh callback configured');
|
|
1036
|
+
return false;
|
|
1037
|
+
}
|
|
1038
|
+
// 防止并发刷新
|
|
1039
|
+
if (this.refreshingTokens.has(account.id)) {
|
|
1040
|
+
console.log(`[ProxyServer] Token refresh already in progress for ${account.email || account.id}`);
|
|
1041
|
+
// 等待刷新完成
|
|
1042
|
+
await this.waitForRetry(1000, signal);
|
|
1043
|
+
return !this.isTokenExpiringSoon(this.accountPool.getAccount(account.id) || account);
|
|
1044
|
+
}
|
|
1045
|
+
this.refreshingTokens.add(account.id);
|
|
1046
|
+
console.log(`[ProxyServer] Refreshing token for ${account.email || account.id}`);
|
|
1047
|
+
try {
|
|
1048
|
+
// 随机延迟 0-3 秒,避免多账号同时刷新被识别为批量操作
|
|
1049
|
+
const jitter = Math.floor(Math.random() * 3000);
|
|
1050
|
+
if (jitter > 0)
|
|
1051
|
+
await this.waitForRetry(jitter, signal);
|
|
1052
|
+
const result = await this.abortable(this.events.onTokenRefresh(account), signal);
|
|
1053
|
+
if (result.success && result.accessToken) {
|
|
1054
|
+
// 更新账号池中的 Token
|
|
1055
|
+
this.accountPool.updateAccount(account.id, {
|
|
1056
|
+
accessToken: result.accessToken,
|
|
1057
|
+
refreshToken: result.refreshToken || account.refreshToken,
|
|
1058
|
+
expiresAt: result.expiresAt
|
|
1059
|
+
});
|
|
1060
|
+
// 通知外部更新
|
|
1061
|
+
this.events.onAccountUpdate?.({
|
|
1062
|
+
...account,
|
|
1063
|
+
accessToken: result.accessToken,
|
|
1064
|
+
refreshToken: result.refreshToken || account.refreshToken,
|
|
1065
|
+
expiresAt: result.expiresAt
|
|
1066
|
+
});
|
|
1067
|
+
console.log(`[ProxyServer] Token refreshed for ${account.email || account.id}`);
|
|
1068
|
+
return true;
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
console.error(`[ProxyServer] Token refresh failed for ${account.email || account.id}: ${result.error}`);
|
|
1072
|
+
this.accountPool.markNeedsRefresh(account.id);
|
|
1073
|
+
return false;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
catch (error) {
|
|
1077
|
+
if (this.isAbortError(error, signal))
|
|
1078
|
+
throw error;
|
|
1079
|
+
console.error(`[ProxyServer] Token refresh error for ${account.email || account.id}:`, error);
|
|
1080
|
+
this.accountPool.markNeedsRefresh(account.id);
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
finally {
|
|
1084
|
+
this.refreshingTokens.delete(account.id);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* 计算 API Key 允许使用的账号 ID 集合(P2-21)
|
|
1089
|
+
* 返回 undefined = 不限制(允许所有账号)
|
|
1090
|
+
*/
|
|
1091
|
+
getAllowedAccountIds(apiKeyId) {
|
|
1092
|
+
if (!apiKeyId)
|
|
1093
|
+
return undefined;
|
|
1094
|
+
const bindings = this.config.apiKeyAccountBindings?.[apiKeyId];
|
|
1095
|
+
if (!bindings || bindings.length === 0)
|
|
1096
|
+
return undefined;
|
|
1097
|
+
return new Set(bindings);
|
|
1098
|
+
}
|
|
1099
|
+
isAccountAllowedForRequest(account, apiKeyId) {
|
|
1100
|
+
if (!account)
|
|
1101
|
+
return true;
|
|
1102
|
+
const allowedIds = this.getAllowedAccountIds(apiKeyId);
|
|
1103
|
+
if (allowedIds && !allowedIds.has(account.id))
|
|
1104
|
+
return false;
|
|
1105
|
+
if (this.config.multiAccountSelectionMode === 'groups') {
|
|
1106
|
+
const allowedGroupIds = new Set(this.config.multiAccountGroupIds || []);
|
|
1107
|
+
const groupId = account.groupId || '__ungrouped__';
|
|
1108
|
+
if (!allowedGroupIds.has(groupId))
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1111
|
+
return true;
|
|
1112
|
+
}
|
|
1113
|
+
getNextAccountForRequest(excludeIds = new Set(), apiKeyId) {
|
|
1114
|
+
const excluded = new Set(excludeIds);
|
|
1115
|
+
for (const account of this.accountPool.getAllAccounts()) {
|
|
1116
|
+
if (!this.isAccountAllowedForRequest(account, apiKeyId))
|
|
1117
|
+
excluded.add(account.id);
|
|
1118
|
+
}
|
|
1119
|
+
return this.accountPool.getNextAccount(excluded);
|
|
1120
|
+
}
|
|
1121
|
+
// 获取可用账号(包含 Token 刷新检查)
|
|
1122
|
+
// P1-8 sessionHint:相同会话尽量复用同一账号(命中 prompt cache + 防风控)
|
|
1123
|
+
// P2-21 apiKeyId:用于过滤 API Key 允许使用的账号子集
|
|
1124
|
+
requiresModelCapabilitySelection(modelId) {
|
|
1125
|
+
if (!modelId)
|
|
1126
|
+
return false;
|
|
1127
|
+
const normalized = (0, modelCatalog_1.normalizeKiroModelIdForCompare)(modelId);
|
|
1128
|
+
return normalized.startsWith('claude-opus-')
|
|
1129
|
+
|| normalized === 'claude-sonnet-4.6'
|
|
1130
|
+
|| normalized === 'deepseek-3.2'
|
|
1131
|
+
|| normalized === 'qwen3-coder-next'
|
|
1132
|
+
|| normalized === 'glm-5'
|
|
1133
|
+
|| normalized.startsWith('minimax-');
|
|
1134
|
+
}
|
|
1135
|
+
async accountSupportsModel(account, modelId, signal) {
|
|
1136
|
+
const normalizedModelId = (0, modelCatalog_1.normalizeKiroModelIdForCompare)(modelId);
|
|
1137
|
+
const cached = this.accountModelCapabilityCache.get(account.id);
|
|
1138
|
+
if (cached && Date.now() - cached.timestamp < this.MODEL_CAPABILITY_CACHE_TTL) {
|
|
1139
|
+
return cached.modelIds.has(normalizedModelId);
|
|
1140
|
+
}
|
|
1141
|
+
try {
|
|
1142
|
+
let currentAccount = account;
|
|
1143
|
+
let models = await (0, kiroApi_1.fetchKiroModels)(currentAccount, signal);
|
|
1144
|
+
if (models.length === 0 && currentAccount.refreshToken) {
|
|
1145
|
+
const refreshed = await this.refreshToken(currentAccount, signal);
|
|
1146
|
+
if (refreshed) {
|
|
1147
|
+
currentAccount = this.accountPool.getAccount(currentAccount.id) || currentAccount;
|
|
1148
|
+
models = await (0, kiroApi_1.fetchKiroModels)(currentAccount, signal);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
const modelIds = new Set(models.map(model => (0, modelCatalog_1.normalizeKiroModelIdForCompare)(model.modelId)));
|
|
1152
|
+
this.accountModelCapabilityCache.set(currentAccount.id, { timestamp: Date.now(), modelIds });
|
|
1153
|
+
for (const model of models) {
|
|
1154
|
+
if (model.tokenLimits?.maxInputTokens)
|
|
1155
|
+
(0, kiroApi_1.setModelContextWindow)(model.modelId, model.tokenLimits.maxInputTokens);
|
|
1156
|
+
}
|
|
1157
|
+
return modelIds.has(normalizedModelId);
|
|
1158
|
+
}
|
|
1159
|
+
catch (error) {
|
|
1160
|
+
if (this.isAbortError(error, signal))
|
|
1161
|
+
throw error;
|
|
1162
|
+
logger_1.proxyLogger.warn('ProxyServer', `Failed to inspect models for ${account.email || account.id.slice(0, 8)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1163
|
+
this.accountModelCapabilityCache.set(account.id, { timestamp: Date.now(), modelIds: new Set() });
|
|
1164
|
+
return false;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
async getNextAccountForModel(excludeIds = new Set(), apiKeyId, modelId, signal) {
|
|
1168
|
+
if (!modelId || !this.requiresModelCapabilitySelection(modelId)) {
|
|
1169
|
+
return this.getNextAccountForRequest(excludeIds, apiKeyId);
|
|
1170
|
+
}
|
|
1171
|
+
const tried = new Set(excludeIds);
|
|
1172
|
+
const total = Math.max(this.accountPool.size, 1);
|
|
1173
|
+
for (let attempt = 0; attempt < total; attempt++) {
|
|
1174
|
+
this.throwIfAborted(signal);
|
|
1175
|
+
const account = this.getNextAccountForRequest(tried, apiKeyId);
|
|
1176
|
+
if (!account || tried.has(account.id))
|
|
1177
|
+
return null;
|
|
1178
|
+
tried.add(account.id);
|
|
1179
|
+
if (await this.accountSupportsModel(account, modelId, signal)) {
|
|
1180
|
+
const selectedAccount = this.accountPool.getAccount(account.id) || account;
|
|
1181
|
+
logger_1.proxyLogger.info('ProxyServer', `Selected ${selectedAccount.email || selectedAccount.id.slice(0, 8)} for model ${modelId}`);
|
|
1182
|
+
return selectedAccount;
|
|
1183
|
+
}
|
|
1184
|
+
logger_1.proxyLogger.info('ProxyServer', `Skipping ${account.email || account.id.slice(0, 8)} because model ${modelId} is not available`);
|
|
1185
|
+
}
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
async getAvailableAccount(signal, sessionHint, apiKeyId, modelId) {
|
|
1189
|
+
const isAllowed = (acc) => this.isAccountAllowedForRequest(acc, apiKeyId);
|
|
1190
|
+
const supportsRequestedModel = async (acc) => {
|
|
1191
|
+
return !modelId || !this.requiresModelCapabilitySelection(modelId) || await this.accountSupportsModel(acc, modelId, signal);
|
|
1192
|
+
};
|
|
1193
|
+
this.throwIfAborted(signal);
|
|
1194
|
+
// 如果 pool 为空,触发懒加载回调尝试同步账号(冷启动场景)
|
|
1195
|
+
if (this.accountPool.size === 0 && this.events.onPoolEmpty) {
|
|
1196
|
+
console.log('[ProxyServer] Account pool empty, triggering lazy sync...');
|
|
1197
|
+
await this.abortable(this.events.onPoolEmpty(), signal);
|
|
1198
|
+
}
|
|
1199
|
+
this.throwIfAborted(signal);
|
|
1200
|
+
// P1-8 会话粘性:优先复用已绑定的账号(同时受 API Key 绑定过滤)
|
|
1201
|
+
if (this.isSessionAffinityActive() && sessionHint) {
|
|
1202
|
+
const sticky = this.pickAccountWithAffinity(sessionHint);
|
|
1203
|
+
if (sticky && isAllowed(sticky) && await supportsRequestedModel(sticky)) {
|
|
1204
|
+
logger_1.proxyLogger.debug('ProxyServer', `Session affinity hit: ${sessionHint.slice(0, 16)} → ${sticky.email || sticky.id.slice(0, 8)}`);
|
|
1205
|
+
// 仍需检查 token 是否需要刷新
|
|
1206
|
+
if (this.isTokenExpiringSoon(sticky)) {
|
|
1207
|
+
const refreshed = await this.refreshToken(sticky, signal);
|
|
1208
|
+
if (refreshed) {
|
|
1209
|
+
return this.accountPool.getAccount(sticky.id) || sticky;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
return sticky;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
let account;
|
|
1218
|
+
if (this.config.enableMultiAccount) {
|
|
1219
|
+
account = await this.getNextAccountForModel(new Set(), apiKeyId, modelId, signal);
|
|
1220
|
+
if (!account) {
|
|
1221
|
+
const status = this.accountPool.getQuotaStatus();
|
|
1222
|
+
if (status.exhausted > 0 && status.available === 0) {
|
|
1223
|
+
console.log(`[ProxyServer] All accounts quota exhausted (${status.exhausted}/${status.total}), no available accounts`);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
else {
|
|
1228
|
+
// 禁用多账号轮询时,优先使用指定的账号
|
|
1229
|
+
if (this.config.selectedAccountIds && this.config.selectedAccountIds.length > 0) {
|
|
1230
|
+
// 使用指定的第一个账号
|
|
1231
|
+
account = this.accountPool.getAccount(this.config.selectedAccountIds[0]);
|
|
1232
|
+
// 检查指定账号是否配额耗尽,若是则尝试自动切换
|
|
1233
|
+
if (account && this.accountPool.isQuotaExhausted(account) && this.config.autoSwitchOnQuotaExhausted) {
|
|
1234
|
+
const nextAccount = this.accountPool.getNextAvailableAccount(account.id);
|
|
1235
|
+
if (nextAccount) {
|
|
1236
|
+
console.log(`[ProxyServer] Selected account ${account.email || account.id} quota exhausted, auto-switching to ${nextAccount.email || nextAccount.id}`);
|
|
1237
|
+
this.config.selectedAccountIds = [nextAccount.id];
|
|
1238
|
+
this.events.onAccountUpdate?.(nextAccount);
|
|
1239
|
+
account = nextAccount;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
if (!account) {
|
|
1243
|
+
console.log(`[ProxyServer] Selected account ${this.config.selectedAccountIds[0]} not found, using first available`);
|
|
1244
|
+
const allAccounts = this.accountPool.getAllAccounts();
|
|
1245
|
+
account = allAccounts.length > 0 ? allAccounts[0] : null;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
else {
|
|
1249
|
+
// 没有指定账号,使用第一个可用账号
|
|
1250
|
+
const allAccounts = this.accountPool.getAllAccounts();
|
|
1251
|
+
account = allAccounts.length > 0 ? allAccounts[0] : null;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (!account)
|
|
1255
|
+
return null;
|
|
1256
|
+
// 自动切换 K-Proxy 设备 ID(如果 K-Proxy 服务可用)
|
|
1257
|
+
this.syncKProxyDeviceId(account);
|
|
1258
|
+
// 检查是否需要刷新 Token
|
|
1259
|
+
if (this.isTokenExpiringSoon(account)) {
|
|
1260
|
+
const refreshed = await this.refreshToken(account, signal);
|
|
1261
|
+
if (!refreshed) {
|
|
1262
|
+
// 刷新失败,如果启用多账号才尝试获取下一个账号
|
|
1263
|
+
if (this.config.enableMultiAccount) {
|
|
1264
|
+
return await this.getNextAccountForModel(new Set([account.id]), apiKeyId, modelId, signal);
|
|
1265
|
+
}
|
|
1266
|
+
return null;
|
|
1267
|
+
}
|
|
1268
|
+
// 返回更新后的账号
|
|
1269
|
+
const refreshedAccount = this.accountPool.getAccount(account.id);
|
|
1270
|
+
if (refreshedAccount && sessionHint)
|
|
1271
|
+
this.rememberAffinity(sessionHint, refreshedAccount.id);
|
|
1272
|
+
return refreshedAccount;
|
|
1273
|
+
}
|
|
1274
|
+
if (sessionHint)
|
|
1275
|
+
this.rememberAffinity(sessionHint, account.id);
|
|
1276
|
+
return account;
|
|
1277
|
+
}
|
|
1278
|
+
// 同步 K-Proxy 设备 ID(根据账号自动切换)
|
|
1279
|
+
syncKProxyDeviceId(account) {
|
|
1280
|
+
const kproxyService = (0, kproxy_1.getKProxyService)();
|
|
1281
|
+
if (!kproxyService || !kproxyService.isRunning()) {
|
|
1282
|
+
return; // K-Proxy 未初始化或未运行
|
|
1283
|
+
}
|
|
1284
|
+
// 尝试切换到账号绑定的设备 ID
|
|
1285
|
+
const switched = kproxyService.switchToAccount(account.id);
|
|
1286
|
+
if (!switched) {
|
|
1287
|
+
// 账号没有绑定设备 ID,自动生成并绑定
|
|
1288
|
+
const newDeviceId = (0, kproxy_1.generateDeviceId)();
|
|
1289
|
+
kproxyService.addDeviceIdMapping({
|
|
1290
|
+
accountId: account.id,
|
|
1291
|
+
deviceId: newDeviceId,
|
|
1292
|
+
description: account.email || `Account ${account.id.substring(0, 8)}`,
|
|
1293
|
+
createdAt: Date.now()
|
|
1294
|
+
});
|
|
1295
|
+
kproxyService.setDeviceId(newDeviceId);
|
|
1296
|
+
logger_1.proxyLogger.info('ProxyServer', `Auto-generated device ID for account ${account.email || account.id.substring(0, 8)}`);
|
|
1297
|
+
}
|
|
1298
|
+
else {
|
|
1299
|
+
logger_1.proxyLogger.debug('ProxyServer', `Switched to device ID for account ${account.email || account.id.substring(0, 8)}`);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
// 带重试的 API 调用
|
|
1303
|
+
async callWithRetry(account, apiCall, _path, signal, apiKeyId, modelId) {
|
|
1304
|
+
const configuredRetries = Math.max(0, this.config.maxRetries ?? 3);
|
|
1305
|
+
const maxAttempts = Math.min(100, Math.max(configuredRetries + 1, this.accountPool.size));
|
|
1306
|
+
const retryDelay = this.config.retryDelayMs || 1000;
|
|
1307
|
+
let lastError = null;
|
|
1308
|
+
let currentAccount = account;
|
|
1309
|
+
// 本次请求累计已尝试的账号 ID,避免重试时循环命中已经失败过的账号
|
|
1310
|
+
const triedIds = new Set([account.id]);
|
|
1311
|
+
/** 切到下一个可用账号;多账号模式带 triedIds 排除,单账号场景退化为旧逻辑 */
|
|
1312
|
+
const switchToNextAccount = async () => {
|
|
1313
|
+
if (this.config.enableMultiAccount) {
|
|
1314
|
+
return await this.getNextAccountForModel(triedIds, apiKeyId, modelId, signal);
|
|
1315
|
+
}
|
|
1316
|
+
if (this.config.autoSwitchOnQuotaExhausted) {
|
|
1317
|
+
const next = this.accountPool.getNextAvailableAccount(triedIds);
|
|
1318
|
+
return this.isAccountAllowedForRequest(next, apiKeyId) ? next : null;
|
|
1319
|
+
}
|
|
1320
|
+
return null;
|
|
1321
|
+
};
|
|
1322
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1323
|
+
this.throwIfAborted(signal);
|
|
1324
|
+
try {
|
|
1325
|
+
const result = await this.runWithModelPacing(currentAccount, modelId, signal, () => apiCall(currentAccount, 0));
|
|
1326
|
+
return { result, account: currentAccount };
|
|
1327
|
+
}
|
|
1328
|
+
catch (error) {
|
|
1329
|
+
if (this.isAbortError(error, signal))
|
|
1330
|
+
throw error;
|
|
1331
|
+
lastError = error;
|
|
1332
|
+
const errMsg = lastError.message || '';
|
|
1333
|
+
console.log(`[ProxyServer] API call failed (attempt ${attempt + 1}/${maxAttempts}, account=${currentAccount.email || currentAccount.id.slice(0, 8)}): ${errMsg}`);
|
|
1334
|
+
// 优先检测账号被长期封禁(不是 token 问题,刷新也没用)
|
|
1335
|
+
// 特征:HTTP 403 + reason: "TEMPORARILY_SUSPENDED" 或 AccountSuspendedException / 423
|
|
1336
|
+
const suspendInfo = this.detectSuspendedError(errMsg);
|
|
1337
|
+
if (suspendInfo) {
|
|
1338
|
+
const newlyMarked = this.accountPool.markSuspended(currentAccount.id, suspendInfo.reason, suspendInfo.message);
|
|
1339
|
+
if (newlyMarked) {
|
|
1340
|
+
this.events.onAccountSuspended?.({
|
|
1341
|
+
accountId: currentAccount.id,
|
|
1342
|
+
email: currentAccount.email,
|
|
1343
|
+
reason: suspendInfo.reason,
|
|
1344
|
+
message: suspendInfo.message
|
|
1345
|
+
});
|
|
1346
|
+
// P1-6 关键事件 → 触发 webhook
|
|
1347
|
+
this.appendAuditLog('account_suspended', {
|
|
1348
|
+
accountId: currentAccount.id,
|
|
1349
|
+
email: currentAccount.email,
|
|
1350
|
+
reason: suspendInfo.reason
|
|
1351
|
+
});
|
|
1352
|
+
this.triggerWebhook('proxy-account-suspended', {
|
|
1353
|
+
title: '反代账号被风控',
|
|
1354
|
+
message: `账号 ${currentAccount.email || currentAccount.id.slice(0, 8)} 被 Kiro 后端标记为 ${suspendInfo.reason},需要人工解封`,
|
|
1355
|
+
level: 'error',
|
|
1356
|
+
fields: {
|
|
1357
|
+
邮箱: currentAccount.email || '-',
|
|
1358
|
+
账号ID: currentAccount.id.slice(0, 8),
|
|
1359
|
+
封禁原因: suspendInfo.reason,
|
|
1360
|
+
详情: this.sanitizeErrorMessage(suspendInfo.message || '').slice(0, 200)
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
console.warn(`[ProxyServer] Account ${currentAccount.email || currentAccount.id} suspended (${suspendInfo.reason}), switching to next available account`);
|
|
1365
|
+
// 切到下个可用账号(跳过被 suspended 的 + 本请求已试过的)
|
|
1366
|
+
const nextAccount = await switchToNextAccount();
|
|
1367
|
+
if (nextAccount && !triedIds.has(nextAccount.id)) {
|
|
1368
|
+
currentAccount = nextAccount;
|
|
1369
|
+
triedIds.add(nextAccount.id);
|
|
1370
|
+
if (!this.config.enableMultiAccount) {
|
|
1371
|
+
this.config.selectedAccountIds = [nextAccount.id];
|
|
1372
|
+
this.events.onAccountUpdate?.(nextAccount);
|
|
1373
|
+
}
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
// 无可切换的账号 → 直接抛出错误给客户端
|
|
1377
|
+
break;
|
|
1378
|
+
}
|
|
1379
|
+
if ((0, accountPool_1.isThrottleError)(errMsg)) {
|
|
1380
|
+
console.log(`[ProxyServer] Throttle error on ${currentAccount.email || currentAccount.id.slice(0, 8)}, switching account`);
|
|
1381
|
+
this.accountPool.recordError(currentAccount.id, accountPool_1.ErrorType.RECOVERABLE, 429);
|
|
1382
|
+
lastError.proxyFailureRecorded = true;
|
|
1383
|
+
const nextAccount = await switchToNextAccount();
|
|
1384
|
+
if (nextAccount && !triedIds.has(nextAccount.id)) {
|
|
1385
|
+
currentAccount = nextAccount;
|
|
1386
|
+
triedIds.add(nextAccount.id);
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
break;
|
|
1390
|
+
}
|
|
1391
|
+
if ((0, accountPool_1.isBillingOrQuotaError)(errMsg)) {
|
|
1392
|
+
console.log(`[ProxyServer] Billing/quota exhausted on ${currentAccount.email || currentAccount.id.slice(0, 8)}, switching account immediately`);
|
|
1393
|
+
this.accountPool.markQuotaExhausted(currentAccount.id);
|
|
1394
|
+
lastError.proxyFailureRecorded = true;
|
|
1395
|
+
const nextAccount = await switchToNextAccount();
|
|
1396
|
+
if (nextAccount && !triedIds.has(nextAccount.id)) {
|
|
1397
|
+
currentAccount = nextAccount;
|
|
1398
|
+
triedIds.add(nextAccount.id);
|
|
1399
|
+
if (!this.config.enableMultiAccount) {
|
|
1400
|
+
this.config.selectedAccountIds = [nextAccount.id];
|
|
1401
|
+
this.events.onAccountUpdate?.(nextAccount);
|
|
1402
|
+
}
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
break;
|
|
1406
|
+
}
|
|
1407
|
+
// 401/403: 尝试刷新 Token
|
|
1408
|
+
if (this.isInvalidModelForAccountError(errMsg)) {
|
|
1409
|
+
console.log(`[ProxyServer] Model not available on ${currentAccount.email || currentAccount.id.slice(0, 8)}, switching account`);
|
|
1410
|
+
const nextAccount = await switchToNextAccount();
|
|
1411
|
+
if (nextAccount && !triedIds.has(nextAccount.id)) {
|
|
1412
|
+
currentAccount = nextAccount;
|
|
1413
|
+
triedIds.add(nextAccount.id);
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
break;
|
|
1417
|
+
}
|
|
1418
|
+
if (errMsg.includes('401') || errMsg.includes('403') || errMsg.includes('Auth')) {
|
|
1419
|
+
console.log('[ProxyServer] Auth error, attempting token refresh');
|
|
1420
|
+
const refreshed = await this.refreshToken(currentAccount, signal);
|
|
1421
|
+
if (refreshed) {
|
|
1422
|
+
currentAccount = this.accountPool.getAccount(currentAccount.id) || currentAccount;
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
// 刷新失败 → 切到没试过的下个账号
|
|
1426
|
+
const nextAccount = await switchToNextAccount();
|
|
1427
|
+
if (nextAccount && !triedIds.has(nextAccount.id)) {
|
|
1428
|
+
currentAccount = nextAccount;
|
|
1429
|
+
triedIds.add(nextAccount.id);
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
// 5xx: 同账号短退避重试一次;再次 5xx 直接 fallback 到没试过的账号(瞬时故障跨账号绕过)
|
|
1434
|
+
if (errMsg.includes('500') || errMsg.includes('502') || errMsg.includes('503') || errMsg.includes('504')) {
|
|
1435
|
+
console.log('[ProxyServer] Server error, retrying');
|
|
1436
|
+
// 第二次及以后的 5xx → 切换账号(旧逻辑会同账号撞死)
|
|
1437
|
+
if (attempt > 0) {
|
|
1438
|
+
const nextAccount = await switchToNextAccount();
|
|
1439
|
+
if (nextAccount && !triedIds.has(nextAccount.id)) {
|
|
1440
|
+
this.accountPool.recordError(currentAccount.id, accountPool_1.ErrorType.RECOVERABLE, 503);
|
|
1441
|
+
console.log(`[ProxyServer] Persistent 5xx on ${currentAccount.email || currentAccount.id.slice(0, 8)}, switching account`);
|
|
1442
|
+
currentAccount = nextAccount;
|
|
1443
|
+
triedIds.add(nextAccount.id);
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
await this.waitForRetry(retryDelay * (attempt + 1), signal);
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
// 其他错误,不重试
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
const finalError = (lastError || new Error('Unknown error'));
|
|
1455
|
+
finalError.proxyAccountId = currentAccount.id;
|
|
1456
|
+
throw finalError;
|
|
1457
|
+
}
|
|
1458
|
+
recordAccountFailure(account, error) {
|
|
1459
|
+
const message = error.message || '';
|
|
1460
|
+
const statusMatch = message.match(/\b(\d{3})\b/);
|
|
1461
|
+
const statusCode = statusMatch ? Number(statusMatch[1]) : undefined;
|
|
1462
|
+
if ((0, accountPool_1.isThrottleError)(message)) {
|
|
1463
|
+
this.accountPool.recordError(account.id, accountPool_1.ErrorType.RECOVERABLE, 429);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
if ((0, accountPool_1.isBillingOrQuotaError)(message)) {
|
|
1467
|
+
if (!this.accountPool.isQuotaExhausted(account))
|
|
1468
|
+
this.accountPool.markQuotaExhausted(account.id);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
this.accountPool.recordError(account.id, statusCode && statusCode >= 500 ? accountPool_1.ErrorType.RECOVERABLE : (0, accountPool_1.classifyError)(statusCode || 500, message), statusCode);
|
|
1472
|
+
}
|
|
1473
|
+
isInvalidModelForAccountError(message) {
|
|
1474
|
+
return /invalid_model_id|invalid model id|please select a different model/i.test(message);
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Retry streaming requests before the first client-visible chunk. Once a
|
|
1478
|
+
* chunk is emitted the stream is committed and cannot safely change account.
|
|
1479
|
+
*/
|
|
1480
|
+
async callStreamWithFailover(initialAccount, payload, onChunk, onComplete, onError, path, signal, apiKeyId, modelId) {
|
|
1481
|
+
const configuredRetries = Math.max(0, this.config.maxRetries ?? 3);
|
|
1482
|
+
const maxAttempts = Math.min(100, Math.max(configuredRetries + 1, this.accountPool.size));
|
|
1483
|
+
const triedIds = new Set([initialAccount.id]);
|
|
1484
|
+
let currentAccount = initialAccount;
|
|
1485
|
+
let streamCommitted = false;
|
|
1486
|
+
let lastError = new Error('Unknown stream error');
|
|
1487
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1488
|
+
this.throwIfAborted(signal);
|
|
1489
|
+
const outcome = await this.runWithModelPacing(currentAccount, modelId, signal, () => new Promise((resolve) => {
|
|
1490
|
+
(0, kiroApi_1.callKiroApiStream)(currentAccount, payload, (text, toolUse, isThinking, reasoningSignature, redactedContent) => {
|
|
1491
|
+
streamCommitted = true;
|
|
1492
|
+
onChunk(currentAccount, text, toolUse, isThinking, reasoningSignature, redactedContent);
|
|
1493
|
+
}, (usage) => {
|
|
1494
|
+
streamCommitted = true;
|
|
1495
|
+
onComplete(currentAccount, usage);
|
|
1496
|
+
resolve({ completed: true });
|
|
1497
|
+
}, (error) => resolve({ completed: false, error }), signal, this.config.preferredEndpoint).catch((error) => resolve({ completed: false, error: error }));
|
|
1498
|
+
}));
|
|
1499
|
+
if (outcome.completed)
|
|
1500
|
+
return;
|
|
1501
|
+
if (this.isAbortError(outcome.error, signal))
|
|
1502
|
+
throw outcome.error;
|
|
1503
|
+
lastError = outcome.error || lastError;
|
|
1504
|
+
if (streamCommitted) {
|
|
1505
|
+
this.recordAccountFailure(currentAccount, lastError);
|
|
1506
|
+
onError(currentAccount, lastError);
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
const message = lastError.message || '';
|
|
1510
|
+
const invalidModelError = this.isInvalidModelForAccountError(message);
|
|
1511
|
+
const suspendInfo = this.detectSuspendedError(message);
|
|
1512
|
+
if (suspendInfo) {
|
|
1513
|
+
const newlyMarked = this.accountPool.markSuspended(currentAccount.id, suspendInfo.reason, suspendInfo.message);
|
|
1514
|
+
if (newlyMarked) {
|
|
1515
|
+
this.events.onAccountSuspended?.({
|
|
1516
|
+
accountId: currentAccount.id,
|
|
1517
|
+
email: currentAccount.email,
|
|
1518
|
+
reason: suspendInfo.reason,
|
|
1519
|
+
message: suspendInfo.message
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
else if ((0, accountPool_1.isBillingOrQuotaError)(message) || (0, accountPool_1.isThrottleError)(message) || /\b5\d\d\b/.test(message)) {
|
|
1524
|
+
this.recordAccountFailure(currentAccount, lastError);
|
|
1525
|
+
}
|
|
1526
|
+
else if (message.includes('401') || message.includes('403') || message.includes('Auth')) {
|
|
1527
|
+
const refreshed = await this.refreshToken(currentAccount, signal);
|
|
1528
|
+
if (refreshed) {
|
|
1529
|
+
currentAccount = this.accountPool.getAccount(currentAccount.id) || currentAccount;
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
else if (invalidModelError) {
|
|
1534
|
+
logger_1.proxyLogger.info('ProxyServer', `Streaming model not available on ${currentAccount.email || currentAccount.id.slice(0, 8)}, switching account`);
|
|
1535
|
+
}
|
|
1536
|
+
else {
|
|
1537
|
+
this.recordAccountFailure(currentAccount, lastError);
|
|
1538
|
+
}
|
|
1539
|
+
const retryable = Boolean(suspendInfo)
|
|
1540
|
+
|| (0, accountPool_1.isBillingOrQuotaError)(message)
|
|
1541
|
+
|| (0, accountPool_1.isThrottleError)(message)
|
|
1542
|
+
|| message.includes('401')
|
|
1543
|
+
|| message.includes('403')
|
|
1544
|
+
|| message.includes('Auth')
|
|
1545
|
+
|| invalidModelError
|
|
1546
|
+
|| /\b5\d\d\b/.test(message);
|
|
1547
|
+
if (!retryable)
|
|
1548
|
+
break;
|
|
1549
|
+
const nextAccount = await this.getNextAccountForModel(triedIds, apiKeyId, modelId, signal);
|
|
1550
|
+
if (!nextAccount || triedIds.has(nextAccount.id))
|
|
1551
|
+
break;
|
|
1552
|
+
console.log(`[ProxyServer] Streaming failover ${path}: ${currentAccount.email || currentAccount.id.slice(0, 8)} -> ${nextAccount.email || nextAccount.id.slice(0, 8)}`);
|
|
1553
|
+
currentAccount = nextAccount;
|
|
1554
|
+
triedIds.add(nextAccount.id);
|
|
1555
|
+
}
|
|
1556
|
+
onError(currentAccount, lastError);
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* 常数时间字符串比较(防时序攻击)
|
|
1560
|
+
* 长度不同时返回 false 但仍走一次 timingSafeEqual 防止旁路
|
|
1561
|
+
*/
|
|
1562
|
+
safeStringEq(a, b) {
|
|
1563
|
+
// Buffer.from 处理 UTF-8 编码
|
|
1564
|
+
const ab = Buffer.from(a, 'utf8');
|
|
1565
|
+
const bb = Buffer.from(b, 'utf8');
|
|
1566
|
+
if (ab.length !== bb.length) {
|
|
1567
|
+
// 仍执行一次比较保证常数时间(用 a 自身比,结果不影响)
|
|
1568
|
+
try {
|
|
1569
|
+
crypto_1.default.timingSafeEqual(ab, ab);
|
|
1570
|
+
}
|
|
1571
|
+
catch { /* ignore */ }
|
|
1572
|
+
return false;
|
|
1573
|
+
}
|
|
1574
|
+
try {
|
|
1575
|
+
return crypto_1.default.timingSafeEqual(ab, bb);
|
|
1576
|
+
}
|
|
1577
|
+
catch {
|
|
1578
|
+
return false;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
// 验证 API Key 并返回匹配的 Key(用于统计)
|
|
1582
|
+
// P0-3 使用 timingSafeEqual 防止时序攻击逐字猜 Key
|
|
1583
|
+
validateApiKey(req) {
|
|
1584
|
+
// 如果没有配置任何 API Key,则跳过验证
|
|
1585
|
+
const hasApiKeys = this.config.apiKeys && this.config.apiKeys.length > 0;
|
|
1586
|
+
const hasLegacyKey = !!this.config.apiKey;
|
|
1587
|
+
if (!hasApiKeys && !hasLegacyKey)
|
|
1588
|
+
return { valid: true };
|
|
1589
|
+
// 从 Authorization 头或 X-Api-Key 头获取 API Key
|
|
1590
|
+
const authHeader = req.headers['authorization'] || '';
|
|
1591
|
+
const apiKeyHeader = req.headers['x-api-key'] || '';
|
|
1592
|
+
let providedKey = '';
|
|
1593
|
+
// Bearer token 格式
|
|
1594
|
+
if (authHeader.startsWith('Bearer ')) {
|
|
1595
|
+
providedKey = authHeader.slice(7);
|
|
1596
|
+
}
|
|
1597
|
+
// 直接 API Key 格式
|
|
1598
|
+
if (!providedKey && apiKeyHeader) {
|
|
1599
|
+
providedKey = apiKeyHeader;
|
|
1600
|
+
}
|
|
1601
|
+
if (!providedKey)
|
|
1602
|
+
return { valid: false };
|
|
1603
|
+
// 检查多 API Key(常数时间比较)
|
|
1604
|
+
if (hasApiKeys) {
|
|
1605
|
+
let matched;
|
|
1606
|
+
for (const k of this.config.apiKeys) {
|
|
1607
|
+
if (!k.enabled || !k.key)
|
|
1608
|
+
continue;
|
|
1609
|
+
if (this.safeStringEq(k.key, providedKey)) {
|
|
1610
|
+
matched = k;
|
|
1611
|
+
// 不 break:继续遍历保持时间一致(小数量数组 OK)
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
if (matched) {
|
|
1615
|
+
if (matched.creditsLimit && matched.usage.totalCredits >= matched.creditsLimit) {
|
|
1616
|
+
return { valid: false, reason: 'Credits limit exceeded' };
|
|
1617
|
+
}
|
|
1618
|
+
return { valid: true, apiKey: matched };
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
// 兼容旧的单 API Key(常数时间比较)
|
|
1622
|
+
if (hasLegacyKey && this.safeStringEq(this.config.apiKey, providedKey)) {
|
|
1623
|
+
return { valid: true };
|
|
1624
|
+
}
|
|
1625
|
+
return { valid: false };
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* P0-4 IP 访问控制
|
|
1629
|
+
* - deniedIPs 优先:命中即拒绝
|
|
1630
|
+
* - allowedIPs 配置后:必须在列表内(白名单模式)
|
|
1631
|
+
* - 都未配置:允许
|
|
1632
|
+
* 支持单 IP 和 CIDR(IPv4 / IPv6 简化处理)
|
|
1633
|
+
*/
|
|
1634
|
+
isClientIPAllowed(clientIP) {
|
|
1635
|
+
if (!clientIP)
|
|
1636
|
+
return { allowed: true };
|
|
1637
|
+
// 规范化(::ffff:1.2.3.4 → 1.2.3.4)
|
|
1638
|
+
const ip = clientIP.startsWith('::ffff:') ? clientIP.slice(7) : clientIP;
|
|
1639
|
+
const matchEntry = (entry) => {
|
|
1640
|
+
const e = entry.trim();
|
|
1641
|
+
if (!e)
|
|
1642
|
+
return false;
|
|
1643
|
+
// CIDR
|
|
1644
|
+
if (e.includes('/')) {
|
|
1645
|
+
return this.ipInCidr(ip, e);
|
|
1646
|
+
}
|
|
1647
|
+
return e === ip;
|
|
1648
|
+
};
|
|
1649
|
+
const denied = this.config.deniedIPs?.find(matchEntry);
|
|
1650
|
+
if (denied)
|
|
1651
|
+
return { allowed: false, reason: `IP ${ip} matches denied entry ${denied}` };
|
|
1652
|
+
const allowList = this.config.allowedIPs;
|
|
1653
|
+
if (allowList && allowList.length > 0) {
|
|
1654
|
+
const allowed = allowList.some(matchEntry);
|
|
1655
|
+
if (!allowed)
|
|
1656
|
+
return { allowed: false, reason: `IP ${ip} not in allowed list` };
|
|
1657
|
+
}
|
|
1658
|
+
return { allowed: true };
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* 简化 IPv4/IPv6 CIDR 匹配(不依赖外部库)
|
|
1662
|
+
* IPv4 CIDR:1.2.3.0/24;IPv6 CIDR:仅前缀逐 bit 比较
|
|
1663
|
+
*/
|
|
1664
|
+
ipInCidr(ip, cidr) {
|
|
1665
|
+
const [range, bitsStr] = cidr.split('/');
|
|
1666
|
+
const bits = parseInt(bitsStr, 10);
|
|
1667
|
+
if (!Number.isFinite(bits))
|
|
1668
|
+
return false;
|
|
1669
|
+
const isV4 = ip.includes('.') && range.includes('.');
|
|
1670
|
+
if (isV4) {
|
|
1671
|
+
const ipNum = this.ipv4ToInt(ip);
|
|
1672
|
+
const rangeNum = this.ipv4ToInt(range);
|
|
1673
|
+
if (ipNum < 0 || rangeNum < 0)
|
|
1674
|
+
return false;
|
|
1675
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
|
1676
|
+
return (ipNum & mask) === (rangeNum & mask);
|
|
1677
|
+
}
|
|
1678
|
+
// IPv6 简化:转字节数组 + 前缀逐 bit 比较
|
|
1679
|
+
const ipBytes = this.ipv6ToBytes(ip);
|
|
1680
|
+
const rangeBytes = this.ipv6ToBytes(range);
|
|
1681
|
+
if (!ipBytes || !rangeBytes)
|
|
1682
|
+
return false;
|
|
1683
|
+
let bitsLeft = bits;
|
|
1684
|
+
for (let i = 0; i < 16 && bitsLeft > 0; i++) {
|
|
1685
|
+
if (bitsLeft >= 8) {
|
|
1686
|
+
if (ipBytes[i] !== rangeBytes[i])
|
|
1687
|
+
return false;
|
|
1688
|
+
bitsLeft -= 8;
|
|
1689
|
+
}
|
|
1690
|
+
else {
|
|
1691
|
+
const mask = (0xff << (8 - bitsLeft)) & 0xff;
|
|
1692
|
+
if ((ipBytes[i] & mask) !== (rangeBytes[i] & mask))
|
|
1693
|
+
return false;
|
|
1694
|
+
bitsLeft = 0;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
return true;
|
|
1698
|
+
}
|
|
1699
|
+
ipv4ToInt(ip) {
|
|
1700
|
+
const parts = ip.split('.').map(p => parseInt(p, 10));
|
|
1701
|
+
if (parts.length !== 4 || parts.some(p => !Number.isFinite(p) || p < 0 || p > 255))
|
|
1702
|
+
return -1;
|
|
1703
|
+
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
1704
|
+
}
|
|
1705
|
+
ipv6ToBytes(ip) {
|
|
1706
|
+
try {
|
|
1707
|
+
// 简化处理:支持 :: 缩写
|
|
1708
|
+
const parts = ip.split('::');
|
|
1709
|
+
let head = [];
|
|
1710
|
+
let tail = [];
|
|
1711
|
+
if (parts.length === 1) {
|
|
1712
|
+
head = parts[0].split(':');
|
|
1713
|
+
}
|
|
1714
|
+
else if (parts.length === 2) {
|
|
1715
|
+
head = parts[0] ? parts[0].split(':') : [];
|
|
1716
|
+
tail = parts[1] ? parts[1].split(':') : [];
|
|
1717
|
+
}
|
|
1718
|
+
else {
|
|
1719
|
+
return null;
|
|
1720
|
+
}
|
|
1721
|
+
const missing = 8 - head.length - tail.length;
|
|
1722
|
+
if (missing < 0)
|
|
1723
|
+
return null;
|
|
1724
|
+
const segments = [...head, ...new Array(missing).fill('0'), ...tail];
|
|
1725
|
+
const bytes = new Uint8Array(16);
|
|
1726
|
+
for (let i = 0; i < 8; i++) {
|
|
1727
|
+
const v = parseInt(segments[i] || '0', 16);
|
|
1728
|
+
if (!Number.isFinite(v) || v < 0 || v > 0xffff)
|
|
1729
|
+
return null;
|
|
1730
|
+
bytes[i * 2] = (v >> 8) & 0xff;
|
|
1731
|
+
bytes[i * 2 + 1] = v & 0xff;
|
|
1732
|
+
}
|
|
1733
|
+
return bytes;
|
|
1734
|
+
}
|
|
1735
|
+
catch {
|
|
1736
|
+
return null;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
/** 取客户端真实 IP(不信任 X-Forwarded-For,仅取 socket address) */
|
|
1740
|
+
getClientIP(req) {
|
|
1741
|
+
return req.socket.remoteAddress || '';
|
|
1742
|
+
}
|
|
1743
|
+
// 记录 API Key 用量
|
|
1744
|
+
recordApiKeyUsage(apiKeyId, credits, inputTokens, outputTokens, model, path) {
|
|
1745
|
+
if (!this.config.apiKeys)
|
|
1746
|
+
return;
|
|
1747
|
+
const apiKey = this.config.apiKeys.find(k => k.id === apiKeyId);
|
|
1748
|
+
if (!apiKey)
|
|
1749
|
+
return;
|
|
1750
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1751
|
+
const now = Date.now();
|
|
1752
|
+
// 更新总计
|
|
1753
|
+
apiKey.usage.totalRequests++;
|
|
1754
|
+
apiKey.usage.totalCredits += credits;
|
|
1755
|
+
apiKey.usage.totalInputTokens += inputTokens;
|
|
1756
|
+
apiKey.usage.totalOutputTokens += outputTokens;
|
|
1757
|
+
apiKey.lastUsedAt = now;
|
|
1758
|
+
// 更新日统计
|
|
1759
|
+
if (!apiKey.usage.daily[today]) {
|
|
1760
|
+
apiKey.usage.daily[today] = { requests: 0, credits: 0, inputTokens: 0, outputTokens: 0 };
|
|
1761
|
+
}
|
|
1762
|
+
apiKey.usage.daily[today].requests++;
|
|
1763
|
+
apiKey.usage.daily[today].credits += credits;
|
|
1764
|
+
apiKey.usage.daily[today].inputTokens += inputTokens;
|
|
1765
|
+
apiKey.usage.daily[today].outputTokens += outputTokens;
|
|
1766
|
+
// 更新模型统计
|
|
1767
|
+
if (model) {
|
|
1768
|
+
if (!apiKey.usage.byModel) {
|
|
1769
|
+
apiKey.usage.byModel = {};
|
|
1770
|
+
}
|
|
1771
|
+
if (!apiKey.usage.byModel[model]) {
|
|
1772
|
+
apiKey.usage.byModel[model] = { requests: 0, credits: 0, inputTokens: 0, outputTokens: 0 };
|
|
1773
|
+
}
|
|
1774
|
+
apiKey.usage.byModel[model].requests++;
|
|
1775
|
+
apiKey.usage.byModel[model].credits += credits;
|
|
1776
|
+
apiKey.usage.byModel[model].inputTokens += inputTokens;
|
|
1777
|
+
apiKey.usage.byModel[model].outputTokens += outputTokens;
|
|
1778
|
+
}
|
|
1779
|
+
// 添加用量历史记录(保留最近 100 条)
|
|
1780
|
+
if (!apiKey.usageHistory) {
|
|
1781
|
+
apiKey.usageHistory = [];
|
|
1782
|
+
}
|
|
1783
|
+
apiKey.usageHistory.unshift({
|
|
1784
|
+
timestamp: now,
|
|
1785
|
+
model: model || 'unknown',
|
|
1786
|
+
inputTokens,
|
|
1787
|
+
outputTokens,
|
|
1788
|
+
credits,
|
|
1789
|
+
path: path || 'unknown'
|
|
1790
|
+
});
|
|
1791
|
+
if (apiKey.usageHistory.length > 100) {
|
|
1792
|
+
apiKey.usageHistory = apiKey.usageHistory.slice(0, 100);
|
|
1793
|
+
}
|
|
1794
|
+
// 触发配置保存事件
|
|
1795
|
+
this.events.onConfigChanged?.(this.config);
|
|
1796
|
+
}
|
|
1797
|
+
// 应用模型映射
|
|
1798
|
+
pickWeightedRoundRobinModel(rule, targets, requestedModel, apiKeyId) {
|
|
1799
|
+
const weights = targets.map((_, index) => {
|
|
1800
|
+
const raw = rule.weights?.[index];
|
|
1801
|
+
return Number.isFinite(raw) && raw && raw > 0 ? Math.min(50, Math.floor(raw)) : 1;
|
|
1802
|
+
});
|
|
1803
|
+
const expandedTargets = [];
|
|
1804
|
+
targets.forEach((target, index) => {
|
|
1805
|
+
for (let i = 0; i < weights[index]; i++)
|
|
1806
|
+
expandedTargets.push(target);
|
|
1807
|
+
});
|
|
1808
|
+
if (expandedTargets.length === 0)
|
|
1809
|
+
return targets[0];
|
|
1810
|
+
const key = [
|
|
1811
|
+
rule.id,
|
|
1812
|
+
rule.name,
|
|
1813
|
+
requestedModel.toLowerCase(),
|
|
1814
|
+
apiKeyId || '*',
|
|
1815
|
+
targets.join('|'),
|
|
1816
|
+
weights.join(',')
|
|
1817
|
+
].join('::');
|
|
1818
|
+
const index = this.modelLoadBalanceState.get(key) || 0;
|
|
1819
|
+
const target = expandedTargets[index % expandedTargets.length];
|
|
1820
|
+
this.modelLoadBalanceState.set(key, (index + 1) % expandedTargets.length);
|
|
1821
|
+
return target;
|
|
1822
|
+
}
|
|
1823
|
+
applyModelMapping(requestedModel, apiKeyId) {
|
|
1824
|
+
const mappings = this.config.modelMappings;
|
|
1825
|
+
if (!mappings || mappings.length === 0)
|
|
1826
|
+
return requestedModel;
|
|
1827
|
+
// 按优先级排序(数字越小优先级越高)
|
|
1828
|
+
const sortedMappings = [...mappings].sort((a, b) => a.priority - b.priority);
|
|
1829
|
+
for (const rule of sortedMappings) {
|
|
1830
|
+
// 检查规则是否启用
|
|
1831
|
+
if (!rule.enabled)
|
|
1832
|
+
continue;
|
|
1833
|
+
// 检查是否适用于当前 API Key
|
|
1834
|
+
if (rule.apiKeyIds && rule.apiKeyIds.length > 0 && apiKeyId) {
|
|
1835
|
+
if (!rule.apiKeyIds.includes(apiKeyId))
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1838
|
+
// 检查源模型是否匹配(支持通配符 *)
|
|
1839
|
+
const sourcePattern = rule.sourceModel.replace(/\*/g, '.*');
|
|
1840
|
+
const regex = new RegExp(`^${sourcePattern}$`, 'i');
|
|
1841
|
+
if (!regex.test(requestedModel))
|
|
1842
|
+
continue;
|
|
1843
|
+
// 匹配成功,根据类型选择目标模型
|
|
1844
|
+
const validTargets = rule.targetModels.filter(t => t.trim());
|
|
1845
|
+
if (validTargets.length === 0)
|
|
1846
|
+
continue;
|
|
1847
|
+
let targetModel;
|
|
1848
|
+
if (rule.type === 'loadbalance' && validTargets.length > 1) {
|
|
1849
|
+
// 负载均衡:根据权重轮询,低流量下也能均匀分配
|
|
1850
|
+
targetModel = this.pickWeightedRoundRobinModel(rule, validTargets, requestedModel, apiKeyId);
|
|
1851
|
+
}
|
|
1852
|
+
else {
|
|
1853
|
+
// replace 或 alias:直接使用第一个目标
|
|
1854
|
+
targetModel = validTargets[0];
|
|
1855
|
+
}
|
|
1856
|
+
logger_1.proxyLogger.info('ProxyServer', `Model mapping applied: ${requestedModel} -> ${targetModel} (rule: ${rule.name}, type: ${rule.type})`);
|
|
1857
|
+
return targetModel;
|
|
1858
|
+
}
|
|
1859
|
+
return requestedModel;
|
|
1860
|
+
}
|
|
1861
|
+
// 处理请求
|
|
1862
|
+
async handleRequest(req, res) {
|
|
1863
|
+
const path = req.url || '/';
|
|
1864
|
+
const method = req.method || 'GET';
|
|
1865
|
+
const clientIP = this.getClientIP(req);
|
|
1866
|
+
const controller = new AbortController();
|
|
1867
|
+
const abortRequest = () => {
|
|
1868
|
+
if (!this.isStopping && res.writableEnded)
|
|
1869
|
+
return;
|
|
1870
|
+
if (!controller.signal.aborted) {
|
|
1871
|
+
controller.abort(new Error(this.isStopping ? 'Proxy server stopped' : 'Client disconnected'));
|
|
1872
|
+
}
|
|
1873
|
+
};
|
|
1874
|
+
this.activeRequests.add(controller);
|
|
1875
|
+
req.on('aborted', abortRequest);
|
|
1876
|
+
res.on('close', abortRequest);
|
|
1877
|
+
// CORS 预检
|
|
1878
|
+
if (method === 'OPTIONS') {
|
|
1879
|
+
this.setCorsHeaders(res);
|
|
1880
|
+
res.writeHead(204);
|
|
1881
|
+
res.end();
|
|
1882
|
+
req.off('aborted', abortRequest);
|
|
1883
|
+
res.off('close', abortRequest);
|
|
1884
|
+
this.activeRequests.delete(controller);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
try {
|
|
1888
|
+
this.setCorsHeaders(res);
|
|
1889
|
+
// P0-4 IP 访问控制(健康检查也走,防止扫描器)
|
|
1890
|
+
const ipCheck = this.isClientIPAllowed(clientIP);
|
|
1891
|
+
if (!ipCheck.allowed) {
|
|
1892
|
+
logger_1.proxyLogger.warn('ProxyServer', `Blocked request from ${clientIP}: ${ipCheck.reason}`);
|
|
1893
|
+
this.appendAuditLog('ip_blocked', { ip: clientIP, path, reason: ipCheck.reason });
|
|
1894
|
+
this.sendError(res, 403, 'Forbidden');
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
// API Key 验证(健康检查端点除外)
|
|
1898
|
+
if (path !== '/health' && path !== '/') {
|
|
1899
|
+
const authResult = this.validateApiKey(req);
|
|
1900
|
+
if (!authResult.valid) {
|
|
1901
|
+
const errorMsg = authResult.reason || 'Invalid or missing API key';
|
|
1902
|
+
const statusCode = authResult.reason === 'Credits limit exceeded' ? 429 : 401;
|
|
1903
|
+
// 401 不返回 reason 详情(防止指纹爬取)
|
|
1904
|
+
this.sendError(res, statusCode, statusCode === 401 ? 'Unauthorized' : errorMsg, this.isAnthropicPath(path) ? 'anthropic' : 'openai');
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
// 将匹配的 API Key 存储到请求对象中,用于后续统计
|
|
1908
|
+
;
|
|
1909
|
+
req.matchedApiKey = authResult.apiKey;
|
|
1910
|
+
// P1-7 按 API Key(或匿名时按 IP)请求限流
|
|
1911
|
+
const rateLimitId = authResult.apiKey?.id || `ip:${clientIP || 'unknown'}`;
|
|
1912
|
+
const rl = this.checkRateLimit(rateLimitId);
|
|
1913
|
+
if (!rl.allowed) {
|
|
1914
|
+
res.setHeader('Retry-After', String(Math.ceil(rl.retryAfterMs / 1000)));
|
|
1915
|
+
res.setHeader('X-RateLimit-Limit', String(this.config.rateLimitPerKeyPerMinute || 0));
|
|
1916
|
+
res.setHeader('X-RateLimit-Remaining', '0');
|
|
1917
|
+
this.sendError(res, 429, 'Rate limit exceeded', this.isAnthropicPath(path) ? 'anthropic' : 'openai');
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
// 记录请求
|
|
1922
|
+
if (this.config.logRequests) {
|
|
1923
|
+
logger_1.proxyLogger.info('ProxyServer', `${method} ${path}`);
|
|
1924
|
+
}
|
|
1925
|
+
// 路由(移除查询参数)
|
|
1926
|
+
const pathWithoutQuery = path.split('?')[0];
|
|
1927
|
+
if (pathWithoutQuery === '/v1/models' || pathWithoutQuery === '/models') {
|
|
1928
|
+
await this.handleModels(res, controller.signal);
|
|
1929
|
+
}
|
|
1930
|
+
else if (pathWithoutQuery === '/v1/chat/completions' || pathWithoutQuery === '/chat/completions') {
|
|
1931
|
+
await this.handleOpenAIChat(req, res, controller.signal);
|
|
1932
|
+
}
|
|
1933
|
+
else if (pathWithoutQuery === '/v1/responses' || pathWithoutQuery === '/responses') {
|
|
1934
|
+
await this.handleOpenAIResponses(req, res, controller.signal);
|
|
1935
|
+
}
|
|
1936
|
+
else if (pathWithoutQuery === '/v1/messages' || pathWithoutQuery === '/messages' || pathWithoutQuery === '/anthropic/v1/messages') {
|
|
1937
|
+
await this.handleClaudeMessages(req, res, controller.signal);
|
|
1938
|
+
}
|
|
1939
|
+
else if (pathWithoutQuery === '/v1/messages/count_tokens' || pathWithoutQuery === '/messages/count_tokens') {
|
|
1940
|
+
// Claude Code token 计数端点 - 返回模拟响应
|
|
1941
|
+
await this.handleCountTokens(req, res, controller.signal);
|
|
1942
|
+
}
|
|
1943
|
+
else if (pathWithoutQuery === '/api/event_logging/batch') {
|
|
1944
|
+
// Claude Code 遥测端点 - 直接返回 200 OK
|
|
1945
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1946
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
1947
|
+
}
|
|
1948
|
+
else if (pathWithoutQuery.startsWith('/v1beta/models/')) {
|
|
1949
|
+
// Gemini v1beta 兼容路由
|
|
1950
|
+
await this.handleGeminiRequest(req, res, pathWithoutQuery, controller.signal);
|
|
1951
|
+
}
|
|
1952
|
+
else if (pathWithoutQuery === '/v1beta/models') {
|
|
1953
|
+
// Gemini 模型列表
|
|
1954
|
+
await this.handleGeminiModels(res, controller.signal);
|
|
1955
|
+
}
|
|
1956
|
+
else if (pathWithoutQuery === '/health' || pathWithoutQuery === '/') {
|
|
1957
|
+
this.handleHealth(res);
|
|
1958
|
+
}
|
|
1959
|
+
else if (pathWithoutQuery === '/metrics' && this.config.enableMetrics) {
|
|
1960
|
+
// P2-16 Prometheus metrics
|
|
1961
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' });
|
|
1962
|
+
res.end(this.renderPrometheusMetrics());
|
|
1963
|
+
}
|
|
1964
|
+
else if (pathWithoutQuery.startsWith('/admin/')) {
|
|
1965
|
+
// 管理 API 端点
|
|
1966
|
+
await this.handleAdminApi(req, res, pathWithoutQuery, controller.signal);
|
|
1967
|
+
}
|
|
1968
|
+
else {
|
|
1969
|
+
// 记录未知路径以便调试
|
|
1970
|
+
console.log(`[ProxyServer] Unknown path: ${path} (method: ${method})`);
|
|
1971
|
+
this.sendError(res, 404, `Not Found: ${pathWithoutQuery}`);
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
catch (error) {
|
|
1975
|
+
if (this.isAbortError(error, controller.signal)) {
|
|
1976
|
+
logger_1.proxyLogger.info('ProxyServer', `Request aborted: ${method} ${path}`);
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
// P0-1 body 超限 → 413
|
|
1980
|
+
if (error instanceof BodyTooLargeError) {
|
|
1981
|
+
logger_1.proxyLogger.warn('ProxyServer', `Body too large from ${clientIP}: ${error.received}/${error.limit} bytes (${path})`);
|
|
1982
|
+
this.sendError(res, 413, `Request body too large (max ${error.limit} bytes)`, this.isAnthropicPath(path) ? 'anthropic' : 'openai');
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
// P0-5 错误响应 sanitize:500 类不吐内部 message
|
|
1986
|
+
console.error('[ProxyServer] Request error:', error);
|
|
1987
|
+
this.sendError(res, 500, 'Internal server error', this.isAnthropicPath(path) ? 'anthropic' : 'openai');
|
|
1988
|
+
this.events.onError?.(error);
|
|
1989
|
+
}
|
|
1990
|
+
finally {
|
|
1991
|
+
req.off('aborted', abortRequest);
|
|
1992
|
+
res.off('close', abortRequest);
|
|
1993
|
+
this.activeRequests.delete(controller);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
// 管理 API 端点
|
|
1997
|
+
async handleAdminApi(req, res, path, signal) {
|
|
1998
|
+
const method = req.method || 'GET';
|
|
1999
|
+
// 管理 API 需要 API Key 验证
|
|
2000
|
+
const authResult = this.validateApiKey(req);
|
|
2001
|
+
if (!authResult.valid) {
|
|
2002
|
+
this.sendError(res, 401, 'Admin API requires authentication');
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
if (path === '/admin/stats' && method === 'GET') {
|
|
2006
|
+
// 获取详细统计
|
|
2007
|
+
this.handleAdminStats(res);
|
|
2008
|
+
}
|
|
2009
|
+
else if (path === '/admin/accounts' && method === 'GET') {
|
|
2010
|
+
// 获取账号列表
|
|
2011
|
+
this.handleAdminAccounts(res);
|
|
2012
|
+
}
|
|
2013
|
+
else if (path === '/admin/config' && method === 'GET') {
|
|
2014
|
+
// 获取配置
|
|
2015
|
+
this.handleAdminConfig(res);
|
|
2016
|
+
}
|
|
2017
|
+
else if (path === '/admin/config' && method === 'POST') {
|
|
2018
|
+
// 更新配置(P1-9 schema 白名单校验,防止任意字段注入)
|
|
2019
|
+
const body = await this.readBody(req, signal);
|
|
2020
|
+
let parsed;
|
|
2021
|
+
try {
|
|
2022
|
+
parsed = JSON.parse(body);
|
|
2023
|
+
}
|
|
2024
|
+
catch {
|
|
2025
|
+
this.sendError(res, 400, 'Invalid JSON body');
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
const safeUpdate = this.filterAdminConfigUpdate(parsed);
|
|
2029
|
+
this.updateConfig(safeUpdate);
|
|
2030
|
+
this.appendAuditLog('config_updated', { fields: Object.keys(safeUpdate) });
|
|
2031
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2032
|
+
res.end(JSON.stringify({ success: true, applied: Object.keys(safeUpdate), config: this.handleAdminConfigPayload() }));
|
|
2033
|
+
}
|
|
2034
|
+
else if (path === '/admin/audit' && method === 'GET') {
|
|
2035
|
+
// P2-17 审计日志
|
|
2036
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2037
|
+
res.end(JSON.stringify({ entries: this.auditLog.slice(-100) }));
|
|
2038
|
+
}
|
|
2039
|
+
else if (path === '/admin/logs' && method === 'GET') {
|
|
2040
|
+
// 获取最近日志
|
|
2041
|
+
this.handleAdminLogs(res);
|
|
2042
|
+
}
|
|
2043
|
+
else if (path === '/admin/cache/clear' && method === 'POST') {
|
|
2044
|
+
// 清除内存缓存(conversationId 映射、模型缓存、prompt cache)
|
|
2045
|
+
const { clearAllCaches } = require('./kiroApi');
|
|
2046
|
+
const cleared = clearAllCaches();
|
|
2047
|
+
const promptCacheCleared = promptCacheTracker_1.promptCacheTracker.clear();
|
|
2048
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2049
|
+
res.end(JSON.stringify({ success: true, cleared: { ...cleared, promptCache: promptCacheCleared } }));
|
|
2050
|
+
}
|
|
2051
|
+
else {
|
|
2052
|
+
this.sendError(res, 404, 'Admin endpoint not found');
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
// 管理 API - 详细统计
|
|
2056
|
+
handleAdminStats(res) {
|
|
2057
|
+
const stats = this.getStats();
|
|
2058
|
+
const accountStats = {};
|
|
2059
|
+
stats.accountStats.forEach((v, k) => { accountStats[k] = v; });
|
|
2060
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2061
|
+
res.end(JSON.stringify({
|
|
2062
|
+
totalRequests: stats.totalRequests,
|
|
2063
|
+
successRequests: stats.successRequests,
|
|
2064
|
+
failedRequests: stats.failedRequests,
|
|
2065
|
+
totalTokens: stats.totalTokens,
|
|
2066
|
+
inputTokens: stats.inputTokens,
|
|
2067
|
+
outputTokens: stats.outputTokens,
|
|
2068
|
+
uptime: Date.now() - stats.startTime,
|
|
2069
|
+
startTime: stats.startTime,
|
|
2070
|
+
accountStats,
|
|
2071
|
+
recentRequests: stats.recentRequests.slice(-50)
|
|
2072
|
+
}));
|
|
2073
|
+
}
|
|
2074
|
+
// 管理 API - 账号列表
|
|
2075
|
+
handleAdminAccounts(res) {
|
|
2076
|
+
const accounts = this.accountPool.getAllAccounts().map(acc => ({
|
|
2077
|
+
id: acc.id,
|
|
2078
|
+
email: acc.email,
|
|
2079
|
+
isAvailable: acc.isAvailable !== false,
|
|
2080
|
+
lastUsed: acc.lastUsed,
|
|
2081
|
+
requestCount: acc.requestCount || 0,
|
|
2082
|
+
errorCount: acc.errorCount || 0,
|
|
2083
|
+
expiresAt: acc.expiresAt,
|
|
2084
|
+
authMethod: acc.authMethod
|
|
2085
|
+
}));
|
|
2086
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2087
|
+
res.end(JSON.stringify({
|
|
2088
|
+
total: accounts.length,
|
|
2089
|
+
available: accounts.filter(a => a.isAvailable).length,
|
|
2090
|
+
accounts
|
|
2091
|
+
}));
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* P1-12 构造脱敏后的配置(apiKeys[].key 全部脱敏,tls 私钥不返回)
|
|
2095
|
+
* 暴露给 /admin/config GET
|
|
2096
|
+
*/
|
|
2097
|
+
handleAdminConfigPayload() {
|
|
2098
|
+
const config = this.getConfig();
|
|
2099
|
+
const maskKey = (k) => {
|
|
2100
|
+
if (!k)
|
|
2101
|
+
return undefined;
|
|
2102
|
+
if (k.length <= 8)
|
|
2103
|
+
return '***';
|
|
2104
|
+
return `${k.slice(0, 4)}***${k.slice(-4)}`;
|
|
2105
|
+
};
|
|
2106
|
+
return {
|
|
2107
|
+
...config,
|
|
2108
|
+
apiKey: maskKey(config.apiKey),
|
|
2109
|
+
apiKeys: config.apiKeys?.map(k => ({ ...k, key: maskKey(k.key) || '***' })),
|
|
2110
|
+
tls: config.tls ? { enabled: config.tls.enabled, hasCert: !!(config.tls.cert || config.tls.certPath), hasKey: !!(config.tls.key || config.tls.keyPath) } : undefined
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
// 管理 API - 配置
|
|
2114
|
+
handleAdminConfig(res) {
|
|
2115
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2116
|
+
res.end(JSON.stringify(this.handleAdminConfigPayload()));
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* P1-9 admin/config POST 字段白名单过滤
|
|
2120
|
+
* 仅允许"可远程改"的字段;apiKeys/apiKey 等敏感字段必须通过本地 IPC 改
|
|
2121
|
+
*/
|
|
2122
|
+
filterAdminConfigUpdate(input) {
|
|
2123
|
+
const allowed = [
|
|
2124
|
+
'enabled', 'enableMultiAccount', 'logRequests', 'logStreamEvents',
|
|
2125
|
+
'maxConcurrent', 'maxRetries', 'retryDelayMs', 'preferredEndpoint',
|
|
2126
|
+
'tokenRefreshBeforeExpiry', 'autoStart', 'clientDrivenToolExecution',
|
|
2127
|
+
'disableTools', 'payloadSizeLimitKB', 'enableTokenBufferReserve',
|
|
2128
|
+
'tokenBufferReserve', 'autoSwitchOnQuotaExhausted', 'accountSelectionStrategy',
|
|
2129
|
+
'multiAccountSelectionMode', 'multiAccountGroupIds', 'modelMappings',
|
|
2130
|
+
'maxRequestBodyBytes', 'allowedIPs', 'deniedIPs',
|
|
2131
|
+
'rateLimitPerKeyPerMinute', 'sessionAffinityEnabled',
|
|
2132
|
+
'keepAliveTimeoutMs', 'headersTimeoutMs', 'recentRequestsLimit',
|
|
2133
|
+
'enableMetrics', 'apiKeyGroupBindings', 'enableAuditLog'
|
|
2134
|
+
// 故意排除:port / host / apiKey / apiKeys / tls / fallbackPort / allowExternalWithoutApiKey
|
|
2135
|
+
// 这些字段会改变监听行为或安全策略,必须本地 IPC 改
|
|
2136
|
+
];
|
|
2137
|
+
const out = {};
|
|
2138
|
+
for (const key of allowed) {
|
|
2139
|
+
if (key in input) {
|
|
2140
|
+
out[key] = input[key];
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
return out;
|
|
2144
|
+
}
|
|
2145
|
+
// 管理 API - 日志
|
|
2146
|
+
handleAdminLogs(res) {
|
|
2147
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2148
|
+
res.end(JSON.stringify({
|
|
2149
|
+
recentRequests: this.stats.recentRequests.slice(-100)
|
|
2150
|
+
}));
|
|
2151
|
+
}
|
|
2152
|
+
// 设置 CORS 头
|
|
2153
|
+
setCorsHeaders(res) {
|
|
2154
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
2155
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
2156
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Api-Key, anthropic-version, anthropic-beta, x-api-key, x-stainless-os, x-stainless-lang, x-stainless-package-version, x-stainless-runtime, x-stainless-runtime-version, x-stainless-arch');
|
|
2157
|
+
res.setHeader('Access-Control-Expose-Headers', 'x-request-id, x-ratelimit-limit-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-requests, x-ratelimit-remaining-tokens, x-ratelimit-reset-requests, x-ratelimit-reset-tokens');
|
|
2158
|
+
}
|
|
2159
|
+
isAnthropicPath(path) {
|
|
2160
|
+
const pathWithoutQuery = path.split('?')[0];
|
|
2161
|
+
return pathWithoutQuery === '/v1/messages'
|
|
2162
|
+
|| pathWithoutQuery === '/messages'
|
|
2163
|
+
|| pathWithoutQuery === '/anthropic/v1/messages'
|
|
2164
|
+
|| pathWithoutQuery === '/v1/messages/count_tokens'
|
|
2165
|
+
|| pathWithoutQuery === '/messages/count_tokens';
|
|
2166
|
+
}
|
|
2167
|
+
getAnthropicErrorType(status) {
|
|
2168
|
+
if (status === 400)
|
|
2169
|
+
return 'invalid_request_error';
|
|
2170
|
+
if (status === 401)
|
|
2171
|
+
return 'authentication_error';
|
|
2172
|
+
if (status === 403)
|
|
2173
|
+
return 'permission_error';
|
|
2174
|
+
if (status === 404)
|
|
2175
|
+
return 'not_found_error';
|
|
2176
|
+
if (status === 429)
|
|
2177
|
+
return 'rate_limit_error';
|
|
2178
|
+
return 'api_error';
|
|
2179
|
+
}
|
|
2180
|
+
buildClaudeUsage(usage, simulatedCache) {
|
|
2181
|
+
// 优先使用 Kiro 后端返回的真实 cache tokens,否则用模拟器的值
|
|
2182
|
+
const cacheWrite = usage.cacheWriteTokens || simulatedCache?.cacheCreationInputTokens || 0;
|
|
2183
|
+
const cacheRead = usage.cacheReadTokens || simulatedCache?.cacheReadInputTokens || 0;
|
|
2184
|
+
// Kiro 的 inputTokens 是全量(含缓存),Anthropic API 规范中 input_tokens 不含缓存部分
|
|
2185
|
+
// 需要扣除 cache tokens 避免客户端双重计费
|
|
2186
|
+
const adjustedInput = Math.max(0, usage.inputTokens - cacheWrite - cacheRead);
|
|
2187
|
+
return {
|
|
2188
|
+
input_tokens: adjustedInput,
|
|
2189
|
+
output_tokens: usage.outputTokens,
|
|
2190
|
+
...(cacheWrite ? { cache_creation_input_tokens: cacheWrite } : {}),
|
|
2191
|
+
...(cacheRead ? { cache_read_input_tokens: cacheRead } : {})
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
estimateTokenCount(value) {
|
|
2195
|
+
if (value === null || value === undefined)
|
|
2196
|
+
return 0;
|
|
2197
|
+
if (typeof value === 'string')
|
|
2198
|
+
return Math.ceil(value.length / 4);
|
|
2199
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
2200
|
+
return 1;
|
|
2201
|
+
if (Array.isArray(value)) {
|
|
2202
|
+
return value.reduce((total, item) => total + this.estimateTokenCount(item), 0);
|
|
2203
|
+
}
|
|
2204
|
+
if (typeof value !== 'object')
|
|
2205
|
+
return 0;
|
|
2206
|
+
const record = value;
|
|
2207
|
+
if (record.type === 'text' || record.type === 'input_text' || record.type === 'output_text')
|
|
2208
|
+
return this.estimateTokenCount(record.text) + 4;
|
|
2209
|
+
if (record.type === 'thinking')
|
|
2210
|
+
return this.estimateTokenCount(record.thinking) + this.estimateTokenCount(record.signature) + 4;
|
|
2211
|
+
if (record.type === 'redacted_thinking')
|
|
2212
|
+
return 8;
|
|
2213
|
+
if (record.type === 'image' || record.type === 'input_image')
|
|
2214
|
+
return 170;
|
|
2215
|
+
if (record.type === 'document' || record.type === 'input_file')
|
|
2216
|
+
return this.estimateTokenCount(record.title) + this.estimateTokenCount(record.name) + this.estimateTokenCount(record.filename) + this.estimateTokenCount(record.source) + this.estimateTokenCount(record.file_data) + 120;
|
|
2217
|
+
if (record.type === 'tool_use')
|
|
2218
|
+
return this.estimateTokenCount(record.name) + this.estimateTokenCount(record.input) + 12;
|
|
2219
|
+
if (record.type === 'tool_result')
|
|
2220
|
+
return this.estimateTokenCount(record.content) + 8;
|
|
2221
|
+
if (typeof record.role === 'string' && 'content' in record)
|
|
2222
|
+
return this.estimateTokenCount(record.content) + 4;
|
|
2223
|
+
if (typeof record.name === 'string' && 'input_schema' in record)
|
|
2224
|
+
return this.estimateTokenCount(record.name) + this.estimateTokenCount(record.description) + this.estimateTokenCount(record.input_schema) + 32;
|
|
2225
|
+
return Object.entries(record).reduce((total, [key, item]) => key === 'cache_control' ? total : total + this.estimateTokenCount(item), 0);
|
|
2226
|
+
}
|
|
2227
|
+
// 健康检查
|
|
2228
|
+
handleHealth(res) {
|
|
2229
|
+
const stats = this.getStats();
|
|
2230
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2231
|
+
res.end(JSON.stringify({
|
|
2232
|
+
status: 'ok',
|
|
2233
|
+
version: '1.0.0',
|
|
2234
|
+
accounts: this.accountPool.size,
|
|
2235
|
+
availableAccounts: this.accountPool.availableCount,
|
|
2236
|
+
stats: {
|
|
2237
|
+
totalRequests: stats.totalRequests,
|
|
2238
|
+
successRequests: stats.successRequests,
|
|
2239
|
+
failedRequests: stats.failedRequests,
|
|
2240
|
+
totalTokens: stats.totalTokens,
|
|
2241
|
+
uptime: Date.now() - stats.startTime
|
|
2242
|
+
}
|
|
2243
|
+
}));
|
|
2244
|
+
}
|
|
2245
|
+
// Claude Code token 计数(模拟响应)
|
|
2246
|
+
async handleCountTokens(req, res, signal) {
|
|
2247
|
+
try {
|
|
2248
|
+
this.throwIfAborted(signal);
|
|
2249
|
+
const body = await this.readBody(req, signal);
|
|
2250
|
+
this.throwIfAborted(signal);
|
|
2251
|
+
const request = JSON.parse(body);
|
|
2252
|
+
if (!Array.isArray(request.messages)) {
|
|
2253
|
+
throw new Error('count_tokens requires messages');
|
|
2254
|
+
}
|
|
2255
|
+
const estimatedTokens = Math.max(1, this.estimateTokenCount(request.system) + this.estimateTokenCount(request.messages) + this.estimateTokenCount(request.tools));
|
|
2256
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2257
|
+
res.end(JSON.stringify({ input_tokens: estimatedTokens }));
|
|
2258
|
+
}
|
|
2259
|
+
catch (error) {
|
|
2260
|
+
if (this.isAbortError(error, signal))
|
|
2261
|
+
return;
|
|
2262
|
+
this.sendError(res, 400, error instanceof Error ? error.message : 'Invalid request body', 'anthropic');
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
// Gemini v1beta 模型列表
|
|
2266
|
+
async handleGeminiModels(res, signal) {
|
|
2267
|
+
const result = await this.getAvailableModels(signal);
|
|
2268
|
+
const geminiModels = result.models.map(m => ({
|
|
2269
|
+
name: `models/${m.id}`,
|
|
2270
|
+
version: '001',
|
|
2271
|
+
displayName: m.name || m.id,
|
|
2272
|
+
description: m.description || '',
|
|
2273
|
+
inputTokenLimit: m.maxInputTokens || 200000,
|
|
2274
|
+
outputTokenLimit: m.maxOutputTokens || 64000,
|
|
2275
|
+
supportedGenerationMethods: ['generateContent', 'streamGenerateContent']
|
|
2276
|
+
}));
|
|
2277
|
+
this.throwIfResponseClosed(res, signal);
|
|
2278
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2279
|
+
res.end(JSON.stringify({ models: geminiModels }));
|
|
2280
|
+
}
|
|
2281
|
+
// Gemini v1beta generateContent / streamGenerateContent
|
|
2282
|
+
async handleGeminiRequest(req, res, path, signal) {
|
|
2283
|
+
const body = await this.readBody(req, signal);
|
|
2284
|
+
this.throwIfAborted(signal);
|
|
2285
|
+
const geminiReq = JSON.parse(body);
|
|
2286
|
+
const matchedApiKey = req.matchedApiKey;
|
|
2287
|
+
// 解析路径: /v1beta/models/{model}:{method}
|
|
2288
|
+
const match = path.match(/\/v1beta\/models\/([^:]+):(\w+)/);
|
|
2289
|
+
if (!match) {
|
|
2290
|
+
this.sendError(res, 400, 'Invalid Gemini endpoint path');
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
const [, modelId, method] = match;
|
|
2294
|
+
const isStream = method === 'streamGenerateContent';
|
|
2295
|
+
// 将 Gemini 请求转为 OpenAI 格式
|
|
2296
|
+
const messages = [];
|
|
2297
|
+
if (geminiReq.systemInstruction?.parts) {
|
|
2298
|
+
const sysText = geminiReq.systemInstruction.parts.map((p) => p.text || '').join('\n');
|
|
2299
|
+
if (sysText)
|
|
2300
|
+
messages.push({ role: 'system', content: sysText });
|
|
2301
|
+
}
|
|
2302
|
+
for (const content of geminiReq.contents || []) {
|
|
2303
|
+
const role = content.role === 'model' ? 'assistant' : 'user';
|
|
2304
|
+
const text = (content.parts || []).map((p) => p.text || '').join('');
|
|
2305
|
+
if (text)
|
|
2306
|
+
messages.push({ role: role, content: text });
|
|
2307
|
+
}
|
|
2308
|
+
if (messages.length === 0) {
|
|
2309
|
+
messages.push({ role: 'user', content: 'Hello' });
|
|
2310
|
+
}
|
|
2311
|
+
const openaiRequest = {
|
|
2312
|
+
model: this.applyModelMapping(modelId, matchedApiKey?.id),
|
|
2313
|
+
messages,
|
|
2314
|
+
stream: isStream,
|
|
2315
|
+
temperature: geminiReq.generationConfig?.temperature,
|
|
2316
|
+
top_p: geminiReq.generationConfig?.topP,
|
|
2317
|
+
max_tokens: geminiReq.generationConfig?.maxOutputTokens
|
|
2318
|
+
};
|
|
2319
|
+
// 复用 OpenAI 流程
|
|
2320
|
+
const startTime = Date.now();
|
|
2321
|
+
this.recordNewRequest();
|
|
2322
|
+
this.throwIfAborted(signal);
|
|
2323
|
+
const account = await this.getAvailableAccount(signal, undefined, matchedApiKey?.id, modelId);
|
|
2324
|
+
this.throwIfAborted(signal);
|
|
2325
|
+
if (!account) {
|
|
2326
|
+
this.sendError(res, 503, 'No available accounts');
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
try {
|
|
2330
|
+
const toolNameRegistry = new toolNameRegistry_1.ToolNameRegistry();
|
|
2331
|
+
const kiroPayload = (0, translator_1.openaiToKiro)(openaiRequest, account.profileArn, toolNameRegistry);
|
|
2332
|
+
if (isStream) {
|
|
2333
|
+
let streamStarted = false;
|
|
2334
|
+
const ensureStreamStarted = () => {
|
|
2335
|
+
if (streamStarted)
|
|
2336
|
+
return;
|
|
2337
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
2338
|
+
streamStarted = true;
|
|
2339
|
+
};
|
|
2340
|
+
return new Promise((resolve) => {
|
|
2341
|
+
this.callStreamWithFailover(account, kiroPayload, (_usedAccount, text) => {
|
|
2342
|
+
if (signal?.aborted || this.isResponseClosed(res))
|
|
2343
|
+
return;
|
|
2344
|
+
if (text) {
|
|
2345
|
+
ensureStreamStarted();
|
|
2346
|
+
const chunk = { candidates: [{ content: { parts: [{ text }], role: 'model' }, finishReason: null }] };
|
|
2347
|
+
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
2348
|
+
}
|
|
2349
|
+
}, (usedAccount, usage) => {
|
|
2350
|
+
if (signal?.aborted || this.isResponseClosed(res)) {
|
|
2351
|
+
resolve();
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
ensureStreamStarted();
|
|
2355
|
+
const finalChunk = { candidates: [{ content: { parts: [{ text: '' }], role: 'model' }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: usage.inputTokens, candidatesTokenCount: usage.outputTokens, totalTokenCount: usage.inputTokens + usage.outputTokens } };
|
|
2356
|
+
res.write(`data: ${JSON.stringify(finalChunk)}\n\n`);
|
|
2357
|
+
res.end();
|
|
2358
|
+
this.recordRequestSuccess();
|
|
2359
|
+
this.stats.totalTokens += usage.inputTokens + usage.outputTokens;
|
|
2360
|
+
this.stats.inputTokens += usage.inputTokens;
|
|
2361
|
+
this.stats.outputTokens += usage.outputTokens;
|
|
2362
|
+
this.stats.totalCredits += usage.credits || 0;
|
|
2363
|
+
this.accountPool.recordSuccess(usedAccount.id, usage.inputTokens + usage.outputTokens);
|
|
2364
|
+
resolve();
|
|
2365
|
+
}, (_usedAccount, error) => {
|
|
2366
|
+
if (this.isAbortError(error, signal) || this.isResponseClosed(res)) {
|
|
2367
|
+
resolve();
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
ensureStreamStarted();
|
|
2371
|
+
res.write(`data: ${JSON.stringify({ error: { message: error.message } })}\n\n`);
|
|
2372
|
+
res.end();
|
|
2373
|
+
this.recordRequestFailed();
|
|
2374
|
+
resolve();
|
|
2375
|
+
}, '/v1beta', signal, matchedApiKey?.id, modelId).catch(error => {
|
|
2376
|
+
if (!this.isAbortError(error, signal) && !this.isResponseClosed(res)) {
|
|
2377
|
+
res.write(`data: ${JSON.stringify({ error: { message: error.message } })}\n\n`);
|
|
2378
|
+
res.end();
|
|
2379
|
+
this.recordRequestFailed();
|
|
2380
|
+
}
|
|
2381
|
+
resolve();
|
|
2382
|
+
});
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
else {
|
|
2386
|
+
const { result, account: usedAccount } = await this.callWithRetry(account, async (acc) => (0, kiroApi_1.callKiroApi)(acc, (0, translator_1.openaiToKiro)(openaiRequest, acc.profileArn, toolNameRegistry), signal), '/v1beta', signal, matchedApiKey?.id, modelId);
|
|
2387
|
+
this.throwIfResponseClosed(res, signal);
|
|
2388
|
+
this.recordRequestSuccess();
|
|
2389
|
+
this.stats.totalTokens += result.usage.inputTokens + result.usage.outputTokens;
|
|
2390
|
+
this.stats.inputTokens += result.usage.inputTokens;
|
|
2391
|
+
this.stats.outputTokens += result.usage.outputTokens;
|
|
2392
|
+
this.stats.totalCredits += result.usage.credits || 0;
|
|
2393
|
+
this.accountPool.recordSuccess(usedAccount.id, result.usage.inputTokens + result.usage.outputTokens);
|
|
2394
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2395
|
+
res.end(JSON.stringify({
|
|
2396
|
+
candidates: [{ content: { parts: [{ text: result.content }], role: 'model' }, finishReason: 'STOP' }],
|
|
2397
|
+
usageMetadata: { promptTokenCount: result.usage.inputTokens, candidatesTokenCount: result.usage.outputTokens, totalTokenCount: result.usage.inputTokens + result.usage.outputTokens }
|
|
2398
|
+
}));
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
catch (error) {
|
|
2402
|
+
this.handleApiError(res, account, error, '/v1beta', modelId, startTime, signal);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
// 模型列表缓存
|
|
2406
|
+
modelCache = null;
|
|
2407
|
+
MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 分钟缓存
|
|
2408
|
+
// 模型列表
|
|
2409
|
+
async handleModels(res, signal) {
|
|
2410
|
+
const now = Date.now();
|
|
2411
|
+
// Kiro 官方模型(与 UI 保持一致)
|
|
2412
|
+
const kiroOfficialModels = [
|
|
2413
|
+
...modelCatalog_1.KIRO_PROXY_MODEL_PRESETS.map(model => buildKiroPresetClientModel(model, now)),
|
|
2414
|
+
buildClientModel({ id: 'auto', created: now, ownedBy: 'kiro-api', description: 'Auto select best model' })
|
|
2415
|
+
];
|
|
2416
|
+
// 隐藏模型(未在官方 ListAvailableModels 中返回,但后端可能支持)
|
|
2417
|
+
const hiddenModels = [
|
|
2418
|
+
buildClientModel({ id: 'claude-3.7-sonnet', created: now, ownedBy: 'kiro-api', description: 'Claude 3.7 Sonnet (hidden)', modelName: 'Claude 3.7 Sonnet', supportedInputTypes: ['TEXT', 'IMAGE'], maxInputTokens: 200000, maxOutputTokens: 64000 }),
|
|
2419
|
+
buildClientModel({ id: 'simple-task', created: now, ownedBy: 'kiro-api', description: 'Kiro fast model for intent classification and lightweight tasks (routes to Haiku)', modelName: 'Simple Task', supportedInputTypes: ['TEXT'], maxInputTokens: 200000, maxOutputTokens: 4096 }),
|
|
2420
|
+
buildClientModel({ id: 'CLAUDE_SONNET_4_20250514_V1_0', created: now, ownedBy: 'kiro-api', description: 'Claude Sonnet 4 (CodeWhisperer internal ID)', modelName: 'Claude Sonnet 4 (CW)', supportedInputTypes: ['TEXT', 'IMAGE'], maxInputTokens: 200000, maxOutputTokens: 64000 }),
|
|
2421
|
+
buildClientModel({ id: 'CLAUDE_HAIKU_4_5_20251001_V1_0', created: now, ownedBy: 'kiro-api', description: 'Claude Haiku 4.5 (CodeWhisperer internal ID)', modelName: 'Claude Haiku 4.5 (CW)', supportedInputTypes: ['TEXT', 'IMAGE'], maxInputTokens: 200000, maxOutputTokens: 64000 }),
|
|
2422
|
+
buildClientModel({ id: 'CLAUDE_3_7_SONNET_20250219_V1_0', created: now, ownedBy: 'kiro-api', description: 'Claude 3.7 Sonnet (CodeWhisperer internal ID)', modelName: 'Claude 3.7 Sonnet (CW)', supportedInputTypes: ['TEXT', 'IMAGE'], maxInputTokens: 200000, maxOutputTokens: 64000 })
|
|
2423
|
+
];
|
|
2424
|
+
// 预设模型(GPT 兼容别名)
|
|
2425
|
+
const presetModels = [
|
|
2426
|
+
buildClientModel({ id: 'gpt-4o', created: now, ownedBy: 'kiro-proxy', description: 'GPT-compatible alias for Kiro' }),
|
|
2427
|
+
buildClientModel({ id: 'gpt-4', created: now, ownedBy: 'kiro-proxy', description: 'GPT-compatible alias for Kiro' }),
|
|
2428
|
+
buildClientModel({ id: 'gpt-4-turbo', created: now, ownedBy: 'kiro-proxy', description: 'GPT-compatible alias for Kiro' }),
|
|
2429
|
+
buildClientModel({ id: 'gpt-3.5-turbo', created: now, ownedBy: 'kiro-proxy', description: 'GPT-compatible alias for Kiro' })
|
|
2430
|
+
];
|
|
2431
|
+
// 尝试从 Kiro API 获取动态模型
|
|
2432
|
+
let kiroModels = [];
|
|
2433
|
+
// 检查缓存
|
|
2434
|
+
if (this.modelCache && (now - this.modelCache.timestamp) < this.MODEL_CACHE_TTL) {
|
|
2435
|
+
kiroModels = this.modelCache.models;
|
|
2436
|
+
}
|
|
2437
|
+
else {
|
|
2438
|
+
// 获取一个可用账号来请求模型列表
|
|
2439
|
+
const account = this.accountPool.getNextAccount();
|
|
2440
|
+
if (account) {
|
|
2441
|
+
try {
|
|
2442
|
+
kiroModels = await (0, kiroApi_1.fetchKiroModels)(account, signal);
|
|
2443
|
+
if (kiroModels.length > 0) {
|
|
2444
|
+
this.modelCache = { models: kiroModels, timestamp: now };
|
|
2445
|
+
// 同步到 kiroApi 的 ctx cache, 供 token 裁剪逻辑使用
|
|
2446
|
+
for (const m of kiroModels) {
|
|
2447
|
+
if (m.tokenLimits?.maxInputTokens) {
|
|
2448
|
+
(0, kiroApi_1.setModelContextWindow)(m.modelId, m.tokenLimits.maxInputTokens);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
logger_1.proxyLogger.info('ProxyServer', `Fetched ${kiroModels.length} models from Kiro API`);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
catch (error) {
|
|
2455
|
+
if (this.isAbortError(error, signal))
|
|
2456
|
+
throw error;
|
|
2457
|
+
console.error('[ProxyServer] Failed to fetch Kiro models:', error);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
// 转换 Kiro 模型为 OpenAI 格式(保持原始 modelId)
|
|
2462
|
+
const dynamicModels = kiroModels.map(m => buildClientModel({
|
|
2463
|
+
id: m.modelId,
|
|
2464
|
+
created: now,
|
|
2465
|
+
ownedBy: 'kiro-api',
|
|
2466
|
+
description: m.description,
|
|
2467
|
+
modelName: m.modelName,
|
|
2468
|
+
supportedInputTypes: m.supportedInputTypes,
|
|
2469
|
+
maxInputTokens: m.tokenLimits?.maxInputTokens,
|
|
2470
|
+
maxOutputTokens: m.tokenLimits?.maxOutputTokens,
|
|
2471
|
+
rateMultiplier: m.rateMultiplier,
|
|
2472
|
+
rateUnit: m.rateUnit,
|
|
2473
|
+
promptCaching: m.promptCaching,
|
|
2474
|
+
additionalModelRequestFieldsSchema: m.additionalModelRequestFieldsSchema,
|
|
2475
|
+
modelProvider: m.modelProvider
|
|
2476
|
+
}));
|
|
2477
|
+
// 合并模型列表,去重
|
|
2478
|
+
const modelIds = new Set();
|
|
2479
|
+
const allModels = [];
|
|
2480
|
+
// 1. 优先添加动态模型(从 API 获取的,包含真实 token limit / input types)
|
|
2481
|
+
for (const m of dynamicModels) {
|
|
2482
|
+
if (!modelIds.has(m.id)) {
|
|
2483
|
+
modelIds.add(m.id);
|
|
2484
|
+
allModels.push(m);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
// 2. 添加隐藏模型(未在官方 ListAvailableModels 中返回,但后端可能支持)
|
|
2488
|
+
for (const m of hiddenModels) {
|
|
2489
|
+
if (!modelIds.has(m.id)) {
|
|
2490
|
+
modelIds.add(m.id);
|
|
2491
|
+
allModels.push(m);
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
// 3. 动态模型缺失时才添加静态兜底
|
|
2495
|
+
for (const m of [...kiroOfficialModels, ...presetModels]) {
|
|
2496
|
+
if (!modelIds.has(m.id)) {
|
|
2497
|
+
modelIds.add(m.id);
|
|
2498
|
+
allModels.push(m);
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
this.throwIfResponseClosed(res, signal);
|
|
2502
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2503
|
+
res.end(JSON.stringify({ object: 'list', data: allModels }));
|
|
2504
|
+
}
|
|
2505
|
+
// 处理 OpenAI Chat Completions 请求
|
|
2506
|
+
async handleOpenAIChat(req, res, signal) {
|
|
2507
|
+
const body = await this.readBody(req, signal);
|
|
2508
|
+
this.throwIfAborted(signal);
|
|
2509
|
+
const request = JSON.parse(body);
|
|
2510
|
+
const matchedApiKey = req.matchedApiKey;
|
|
2511
|
+
// 提取 session hint(用于稳定 conversationId),拼入 API Key hash 隔离不同用户
|
|
2512
|
+
const rawHintChat = ProxyServer.extractSessionHint(req, request);
|
|
2513
|
+
if (!request.conversation_id && rawHintChat) {
|
|
2514
|
+
const keyPrefix = matchedApiKey?.id?.slice(0, 8) || 'default';
|
|
2515
|
+
request.conversation_id = `${keyPrefix}:${rawHintChat}`;
|
|
2516
|
+
}
|
|
2517
|
+
const affinityHintChat = request.conversation_id;
|
|
2518
|
+
// 应用模型映射
|
|
2519
|
+
request.model = this.applyModelMapping(request.model, matchedApiKey?.id);
|
|
2520
|
+
const startTime = Date.now();
|
|
2521
|
+
this.recordNewRequest();
|
|
2522
|
+
this.events.onRequest?.({ path: '/v1/chat/completions', method: 'POST' });
|
|
2523
|
+
let processedRequest;
|
|
2524
|
+
try {
|
|
2525
|
+
processedRequest = await this.resolveOpenAIHttpImages(this.prepareOpenAIRequest(request), signal);
|
|
2526
|
+
}
|
|
2527
|
+
catch (error) {
|
|
2528
|
+
if (this.isAbortError(error, signal))
|
|
2529
|
+
return;
|
|
2530
|
+
this.recordRequestFailed();
|
|
2531
|
+
const message = error instanceof Error ? error.message : 'Invalid request';
|
|
2532
|
+
this.sendError(res, 400, message);
|
|
2533
|
+
this.events.onResponse?.({ path: '/v1/chat/completions', model: request.model, status: 400, error: message });
|
|
2534
|
+
this.recordRequest({ path: '/v1/chat/completions', model: request.model, responseTime: Date.now() - startTime, success: false, error: message });
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
// 获取账号(包含 Token 刷新检查 + 会话粘性 + API Key 账号白名单)
|
|
2538
|
+
this.throwIfAborted(signal);
|
|
2539
|
+
const account = await this.getAvailableAccount(signal, affinityHintChat, matchedApiKey?.id, request.model);
|
|
2540
|
+
this.throwIfAborted(signal);
|
|
2541
|
+
if (!account) {
|
|
2542
|
+
this.recordRequestFailed();
|
|
2543
|
+
const quotaStatus = this.accountPool.getQuotaStatus();
|
|
2544
|
+
const errorMsg = quotaStatus.exhausted > 0 && quotaStatus.available === 0
|
|
2545
|
+
? `All accounts quota exhausted (${quotaStatus.exhausted}/${quotaStatus.total} exhausted, ${quotaStatus.cooldown} in cooldown)`
|
|
2546
|
+
: 'No available accounts';
|
|
2547
|
+
this.sendError(res, 503, errorMsg);
|
|
2548
|
+
this.events.onResponse?.({ path: '/v1/chat/completions', model: request.model, status: 503, error: errorMsg });
|
|
2549
|
+
this.recordRequest({ path: '/v1/chat/completions', model: request.model, success: false, error: errorMsg });
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
this.events.onRequest?.({ path: '/v1/chat/completions', method: 'POST', accountId: account.id });
|
|
2553
|
+
try {
|
|
2554
|
+
const toolNameRegistry = new toolNameRegistry_1.ToolNameRegistry();
|
|
2555
|
+
// 转换为 Kiro 格式
|
|
2556
|
+
const kiroPayload = (0, translator_1.openaiToKiro)(processedRequest, account.profileArn, toolNameRegistry);
|
|
2557
|
+
// 记录请求详情到日志
|
|
2558
|
+
if (this.config.logRequests) {
|
|
2559
|
+
const userInput = kiroPayload.conversationState.currentMessage?.userInputMessage;
|
|
2560
|
+
const contentLength = typeof userInput?.content === 'string' ? userInput.content.length : 0;
|
|
2561
|
+
const toolsCount = userInput?.userInputMessageContext?.tools?.length || 0;
|
|
2562
|
+
const historyLength = kiroPayload.conversationState.history?.length || 0;
|
|
2563
|
+
const hasImages = (userInput?.images?.length || 0) > 0;
|
|
2564
|
+
logger_1.proxyLogger.info('ProxyServer', `OpenAI API: ${request.model}`, {
|
|
2565
|
+
model: request.model,
|
|
2566
|
+
stream: request.stream,
|
|
2567
|
+
contentLength,
|
|
2568
|
+
toolsCount,
|
|
2569
|
+
historyLength,
|
|
2570
|
+
hasImages,
|
|
2571
|
+
accountId: account.id
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
if (request.stream) {
|
|
2575
|
+
// 流式响应(流式不使用重试机制,错误由流处理)
|
|
2576
|
+
await this.handleOpenAIStream(res, account, kiroPayload, request.model, startTime, 0, undefined, false, matchedApiKey, toolNameRegistry, signal);
|
|
2577
|
+
}
|
|
2578
|
+
else {
|
|
2579
|
+
// 非流式响应(带重试机制)
|
|
2580
|
+
const { result, account: usedAccount } = await this.callWithRetry(account, async (acc) => {
|
|
2581
|
+
const retryPayload = (0, translator_1.openaiToKiro)(processedRequest, acc.profileArn, toolNameRegistry);
|
|
2582
|
+
return (0, kiroApi_1.callKiroApi)(acc, retryPayload, signal);
|
|
2583
|
+
}, '/v1/chat/completions', signal, matchedApiKey?.id, request.model);
|
|
2584
|
+
const response = (0, translator_1.kiroToOpenaiResponse)(result.content, result.toolUses, result.usage, request.model, toolNameRegistry, result.reasoningContent);
|
|
2585
|
+
this.throwIfResponseClosed(res, signal);
|
|
2586
|
+
this.recordRequestSuccess();
|
|
2587
|
+
this.stats.totalTokens += result.usage.inputTokens + result.usage.outputTokens;
|
|
2588
|
+
this.stats.inputTokens += result.usage.inputTokens;
|
|
2589
|
+
this.stats.outputTokens += result.usage.outputTokens;
|
|
2590
|
+
this.accountPool.recordSuccess(usedAccount.id, result.usage.inputTokens + result.usage.outputTokens);
|
|
2591
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2592
|
+
res.end(JSON.stringify(response));
|
|
2593
|
+
const respTime = Date.now() - startTime;
|
|
2594
|
+
this.events.onResponse?.({ path: '/v1/chat/completions', model: request.model, status: 200, tokens: result.usage.inputTokens + result.usage.outputTokens, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens, cacheReadTokens: result.usage.cacheReadTokens, reasoningTokens: result.usage.reasoningTokens, credits: result.usage.credits, responseTime: respTime });
|
|
2595
|
+
this.recordRequest({ path: '/v1/chat/completions', model: request.model, accountId: usedAccount.id, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens, credits: result.usage.credits, responseTime: respTime, success: true });
|
|
2596
|
+
// 记录 API Key 用量
|
|
2597
|
+
if (matchedApiKey) {
|
|
2598
|
+
this.recordApiKeyUsage(matchedApiKey.id, result.usage.credits || 0, result.usage.inputTokens, result.usage.outputTokens, request.model, '/v1/chat/completions');
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
catch (error) {
|
|
2603
|
+
this.handleApiError(res, account, error, '/v1/chat/completions', request.model, startTime, signal);
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
async handleOpenAIResponses(req, res, signal) {
|
|
2607
|
+
const body = await this.readBody(req, signal);
|
|
2608
|
+
this.throwIfAborted(signal);
|
|
2609
|
+
const matchedApiKey = req.matchedApiKey;
|
|
2610
|
+
const startTime = Date.now();
|
|
2611
|
+
this.recordNewRequest();
|
|
2612
|
+
this.events.onRequest?.({ path: '/v1/responses', method: 'POST' });
|
|
2613
|
+
let responseRequest;
|
|
2614
|
+
let chatRequest;
|
|
2615
|
+
let processedRequest;
|
|
2616
|
+
let affinityHintResp;
|
|
2617
|
+
try {
|
|
2618
|
+
responseRequest = JSON.parse(body);
|
|
2619
|
+
chatRequest = (0, translator_1.responsesToOpenAIChat)(responseRequest);
|
|
2620
|
+
// session hint:用于会话粘性
|
|
2621
|
+
const rawHintResp = ProxyServer.extractSessionHint(req, responseRequest);
|
|
2622
|
+
if (rawHintResp) {
|
|
2623
|
+
const keyPrefix = matchedApiKey?.id?.slice(0, 8) || 'default';
|
|
2624
|
+
affinityHintResp = `${keyPrefix}:${rawHintResp}`;
|
|
2625
|
+
}
|
|
2626
|
+
chatRequest.model = this.applyModelMapping(chatRequest.model, matchedApiKey?.id);
|
|
2627
|
+
processedRequest = await this.resolveOpenAIHttpImages(this.prepareOpenAIRequest(chatRequest), signal);
|
|
2628
|
+
}
|
|
2629
|
+
catch (error) {
|
|
2630
|
+
if (this.isAbortError(error, signal))
|
|
2631
|
+
return;
|
|
2632
|
+
this.recordRequestFailed();
|
|
2633
|
+
const message = error instanceof Error ? error.message : 'Invalid request';
|
|
2634
|
+
this.sendError(res, 400, message);
|
|
2635
|
+
this.events.onResponse?.({ path: '/v1/responses', status: 400, error: message });
|
|
2636
|
+
this.recordRequest({ path: '/v1/responses', responseTime: Date.now() - startTime, success: false, error: message });
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
this.throwIfAborted(signal);
|
|
2640
|
+
const account = await this.getAvailableAccount(signal, affinityHintResp, matchedApiKey?.id, chatRequest.model);
|
|
2641
|
+
this.throwIfAborted(signal);
|
|
2642
|
+
if (!account) {
|
|
2643
|
+
this.recordRequestFailed();
|
|
2644
|
+
const quotaStatus = this.accountPool.getQuotaStatus();
|
|
2645
|
+
const errorMsg = quotaStatus.exhausted > 0 && quotaStatus.available === 0
|
|
2646
|
+
? `All accounts quota exhausted (${quotaStatus.exhausted}/${quotaStatus.total} exhausted, ${quotaStatus.cooldown} in cooldown)`
|
|
2647
|
+
: 'No available accounts';
|
|
2648
|
+
this.sendError(res, 503, errorMsg);
|
|
2649
|
+
this.events.onResponse?.({ path: '/v1/responses', model: chatRequest.model, status: 503, error: errorMsg });
|
|
2650
|
+
this.recordRequest({ path: '/v1/responses', model: chatRequest.model, success: false, error: 'No available accounts' });
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
this.events.onRequest?.({ path: '/v1/responses', method: 'POST', accountId: account.id });
|
|
2654
|
+
try {
|
|
2655
|
+
const toolNameRegistry = new toolNameRegistry_1.ToolNameRegistry();
|
|
2656
|
+
if (processedRequest.stream) {
|
|
2657
|
+
res.writeHead(200, {
|
|
2658
|
+
'Content-Type': 'text/event-stream',
|
|
2659
|
+
'Cache-Control': 'no-cache',
|
|
2660
|
+
'Connection': 'keep-alive'
|
|
2661
|
+
});
|
|
2662
|
+
const responseId = `resp_${(0, uuid_1.v4)()}`;
|
|
2663
|
+
res.write(`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: { id: responseId, object: 'response', created_at: Math.floor(Date.now() / 1000), model: chatRequest.model, output: [] } })}\n\n`);
|
|
2664
|
+
const { result, account: usedAccount } = await this.callWithRetry(account, async (acc) => {
|
|
2665
|
+
const retryPayload = (0, translator_1.openaiToKiro)(processedRequest, acc.profileArn, toolNameRegistry);
|
|
2666
|
+
return (0, kiroApi_1.callKiroApi)(acc, retryPayload, signal);
|
|
2667
|
+
}, '/v1/responses', signal, matchedApiKey?.id, chatRequest.model);
|
|
2668
|
+
const chatResponse = (0, translator_1.kiroToOpenaiResponse)(result.content, result.toolUses, result.usage, chatRequest.model, toolNameRegistry, result.reasoningContent);
|
|
2669
|
+
this.throwIfResponseClosed(res, signal);
|
|
2670
|
+
const response = (0, translator_1.openAIChatToResponsesResponse)(chatResponse, responseRequest.previous_response_id);
|
|
2671
|
+
const streamedResponse = { ...response, id: responseId };
|
|
2672
|
+
streamedResponse.output.forEach((item, outputIndex) => {
|
|
2673
|
+
this.throwIfResponseClosed(res, signal);
|
|
2674
|
+
res.write(`event: response.output_item.added\ndata: ${JSON.stringify({ type: 'response.output_item.added', output_index: outputIndex, item })}\n\n`);
|
|
2675
|
+
if (item.type === 'message') {
|
|
2676
|
+
item.content.forEach((part, contentIndex) => {
|
|
2677
|
+
this.throwIfResponseClosed(res, signal);
|
|
2678
|
+
res.write(`event: response.content_part.added\ndata: ${JSON.stringify({ type: 'response.content_part.added', item_id: item.id, output_index: outputIndex, content_index: contentIndex, part: { type: part.type, text: '' } })}\n\n`);
|
|
2679
|
+
if (part.text) {
|
|
2680
|
+
res.write(`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', item_id: item.id, output_index: outputIndex, content_index: contentIndex, delta: part.text })}\n\n`);
|
|
2681
|
+
}
|
|
2682
|
+
res.write(`event: response.output_text.done\ndata: ${JSON.stringify({ type: 'response.output_text.done', item_id: item.id, output_index: outputIndex, content_index: contentIndex, text: part.text })}\n\n`);
|
|
2683
|
+
res.write(`event: response.content_part.done\ndata: ${JSON.stringify({ type: 'response.content_part.done', item_id: item.id, output_index: outputIndex, content_index: contentIndex, part })}\n\n`);
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
else {
|
|
2687
|
+
if (item.arguments) {
|
|
2688
|
+
res.write(`event: response.function_call_arguments.delta\ndata: ${JSON.stringify({ type: 'response.function_call_arguments.delta', item_id: item.id, output_index: outputIndex, delta: item.arguments })}\n\n`);
|
|
2689
|
+
}
|
|
2690
|
+
res.write(`event: response.function_call_arguments.done\ndata: ${JSON.stringify({ type: 'response.function_call_arguments.done', item_id: item.id, output_index: outputIndex, arguments: item.arguments })}\n\n`);
|
|
2691
|
+
}
|
|
2692
|
+
this.throwIfResponseClosed(res, signal);
|
|
2693
|
+
res.write(`event: response.output_item.done\ndata: ${JSON.stringify({ type: 'response.output_item.done', output_index: outputIndex, item })}\n\n`);
|
|
2694
|
+
});
|
|
2695
|
+
this.throwIfResponseClosed(res, signal);
|
|
2696
|
+
res.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamedResponse })}\n\n`);
|
|
2697
|
+
res.end();
|
|
2698
|
+
this.recordRequestSuccess();
|
|
2699
|
+
this.stats.totalTokens += result.usage.inputTokens + result.usage.outputTokens;
|
|
2700
|
+
this.stats.inputTokens += result.usage.inputTokens;
|
|
2701
|
+
this.stats.outputTokens += result.usage.outputTokens;
|
|
2702
|
+
this.accountPool.recordSuccess(usedAccount.id, result.usage.inputTokens + result.usage.outputTokens);
|
|
2703
|
+
const respTime = Date.now() - startTime;
|
|
2704
|
+
this.events.onResponse?.({ path: '/v1/responses', model: chatRequest.model, status: 200, tokens: result.usage.inputTokens + result.usage.outputTokens, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens, cacheReadTokens: result.usage.cacheReadTokens, reasoningTokens: result.usage.reasoningTokens, credits: result.usage.credits, responseTime: respTime });
|
|
2705
|
+
this.recordRequest({ path: '/v1/responses', model: chatRequest.model, accountId: usedAccount.id, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens, credits: result.usage.credits, responseTime: respTime, success: true });
|
|
2706
|
+
if (matchedApiKey) {
|
|
2707
|
+
this.recordApiKeyUsage(matchedApiKey.id, result.usage.credits || 0, result.usage.inputTokens, result.usage.outputTokens, chatRequest.model, '/v1/responses');
|
|
2708
|
+
}
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2711
|
+
const { result, account: usedAccount } = await this.callWithRetry(account, async (acc) => {
|
|
2712
|
+
const retryPayload = (0, translator_1.openaiToKiro)(processedRequest, acc.profileArn, toolNameRegistry);
|
|
2713
|
+
return (0, kiroApi_1.callKiroApi)(acc, retryPayload, signal);
|
|
2714
|
+
}, '/v1/responses', signal, matchedApiKey?.id, chatRequest.model);
|
|
2715
|
+
const chatResponse = (0, translator_1.kiroToOpenaiResponse)(result.content, result.toolUses, result.usage, chatRequest.model, toolNameRegistry, result.reasoningContent);
|
|
2716
|
+
this.throwIfResponseClosed(res, signal);
|
|
2717
|
+
const response = (0, translator_1.openAIChatToResponsesResponse)(chatResponse, responseRequest.previous_response_id);
|
|
2718
|
+
this.recordRequestSuccess();
|
|
2719
|
+
this.stats.totalTokens += result.usage.inputTokens + result.usage.outputTokens;
|
|
2720
|
+
this.stats.inputTokens += result.usage.inputTokens;
|
|
2721
|
+
this.stats.outputTokens += result.usage.outputTokens;
|
|
2722
|
+
this.accountPool.recordSuccess(usedAccount.id, result.usage.inputTokens + result.usage.outputTokens);
|
|
2723
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2724
|
+
res.end(JSON.stringify(response));
|
|
2725
|
+
const respTime = Date.now() - startTime;
|
|
2726
|
+
this.events.onResponse?.({ path: '/v1/responses', model: chatRequest.model, status: 200, tokens: result.usage.inputTokens + result.usage.outputTokens, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens, cacheReadTokens: result.usage.cacheReadTokens, reasoningTokens: result.usage.reasoningTokens, credits: result.usage.credits, responseTime: respTime });
|
|
2727
|
+
this.recordRequest({ path: '/v1/responses', model: chatRequest.model, accountId: usedAccount.id, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens, credits: result.usage.credits, responseTime: respTime, success: true });
|
|
2728
|
+
if (matchedApiKey) {
|
|
2729
|
+
this.recordApiKeyUsage(matchedApiKey.id, result.usage.credits || 0, result.usage.inputTokens, result.usage.outputTokens, chatRequest.model, '/v1/responses');
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
catch (error) {
|
|
2733
|
+
this.handleApiError(res, account, error, '/v1/responses', chatRequest.model, startTime, signal);
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
// 处理 OpenAI 流式响应
|
|
2737
|
+
async handleOpenAIStream(res, account, kiroPayload, model, startTime, currentRound = 0, streamId, headersSent = false, matchedApiKey, toolNameRegistry = new toolNameRegistry_1.ToolNameRegistry(), signal) {
|
|
2738
|
+
const id = streamId || `chatcmpl-${(0, uuid_1.v4)()}`;
|
|
2739
|
+
let toolCallIndex = 0;
|
|
2740
|
+
const pendingToolCalls = new Map();
|
|
2741
|
+
let collectedContent = '';
|
|
2742
|
+
let streamStarted = headersSent;
|
|
2743
|
+
const ensureStreamStarted = () => {
|
|
2744
|
+
if (streamStarted)
|
|
2745
|
+
return;
|
|
2746
|
+
res.writeHead(200, {
|
|
2747
|
+
'Content-Type': 'text/event-stream',
|
|
2748
|
+
'Cache-Control': 'no-cache',
|
|
2749
|
+
'Connection': 'keep-alive'
|
|
2750
|
+
});
|
|
2751
|
+
if (currentRound === 0) {
|
|
2752
|
+
const initialChunk = (0, translator_1.createOpenaiStreamChunk)(id, model, { role: 'assistant' });
|
|
2753
|
+
res.write(`data: ${JSON.stringify(initialChunk)}\n\n`);
|
|
2754
|
+
}
|
|
2755
|
+
streamStarted = true;
|
|
2756
|
+
};
|
|
2757
|
+
return new Promise((resolve) => {
|
|
2758
|
+
this.callStreamWithFailover(account, kiroPayload, (_usedAccount, text, toolUse, isThinking) => {
|
|
2759
|
+
if (signal?.aborted || this.isResponseClosed(res))
|
|
2760
|
+
return;
|
|
2761
|
+
ensureStreamStarted();
|
|
2762
|
+
if (text && text.trim()) {
|
|
2763
|
+
if (isThinking) {
|
|
2764
|
+
// 原生 thinking 内容 → 输出为 reasoning_content
|
|
2765
|
+
const chunk = (0, translator_1.createOpenaiStreamChunk)(id, model, { reasoning_content: text });
|
|
2766
|
+
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
2767
|
+
}
|
|
2768
|
+
else {
|
|
2769
|
+
// 普通文本内容
|
|
2770
|
+
collectedContent += text;
|
|
2771
|
+
const chunk = (0, translator_1.createOpenaiStreamChunk)(id, model, { content: text });
|
|
2772
|
+
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
if (toolUse) {
|
|
2776
|
+
const idx = toolCallIndex++;
|
|
2777
|
+
const restoredToolUse = toolNameRegistry.restoreToolUse(toolUse);
|
|
2778
|
+
pendingToolCalls.set(toolUse.toolUseId, {
|
|
2779
|
+
index: idx,
|
|
2780
|
+
name: toolUse.name,
|
|
2781
|
+
arguments: JSON.stringify(toolUse.input)
|
|
2782
|
+
});
|
|
2783
|
+
const toolChunk = (0, translator_1.createOpenaiStreamChunk)(id, model, {
|
|
2784
|
+
tool_calls: [{
|
|
2785
|
+
index: idx,
|
|
2786
|
+
id: toolUse.toolUseId,
|
|
2787
|
+
type: 'function',
|
|
2788
|
+
function: {
|
|
2789
|
+
name: restoredToolUse.name,
|
|
2790
|
+
arguments: JSON.stringify(toolUse.input)
|
|
2791
|
+
}
|
|
2792
|
+
}]
|
|
2793
|
+
});
|
|
2794
|
+
res.write(`data: ${JSON.stringify(toolChunk)}\n\n`);
|
|
2795
|
+
}
|
|
2796
|
+
}, async (usedAccount, usage) => {
|
|
2797
|
+
if (signal?.aborted || this.isResponseClosed(res)) {
|
|
2798
|
+
resolve();
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
ensureStreamStarted();
|
|
2802
|
+
this.recordRequestSuccess();
|
|
2803
|
+
this.stats.totalTokens += usage.inputTokens + usage.outputTokens;
|
|
2804
|
+
this.stats.inputTokens += usage.inputTokens;
|
|
2805
|
+
this.stats.outputTokens += usage.outputTokens;
|
|
2806
|
+
this.stats.cacheReadTokens += usage.cacheReadTokens || 0;
|
|
2807
|
+
this.stats.cacheWriteTokens += usage.cacheWriteTokens || 0;
|
|
2808
|
+
this.stats.reasoningTokens += usage.reasoningTokens || 0;
|
|
2809
|
+
this.stats.totalCredits += usage.credits || 0;
|
|
2810
|
+
this.events.onCreditsUpdate?.(this.stats.totalCredits);
|
|
2811
|
+
this.events.onTokensUpdate?.(this.stats.inputTokens, this.stats.outputTokens);
|
|
2812
|
+
this.accountPool.recordSuccess(usedAccount.id, usage.inputTokens + usage.outputTokens);
|
|
2813
|
+
const oaiRespTime = Date.now() - startTime;
|
|
2814
|
+
this.events.onResponse?.({ path: '/v1/chat/completions', model, status: 200, tokens: usage.inputTokens + usage.outputTokens, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, cacheReadTokens: usage.cacheReadTokens, reasoningTokens: usage.reasoningTokens, credits: usage.credits, responseTime: oaiRespTime });
|
|
2815
|
+
this.recordRequest({ path: '/v1/chat/completions', model, accountId: usedAccount.id, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, credits: usage.credits, responseTime: oaiRespTime, success: true });
|
|
2816
|
+
// 记录 API Key 用量
|
|
2817
|
+
if (matchedApiKey) {
|
|
2818
|
+
this.recordApiKeyUsage(matchedApiKey.id, usage.credits || 0, usage.inputTokens, usage.outputTokens, model, '/v1/chat/completions');
|
|
2819
|
+
}
|
|
2820
|
+
// 发送结束 chunk(包含完整 usage 信息)
|
|
2821
|
+
const hasToolCalls = pendingToolCalls.size > 0;
|
|
2822
|
+
const finishReason = hasToolCalls ? 'tool_calls' : 'stop';
|
|
2823
|
+
const usageInfo = {
|
|
2824
|
+
prompt_tokens: usage.inputTokens,
|
|
2825
|
+
completion_tokens: usage.outputTokens,
|
|
2826
|
+
total_tokens: usage.inputTokens + usage.outputTokens
|
|
2827
|
+
};
|
|
2828
|
+
// 添加 cache tokens 详情
|
|
2829
|
+
if (usage.cacheReadTokens && usage.cacheReadTokens > 0) {
|
|
2830
|
+
usageInfo.prompt_tokens_details = { cached_tokens: usage.cacheReadTokens };
|
|
2831
|
+
}
|
|
2832
|
+
// 添加 reasoning tokens 详情
|
|
2833
|
+
if (usage.reasoningTokens && usage.reasoningTokens > 0) {
|
|
2834
|
+
usageInfo.completion_tokens_details = { reasoning_tokens: usage.reasoningTokens };
|
|
2835
|
+
}
|
|
2836
|
+
const finalChunk = (0, translator_1.createOpenaiStreamChunk)(id, model, {}, finishReason, usageInfo);
|
|
2837
|
+
res.write(`data: ${JSON.stringify(finalChunk)}\n\n`);
|
|
2838
|
+
res.write('data: [DONE]\n\n');
|
|
2839
|
+
res.end();
|
|
2840
|
+
resolve();
|
|
2841
|
+
}, (usedAccount, error) => {
|
|
2842
|
+
if (this.isAbortError(error, signal) || this.isResponseClosed(res)) {
|
|
2843
|
+
resolve();
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
ensureStreamStarted();
|
|
2847
|
+
console.error('[ProxyServer] Stream error:', error);
|
|
2848
|
+
res.write(`data: ${JSON.stringify({ error: { message: error.message } })}\n\n`);
|
|
2849
|
+
res.end();
|
|
2850
|
+
this.recordRequestFailed();
|
|
2851
|
+
this.events.onResponse?.({ path: '/v1/chat/completions', model, status: 500, error: error.message });
|
|
2852
|
+
this.recordRequest({ path: '/v1/chat/completions', model, accountId: usedAccount.id, responseTime: Date.now() - startTime, success: false, error: error.message });
|
|
2853
|
+
resolve();
|
|
2854
|
+
}, '/v1/chat/completions', signal, matchedApiKey?.id, model).catch(error => {
|
|
2855
|
+
if (!this.isAbortError(error, signal) && !this.isResponseClosed(res)) {
|
|
2856
|
+
res.write(`data: ${JSON.stringify({ error: { message: error.message } })}\n\n`);
|
|
2857
|
+
res.end();
|
|
2858
|
+
this.recordRequestFailed();
|
|
2859
|
+
}
|
|
2860
|
+
resolve();
|
|
2861
|
+
});
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
// 处理 Claude Messages 请求
|
|
2865
|
+
async handleClaudeMessages(req, res, signal) {
|
|
2866
|
+
const body = await this.readBody(req, signal);
|
|
2867
|
+
this.throwIfAborted(signal);
|
|
2868
|
+
const request = JSON.parse(body);
|
|
2869
|
+
const matchedApiKey = req.matchedApiKey;
|
|
2870
|
+
// 提取 session hint(用于稳定 conversationId),拼入 API Key hash 隔离不同用户
|
|
2871
|
+
const rawHint = ProxyServer.extractSessionHint(req, request);
|
|
2872
|
+
if (!request.conversation_id && rawHint) {
|
|
2873
|
+
const keyPrefix = matchedApiKey?.id?.slice(0, 8) || 'default';
|
|
2874
|
+
request.conversation_id = `${keyPrefix}:${rawHint}`;
|
|
2875
|
+
}
|
|
2876
|
+
// P1-8 会话粘性使用 conversation_id 作为粘性 key(已包含 API Key 前缀)
|
|
2877
|
+
const affinityHint = request.conversation_id;
|
|
2878
|
+
// 应用模型映射
|
|
2879
|
+
request.model = this.applyModelMapping(request.model, matchedApiKey?.id);
|
|
2880
|
+
const startTime = Date.now();
|
|
2881
|
+
this.recordNewRequest();
|
|
2882
|
+
this.events.onRequest?.({ path: '/v1/messages', method: 'POST' });
|
|
2883
|
+
let processedRequest;
|
|
2884
|
+
try {
|
|
2885
|
+
processedRequest = await this.resolveClaudeHttpImages(this.prepareClaudeRequest(request), signal);
|
|
2886
|
+
}
|
|
2887
|
+
catch (error) {
|
|
2888
|
+
if (this.isAbortError(error, signal))
|
|
2889
|
+
return;
|
|
2890
|
+
this.recordRequestFailed();
|
|
2891
|
+
const message = error instanceof Error ? error.message : 'Invalid request';
|
|
2892
|
+
this.sendError(res, 400, message, 'anthropic');
|
|
2893
|
+
this.events.onResponse?.({ path: '/v1/messages', model: request.model, status: 400, error: message });
|
|
2894
|
+
this.recordRequest({ path: '/v1/messages', model: request.model, responseTime: Date.now() - startTime, success: false, error: message });
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
// 获取账号(包含 Token 刷新检查 + 会话粘性 + API Key 账号白名单)
|
|
2898
|
+
this.throwIfAborted(signal);
|
|
2899
|
+
const account = await this.getAvailableAccount(signal, affinityHint, matchedApiKey?.id, request.model);
|
|
2900
|
+
this.throwIfAborted(signal);
|
|
2901
|
+
if (!account) {
|
|
2902
|
+
this.recordRequestFailed();
|
|
2903
|
+
const quotaStatus = this.accountPool.getQuotaStatus();
|
|
2904
|
+
const errorMsg = quotaStatus.exhausted > 0 && quotaStatus.available === 0
|
|
2905
|
+
? `All accounts quota exhausted (${quotaStatus.exhausted}/${quotaStatus.total} exhausted, ${quotaStatus.cooldown} in cooldown)`
|
|
2906
|
+
: 'No available accounts';
|
|
2907
|
+
this.sendError(res, 503, errorMsg, 'anthropic');
|
|
2908
|
+
this.events.onResponse?.({ path: '/v1/messages', model: request.model, status: 503, error: errorMsg });
|
|
2909
|
+
this.recordRequest({ path: '/v1/messages', model: request.model, success: false, error: errorMsg });
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
this.events.onRequest?.({ path: '/v1/messages', method: 'POST', accountId: account.id });
|
|
2913
|
+
try {
|
|
2914
|
+
const toolNameRegistry = new toolNameRegistry_1.ToolNameRegistry();
|
|
2915
|
+
const kiroPayload = (0, translator_1.claudeToKiro)(processedRequest, account.profileArn, toolNameRegistry);
|
|
2916
|
+
// 构建 prompt cache profile(用于模拟缓存 usage)
|
|
2917
|
+
const estimatedInputTokens = Math.max(1, Math.round(JSON.stringify(kiroPayload).length * 0.3));
|
|
2918
|
+
const cacheProfile = promptCacheTracker_1.promptCacheTracker.buildClaudeProfile(processedRequest.system, processedRequest.messages, processedRequest.tools, estimatedInputTokens, processedRequest.model);
|
|
2919
|
+
const cacheUsage = promptCacheTracker_1.promptCacheTracker.compute(account.id, cacheProfile);
|
|
2920
|
+
if (cacheProfile) {
|
|
2921
|
+
logger_1.proxyLogger.info('ProxyServer', `Prompt cache: ${cacheProfile.breakpoints.length} breakpoints, creation=${cacheUsage.cacheCreationInputTokens}, read=${cacheUsage.cacheReadInputTokens}`);
|
|
2922
|
+
}
|
|
2923
|
+
// 记录请求详情到日志
|
|
2924
|
+
if (this.config.logRequests) {
|
|
2925
|
+
const userInput = kiroPayload.conversationState.currentMessage?.userInputMessage;
|
|
2926
|
+
const contentLength = typeof userInput?.content === 'string' ? userInput.content.length : 0;
|
|
2927
|
+
const toolsCount = userInput?.userInputMessageContext?.tools?.length || 0;
|
|
2928
|
+
const historyLength = kiroPayload.conversationState.history?.length || 0;
|
|
2929
|
+
const hasImages = (userInput?.images?.length || 0) > 0;
|
|
2930
|
+
logger_1.proxyLogger.info('ProxyServer', `Claude API: ${request.model}`, {
|
|
2931
|
+
model: request.model,
|
|
2932
|
+
stream: request.stream,
|
|
2933
|
+
contentLength,
|
|
2934
|
+
toolsCount,
|
|
2935
|
+
historyLength,
|
|
2936
|
+
hasImages,
|
|
2937
|
+
accountId: account.id.substring(0, 8) + '...'
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
if (request.stream) {
|
|
2941
|
+
// 流式响应(流式不使用重试机制,错误由流处理)
|
|
2942
|
+
await this.handleClaudeStream(res, account, kiroPayload, request.model, startTime, 0, undefined, false, 0, matchedApiKey, toolNameRegistry, signal, cacheProfile ? { ...cacheUsage, cacheProfile, accountId: account.id } : undefined);
|
|
2943
|
+
}
|
|
2944
|
+
else {
|
|
2945
|
+
// 非流式响应(带重试机制)
|
|
2946
|
+
const { result, account: usedAccount } = await this.callWithRetry(account, async (acc) => {
|
|
2947
|
+
const retryPayload = (0, translator_1.claudeToKiro)(processedRequest, acc.profileArn, toolNameRegistry);
|
|
2948
|
+
return (0, kiroApi_1.callKiroApi)(acc, retryPayload, signal);
|
|
2949
|
+
}, '/v1/messages', signal, matchedApiKey?.id, request.model);
|
|
2950
|
+
const response = (0, translator_1.kiroToClaudeResponse)(result.content, result.toolUses, result.usage, request.model, toolNameRegistry, result.reasoningContent);
|
|
2951
|
+
// 用缓存模拟的 usage 覆盖(如果有 cache profile)
|
|
2952
|
+
if (cacheProfile && cacheUsage) {
|
|
2953
|
+
if (cacheUsage.cacheCreationInputTokens > 0)
|
|
2954
|
+
response.usage.cache_creation_input_tokens = cacheUsage.cacheCreationInputTokens;
|
|
2955
|
+
if (cacheUsage.cacheReadInputTokens > 0)
|
|
2956
|
+
response.usage.cache_read_input_tokens = cacheUsage.cacheReadInputTokens;
|
|
2957
|
+
promptCacheTracker_1.promptCacheTracker.update(usedAccount.id, cacheProfile);
|
|
2958
|
+
}
|
|
2959
|
+
this.throwIfResponseClosed(res, signal);
|
|
2960
|
+
this.recordRequestSuccess();
|
|
2961
|
+
this.stats.totalTokens += result.usage.inputTokens + result.usage.outputTokens;
|
|
2962
|
+
this.stats.inputTokens += result.usage.inputTokens;
|
|
2963
|
+
this.stats.outputTokens += result.usage.outputTokens;
|
|
2964
|
+
this.accountPool.recordSuccess(usedAccount.id, result.usage.inputTokens + result.usage.outputTokens);
|
|
2965
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2966
|
+
res.end(JSON.stringify(response));
|
|
2967
|
+
const respTime = Date.now() - startTime;
|
|
2968
|
+
this.events.onResponse?.({ path: '/v1/messages', model: request.model, status: 200, tokens: result.usage.inputTokens + result.usage.outputTokens, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens, cacheReadTokens: result.usage.cacheReadTokens, reasoningTokens: result.usage.reasoningTokens, credits: result.usage.credits, responseTime: respTime });
|
|
2969
|
+
this.recordRequest({ path: '/v1/messages', model: request.model, accountId: usedAccount.id, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens, credits: result.usage.credits, responseTime: respTime, success: true });
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
catch (error) {
|
|
2973
|
+
this.handleApiError(res, account, error, '/v1/messages', request.model, startTime, signal);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
// 处理 Claude 流式响应
|
|
2977
|
+
async handleClaudeStream(res, account, kiroPayload, model, startTime, currentRound = 0, msgId, headersSent = false, contentBlockIndex = 0, matchedApiKey, toolNameRegistry = new toolNameRegistry_1.ToolNameRegistry(), signal, simulatedCacheUsage) {
|
|
2978
|
+
const id = msgId || `msg_${(0, uuid_1.v4)()}`;
|
|
2979
|
+
let currentBlockIndex = contentBlockIndex;
|
|
2980
|
+
let hasStartedTextBlock = false;
|
|
2981
|
+
let hasStartedThinkingBlock = false;
|
|
2982
|
+
let pendingThinkingSignature;
|
|
2983
|
+
let collectedContent = '';
|
|
2984
|
+
const pendingToolCalls = new Map();
|
|
2985
|
+
const flushThinkingSignature = () => {
|
|
2986
|
+
if (!pendingThinkingSignature)
|
|
2987
|
+
return;
|
|
2988
|
+
const signatureDelta = (0, translator_1.createClaudeStreamEvent)('content_block_delta', {
|
|
2989
|
+
index: currentBlockIndex,
|
|
2990
|
+
delta: { type: 'signature_delta', signature: pendingThinkingSignature }
|
|
2991
|
+
});
|
|
2992
|
+
res.write(`event: content_block_delta\ndata: ${JSON.stringify(signatureDelta)}\n\n`);
|
|
2993
|
+
pendingThinkingSignature = undefined;
|
|
2994
|
+
};
|
|
2995
|
+
// 估算输入 tokens(基于 payload 大小)
|
|
2996
|
+
const estimatedInputTokens = Math.max(1, Math.round(JSON.stringify(kiroPayload).length / 3));
|
|
2997
|
+
let streamStarted = headersSent;
|
|
2998
|
+
const ensureStreamStarted = () => {
|
|
2999
|
+
if (streamStarted)
|
|
3000
|
+
return;
|
|
3001
|
+
res.writeHead(200, {
|
|
3002
|
+
'Content-Type': 'text/event-stream',
|
|
3003
|
+
'Cache-Control': 'no-cache',
|
|
3004
|
+
'Connection': 'keep-alive'
|
|
3005
|
+
});
|
|
3006
|
+
if (currentRound === 0) {
|
|
3007
|
+
const messageStart = (0, translator_1.createClaudeStreamEvent)('message_start', {
|
|
3008
|
+
message: {
|
|
3009
|
+
id,
|
|
3010
|
+
type: 'message',
|
|
3011
|
+
role: 'assistant',
|
|
3012
|
+
content: [],
|
|
3013
|
+
model,
|
|
3014
|
+
stop_reason: null,
|
|
3015
|
+
stop_sequence: null,
|
|
3016
|
+
usage: { input_tokens: estimatedInputTokens, output_tokens: 0 }
|
|
3017
|
+
}
|
|
3018
|
+
});
|
|
3019
|
+
res.write(`event: message_start\ndata: ${JSON.stringify(messageStart)}\n\n`);
|
|
3020
|
+
}
|
|
3021
|
+
streamStarted = true;
|
|
3022
|
+
};
|
|
3023
|
+
return new Promise((resolve) => {
|
|
3024
|
+
this.callStreamWithFailover(account, kiroPayload, (_usedAccount, text, toolUse, isThinking, reasoningSignature, redactedContent) => {
|
|
3025
|
+
if (signal?.aborted || this.isResponseClosed(res))
|
|
3026
|
+
return;
|
|
3027
|
+
ensureStreamStarted();
|
|
3028
|
+
// 优先处理 redacted_thinking(加密的 thinking 块,需单独 content_block)
|
|
3029
|
+
if (redactedContent) {
|
|
3030
|
+
if (hasStartedTextBlock) {
|
|
3031
|
+
const blockStop = (0, translator_1.createClaudeStreamEvent)('content_block_stop', { index: currentBlockIndex });
|
|
3032
|
+
res.write(`event: content_block_stop\ndata: ${JSON.stringify(blockStop)}\n\n`);
|
|
3033
|
+
currentBlockIndex++;
|
|
3034
|
+
hasStartedTextBlock = false;
|
|
3035
|
+
}
|
|
3036
|
+
if (hasStartedThinkingBlock) {
|
|
3037
|
+
flushThinkingSignature();
|
|
3038
|
+
const blockStop = (0, translator_1.createClaudeStreamEvent)('content_block_stop', { index: currentBlockIndex });
|
|
3039
|
+
res.write(`event: content_block_stop\ndata: ${JSON.stringify(blockStop)}\n\n`);
|
|
3040
|
+
currentBlockIndex++;
|
|
3041
|
+
hasStartedThinkingBlock = false;
|
|
3042
|
+
}
|
|
3043
|
+
const blockStart = (0, translator_1.createClaudeStreamEvent)('content_block_start', {
|
|
3044
|
+
index: currentBlockIndex,
|
|
3045
|
+
content_block: { type: 'redacted_thinking', data: redactedContent }
|
|
3046
|
+
});
|
|
3047
|
+
res.write(`event: content_block_start\ndata: ${JSON.stringify(blockStart)}\n\n`);
|
|
3048
|
+
const blockStop = (0, translator_1.createClaudeStreamEvent)('content_block_stop', { index: currentBlockIndex });
|
|
3049
|
+
res.write(`event: content_block_stop\ndata: ${JSON.stringify(blockStop)}\n\n`);
|
|
3050
|
+
currentBlockIndex++;
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
if (text && text.trim()) {
|
|
3054
|
+
if (isThinking) {
|
|
3055
|
+
// 原生 thinking 内容 → 输出为 Anthropic thinking block
|
|
3056
|
+
if (hasStartedTextBlock) {
|
|
3057
|
+
const blockStop = (0, translator_1.createClaudeStreamEvent)('content_block_stop', { index: currentBlockIndex });
|
|
3058
|
+
res.write(`event: content_block_stop\ndata: ${JSON.stringify(blockStop)}\n\n`);
|
|
3059
|
+
currentBlockIndex++;
|
|
3060
|
+
hasStartedTextBlock = false;
|
|
3061
|
+
}
|
|
3062
|
+
if (!hasStartedThinkingBlock) {
|
|
3063
|
+
const blockStart = (0, translator_1.createClaudeStreamEvent)('content_block_start', {
|
|
3064
|
+
index: currentBlockIndex,
|
|
3065
|
+
content_block: { type: 'thinking', thinking: '' }
|
|
3066
|
+
});
|
|
3067
|
+
res.write(`event: content_block_start\ndata: ${JSON.stringify(blockStart)}\n\n`);
|
|
3068
|
+
hasStartedThinkingBlock = true;
|
|
3069
|
+
}
|
|
3070
|
+
const delta = (0, translator_1.createClaudeStreamEvent)('content_block_delta', {
|
|
3071
|
+
index: currentBlockIndex,
|
|
3072
|
+
delta: { type: 'thinking_delta', thinking: text }
|
|
3073
|
+
});
|
|
3074
|
+
res.write(`event: content_block_delta\ndata: ${JSON.stringify(delta)}\n\n`);
|
|
3075
|
+
if (reasoningSignature) {
|
|
3076
|
+
pendingThinkingSignature = reasoningSignature;
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
else {
|
|
3080
|
+
// 普通文本内容
|
|
3081
|
+
if (hasStartedThinkingBlock) {
|
|
3082
|
+
flushThinkingSignature();
|
|
3083
|
+
const blockStop = (0, translator_1.createClaudeStreamEvent)('content_block_stop', { index: currentBlockIndex });
|
|
3084
|
+
res.write(`event: content_block_stop\ndata: ${JSON.stringify(blockStop)}\n\n`);
|
|
3085
|
+
currentBlockIndex++;
|
|
3086
|
+
hasStartedThinkingBlock = false;
|
|
3087
|
+
}
|
|
3088
|
+
collectedContent += text;
|
|
3089
|
+
if (!hasStartedTextBlock) {
|
|
3090
|
+
const blockStart = (0, translator_1.createClaudeStreamEvent)('content_block_start', {
|
|
3091
|
+
index: currentBlockIndex,
|
|
3092
|
+
content_block: { type: 'text', text: '' }
|
|
3093
|
+
});
|
|
3094
|
+
res.write(`event: content_block_start\ndata: ${JSON.stringify(blockStart)}\n\n`);
|
|
3095
|
+
hasStartedTextBlock = true;
|
|
3096
|
+
}
|
|
3097
|
+
const delta = (0, translator_1.createClaudeStreamEvent)('content_block_delta', {
|
|
3098
|
+
index: currentBlockIndex,
|
|
3099
|
+
delta: { type: 'text_delta', text }
|
|
3100
|
+
});
|
|
3101
|
+
res.write(`event: content_block_delta\ndata: ${JSON.stringify(delta)}\n\n`);
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
else if (isThinking && reasoningSignature) {
|
|
3105
|
+
if (!hasStartedThinkingBlock) {
|
|
3106
|
+
const blockStart = (0, translator_1.createClaudeStreamEvent)('content_block_start', {
|
|
3107
|
+
index: currentBlockIndex,
|
|
3108
|
+
content_block: { type: 'thinking', thinking: '' }
|
|
3109
|
+
});
|
|
3110
|
+
res.write(`event: content_block_start\ndata: ${JSON.stringify(blockStart)}\n\n`);
|
|
3111
|
+
hasStartedThinkingBlock = true;
|
|
3112
|
+
}
|
|
3113
|
+
pendingThinkingSignature = reasoningSignature;
|
|
3114
|
+
}
|
|
3115
|
+
if (toolUse) {
|
|
3116
|
+
const restoredToolUse = toolNameRegistry.restoreToolUse(toolUse);
|
|
3117
|
+
if (hasStartedThinkingBlock) {
|
|
3118
|
+
flushThinkingSignature();
|
|
3119
|
+
const blockStop = (0, translator_1.createClaudeStreamEvent)('content_block_stop', { index: currentBlockIndex });
|
|
3120
|
+
res.write(`event: content_block_stop\ndata: ${JSON.stringify(blockStop)}\n\n`);
|
|
3121
|
+
currentBlockIndex++;
|
|
3122
|
+
hasStartedThinkingBlock = false;
|
|
3123
|
+
}
|
|
3124
|
+
// 结束之前的文本块
|
|
3125
|
+
if (hasStartedTextBlock) {
|
|
3126
|
+
const blockStop = (0, translator_1.createClaudeStreamEvent)('content_block_stop', { index: currentBlockIndex });
|
|
3127
|
+
res.write(`event: content_block_stop\ndata: ${JSON.stringify(blockStop)}\n\n`);
|
|
3128
|
+
currentBlockIndex++;
|
|
3129
|
+
hasStartedTextBlock = false;
|
|
3130
|
+
}
|
|
3131
|
+
// 记录工具调用
|
|
3132
|
+
pendingToolCalls.set(toolUse.toolUseId, { name: toolUse.name, input: toolUse.input });
|
|
3133
|
+
// 开始工具块
|
|
3134
|
+
const toolBlockStart = (0, translator_1.createClaudeStreamEvent)('content_block_start', {
|
|
3135
|
+
index: currentBlockIndex,
|
|
3136
|
+
content_block: { type: 'tool_use', id: toolUse.toolUseId, name: restoredToolUse.name, input: {} }
|
|
3137
|
+
});
|
|
3138
|
+
res.write(`event: content_block_start\ndata: ${JSON.stringify(toolBlockStart)}\n\n`);
|
|
3139
|
+
// 发送工具输入
|
|
3140
|
+
const toolDelta = (0, translator_1.createClaudeStreamEvent)('content_block_delta', {
|
|
3141
|
+
index: currentBlockIndex,
|
|
3142
|
+
delta: { type: 'input_json_delta', partial_json: JSON.stringify(toolUse.input) }
|
|
3143
|
+
});
|
|
3144
|
+
res.write(`event: content_block_delta\ndata: ${JSON.stringify(toolDelta)}\n\n`);
|
|
3145
|
+
// 结束工具块
|
|
3146
|
+
const toolBlockStop = (0, translator_1.createClaudeStreamEvent)('content_block_stop', { index: currentBlockIndex });
|
|
3147
|
+
res.write(`event: content_block_stop\ndata: ${JSON.stringify(toolBlockStop)}\n\n`);
|
|
3148
|
+
currentBlockIndex++;
|
|
3149
|
+
}
|
|
3150
|
+
}, async (usedAccount, usage) => {
|
|
3151
|
+
if (signal?.aborted || this.isResponseClosed(res)) {
|
|
3152
|
+
resolve();
|
|
3153
|
+
return;
|
|
3154
|
+
}
|
|
3155
|
+
ensureStreamStarted();
|
|
3156
|
+
if (hasStartedThinkingBlock) {
|
|
3157
|
+
flushThinkingSignature();
|
|
3158
|
+
const blockStop = (0, translator_1.createClaudeStreamEvent)('content_block_stop', { index: currentBlockIndex });
|
|
3159
|
+
res.write(`event: content_block_stop\ndata: ${JSON.stringify(blockStop)}\n\n`);
|
|
3160
|
+
currentBlockIndex++;
|
|
3161
|
+
hasStartedThinkingBlock = false;
|
|
3162
|
+
}
|
|
3163
|
+
// 结束最后的文本块
|
|
3164
|
+
if (hasStartedTextBlock) {
|
|
3165
|
+
const blockStop = (0, translator_1.createClaudeStreamEvent)('content_block_stop', { index: currentBlockIndex });
|
|
3166
|
+
res.write(`event: content_block_stop\ndata: ${JSON.stringify(blockStop)}\n\n`);
|
|
3167
|
+
currentBlockIndex++;
|
|
3168
|
+
}
|
|
3169
|
+
this.recordRequestSuccess();
|
|
3170
|
+
this.stats.totalTokens += usage.inputTokens + usage.outputTokens;
|
|
3171
|
+
this.stats.inputTokens += usage.inputTokens;
|
|
3172
|
+
this.stats.outputTokens += usage.outputTokens;
|
|
3173
|
+
this.stats.totalCredits += usage.credits || 0;
|
|
3174
|
+
this.events.onCreditsUpdate?.(this.stats.totalCredits);
|
|
3175
|
+
this.events.onTokensUpdate?.(this.stats.inputTokens, this.stats.outputTokens);
|
|
3176
|
+
this.accountPool.recordSuccess(usedAccount.id, usage.inputTokens + usage.outputTokens);
|
|
3177
|
+
this.stats.cacheReadTokens += usage.cacheReadTokens || simulatedCacheUsage?.cacheReadInputTokens || 0;
|
|
3178
|
+
this.stats.cacheWriteTokens += usage.cacheWriteTokens || simulatedCacheUsage?.cacheCreationInputTokens || 0;
|
|
3179
|
+
this.stats.reasoningTokens += usage.reasoningTokens || 0;
|
|
3180
|
+
const respTime = Date.now() - startTime;
|
|
3181
|
+
this.events.onResponse?.({ path: '/v1/messages', model, status: 200, tokens: usage.inputTokens + usage.outputTokens, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, cacheReadTokens: usage.cacheReadTokens || simulatedCacheUsage?.cacheReadInputTokens, reasoningTokens: usage.reasoningTokens, credits: usage.credits, responseTime: respTime });
|
|
3182
|
+
this.recordRequest({ path: '/v1/messages', model, accountId: usedAccount.id, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, credits: usage.credits, responseTime: respTime, success: true });
|
|
3183
|
+
// 记录 API Key 用量
|
|
3184
|
+
if (matchedApiKey) {
|
|
3185
|
+
this.recordApiKeyUsage(matchedApiKey.id, usage.credits || 0, usage.inputTokens, usage.outputTokens, model, '/v1/messages');
|
|
3186
|
+
}
|
|
3187
|
+
// 成功后更新 prompt cache tracker
|
|
3188
|
+
if (simulatedCacheUsage?.cacheProfile) {
|
|
3189
|
+
promptCacheTracker_1.promptCacheTracker.update(usedAccount.id, simulatedCacheUsage.cacheProfile);
|
|
3190
|
+
}
|
|
3191
|
+
// 发送 message_delta(包含完整 usage 信息)
|
|
3192
|
+
const hasToolCalls = pendingToolCalls.size > 0;
|
|
3193
|
+
const stopReason = hasToolCalls ? 'tool_use' : 'end_turn';
|
|
3194
|
+
const messageDelta = (0, translator_1.createClaudeStreamEvent)('message_delta', {
|
|
3195
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
3196
|
+
usage: this.buildClaudeUsage(usage, simulatedCacheUsage)
|
|
3197
|
+
});
|
|
3198
|
+
res.write(`event: message_delta\ndata: ${JSON.stringify(messageDelta)}\n\n`);
|
|
3199
|
+
// 发送 message_stop
|
|
3200
|
+
const messageStop = (0, translator_1.createClaudeStreamEvent)('message_stop');
|
|
3201
|
+
res.write(`event: message_stop\ndata: ${JSON.stringify(messageStop)}\n\n`);
|
|
3202
|
+
res.end();
|
|
3203
|
+
resolve();
|
|
3204
|
+
}, (usedAccount, error) => {
|
|
3205
|
+
if (this.isAbortError(error, signal) || this.isResponseClosed(res)) {
|
|
3206
|
+
resolve();
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
ensureStreamStarted();
|
|
3210
|
+
console.error('[ProxyServer] Stream error:', error);
|
|
3211
|
+
const errorEvent = (0, translator_1.createClaudeStreamEvent)('error', {
|
|
3212
|
+
error: { type: 'api_error', message: error.message }
|
|
3213
|
+
});
|
|
3214
|
+
res.write(`event: error\ndata: ${JSON.stringify(errorEvent)}\n\n`);
|
|
3215
|
+
res.end();
|
|
3216
|
+
this.recordRequestFailed();
|
|
3217
|
+
this.events.onResponse?.({ path: '/v1/messages', model, status: 500, error: error.message });
|
|
3218
|
+
this.recordRequest({ path: '/v1/messages', model, accountId: usedAccount.id, responseTime: Date.now() - startTime, success: false, error: error.message });
|
|
3219
|
+
resolve();
|
|
3220
|
+
}, '/v1/messages', signal, matchedApiKey?.id, model).catch(error => {
|
|
3221
|
+
if (!this.isAbortError(error, signal) && !this.isResponseClosed(res)) {
|
|
3222
|
+
const errorEvent = (0, translator_1.createClaudeStreamEvent)('error', {
|
|
3223
|
+
error: { type: 'api_error', message: error.message }
|
|
3224
|
+
});
|
|
3225
|
+
res.write(`event: error\ndata: ${JSON.stringify(errorEvent)}\n\n`);
|
|
3226
|
+
res.end();
|
|
3227
|
+
this.recordRequestFailed();
|
|
3228
|
+
}
|
|
3229
|
+
resolve();
|
|
3230
|
+
});
|
|
3231
|
+
});
|
|
3232
|
+
}
|
|
3233
|
+
// 处理 API 错误
|
|
3234
|
+
handleApiError(res, account, error, path, model, startTime, signal) {
|
|
3235
|
+
if (this.isAbortError(error, signal) || this.isResponseClosed(res))
|
|
3236
|
+
return;
|
|
3237
|
+
this.recordRequestFailed();
|
|
3238
|
+
const attemptError = error;
|
|
3239
|
+
const failedAccount = attemptError.proxyAccountId
|
|
3240
|
+
? this.accountPool.getAccount(attemptError.proxyAccountId) || account
|
|
3241
|
+
: account;
|
|
3242
|
+
const errCode = error.message.match(/(\d{3})/)?.[1];
|
|
3243
|
+
const parsedCode = errCode ? parseInt(errCode) : 500;
|
|
3244
|
+
const isAuthError = error.message.includes('401') || error.message.includes('403') || error.message.includes('Auth');
|
|
3245
|
+
if (!attemptError.proxyFailureRecorded)
|
|
3246
|
+
this.recordAccountFailure(failedAccount, error);
|
|
3247
|
+
let statusCode = parsedCode;
|
|
3248
|
+
if ((0, accountPool_1.isBillingOrQuotaError)(error.message))
|
|
3249
|
+
statusCode = 402;
|
|
3250
|
+
else if ((0, accountPool_1.isThrottleError)(error.message))
|
|
3251
|
+
statusCode = 429;
|
|
3252
|
+
if (isAuthError)
|
|
3253
|
+
statusCode = 401;
|
|
3254
|
+
if (res.headersSent) {
|
|
3255
|
+
if (!this.isResponseClosed(res)) {
|
|
3256
|
+
if (path === '/v1/responses' || path === '/responses') {
|
|
3257
|
+
res.write(`event: response.failed\ndata: ${JSON.stringify({ type: 'response.failed', error: { type: 'api_error', message: error.message } })}\n\n`);
|
|
3258
|
+
}
|
|
3259
|
+
res.end();
|
|
3260
|
+
}
|
|
3261
|
+
this.events.onResponse?.({ path, status: statusCode, error: error.message });
|
|
3262
|
+
this.recordRequest({ path, model, accountId: failedAccount.id, responseTime: startTime ? Date.now() - startTime : 0, success: false, error: error.message });
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
this.sendError(res, statusCode, error.message, this.isAnthropicPath(path) ? 'anthropic' : 'openai');
|
|
3266
|
+
this.events.onResponse?.({ path, status: statusCode, error: error.message });
|
|
3267
|
+
this.recordRequest({ path, model, accountId: failedAccount.id, responseTime: startTime ? Date.now() - startTime : 0, success: false, error: error.message });
|
|
3268
|
+
}
|
|
3269
|
+
// 读取请求体
|
|
3270
|
+
/**
|
|
3271
|
+
* 读取请求体,限制最大字节数以防 DoS
|
|
3272
|
+
* - Content-Length 头超限:立即 reject
|
|
3273
|
+
* - 流式累加超限:销毁连接并 reject
|
|
3274
|
+
* 触发 BodyTooLarge 错误时上层会发 413 Payload Too Large
|
|
3275
|
+
*/
|
|
3276
|
+
readBody(req, signal) {
|
|
3277
|
+
const maxBytes = Math.max(1024, this.config.maxRequestBodyBytes ?? 10 * 1024 * 1024);
|
|
3278
|
+
// 优先用 Content-Length 提前拒绝(避免分配缓冲)
|
|
3279
|
+
const declaredLen = parseInt(req.headers['content-length'] || '0', 10);
|
|
3280
|
+
if (Number.isFinite(declaredLen) && declaredLen > maxBytes) {
|
|
3281
|
+
return Promise.reject(new BodyTooLargeError(declaredLen, maxBytes));
|
|
3282
|
+
}
|
|
3283
|
+
return new Promise((resolve, reject) => {
|
|
3284
|
+
const chunks = [];
|
|
3285
|
+
let total = 0;
|
|
3286
|
+
const cleanup = () => {
|
|
3287
|
+
req.off('data', onData);
|
|
3288
|
+
req.off('end', onEnd);
|
|
3289
|
+
req.off('error', onError);
|
|
3290
|
+
req.off('aborted', onAborted);
|
|
3291
|
+
signal?.removeEventListener('abort', onAbort);
|
|
3292
|
+
};
|
|
3293
|
+
const onData = (chunk) => {
|
|
3294
|
+
total += chunk.length;
|
|
3295
|
+
if (total > maxBytes) {
|
|
3296
|
+
cleanup();
|
|
3297
|
+
try {
|
|
3298
|
+
req.destroy();
|
|
3299
|
+
}
|
|
3300
|
+
catch { /* ignore */ }
|
|
3301
|
+
reject(new BodyTooLargeError(total, maxBytes));
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3304
|
+
chunks.push(chunk);
|
|
3305
|
+
};
|
|
3306
|
+
const onEnd = () => {
|
|
3307
|
+
cleanup();
|
|
3308
|
+
resolve(Buffer.concat(chunks, total).toString('utf8'));
|
|
3309
|
+
};
|
|
3310
|
+
const onError = (error) => {
|
|
3311
|
+
cleanup();
|
|
3312
|
+
reject(error);
|
|
3313
|
+
};
|
|
3314
|
+
const onAborted = () => {
|
|
3315
|
+
cleanup();
|
|
3316
|
+
reject(new Error('Client disconnected'));
|
|
3317
|
+
};
|
|
3318
|
+
const onAbort = () => {
|
|
3319
|
+
cleanup();
|
|
3320
|
+
reject(this.getAbortError(signal));
|
|
3321
|
+
};
|
|
3322
|
+
if (signal?.aborted) {
|
|
3323
|
+
reject(this.getAbortError(signal));
|
|
3324
|
+
return;
|
|
3325
|
+
}
|
|
3326
|
+
req.on('data', onData);
|
|
3327
|
+
req.on('end', onEnd);
|
|
3328
|
+
req.on('error', onError);
|
|
3329
|
+
req.on('aborted', onAborted);
|
|
3330
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
3331
|
+
});
|
|
3332
|
+
}
|
|
3333
|
+
// 发送错误响应
|
|
3334
|
+
// P0-5 自动 sanitize:500 类不吐 message 详情;4xx 客户端错误正常返回
|
|
3335
|
+
sendError(res, status, message, format = 'openai') {
|
|
3336
|
+
if (res.writableEnded || res.destroyed)
|
|
3337
|
+
return;
|
|
3338
|
+
// 500-599 强制使用通用消息(防止泄露内部信息)
|
|
3339
|
+
const safeMessage = status >= 500 && status < 600
|
|
3340
|
+
? this.sanitizeErrorMessage(message) || 'Internal server error'
|
|
3341
|
+
: message;
|
|
3342
|
+
// P1-6 503 → 触发 webhook(已有 5 分钟去重)
|
|
3343
|
+
if (status === 503) {
|
|
3344
|
+
this.notifyAllAccountsExhausted('unknown');
|
|
3345
|
+
}
|
|
3346
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
3347
|
+
if (format === 'anthropic') {
|
|
3348
|
+
res.end(JSON.stringify({
|
|
3349
|
+
type: 'error',
|
|
3350
|
+
error: {
|
|
3351
|
+
type: this.getAnthropicErrorType(status),
|
|
3352
|
+
message: safeMessage
|
|
3353
|
+
}
|
|
3354
|
+
}));
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
3357
|
+
res.end(JSON.stringify({ error: { message: safeMessage, type: 'error', code: status } }));
|
|
3358
|
+
}
|
|
3359
|
+
/**
|
|
3360
|
+
* P0-5 / P2-19 错误消息脱敏(移除可能含的 Bearer/Token/路径等敏感信息)
|
|
3361
|
+
* 用于错误响应和日志输出
|
|
3362
|
+
*/
|
|
3363
|
+
sanitizeErrorMessage(msg) {
|
|
3364
|
+
if (!msg)
|
|
3365
|
+
return '';
|
|
3366
|
+
return msg
|
|
3367
|
+
// Bearer xxxx → Bearer ***
|
|
3368
|
+
.replace(/Bearer\s+[A-Za-z0-9\-_.~+/]+=*/gi, 'Bearer ***')
|
|
3369
|
+
// access_token / refresh_token / api_key / x-api-key 字段值
|
|
3370
|
+
.replace(/(access[_-]?token|refresh[_-]?token|api[_-]?key|x-api-key)["'\s:=]+[^"',\s}]+/gi, '$1=***')
|
|
3371
|
+
// 长 base64/JWT(>= 40 chars)替换为占位
|
|
3372
|
+
.replace(/eyJ[A-Za-z0-9\-_]{20,}/g, 'eyJ***')
|
|
3373
|
+
// Windows 用户路径
|
|
3374
|
+
.replace(/C:\\Users\\[^\\/\s]+/gi, 'C:\\Users\\***')
|
|
3375
|
+
// Linux/Mac home 路径
|
|
3376
|
+
.replace(/\/home\/[^\s/]+/g, '/home/***')
|
|
3377
|
+
.replace(/\/Users\/[^\s/]+/g, '/Users/***');
|
|
3378
|
+
}
|
|
3379
|
+
/**
|
|
3380
|
+
* P1-7 滑动窗口限流:每分钟 N 次(按 API Key id 或 IP)
|
|
3381
|
+
* 0 = 不限制
|
|
3382
|
+
*/
|
|
3383
|
+
checkRateLimit(id) {
|
|
3384
|
+
const limit = this.config.rateLimitPerKeyPerMinute || 0;
|
|
3385
|
+
if (limit <= 0)
|
|
3386
|
+
return { allowed: true, retryAfterMs: 0 };
|
|
3387
|
+
const now = Date.now();
|
|
3388
|
+
const bucket = this.rateLimitBuckets.get(id);
|
|
3389
|
+
if (!bucket || now - bucket.windowStart >= 60_000) {
|
|
3390
|
+
this.rateLimitBuckets.set(id, { count: 1, windowStart: now });
|
|
3391
|
+
return { allowed: true, retryAfterMs: 0 };
|
|
3392
|
+
}
|
|
3393
|
+
if (bucket.count >= limit) {
|
|
3394
|
+
return { allowed: false, retryAfterMs: 60_000 - (now - bucket.windowStart) };
|
|
3395
|
+
}
|
|
3396
|
+
bucket.count++;
|
|
3397
|
+
return { allowed: true, retryAfterMs: 0 };
|
|
3398
|
+
}
|
|
3399
|
+
/** 定期清理过期的限流桶 / 会话粘性条目(避免内存泄漏) */
|
|
3400
|
+
cleanupExpiredCaches() {
|
|
3401
|
+
const now = Date.now();
|
|
3402
|
+
// 限流桶过期 2 分钟
|
|
3403
|
+
for (const [key, bucket] of this.rateLimitBuckets) {
|
|
3404
|
+
if (now - bucket.windowStart > 120_000)
|
|
3405
|
+
this.rateLimitBuckets.delete(key);
|
|
3406
|
+
}
|
|
3407
|
+
// 粘性会话过期 10 分钟
|
|
3408
|
+
for (const [key, entry] of this.sessionAffinity) {
|
|
3409
|
+
if (now - entry.lastAt > 600_000)
|
|
3410
|
+
this.sessionAffinity.delete(key);
|
|
3411
|
+
}
|
|
3412
|
+
// 审计日志最多 200 条
|
|
3413
|
+
if (this.auditLog.length > 200) {
|
|
3414
|
+
this.auditLog = this.auditLog.slice(-200);
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
/**
|
|
3418
|
+
* P1-8 会话粘性账号选择:相同 session hint 优先复用同一账号
|
|
3419
|
+
* 实现方式:用 sessionHint hash 索引到固定账号;账号失效时自动失效粘性
|
|
3420
|
+
*/
|
|
3421
|
+
pickAccountWithAffinity(sessionHint) {
|
|
3422
|
+
if (!this.isSessionAffinityActive() || !sessionHint)
|
|
3423
|
+
return null;
|
|
3424
|
+
const entry = this.sessionAffinity.get(sessionHint);
|
|
3425
|
+
if (entry) {
|
|
3426
|
+
const account = this.accountPool.getAccount(entry.accountId);
|
|
3427
|
+
// 校验账号仍可用,避免 affinity 把请求送回耗尽/冷却/封禁账号。
|
|
3428
|
+
if (account && this.accountPool.isAccountAvailable(account)) {
|
|
3429
|
+
entry.lastAt = Date.now();
|
|
3430
|
+
return account;
|
|
3431
|
+
}
|
|
3432
|
+
// 已失效 → 清掉粘性
|
|
3433
|
+
this.sessionAffinity.delete(sessionHint);
|
|
3434
|
+
}
|
|
3435
|
+
return null;
|
|
3436
|
+
}
|
|
3437
|
+
/** 记录粘性映射 */
|
|
3438
|
+
rememberAffinity(sessionHint, accountId) {
|
|
3439
|
+
if (!this.isSessionAffinityActive() || !sessionHint)
|
|
3440
|
+
return;
|
|
3441
|
+
this.sessionAffinity.set(sessionHint, { accountId, lastAt: Date.now() });
|
|
3442
|
+
}
|
|
3443
|
+
/** P2-17 审计日志 */
|
|
3444
|
+
appendAuditLog(type, data) {
|
|
3445
|
+
if (!this.config.enableAuditLog)
|
|
3446
|
+
return;
|
|
3447
|
+
this.auditLog.push({ ts: Date.now(), type, data });
|
|
3448
|
+
if (this.auditLog.length > 200)
|
|
3449
|
+
this.auditLog.shift();
|
|
3450
|
+
}
|
|
3451
|
+
/** 获取审计日志(供管理 API) */
|
|
3452
|
+
getAuditLog() {
|
|
3453
|
+
return this.auditLog;
|
|
3454
|
+
}
|
|
3455
|
+
/** 注入 webhook 触发器(由 main/index.ts 注入,调用 renderer 的 webhook store) */
|
|
3456
|
+
setWebhookTrigger(fn) {
|
|
3457
|
+
this.webhookTrigger = fn;
|
|
3458
|
+
}
|
|
3459
|
+
/** 关键事件去重时间戳(5 分钟内同事件不重复推) */
|
|
3460
|
+
lastWebhookByEvent = new Map();
|
|
3461
|
+
/** P1-6 触发 webhook(封装错误处理 + 5 分钟去重) */
|
|
3462
|
+
triggerWebhook(event, payload) {
|
|
3463
|
+
const now = Date.now();
|
|
3464
|
+
const last = this.lastWebhookByEvent.get(event) || 0;
|
|
3465
|
+
if (now - last < 5 * 60_000)
|
|
3466
|
+
return; // 同事件 5 分钟内不重复推
|
|
3467
|
+
this.lastWebhookByEvent.set(event, now);
|
|
3468
|
+
try {
|
|
3469
|
+
this.webhookTrigger?.(event, payload);
|
|
3470
|
+
}
|
|
3471
|
+
catch (err) {
|
|
3472
|
+
logger_1.proxyLogger.warn('ProxyServer', `Webhook trigger failed: ${err.message}`);
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
/** 全员配额耗尽 webhook(503 时调用) */
|
|
3476
|
+
notifyAllAccountsExhausted(path, model) {
|
|
3477
|
+
const quota = this.accountPool.getQuotaStatus();
|
|
3478
|
+
this.appendAuditLog('all_accounts_exhausted', { path, model, ...quota });
|
|
3479
|
+
this.triggerWebhook('proxy-all-exhausted', {
|
|
3480
|
+
title: '反代账号全部不可用',
|
|
3481
|
+
message: `所有账号配额耗尽或冷却中(exhausted=${quota.exhausted}/${quota.total},cooldown=${quota.cooldown})`,
|
|
3482
|
+
level: 'error',
|
|
3483
|
+
fields: { 端点: path, 模型: model || '-', 总账号: quota.total, 配额耗尽: quota.exhausted, 冷却中: quota.cooldown, 可用: quota.available }
|
|
3484
|
+
});
|
|
3485
|
+
}
|
|
3486
|
+
/** P2-16 Prometheus metrics 文本 */
|
|
3487
|
+
renderPrometheusMetrics() {
|
|
3488
|
+
const s = this.stats;
|
|
3489
|
+
const ap = this.accountPool;
|
|
3490
|
+
const lines = [];
|
|
3491
|
+
lines.push('# HELP kiro_proxy_requests_total Total requests handled');
|
|
3492
|
+
lines.push('# TYPE kiro_proxy_requests_total counter');
|
|
3493
|
+
lines.push(`kiro_proxy_requests_total ${s.totalRequests}`);
|
|
3494
|
+
lines.push('# HELP kiro_proxy_requests_success_total Total successful requests');
|
|
3495
|
+
lines.push('# TYPE kiro_proxy_requests_success_total counter');
|
|
3496
|
+
lines.push(`kiro_proxy_requests_success_total ${s.successRequests}`);
|
|
3497
|
+
lines.push('# HELP kiro_proxy_requests_failed_total Total failed requests');
|
|
3498
|
+
lines.push('# TYPE kiro_proxy_requests_failed_total counter');
|
|
3499
|
+
lines.push(`kiro_proxy_requests_failed_total ${s.failedRequests}`);
|
|
3500
|
+
lines.push('# HELP kiro_proxy_tokens_total Total tokens consumed');
|
|
3501
|
+
lines.push('# TYPE kiro_proxy_tokens_total counter');
|
|
3502
|
+
lines.push(`kiro_proxy_tokens_total{type="input"} ${s.inputTokens}`);
|
|
3503
|
+
lines.push(`kiro_proxy_tokens_total{type="output"} ${s.outputTokens}`);
|
|
3504
|
+
lines.push(`kiro_proxy_tokens_total{type="cache_read"} ${s.cacheReadTokens}`);
|
|
3505
|
+
lines.push(`kiro_proxy_tokens_total{type="cache_write"} ${s.cacheWriteTokens}`);
|
|
3506
|
+
lines.push('# HELP kiro_proxy_credits_total Total credits consumed');
|
|
3507
|
+
lines.push('# TYPE kiro_proxy_credits_total counter');
|
|
3508
|
+
lines.push(`kiro_proxy_credits_total ${s.totalCredits}`);
|
|
3509
|
+
lines.push('# HELP kiro_proxy_accounts Accounts by status');
|
|
3510
|
+
lines.push('# TYPE kiro_proxy_accounts gauge');
|
|
3511
|
+
const quota = ap.getQuotaStatus();
|
|
3512
|
+
lines.push(`kiro_proxy_accounts{status="total"} ${quota.total}`);
|
|
3513
|
+
lines.push(`kiro_proxy_accounts{status="available"} ${quota.available}`);
|
|
3514
|
+
lines.push(`kiro_proxy_accounts{status="exhausted"} ${quota.exhausted}`);
|
|
3515
|
+
lines.push(`kiro_proxy_accounts{status="cooldown"} ${quota.cooldown}`);
|
|
3516
|
+
lines.push('# HELP kiro_proxy_uptime_seconds Server uptime in seconds');
|
|
3517
|
+
lines.push('# TYPE kiro_proxy_uptime_seconds gauge');
|
|
3518
|
+
lines.push(`kiro_proxy_uptime_seconds ${Math.floor((Date.now() - s.startTime) / 1000)}`);
|
|
3519
|
+
return lines.join('\n') + '\n';
|
|
3520
|
+
}
|
|
3521
|
+
// 记录请求到 recentRequests
|
|
3522
|
+
recordRequest(log) {
|
|
3523
|
+
this.stats.recentRequests.push({
|
|
3524
|
+
timestamp: Date.now(),
|
|
3525
|
+
path: log.path,
|
|
3526
|
+
model: log.model || 'unknown',
|
|
3527
|
+
accountId: log.accountId || 'unknown',
|
|
3528
|
+
inputTokens: log.inputTokens || 0,
|
|
3529
|
+
outputTokens: log.outputTokens || 0,
|
|
3530
|
+
credits: log.credits,
|
|
3531
|
+
responseTime: log.responseTime || 0,
|
|
3532
|
+
success: log.success,
|
|
3533
|
+
// P2-19 错误消息脱敏
|
|
3534
|
+
error: log.error ? this.sanitizeErrorMessage(log.error).slice(0, 500) : undefined
|
|
3535
|
+
});
|
|
3536
|
+
// P2-15 可配置上限(默认 100,最多 10000)
|
|
3537
|
+
const limit = Math.min(10000, Math.max(20, this.config.recentRequestsLimit || 100));
|
|
3538
|
+
if (this.stats.recentRequests.length > limit) {
|
|
3539
|
+
this.stats.recentRequests = this.stats.recentRequests.slice(-limit);
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
exports.ProxyServer = ProxyServer;
|