hequ commited on
Commit
6c6056a
·
verified ·
1 Parent(s): 75031b4

Upload 224 files

Browse files
.env.example CHANGED
@@ -37,6 +37,17 @@ CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-20
37
  # 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
38
  CLAUDE_OVERLOAD_HANDLING_MINUTES=0
39
 
 
 
 
 
 
 
 
 
 
 
 
40
  # 🌐 代理配置
41
  DEFAULT_PROXY_TIMEOUT=600000
42
  MAX_PROXY_RETRIES=3
 
37
  # 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
38
  CLAUDE_OVERLOAD_HANDLING_MINUTES=0
39
 
40
+ # 400错误处理:0表示禁用,>0表示临时禁用时间(分钟)
41
+ # 只有匹配特定错误模式的 400 才会触发临时禁用
42
+ # - organization has been disabled
43
+ # - account has been disabled
44
+ # - account is disabled
45
+ # - no account supporting
46
+ # - account not found
47
+ # - invalid account
48
+ # - Too many active sessions
49
+ CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES=10
50
+
51
  # 🌐 代理配置
52
  DEFAULT_PROXY_TIMEOUT=600000
53
  MAX_PROXY_RETRIES=3
VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.179
 
1
+ 1.1.181
src/routes/admin.js CHANGED
@@ -3624,8 +3624,8 @@ router.post('/bedrock-accounts', authenticateAdmin, async (req, res) => {
3624
  }
3625
 
3626
  logger.success(`☁️ Admin created Bedrock account: ${name}`)
3627
- const formattedAccount = formatAccountExpiry(formattedAccount)
3628
- return res.json({ success: true, data: result.data })
3629
  } catch (error) {
3630
  logger.error('❌ Failed to create Bedrock account:', error)
3631
  return res
@@ -4078,8 +4078,8 @@ router.post('/gemini-accounts', authenticateAdmin, async (req, res) => {
4078
  }
4079
 
4080
  logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`)
4081
- const formattedAccount = formatAccountExpiry(formattedAccount)
4082
- return res.json({ success: true, data: newAccount })
4083
  } catch (error) {
4084
  logger.error('❌ Failed to create Gemini account:', error)
4085
  return res.status(500).json({ error: 'Failed to create account', message: error.message })
@@ -5736,7 +5736,7 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
5736
  try {
5737
  const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
5738
 
5739
- const allowedGroups = ['claude', 'openai', 'gemini']
5740
  if (!allowedGroups.includes(group)) {
5741
  return res.status(400).json({
5742
  success: false,
@@ -5747,7 +5747,8 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
5747
  const groupLabels = {
5748
  claude: 'Claude账户',
5749
  openai: 'OpenAI账户',
5750
- gemini: 'Gemini账户'
 
5751
  }
5752
 
5753
  // 拉取各平台账号列表
@@ -5815,6 +5816,17 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
5815
  platform: 'gemini'
5816
  }
5817
  })
 
 
 
 
 
 
 
 
 
 
 
5818
  }
5819
 
5820
  if (!accounts || accounts.length === 0) {
@@ -7171,6 +7183,7 @@ router.post('/openai-accounts/exchange-code', authenticateAdmin, async (req, res
7171
  // 配置代理(如果有)
7172
  const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy)
7173
  if (proxyAgent) {
 
7174
  axiosConfig.httpsAgent = proxyAgent
7175
  axiosConfig.proxy = false
7176
  }
 
3624
  }
3625
 
3626
  logger.success(`☁️ Admin created Bedrock account: ${name}`)
3627
+ const formattedAccount = formatAccountExpiry(result.data)
3628
+ return res.json({ success: true, data: formattedAccount })
3629
  } catch (error) {
3630
  logger.error('❌ Failed to create Bedrock account:', error)
3631
  return res
 
4078
  }
4079
 
4080
  logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`)
4081
+ const formattedAccount = formatAccountExpiry(newAccount)
4082
+ return res.json({ success: true, data: formattedAccount })
4083
  } catch (error) {
4084
  logger.error('❌ Failed to create Gemini account:', error)
4085
  return res.status(500).json({ error: 'Failed to create account', message: error.message })
 
5736
  try {
5737
  const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
5738
 
5739
+ const allowedGroups = ['claude', 'openai', 'gemini', 'droid']
5740
  if (!allowedGroups.includes(group)) {
5741
  return res.status(400).json({
5742
  success: false,
 
5747
  const groupLabels = {
5748
  claude: 'Claude账户',
5749
  openai: 'OpenAI账户',
5750
+ gemini: 'Gemini账户',
5751
+ droid: 'Droid账户'
5752
  }
5753
 
5754
  // 拉取各平台账号列表
 
5816
  platform: 'gemini'
5817
  }
5818
  })
5819
+ } else if (group === 'droid') {
5820
+ const droidAccounts = await droidAccountService.getAllAccounts()
5821
+ accounts = droidAccounts.map((account) => {
5822
+ const id = String(account.id || '')
5823
+ const shortId = id ? id.slice(0, 8) : '未知'
5824
+ return {
5825
+ id,
5826
+ name: account.name || account.ownerEmail || account.ownerName || `Droid账号 ${shortId}`,
5827
+ platform: 'droid'
5828
+ }
5829
+ })
5830
  }
5831
 
5832
  if (!accounts || accounts.length === 0) {
 
7183
  // 配置代理(如果有)
7184
  const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy)
7185
  if (proxyAgent) {
7186
+ axiosConfig.httpAgent = proxyAgent
7187
  axiosConfig.httpsAgent = proxyAgent
7188
  axiosConfig.proxy = false
7189
  }
src/routes/api.js CHANGED
@@ -11,6 +11,7 @@ const logger = require('../utils/logger')
11
  const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
12
  const sessionHelper = require('../utils/sessionHelper')
13
  const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
 
14
  const router = express.Router()
15
 
16
  function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
@@ -947,7 +948,13 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
947
  // 尝试解析并返回JSON响应
948
  try {
949
  const jsonData = JSON.parse(response.body)
950
- res.json(jsonData)
 
 
 
 
 
 
951
  } catch (parseError) {
952
  res.send(response.body)
953
  }
 
11
  const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
12
  const sessionHelper = require('../utils/sessionHelper')
13
  const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
14
+ const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
15
  const router = express.Router()
16
 
17
  function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
 
948
  // 尝试解析并返回JSON响应
949
  try {
950
  const jsonData = JSON.parse(response.body)
951
+ // 对于非 2xx 响应,清理供应商特定信息
952
+ if (response.statusCode < 200 || response.statusCode >= 300) {
953
+ const sanitizedData = sanitizeUpstreamError(jsonData)
954
+ res.json(sanitizedData)
955
+ } else {
956
+ res.json(jsonData)
957
+ }
958
  } catch (parseError) {
959
  res.send(response.body)
960
  }
src/routes/openaiRoutes.js CHANGED
@@ -332,6 +332,7 @@ const handleResponses = async (req, res) => {
332
 
333
  // 如果有代理,添加代理配置
334
  if (proxyAgent) {
 
335
  axiosConfig.httpsAgent = proxyAgent
336
  axiosConfig.proxy = false
337
  logger.info(`🌐 Using proxy for OpenAI request: ${ProxyHelper.getProxyDescription(proxy)}`)
 
332
 
333
  // 如果有代理,添加代理配置
334
  if (proxyAgent) {
335
+ axiosConfig.httpAgent = proxyAgent
336
  axiosConfig.httpsAgent = proxyAgent
337
  axiosConfig.proxy = false
338
  logger.info(`🌐 Using proxy for OpenAI request: ${ProxyHelper.getProxyDescription(proxy)}`)
src/routes/standardGeminiRoutes.js CHANGED
@@ -44,6 +44,90 @@ function ensureGeminiPermissionMiddleware(req, res, next) {
44
  return undefined
45
  }
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  // 标准 Gemini API 路由处理器
48
  // 这些路由将挂载在 /gemini 路径下,处理标准 Gemini API 格式的请求
49
  // 标准格式: /gemini/v1beta/models/{model}:generateContent
@@ -552,21 +636,38 @@ async function handleStandardStreamGenerateContent(req, res) {
552
  }
553
  })
554
  } catch (error) {
 
 
555
  logger.error(`Error in standard streamGenerateContent endpoint`, {
556
  message: error.message,
557
  status: error.response?.status,
558
  statusText: error.response?.statusText,
559
- responseData: error.response?.data,
560
  stack: error.stack
561
  })
562
 
563
  if (!res.headersSent) {
564
- res.status(500).json({
 
565
  error: {
566
- message: error.message || 'Internal server error',
567
  type: 'api_error'
568
  }
569
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  }
571
  } finally {
572
  // 清理资源
 
44
  return undefined
45
  }
46
 
47
+ // 判断对象是否为可读流
48
+ function isReadableStream(value) {
49
+ return value && typeof value.on === 'function' && typeof value.pipe === 'function'
50
+ }
51
+
52
+ // 读取可读流内容为字符串
53
+ async function readStreamToString(stream) {
54
+ return new Promise((resolve, reject) => {
55
+ let result = ''
56
+
57
+ try {
58
+ if (typeof stream.setEncoding === 'function') {
59
+ stream.setEncoding('utf8')
60
+ }
61
+ } catch (error) {
62
+ logger.warn('设置流编码失败:', error)
63
+ }
64
+
65
+ stream.on('data', (chunk) => {
66
+ result += chunk
67
+ })
68
+
69
+ stream.on('end', () => {
70
+ resolve(result)
71
+ })
72
+
73
+ stream.on('error', (error) => {
74
+ reject(error)
75
+ })
76
+ })
77
+ }
78
+
79
+ // 规范化上游 Axios 错误信息
80
+ async function normalizeAxiosStreamError(error) {
81
+ const status = error.response?.status
82
+ const statusText = error.response?.statusText
83
+ const responseData = error.response?.data
84
+ let rawBody = null
85
+ let parsedBody = null
86
+
87
+ if (responseData) {
88
+ try {
89
+ if (isReadableStream(responseData)) {
90
+ rawBody = await readStreamToString(responseData)
91
+ } else if (Buffer.isBuffer(responseData)) {
92
+ rawBody = responseData.toString('utf8')
93
+ } else if (typeof responseData === 'string') {
94
+ rawBody = responseData
95
+ } else {
96
+ rawBody = JSON.stringify(responseData)
97
+ }
98
+ } catch (streamError) {
99
+ logger.warn('读取 Gemini 上游错误流失败:', streamError)
100
+ }
101
+ }
102
+
103
+ if (rawBody) {
104
+ if (typeof rawBody === 'string') {
105
+ try {
106
+ parsedBody = JSON.parse(rawBody)
107
+ } catch (parseError) {
108
+ parsedBody = rawBody
109
+ }
110
+ } else {
111
+ parsedBody = rawBody
112
+ }
113
+ }
114
+
115
+ let finalMessage = error.message || 'Internal server error'
116
+ if (parsedBody && typeof parsedBody === 'object') {
117
+ finalMessage = parsedBody.error?.message || parsedBody.message || finalMessage
118
+ } else if (typeof parsedBody === 'string' && parsedBody.trim()) {
119
+ finalMessage = parsedBody.trim()
120
+ }
121
+
122
+ return {
123
+ status,
124
+ statusText,
125
+ message: finalMessage,
126
+ parsedBody,
127
+ rawBody
128
+ }
129
+ }
130
+
131
  // 标准 Gemini API 路由处理器
132
  // 这些路由将挂载在 /gemini 路径下,处理标准 Gemini API 格式的请求
133
  // 标准格式: /gemini/v1beta/models/{model}:generateContent
 
636
  }
637
  })
638
  } catch (error) {
639
+ const normalizedError = await normalizeAxiosStreamError(error)
640
+
641
  logger.error(`Error in standard streamGenerateContent endpoint`, {
642
  message: error.message,
643
  status: error.response?.status,
644
  statusText: error.response?.statusText,
645
+ responseData: normalizedError.parsedBody || normalizedError.rawBody,
646
  stack: error.stack
647
  })
648
 
649
  if (!res.headersSent) {
650
+ const statusCode = normalizedError.status || 500
651
+ const responseBody = {
652
  error: {
653
+ message: normalizedError.message,
654
  type: 'api_error'
655
  }
656
+ }
657
+
658
+ if (normalizedError.status) {
659
+ responseBody.error.upstreamStatus = normalizedError.status
660
+ }
661
+ if (normalizedError.statusText) {
662
+ responseBody.error.upstreamStatusText = normalizedError.statusText
663
+ }
664
+ if (normalizedError.parsedBody && typeof normalizedError.parsedBody === 'object') {
665
+ responseBody.error.upstreamResponse = normalizedError.parsedBody
666
+ } else if (normalizedError.rawBody) {
667
+ responseBody.error.upstreamRaw = normalizedError.rawBody
668
+ }
669
+
670
+ return res.status(statusCode).json(responseBody)
671
  }
672
  } finally {
673
  // 清理资源
src/services/apiKeyService.js CHANGED
@@ -4,6 +4,63 @@ const config = require('../../config/config')
4
  const redis = require('../models/redis')
5
  const logger = require('../utils/logger')
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  class ApiKeyService {
8
  constructor() {
9
  this.prefix = config.security.apiKeyPrefix
@@ -418,6 +475,7 @@ class ApiKeyService {
418
  try {
419
  let apiKeys = await redis.getAllApiKeys()
420
  const client = redis.getClientSafe()
 
421
 
422
  // 默认过滤掉已删除的API Keys
423
  if (!includeDeleted) {
@@ -524,6 +582,48 @@ class ApiKeyService {
524
  if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) {
525
  delete key.ccrAccountId
526
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  delete key.apiKey // 不返回哈希后的key
528
  }
529
 
@@ -1161,6 +1261,129 @@ class ApiKeyService {
1161
  }
1162
  }
1163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1164
  // 🔔 发布计费事件(内部方法)
1165
  async _publishBillingEvent(eventData) {
1166
  try {
 
4
  const redis = require('../models/redis')
5
  const logger = require('../utils/logger')
6
 
7
+ const ACCOUNT_TYPE_CONFIG = {
8
+ claude: { prefix: 'claude:account:' },
9
+ 'claude-console': { prefix: 'claude_console_account:' },
10
+ openai: { prefix: 'openai:account:' },
11
+ 'openai-responses': { prefix: 'openai_responses_account:' },
12
+ 'azure-openai': { prefix: 'azure_openai:account:' },
13
+ gemini: { prefix: 'gemini_account:' },
14
+ droid: { prefix: 'droid:account:' }
15
+ }
16
+
17
+ const ACCOUNT_TYPE_PRIORITY = [
18
+ 'openai',
19
+ 'openai-responses',
20
+ 'azure-openai',
21
+ 'claude',
22
+ 'claude-console',
23
+ 'gemini',
24
+ 'droid'
25
+ ]
26
+
27
+ const ACCOUNT_CATEGORY_MAP = {
28
+ claude: 'claude',
29
+ 'claude-console': 'claude',
30
+ openai: 'openai',
31
+ 'openai-responses': 'openai',
32
+ 'azure-openai': 'openai',
33
+ gemini: 'gemini',
34
+ droid: 'droid'
35
+ }
36
+
37
+ function normalizeAccountTypeKey(type) {
38
+ if (!type) {
39
+ return null
40
+ }
41
+ const lower = String(type).toLowerCase()
42
+ if (lower === 'claude_console') {
43
+ return 'claude-console'
44
+ }
45
+ if (lower === 'openai_responses' || lower === 'openai-response' || lower === 'openai-responses') {
46
+ return 'openai-responses'
47
+ }
48
+ if (lower === 'azure_openai' || lower === 'azureopenai' || lower === 'azure-openai') {
49
+ return 'azure-openai'
50
+ }
51
+ return lower
52
+ }
53
+
54
+ function sanitizeAccountIdForType(accountId, accountType) {
55
+ if (!accountId || typeof accountId !== 'string') {
56
+ return accountId
57
+ }
58
+ if (accountType === 'openai-responses') {
59
+ return accountId.replace(/^responses:/, '')
60
+ }
61
+ return accountId
62
+ }
63
+
64
  class ApiKeyService {
65
  constructor() {
66
  this.prefix = config.security.apiKeyPrefix
 
475
  try {
476
  let apiKeys = await redis.getAllApiKeys()
477
  const client = redis.getClientSafe()
478
+ const accountInfoCache = new Map()
479
 
480
  // 默认过滤掉已删除的API Keys
481
  if (!includeDeleted) {
 
582
  if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) {
583
  delete key.ccrAccountId
584
  }
585
+
586
+ let lastUsageRecord = null
587
+ try {
588
+ const usageRecords = await redis.getUsageRecords(key.id, 1)
589
+ if (Array.isArray(usageRecords) && usageRecords.length > 0) {
590
+ lastUsageRecord = usageRecords[0]
591
+ }
592
+ } catch (error) {
593
+ logger.debug(`加载 API Key ${key.id} 的使用记录失败:`, error)
594
+ }
595
+
596
+ if (lastUsageRecord && (lastUsageRecord.accountId || lastUsageRecord.accountType)) {
597
+ const resolvedAccount = await this._resolveLastUsageAccount(
598
+ key,
599
+ lastUsageRecord,
600
+ accountInfoCache,
601
+ client
602
+ )
603
+
604
+ if (resolvedAccount) {
605
+ key.lastUsage = {
606
+ accountId: resolvedAccount.accountId,
607
+ rawAccountId: lastUsageRecord.accountId || resolvedAccount.accountId,
608
+ accountType: resolvedAccount.accountType,
609
+ accountCategory: resolvedAccount.accountCategory,
610
+ accountName: resolvedAccount.accountName,
611
+ recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null
612
+ }
613
+ } else {
614
+ key.lastUsage = {
615
+ accountId: null,
616
+ rawAccountId: lastUsageRecord.accountId || null,
617
+ accountType: 'deleted',
618
+ accountCategory: 'deleted',
619
+ accountName: '已删除',
620
+ recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null
621
+ }
622
+ }
623
+ } else {
624
+ key.lastUsage = null
625
+ }
626
+
627
  delete key.apiKey // 不返回哈希后的key
628
  }
629
 
 
1261
  }
1262
  }
1263
 
1264
+ async _fetchAccountInfo(accountId, accountType, cache, client) {
1265
+ if (!client || !accountId || !accountType) {
1266
+ return null
1267
+ }
1268
+
1269
+ const cacheKey = `${accountType}:${accountId}`
1270
+ if (cache.has(cacheKey)) {
1271
+ return cache.get(cacheKey)
1272
+ }
1273
+
1274
+ const accountConfig = ACCOUNT_TYPE_CONFIG[accountType]
1275
+ if (!accountConfig) {
1276
+ cache.set(cacheKey, null)
1277
+ return null
1278
+ }
1279
+
1280
+ const redisKey = `${accountConfig.prefix}${accountId}`
1281
+ let accountData = null
1282
+ try {
1283
+ accountData = await client.hgetall(redisKey)
1284
+ } catch (error) {
1285
+ logger.debug(`加载账号信息失败 ${redisKey}:`, error)
1286
+ }
1287
+
1288
+ if (accountData && Object.keys(accountData).length > 0) {
1289
+ const displayName =
1290
+ accountData.name ||
1291
+ accountData.displayName ||
1292
+ accountData.email ||
1293
+ accountData.username ||
1294
+ accountData.description ||
1295
+ accountId
1296
+
1297
+ const info = { id: accountId, name: displayName }
1298
+ cache.set(cacheKey, info)
1299
+ return info
1300
+ }
1301
+
1302
+ cache.set(cacheKey, null)
1303
+ return null
1304
+ }
1305
+
1306
+ async _resolveAccountByUsageRecord(usageRecord, cache, client) {
1307
+ if (!usageRecord || !client) {
1308
+ return null
1309
+ }
1310
+
1311
+ const rawAccountId = usageRecord.accountId || null
1312
+ const rawAccountType = normalizeAccountTypeKey(usageRecord.accountType)
1313
+ const modelName = usageRecord.model || usageRecord.actualModel || usageRecord.service || null
1314
+
1315
+ if (!rawAccountId && !rawAccountType) {
1316
+ return null
1317
+ }
1318
+
1319
+ const candidateIds = new Set()
1320
+ if (rawAccountId) {
1321
+ candidateIds.add(rawAccountId)
1322
+ if (typeof rawAccountId === 'string' && rawAccountId.startsWith('responses:')) {
1323
+ candidateIds.add(rawAccountId.replace(/^responses:/, ''))
1324
+ }
1325
+ }
1326
+
1327
+ if (candidateIds.size === 0) {
1328
+ return null
1329
+ }
1330
+
1331
+ const typeCandidates = []
1332
+ const pushType = (type) => {
1333
+ const normalized = normalizeAccountTypeKey(type)
1334
+ if (normalized && ACCOUNT_TYPE_CONFIG[normalized] && !typeCandidates.includes(normalized)) {
1335
+ typeCandidates.push(normalized)
1336
+ }
1337
+ }
1338
+
1339
+ pushType(rawAccountType)
1340
+
1341
+ if (modelName) {
1342
+ const lowerModel = modelName.toLowerCase()
1343
+ if (lowerModel.includes('gpt') || lowerModel.includes('openai')) {
1344
+ pushType('openai')
1345
+ pushType('openai-responses')
1346
+ pushType('azure-openai')
1347
+ } else if (lowerModel.includes('gemini')) {
1348
+ pushType('gemini')
1349
+ } else if (lowerModel.includes('claude') || lowerModel.includes('anthropic')) {
1350
+ pushType('claude')
1351
+ pushType('claude-console')
1352
+ } else if (lowerModel.includes('droid')) {
1353
+ pushType('droid')
1354
+ }
1355
+ }
1356
+
1357
+ ACCOUNT_TYPE_PRIORITY.forEach(pushType)
1358
+
1359
+ for (const type of typeCandidates) {
1360
+ const accountConfig = ACCOUNT_TYPE_CONFIG[type]
1361
+ if (!accountConfig) {
1362
+ continue
1363
+ }
1364
+
1365
+ for (const candidateId of candidateIds) {
1366
+ const normalizedId = sanitizeAccountIdForType(candidateId, type)
1367
+ const accountInfo = await this._fetchAccountInfo(normalizedId, type, cache, client)
1368
+ if (accountInfo) {
1369
+ return {
1370
+ accountId: normalizedId,
1371
+ accountName: accountInfo.name,
1372
+ accountType: type,
1373
+ accountCategory: ACCOUNT_CATEGORY_MAP[type] || 'other',
1374
+ rawAccountId: rawAccountId || normalizedId
1375
+ }
1376
+ }
1377
+ }
1378
+ }
1379
+
1380
+ return null
1381
+ }
1382
+
1383
+ async _resolveLastUsageAccount(apiKey, usageRecord, cache, client) {
1384
+ return await this._resolveAccountByUsageRecord(usageRecord, cache, client)
1385
+ }
1386
+
1387
  // 🔔 发布计费事件(内部方法)
1388
  async _publishBillingEvent(eventData) {
1389
  try {
src/services/azureOpenaiRelayService.js CHANGED
@@ -82,7 +82,9 @@ async function handleAzureOpenAIRequest({
82
 
83
  // 如果有代理,添加代理配置
84
  if (proxyAgent) {
 
85
  axiosConfig.httpsAgent = proxyAgent
 
86
  // 为代理添加额外的keep-alive设置
87
  if (proxyAgent.options) {
88
  proxyAgent.options.keepAlive = true
 
82
 
83
  // 如果有代理,添加代理配置
84
  if (proxyAgent) {
85
+ axiosConfig.httpAgent = proxyAgent
86
  axiosConfig.httpsAgent = proxyAgent
87
+ axiosConfig.proxy = false
88
  // 为代理添加额外的keep-alive设置
89
  if (proxyAgent.options) {
90
  proxyAgent.options.keepAlive = true
src/services/ccrRelayService.js CHANGED
@@ -121,12 +121,17 @@ class CcrRelayService {
121
  'User-Agent': userAgent,
122
  ...filteredHeaders
123
  },
124
- httpsAgent: proxyAgent,
125
  timeout: config.requestTimeout || 600000,
126
  signal: abortController.signal,
127
  validateStatus: () => true // 接受所有状态码
128
  }
129
 
 
 
 
 
 
 
130
  // 根据 API Key 格式选择认证方式
131
  if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
132
  // Anthropic 官方 API Key 使用 x-api-key
@@ -345,12 +350,17 @@ class CcrRelayService {
345
  'User-Agent': userAgent,
346
  ...filteredHeaders
347
  },
348
- httpsAgent: proxyAgent,
349
  timeout: config.requestTimeout || 600000,
350
  responseType: 'stream',
351
  validateStatus: () => true // 接受所有状态码
352
  }
353
 
 
 
 
 
 
 
354
  // 根据 API Key 格式选择认证方式
355
  if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
356
  // Anthropic 官方 API Key 使用 x-api-key
 
121
  'User-Agent': userAgent,
122
  ...filteredHeaders
123
  },
 
124
  timeout: config.requestTimeout || 600000,
125
  signal: abortController.signal,
126
  validateStatus: () => true // 接受所有状态码
127
  }
128
 
129
+ if (proxyAgent) {
130
+ requestConfig.httpAgent = proxyAgent
131
+ requestConfig.httpsAgent = proxyAgent
132
+ requestConfig.proxy = false
133
+ }
134
+
135
  // 根据 API Key 格式选择认证方式
136
  if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
137
  // Anthropic 官方 API Key 使用 x-api-key
 
350
  'User-Agent': userAgent,
351
  ...filteredHeaders
352
  },
 
353
  timeout: config.requestTimeout || 600000,
354
  responseType: 'stream',
355
  validateStatus: () => true // 接受所有状态码
356
  }
357
 
358
+ if (proxyAgent) {
359
+ requestConfig.httpAgent = proxyAgent
360
+ requestConfig.httpsAgent = proxyAgent
361
+ requestConfig.proxy = false
362
+ }
363
+
364
  // 根据 API Key 格式选择认证方式
365
  if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
366
  // Anthropic 官方 API Key 使用 x-api-key
src/services/claudeAccountService.js CHANGED
@@ -248,6 +248,24 @@ class ClaudeAccountService {
248
  // 创建代理agent
249
  const agent = this._createProxyAgent(accountData.proxy)
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  const response = await axios.post(
252
  this.claudeApiUrl,
253
  {
@@ -255,18 +273,7 @@ class ClaudeAccountService {
255
  refresh_token: refreshToken,
256
  client_id: this.claudeOauthClientId
257
  },
258
- {
259
- headers: {
260
- 'Content-Type': 'application/json',
261
- Accept: 'application/json, text/plain, */*',
262
- 'User-Agent': 'claude-cli/1.0.56 (external, cli)',
263
- 'Accept-Language': 'en-US,en;q=0.9',
264
- Referer: 'https://claude.ai/',
265
- Origin: 'https://claude.ai'
266
- },
267
- httpsAgent: agent,
268
- timeout: 30000
269
- }
270
  )
271
 
272
  if (response.status === 200) {
@@ -1824,7 +1831,7 @@ class ClaudeAccountService {
1824
  logger.debug(`📊 Fetching OAuth usage for account: ${accountData.name} (${accountId})`)
1825
 
1826
  // 请求 OAuth usage 接口
1827
- const response = await axios.get('https://api.anthropic.com/api/oauth/usage', {
1828
  headers: {
1829
  Authorization: `Bearer ${accessToken}`,
1830
  'Content-Type': 'application/json',
@@ -1833,9 +1840,16 @@ class ClaudeAccountService {
1833
  'User-Agent': 'claude-cli/1.0.56 (external, cli)',
1834
  'Accept-Language': 'en-US,en;q=0.9'
1835
  },
1836
- httpsAgent: agent,
1837
  timeout: 15000
1838
- })
 
 
 
 
 
 
 
 
1839
 
1840
  if (response.status === 200 && response.data) {
1841
  logger.debug('✅ Successfully fetched OAuth usage data:', {
@@ -2003,7 +2017,7 @@ class ClaudeAccountService {
2003
  logger.info(`📊 Fetching profile info for account: ${accountData.name} (${accountId})`)
2004
 
2005
  // 请求 profile 接口
2006
- const response = await axios.get('https://api.anthropic.com/api/oauth/profile', {
2007
  headers: {
2008
  Authorization: `Bearer ${accessToken}`,
2009
  'Content-Type': 'application/json',
@@ -2011,9 +2025,16 @@ class ClaudeAccountService {
2011
  'User-Agent': 'claude-cli/1.0.56 (external, cli)',
2012
  'Accept-Language': 'en-US,en;q=0.9'
2013
  },
2014
- httpsAgent: agent,
2015
  timeout: 15000
2016
- })
 
 
 
 
 
 
 
 
2017
 
2018
  if (response.status === 200 && response.data) {
2019
  const profileData = response.data
 
248
  // 创建代理agent
249
  const agent = this._createProxyAgent(accountData.proxy)
250
 
251
+ const axiosConfig = {
252
+ headers: {
253
+ 'Content-Type': 'application/json',
254
+ Accept: 'application/json, text/plain, */*',
255
+ 'User-Agent': 'claude-cli/1.0.56 (external, cli)',
256
+ 'Accept-Language': 'en-US,en;q=0.9',
257
+ Referer: 'https://claude.ai/',
258
+ Origin: 'https://claude.ai'
259
+ },
260
+ timeout: 30000
261
+ }
262
+
263
+ if (agent) {
264
+ axiosConfig.httpAgent = agent
265
+ axiosConfig.httpsAgent = agent
266
+ axiosConfig.proxy = false
267
+ }
268
+
269
  const response = await axios.post(
270
  this.claudeApiUrl,
271
  {
 
273
  refresh_token: refreshToken,
274
  client_id: this.claudeOauthClientId
275
  },
276
+ axiosConfig
 
 
 
 
 
 
 
 
 
 
 
277
  )
278
 
279
  if (response.status === 200) {
 
1831
  logger.debug(`📊 Fetching OAuth usage for account: ${accountData.name} (${accountId})`)
1832
 
1833
  // 请求 OAuth usage 接口
1834
+ const axiosConfig = {
1835
  headers: {
1836
  Authorization: `Bearer ${accessToken}`,
1837
  'Content-Type': 'application/json',
 
1840
  'User-Agent': 'claude-cli/1.0.56 (external, cli)',
1841
  'Accept-Language': 'en-US,en;q=0.9'
1842
  },
 
1843
  timeout: 15000
1844
+ }
1845
+
1846
+ if (agent) {
1847
+ axiosConfig.httpAgent = agent
1848
+ axiosConfig.httpsAgent = agent
1849
+ axiosConfig.proxy = false
1850
+ }
1851
+
1852
+ const response = await axios.get('https://api.anthropic.com/api/oauth/usage', axiosConfig)
1853
 
1854
  if (response.status === 200 && response.data) {
1855
  logger.debug('✅ Successfully fetched OAuth usage data:', {
 
2017
  logger.info(`📊 Fetching profile info for account: ${accountData.name} (${accountId})`)
2018
 
2019
  // 请求 profile 接口
2020
+ const axiosConfig = {
2021
  headers: {
2022
  Authorization: `Bearer ${accessToken}`,
2023
  'Content-Type': 'application/json',
 
2025
  'User-Agent': 'claude-cli/1.0.56 (external, cli)',
2026
  'Accept-Language': 'en-US,en;q=0.9'
2027
  },
 
2028
  timeout: 15000
2029
+ }
2030
+
2031
+ if (agent) {
2032
+ axiosConfig.httpAgent = agent
2033
+ axiosConfig.httpsAgent = agent
2034
+ axiosConfig.proxy = false
2035
+ }
2036
+
2037
+ const response = await axios.get('https://api.anthropic.com/api/oauth/profile', axiosConfig)
2038
 
2039
  if (response.status === 200 && response.data) {
2040
  const profileData = response.data
src/services/claudeConsoleAccountService.js CHANGED
@@ -36,6 +36,20 @@ class ClaudeConsoleAccountService {
36
  )
37
  }
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  // 🏢 创建Claude Console账户
40
  async createAccount(options = {}) {
41
  const {
@@ -690,6 +704,183 @@ class ClaudeConsoleAccountService {
690
  }
691
  }
692
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
  // 🚫 标记账号为过载状态(529错误)
694
  async markAccountOverloaded(accountId) {
695
  try {
 
36
  )
37
  }
38
 
39
+ _getBlockedHandlingMinutes() {
40
+ const raw = process.env.CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES
41
+ if (raw === undefined || raw === null || raw === '') {
42
+ return 0
43
+ }
44
+
45
+ const parsed = Number.parseInt(raw, 10)
46
+ if (!Number.isFinite(parsed) || parsed <= 0) {
47
+ return 0
48
+ }
49
+
50
+ return parsed
51
+ }
52
+
53
  // 🏢 创建Claude Console账户
54
  async createAccount(options = {}) {
55
  const {
 
704
  }
705
  }
706
 
707
+ // 🚫 标记账号为临时封禁状态(400错误 - 账户临时禁用)
708
+ async markConsoleAccountBlocked(accountId, errorDetails = '') {
709
+ try {
710
+ const client = redis.getClientSafe()
711
+ const account = await this.getAccount(accountId)
712
+
713
+ if (!account) {
714
+ throw new Error('Account not found')
715
+ }
716
+
717
+ const blockedMinutes = this._getBlockedHandlingMinutes()
718
+
719
+ if (blockedMinutes <= 0) {
720
+ logger.info(
721
+ `ℹ️ CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES 未设置或为0,跳过账户封禁:${account.name} (${accountId})`
722
+ )
723
+
724
+ if (account.blockedStatus === 'blocked') {
725
+ try {
726
+ await this.removeAccountBlocked(accountId)
727
+ } catch (cleanupError) {
728
+ logger.warn(`⚠️ 尝试移除账户封禁状态失败:${accountId}`, cleanupError)
729
+ }
730
+ }
731
+
732
+ return { success: false, skipped: true }
733
+ }
734
+
735
+ const updates = {
736
+ blockedAt: new Date().toISOString(),
737
+ blockedStatus: 'blocked',
738
+ isActive: 'false', // 禁用账户(与429保持一致)
739
+ schedulable: 'false', // 停止调度(与429保持一致)
740
+ status: 'account_blocked', // 设置状态(与429保持一致)
741
+ errorMessage: '账户临时被禁用(400错误)',
742
+ // 使用独立的封禁自动停止标记
743
+ blockedAutoStopped: 'true'
744
+ }
745
+
746
+ await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
747
+
748
+ // 发送Webhook通知,包含完整错误详情
749
+ try {
750
+ const webhookNotifier = require('../utils/webhookNotifier')
751
+ await webhookNotifier.sendAccountAnomalyNotification({
752
+ accountId,
753
+ accountName: account.name || 'Claude Console Account',
754
+ platform: 'claude-console',
755
+ status: 'error',
756
+ errorCode: 'CLAUDE_CONSOLE_BLOCKED',
757
+ reason: `账户临时被禁用(400错误)。账户将在 ${blockedMinutes} 分钟后自动恢复。`,
758
+ errorDetails: errorDetails || '无错误详情',
759
+ timestamp: new Date().toISOString()
760
+ })
761
+ } catch (webhookError) {
762
+ logger.error('Failed to send blocked webhook notification:', webhookError)
763
+ }
764
+
765
+ logger.warn(`🚫 Claude Console account temporarily blocked: ${account.name} (${accountId})`)
766
+ return { success: true }
767
+ } catch (error) {
768
+ logger.error(`❌ Failed to mark Claude Console account as blocked: ${accountId}`, error)
769
+ throw error
770
+ }
771
+ }
772
+
773
+ // ✅ 移除账号的临时封禁状态
774
+ async removeAccountBlocked(accountId) {
775
+ try {
776
+ const client = redis.getClientSafe()
777
+ const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
778
+
779
+ // 获取账户当前状态和额度信息
780
+ const [currentStatus, quotaStoppedAt] = await client.hmget(
781
+ accountKey,
782
+ 'status',
783
+ 'quotaStoppedAt'
784
+ )
785
+
786
+ // 删除封禁相关字段
787
+ await client.hdel(accountKey, 'blockedAt', 'blockedStatus')
788
+
789
+ // 根据不同情况决定是否恢复账户
790
+ if (currentStatus === 'account_blocked') {
791
+ if (quotaStoppedAt) {
792
+ // 还有额度限制,改为quota_exceeded状态
793
+ await client.hset(accountKey, {
794
+ status: 'quota_exceeded'
795
+ // isActive保持false
796
+ })
797
+ logger.info(
798
+ `⚠️ Blocked status removed but quota exceeded remains for account: ${accountId}`
799
+ )
800
+ } else {
801
+ // 没有额度限制,完全恢复
802
+ const accountData = await client.hgetall(accountKey)
803
+ const updateData = {
804
+ isActive: 'true',
805
+ status: 'active',
806
+ errorMessage: ''
807
+ }
808
+
809
+ const hadAutoStop = accountData.blockedAutoStopped === 'true'
810
+
811
+ // 只恢复因封禁而自动停止的账户
812
+ if (hadAutoStop && accountData.schedulable === 'false') {
813
+ updateData.schedulable = 'true' // 恢复调度
814
+ logger.info(
815
+ `✅ Auto-resuming scheduling for Claude Console account ${accountId} after blocked status cleared`
816
+ )
817
+ }
818
+
819
+ if (hadAutoStop) {
820
+ await client.hdel(accountKey, 'blockedAutoStopped')
821
+ }
822
+
823
+ await client.hset(accountKey, updateData)
824
+ logger.success(`✅ Blocked status removed and account re-enabled: ${accountId}`)
825
+ }
826
+ } else {
827
+ if (await client.hdel(accountKey, 'blockedAutoStopped')) {
828
+ logger.info(
829
+ `ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during blocked status recovery`
830
+ )
831
+ }
832
+ logger.success(`✅ Blocked status removed for Claude Console account: ${accountId}`)
833
+ }
834
+
835
+ return { success: true }
836
+ } catch (error) {
837
+ logger.error(
838
+ `❌ Failed to remove blocked status for Claude Console account: ${accountId}`,
839
+ error
840
+ )
841
+ throw error
842
+ }
843
+ }
844
+
845
+ // 🔍 检查账号是否处于临时封禁状态
846
+ async isAccountBlocked(accountId) {
847
+ try {
848
+ const account = await this.getAccount(accountId)
849
+ if (!account) {
850
+ return false
851
+ }
852
+
853
+ if (account.blockedStatus === 'blocked' && account.blockedAt) {
854
+ const blockedDuration = this._getBlockedHandlingMinutes()
855
+
856
+ if (blockedDuration <= 0) {
857
+ await this.removeAccountBlocked(accountId)
858
+ return false
859
+ }
860
+
861
+ const blockedAt = new Date(account.blockedAt)
862
+ const now = new Date()
863
+ const minutesSinceBlocked = (now - blockedAt) / (1000 * 60)
864
+
865
+ // 禁用时长过后自动恢复
866
+ if (minutesSinceBlocked >= blockedDuration) {
867
+ await this.removeAccountBlocked(accountId)
868
+ return false
869
+ }
870
+
871
+ return true
872
+ }
873
+
874
+ return false
875
+ } catch (error) {
876
+ logger.error(
877
+ `❌ Failed to check blocked status for Claude Console account: ${accountId}`,
878
+ error
879
+ )
880
+ return false
881
+ }
882
+ }
883
+
884
  // 🚫 标记账号为过载状态(529错误)
885
  async markAccountOverloaded(accountId) {
886
  try {
src/services/claudeConsoleRelayService.js CHANGED
@@ -2,6 +2,11 @@ const axios = require('axios')
2
  const claudeConsoleAccountService = require('./claudeConsoleAccountService')
3
  const logger = require('../utils/logger')
4
  const config = require('../../config/config')
 
 
 
 
 
5
 
6
  class ClaudeConsoleRelayService {
7
  constructor() {
@@ -122,12 +127,17 @@ class ClaudeConsoleRelayService {
122
  'User-Agent': userAgent,
123
  ...filteredHeaders
124
  },
125
- httpsAgent: proxyAgent,
126
  timeout: config.requestTimeout || 600000,
127
  signal: abortController.signal,
128
  validateStatus: () => true // 接受所有状态码
129
  }
130
 
 
 
 
 
 
 
131
  // 根据 API Key 格式选择认证方式
132
  if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
133
  // Anthropic 官方 API Key 使用 x-api-key
@@ -172,14 +182,49 @@ class ClaudeConsoleRelayService {
172
  logger.debug(
173
  `[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}`
174
  )
175
- logger.debug(
176
- `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`
177
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
  // 检查错误状态并相应处理
180
  if (response.status === 401) {
181
  logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
182
  await claudeConsoleAccountService.markAccountUnauthorized(accountId)
 
 
 
 
 
 
 
 
183
  } else if (response.status === 429) {
184
  logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
185
  // 收到429先检查是否因为超过了手动配置的每日额度
@@ -206,9 +251,30 @@ class ClaudeConsoleRelayService {
206
  // 更新最后使用时间
207
  await this._updateLastUsedTime(accountId)
208
 
209
- const responseBody =
210
- typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
211
- logger.debug(`[DEBUG] Final response body to return: ${responseBody}`)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
  return {
214
  statusCode: response.status,
@@ -353,12 +419,17 @@ class ClaudeConsoleRelayService {
353
  'User-Agent': userAgent,
354
  ...filteredHeaders
355
  },
356
- httpsAgent: proxyAgent,
357
  timeout: config.requestTimeout || 600000,
358
  responseType: 'stream',
359
  validateStatus: () => true // 接受所有状态码
360
  }
361
 
 
 
 
 
 
 
362
  // 根据 API Key 格式选择认证方式
363
  if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
364
  // Anthropic 官方 API Key 使用 x-api-key
@@ -388,44 +459,83 @@ class ClaudeConsoleRelayService {
388
  `❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}`
389
  )
390
 
391
- if (response.status === 401) {
392
- claudeConsoleAccountService.markAccountUnauthorized(accountId)
393
- } else if (response.status === 429) {
394
- claudeConsoleAccountService.markAccountRateLimited(accountId)
395
- // 检查是否因为超过每日额度
396
- claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
397
- logger.error('❌ Failed to check quota after 429 error:', err)
398
- })
399
- } else if (response.status === 529) {
400
- claudeConsoleAccountService.markAccountOverloaded(accountId)
401
- }
402
 
403
- // 设置错误响应的状态码和响应头
404
- if (!responseStream.headersSent) {
405
- const errorHeaders = {
406
- 'Content-Type': response.headers['content-type'] || 'application/json',
407
- 'Cache-Control': 'no-cache',
408
- Connection: 'keep-alive'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  }
410
- // 避免 Transfer-Encoding 冲突,让 Express 自动处理
411
- delete errorHeaders['Transfer-Encoding']
412
- delete errorHeaders['Content-Length']
413
- responseStream.writeHead(response.status, errorHeaders)
414
- }
415
 
416
- // 直接透传错误数据,不进行包装
417
- response.data.on('data', (chunk) => {
418
- if (!responseStream.destroyed) {
419
- responseStream.write(chunk)
 
 
420
  }
421
- })
422
 
423
- response.data.on('end', () => {
424
- if (!responseStream.destroyed) {
425
- responseStream.end()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  }
427
  resolve() // 不抛出异常,正常完成流处理
428
  })
 
429
  return
430
  }
431
 
 
2
  const claudeConsoleAccountService = require('./claudeConsoleAccountService')
3
  const logger = require('../utils/logger')
4
  const config = require('../../config/config')
5
+ const {
6
+ sanitizeUpstreamError,
7
+ sanitizeErrorMessage,
8
+ isAccountDisabledError
9
+ } = require('../utils/errorSanitizer')
10
 
11
  class ClaudeConsoleRelayService {
12
  constructor() {
 
127
  'User-Agent': userAgent,
128
  ...filteredHeaders
129
  },
 
130
  timeout: config.requestTimeout || 600000,
131
  signal: abortController.signal,
132
  validateStatus: () => true // 接受所有状态码
133
  }
134
 
135
+ if (proxyAgent) {
136
+ requestConfig.httpAgent = proxyAgent
137
+ requestConfig.httpsAgent = proxyAgent
138
+ requestConfig.proxy = false
139
+ }
140
+
141
  // 根据 API Key 格式选择认证方式
142
  if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
143
  // Anthropic 官方 API Key 使用 x-api-key
 
182
  logger.debug(
183
  `[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}`
184
  )
185
+
186
+ // 对于错误响应,记录原始错误和清理后的预览
187
+ if (response.status < 200 || response.status >= 300) {
188
+ // 记录原始错误响应(包含供应商信息,用于调试)
189
+ const rawData =
190
+ typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
191
+ logger.error(
192
+ `📝 Upstream error response from ${account?.name || accountId}: ${rawData.substring(0, 500)}`
193
+ )
194
+
195
+ // 记录清理后的数据到error
196
+ try {
197
+ const responseData =
198
+ typeof response.data === 'string' ? JSON.parse(response.data) : response.data
199
+ const sanitizedData = sanitizeUpstreamError(responseData)
200
+ logger.error(`🧹 [SANITIZED] Error response to client: ${JSON.stringify(sanitizedData)}`)
201
+ } catch (e) {
202
+ const rawText =
203
+ typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
204
+ const sanitizedText = sanitizeErrorMessage(rawText)
205
+ logger.error(`🧹 [SANITIZED] Error response to client: ${sanitizedText}`)
206
+ }
207
+ } else {
208
+ logger.debug(
209
+ `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`
210
+ )
211
+ }
212
+
213
+ // 检查是否为账户禁用/不可用的 400 错误
214
+ const accountDisabledError = isAccountDisabledError(response.status, response.data)
215
 
216
  // 检查错误状态并相应处理
217
  if (response.status === 401) {
218
  logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
219
  await claudeConsoleAccountService.markAccountUnauthorized(accountId)
220
+ } else if (accountDisabledError) {
221
+ logger.error(
222
+ `🚫 Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
223
+ )
224
+ // 传入完整的错误详情到 webhook
225
+ const errorDetails =
226
+ typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
227
+ await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
228
  } else if (response.status === 429) {
229
  logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
230
  // 收到429先检查是否因为超过了手动配置的每日额度
 
251
  // 更新最后使用时间
252
  await this._updateLastUsedTime(accountId)
253
 
254
+ // 准备响应体并清理错误信息(如果是错误响应)
255
+ let responseBody
256
+ if (response.status < 200 || response.status >= 300) {
257
+ // 错误响应,清理供应商信息
258
+ try {
259
+ const responseData =
260
+ typeof response.data === 'string' ? JSON.parse(response.data) : response.data
261
+ const sanitizedData = sanitizeUpstreamError(responseData)
262
+ responseBody = JSON.stringify(sanitizedData)
263
+ logger.debug(`🧹 Sanitized error response`)
264
+ } catch (parseError) {
265
+ // 如果无法解析为JSON,尝试清理文本
266
+ const rawText =
267
+ typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
268
+ responseBody = sanitizeErrorMessage(rawText)
269
+ logger.debug(`���� Sanitized error text`)
270
+ }
271
+ } else {
272
+ // 成功响应,不需要清理
273
+ responseBody =
274
+ typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
275
+ }
276
+
277
+ logger.debug(`[DEBUG] Final response body to return: ${responseBody.substring(0, 200)}...`)
278
 
279
  return {
280
  statusCode: response.status,
 
419
  'User-Agent': userAgent,
420
  ...filteredHeaders
421
  },
 
422
  timeout: config.requestTimeout || 600000,
423
  responseType: 'stream',
424
  validateStatus: () => true // 接受所有状态码
425
  }
426
 
427
+ if (proxyAgent) {
428
+ requestConfig.httpAgent = proxyAgent
429
+ requestConfig.httpsAgent = proxyAgent
430
+ requestConfig.proxy = false
431
+ }
432
+
433
  // 根据 API Key 格式选择认证方式
434
  if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
435
  // Anthropic 官方 API Key 使用 x-api-key
 
459
  `❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}`
460
  )
461
 
462
+ // 收集错误数据用于检测
463
+ let errorDataForCheck = ''
464
+ const errorChunks = []
 
 
 
 
 
 
 
 
465
 
466
+ response.data.on('data', (chunk) => {
467
+ errorChunks.push(chunk)
468
+ errorDataForCheck += chunk.toString()
469
+ })
470
+
471
+ response.data.on('end', async () => {
472
+ // 记录原始错误消息到日志(方便调试,包含供应商信息)
473
+ logger.error(
474
+ `📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
475
+ )
476
+
477
+ // 检查是否为账户禁用错误
478
+ const accountDisabledError = isAccountDisabledError(
479
+ response.status,
480
+ errorDataForCheck
481
+ )
482
+
483
+ if (response.status === 401) {
484
+ await claudeConsoleAccountService.markAccountUnauthorized(accountId)
485
+ } else if (accountDisabledError) {
486
+ logger.error(
487
+ `🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
488
+ )
489
+ // 传入完整的错误详情到 webhook
490
+ await claudeConsoleAccountService.markConsoleAccountBlocked(
491
+ accountId,
492
+ errorDataForCheck
493
+ )
494
+ } else if (response.status === 429) {
495
+ await claudeConsoleAccountService.markAccountRateLimited(accountId)
496
+ // 检查是否因为超过每日额度
497
+ claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
498
+ logger.error('❌ Failed to check quota after 429 error:', err)
499
+ })
500
+ } else if (response.status === 529) {
501
+ await claudeConsoleAccountService.markAccountOverloaded(accountId)
502
  }
 
 
 
 
 
503
 
504
+ // 设置响应头
505
+ if (!responseStream.headersSent) {
506
+ responseStream.writeHead(response.status, {
507
+ 'Content-Type': 'application/json',
508
+ 'Cache-Control': 'no-cache'
509
+ })
510
  }
 
511
 
512
+ // 清理并发送错误响应
513
+ try {
514
+ const fullErrorData = Buffer.concat(errorChunks).toString()
515
+ const errorJson = JSON.parse(fullErrorData)
516
+ const sanitizedError = sanitizeUpstreamError(errorJson)
517
+
518
+ // 记录清理后的错误消息(发送给客户端的,完整记录)
519
+ logger.error(
520
+ `🧹 [Stream] [SANITIZED] Error response to client: ${JSON.stringify(sanitizedError)}`
521
+ )
522
+
523
+ if (!responseStream.destroyed) {
524
+ responseStream.write(JSON.stringify(sanitizedError))
525
+ responseStream.end()
526
+ }
527
+ } catch (parseError) {
528
+ const sanitizedText = sanitizeErrorMessage(errorDataForCheck)
529
+ logger.error(`🧹 [Stream] [SANITIZED] Error response to client: ${sanitizedText}`)
530
+
531
+ if (!responseStream.destroyed) {
532
+ responseStream.write(sanitizedText)
533
+ responseStream.end()
534
+ }
535
  }
536
  resolve() // 不抛出异常,正常完成流处理
537
  })
538
+
539
  return
540
  }
541
 
src/services/claudeRelayService.js CHANGED
@@ -227,6 +227,9 @@ class ClaudeRelayService {
227
  options
228
  )
229
 
 
 
 
230
  // 移除监听器(请求成功完成)
231
  if (clientRequest) {
232
  clientRequest.removeListener('close', handleClientDisconnect)
 
227
  options
228
  )
229
 
230
+ response.accountId = accountId
231
+ response.accountType = accountType
232
+
233
  // 移除监听器(请求成功完成)
234
  if (clientRequest) {
235
  clientRequest.removeListener('close', handleClientDisconnect)
src/services/droidAccountService.js CHANGED
@@ -438,6 +438,7 @@ class DroidAccountService {
438
  if (proxyAgent) {
439
  requestOptions.httpAgent = proxyAgent
440
  requestOptions.httpsAgent = proxyAgent
 
441
  logger.info(
442
  `🌐 使用代理验证 Droid Refresh Token: ${ProxyHelper.getProxyDescription(proxyConfig)}`
443
  )
@@ -506,6 +507,7 @@ class DroidAccountService {
506
  if (proxyAgent) {
507
  requestOptions.httpAgent = proxyAgent
508
  requestOptions.httpsAgent = proxyAgent
 
509
  }
510
  }
511
 
 
438
  if (proxyAgent) {
439
  requestOptions.httpAgent = proxyAgent
440
  requestOptions.httpsAgent = proxyAgent
441
+ requestOptions.proxy = false
442
  logger.info(
443
  `🌐 使用代理验证 Droid Refresh Token: ${ProxyHelper.getProxyDescription(proxyConfig)}`
444
  )
 
507
  if (proxyAgent) {
508
  requestOptions.httpAgent = proxyAgent
509
  requestOptions.httpsAgent = proxyAgent
510
+ requestOptions.proxy = false
511
  }
512
  }
513
 
src/services/droidRelayService.js CHANGED
@@ -309,7 +309,8 @@ class DroidRelayService {
309
  responseType: 'json',
310
  ...(proxyAgent && {
311
  httpAgent: proxyAgent,
312
- httpsAgent: proxyAgent
 
313
  })
314
  }
315
 
 
309
  responseType: 'json',
310
  ...(proxyAgent && {
311
  httpAgent: proxyAgent,
312
+ httpsAgent: proxyAgent,
313
+ proxy: false
314
  })
315
  }
316
 
src/services/geminiAccountService.js CHANGED
@@ -1048,6 +1048,7 @@ async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
1048
 
1049
  // 验证凭据本地有效性
1050
  const { token } = await client.getAccessToken()
 
1051
  if (!token) {
1052
  return false
1053
  }
@@ -1066,6 +1067,54 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) {
1066
  const CODE_ASSIST_API_VERSION = 'v1internal'
1067
 
1068
  const { token } = await client.getAccessToken()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1069
 
1070
  // 创建ClientMetadata
1071
  const clientMetadata = {
@@ -1100,9 +1149,10 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) {
1100
  }
1101
 
1102
  // 添加代理配置
1103
- const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
1104
  if (proxyAgent) {
 
1105
  axiosConfig.httpsAgent = proxyAgent
 
1106
  logger.info(
1107
  `🌐 Using proxy for Gemini loadCodeAssist: ${ProxyHelper.getProxyDescription(proxyConfig)}`
1108
  )
@@ -1176,7 +1226,9 @@ async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfi
1176
  // 添加代理配置
1177
  const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
1178
  if (proxyAgent) {
 
1179
  baseAxiosConfig.httpsAgent = proxyAgent
 
1180
  logger.info(
1181
  `🌐 Using proxy for Gemini onboardUser: ${ProxyHelper.getProxyDescription(proxyConfig)}`
1182
  )
@@ -1307,7 +1359,9 @@ async function countTokens(client, contents, model = 'gemini-2.0-flash-exp', pro
1307
  // 添加代理配置
1308
  const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
1309
  if (proxyAgent) {
 
1310
  axiosConfig.httpsAgent = proxyAgent
 
1311
  logger.info(
1312
  `🌐 Using proxy for Gemini countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
1313
  )
@@ -1382,7 +1436,9 @@ async function generateContent(
1382
  // 添加代理配置
1383
  const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
1384
  if (proxyAgent) {
 
1385
  axiosConfig.httpsAgent = proxyAgent
 
1386
  logger.info(
1387
  `🌐 Using proxy for Gemini generateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
1388
  )
@@ -1456,7 +1512,9 @@ async function generateContentStream(
1456
  // 添加代理配置
1457
  const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
1458
  if (proxyAgent) {
 
1459
  axiosConfig.httpsAgent = proxyAgent
 
1460
  logger.info(
1461
  `🌐 Using proxy for Gemini streamGenerateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
1462
  )
 
1048
 
1049
  // 验证凭据本地有效性
1050
  const { token } = await client.getAccessToken()
1051
+
1052
  if (!token) {
1053
  return false
1054
  }
 
1067
  const CODE_ASSIST_API_VERSION = 'v1internal'
1068
 
1069
  const { token } = await client.getAccessToken()
1070
+ const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
1071
+
1072
+ const tokenInfoConfig = {
1073
+ url: 'https://oauth2.googleapis.com/tokeninfo',
1074
+ method: 'POST',
1075
+ headers: {
1076
+ Authorization: `Bearer ${token}`,
1077
+ 'Content-Type': 'application/x-www-form-urlencoded'
1078
+ },
1079
+ data: new URLSearchParams({ access_token: token }).toString(),
1080
+ timeout: 15000
1081
+ }
1082
+
1083
+ if (proxyAgent) {
1084
+ tokenInfoConfig.httpAgent = proxyAgent
1085
+ tokenInfoConfig.httpsAgent = proxyAgent
1086
+ tokenInfoConfig.proxy = false
1087
+ }
1088
+
1089
+ try {
1090
+ await axios(tokenInfoConfig)
1091
+ logger.info('📋 tokeninfo 接口验证成功')
1092
+ } catch (error) {
1093
+ logger.info('tokeninfo 接口获取失败', error)
1094
+ }
1095
+
1096
+ const userInfoConfig = {
1097
+ url: 'https://www.googleapis.com/oauth2/v2/userinfo',
1098
+ method: 'GET',
1099
+ headers: {
1100
+ Authorization: `Bearer ${token}`,
1101
+ Accept: '*/*'
1102
+ },
1103
+ timeout: 15000
1104
+ }
1105
+
1106
+ if (proxyAgent) {
1107
+ userInfoConfig.httpAgent = proxyAgent
1108
+ userInfoConfig.httpsAgent = proxyAgent
1109
+ userInfoConfig.proxy = false
1110
+ }
1111
+
1112
+ try {
1113
+ await axios(userInfoConfig)
1114
+ logger.info('📋 userinfo 接口获取成功')
1115
+ } catch (error) {
1116
+ logger.info('userinfo 接口获取失败', error)
1117
+ }
1118
 
1119
  // 创建ClientMetadata
1120
  const clientMetadata = {
 
1149
  }
1150
 
1151
  // 添加代理配置
 
1152
  if (proxyAgent) {
1153
+ axiosConfig.httpAgent = proxyAgent
1154
  axiosConfig.httpsAgent = proxyAgent
1155
+ axiosConfig.proxy = false
1156
  logger.info(
1157
  `🌐 Using proxy for Gemini loadCodeAssist: ${ProxyHelper.getProxyDescription(proxyConfig)}`
1158
  )
 
1226
  // 添加代理配置
1227
  const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
1228
  if (proxyAgent) {
1229
+ baseAxiosConfig.httpAgent = proxyAgent
1230
  baseAxiosConfig.httpsAgent = proxyAgent
1231
+ baseAxiosConfig.proxy = false
1232
  logger.info(
1233
  `🌐 Using proxy for Gemini onboardUser: ${ProxyHelper.getProxyDescription(proxyConfig)}`
1234
  )
 
1359
  // 添加代理配置
1360
  const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
1361
  if (proxyAgent) {
1362
+ axiosConfig.httpAgent = proxyAgent
1363
  axiosConfig.httpsAgent = proxyAgent
1364
+ axiosConfig.proxy = false
1365
  logger.info(
1366
  `🌐 Using proxy for Gemini countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
1367
  )
 
1436
  // 添加代理配置
1437
  const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
1438
  if (proxyAgent) {
1439
+ axiosConfig.httpAgent = proxyAgent
1440
  axiosConfig.httpsAgent = proxyAgent
1441
+ axiosConfig.proxy = false
1442
  logger.info(
1443
  `🌐 Using proxy for Gemini generateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
1444
  )
 
1512
  // 添加代理配置
1513
  const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
1514
  if (proxyAgent) {
1515
+ axiosConfig.httpAgent = proxyAgent
1516
  axiosConfig.httpsAgent = proxyAgent
1517
+ axiosConfig.proxy = false
1518
  logger.info(
1519
  `🌐 Using proxy for Gemini streamGenerateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
1520
  )
src/services/geminiRelayService.js CHANGED
@@ -279,7 +279,9 @@ async function sendGeminiRequest({
279
  // 添加代理配置
280
  const proxyAgent = createProxyAgent(proxy)
281
  if (proxyAgent) {
 
282
  axiosConfig.httpsAgent = proxyAgent
 
283
  logger.info(`🌐 Using proxy for Gemini API request: ${ProxyHelper.getProxyDescription(proxy)}`)
284
  } else {
285
  logger.debug('🌐 No proxy configured for Gemini API request')
@@ -387,7 +389,9 @@ async function getAvailableModels(accessToken, proxy, projectId, location = 'us-
387
 
388
  const proxyAgent = createProxyAgent(proxy)
389
  if (proxyAgent) {
 
390
  axiosConfig.httpsAgent = proxyAgent
 
391
  logger.info(
392
  `🌐 Using proxy for Gemini models request: ${ProxyHelper.getProxyDescription(proxy)}`
393
  )
@@ -488,7 +492,9 @@ async function countTokens({
488
  // 添加代理配置
489
  const proxyAgent = createProxyAgent(proxy)
490
  if (proxyAgent) {
 
491
  axiosConfig.httpsAgent = proxyAgent
 
492
  logger.info(
493
  `🌐 Using proxy for Gemini countTokens request: ${ProxyHelper.getProxyDescription(proxy)}`
494
  )
 
279
  // 添加代理配置
280
  const proxyAgent = createProxyAgent(proxy)
281
  if (proxyAgent) {
282
+ axiosConfig.httpAgent = proxyAgent
283
  axiosConfig.httpsAgent = proxyAgent
284
+ axiosConfig.proxy = false
285
  logger.info(`🌐 Using proxy for Gemini API request: ${ProxyHelper.getProxyDescription(proxy)}`)
286
  } else {
287
  logger.debug('🌐 No proxy configured for Gemini API request')
 
389
 
390
  const proxyAgent = createProxyAgent(proxy)
391
  if (proxyAgent) {
392
+ axiosConfig.httpAgent = proxyAgent
393
  axiosConfig.httpsAgent = proxyAgent
394
+ axiosConfig.proxy = false
395
  logger.info(
396
  `🌐 Using proxy for Gemini models request: ${ProxyHelper.getProxyDescription(proxy)}`
397
  )
 
492
  // 添加代理配置
493
  const proxyAgent = createProxyAgent(proxy)
494
  if (proxyAgent) {
495
+ axiosConfig.httpAgent = proxyAgent
496
  axiosConfig.httpsAgent = proxyAgent
497
+ axiosConfig.proxy = false
498
  logger.info(
499
  `🌐 Using proxy for Gemini countTokens request: ${ProxyHelper.getProxyDescription(proxy)}`
500
  )
src/services/openaiAccountService.js CHANGED
@@ -223,6 +223,7 @@ async function refreshAccessToken(refreshToken, proxy = null) {
223
  // 配置代理(如果有)
224
  const proxyAgent = ProxyHelper.createProxyAgent(proxy)
225
  if (proxyAgent) {
 
226
  requestOptions.httpsAgent = proxyAgent
227
  requestOptions.proxy = false
228
  logger.info(
 
223
  // 配置代理(如果有)
224
  const proxyAgent = ProxyHelper.createProxyAgent(proxy)
225
  if (proxyAgent) {
226
+ requestOptions.httpAgent = proxyAgent
227
  requestOptions.httpsAgent = proxyAgent
228
  requestOptions.proxy = false
229
  logger.info(
src/services/openaiResponsesRelayService.js CHANGED
@@ -107,6 +107,7 @@ class OpenAIResponsesRelayService {
107
  if (fullAccount.proxy) {
108
  const proxyAgent = ProxyHelper.createProxyAgent(fullAccount.proxy)
109
  if (proxyAgent) {
 
110
  requestOptions.httpsAgent = proxyAgent
111
  requestOptions.proxy = false
112
  logger.info(
 
107
  if (fullAccount.proxy) {
108
  const proxyAgent = ProxyHelper.createProxyAgent(fullAccount.proxy)
109
  if (proxyAgent) {
110
+ requestOptions.httpAgent = proxyAgent
111
  requestOptions.httpsAgent = proxyAgent
112
  requestOptions.proxy = false
113
  logger.info(
src/services/unifiedClaudeScheduler.js CHANGED
@@ -527,68 +527,86 @@ class UnifiedClaudeScheduler {
527
  logger.info(`📋 Found ${consoleAccounts.length} total Claude Console accounts`)
528
 
529
  for (const account of consoleAccounts) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  logger.info(
531
- `🔍 Checking Claude Console account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
532
  )
533
 
534
- // 注意:getAllAccounts返回的isActive是布尔值
535
  if (
536
- account.isActive === true &&
537
- account.status === 'active' &&
538
- account.accountType === 'shared' &&
539
- this._isSchedulable(account.schedulable)
540
  ) {
541
  // 检查是否可调度
542
 
543
  // 检查模型支持
544
- if (!this._isModelSupportedByAccount(account, 'claude-console', requestedModel)) {
545
  continue
546
  }
547
 
548
  // 检查订阅是否过期
549
- if (claudeConsoleAccountService.isSubscriptionExpired(account)) {
550
  logger.debug(
551
- `⏰ Claude Console account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
552
  )
553
  continue
554
  }
555
 
556
  // 主动触发一次额度检查,确保状态即时生效
557
  try {
558
- await claudeConsoleAccountService.checkQuotaUsage(account.id)
559
  } catch (e) {
560
  logger.warn(
561
- `Failed to check quota for Claude Console account ${account.name}: ${e.message}`
562
  )
563
  // 继续处理该账号
564
  }
565
 
566
  // 检查是否被限流
567
- const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id)
568
- const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(account.id)
 
 
 
 
569
 
570
  if (!isRateLimited && !isQuotaExceeded) {
571
  availableAccounts.push({
572
- ...account,
573
- accountId: account.id,
574
  accountType: 'claude-console',
575
- priority: parseInt(account.priority) || 50,
576
- lastUsedAt: account.lastUsedAt || '0'
577
  })
578
  logger.info(
579
- `✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})`
580
  )
581
  } else {
582
  if (isRateLimited) {
583
- logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
584
  }
585
  if (isQuotaExceeded) {
586
- logger.warn(`💰 Claude Console account ${account.name} quota exceeded`)
587
  }
588
  }
589
  } else {
590
  logger.info(
591
- `❌ Claude Console account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
592
  )
593
  }
594
  }
 
527
  logger.info(`📋 Found ${consoleAccounts.length} total Claude Console accounts`)
528
 
529
  for (const account of consoleAccounts) {
530
+ // 主动检查封禁状态并尝试恢复(在过滤之前执行,确保可以恢复被封禁的账户)
531
+ const wasBlocked = await claudeConsoleAccountService.isAccountBlocked(account.id)
532
+
533
+ // 如果账户之前被封禁但现在已恢复,重新获取最新状态
534
+ let currentAccount = account
535
+ if (wasBlocked === false && account.status === 'account_blocked') {
536
+ // 可能刚刚被恢复,重新获取账户状态
537
+ const freshAccount = await claudeConsoleAccountService.getAccount(account.id)
538
+ if (freshAccount) {
539
+ currentAccount = freshAccount
540
+ logger.info(`🔄 Account ${account.name} was recovered from blocked status`)
541
+ }
542
+ }
543
+
544
  logger.info(
545
+ `🔍 Checking Claude Console account: ${currentAccount.name} - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}`
546
  )
547
 
548
+ // 注意:getAllAccounts返回的isActive是布尔值,getAccount返回的也是布尔值
549
  if (
550
+ currentAccount.isActive === true &&
551
+ currentAccount.status === 'active' &&
552
+ currentAccount.accountType === 'shared' &&
553
+ this._isSchedulable(currentAccount.schedulable)
554
  ) {
555
  // 检查是否可调度
556
 
557
  // 检查模型支持
558
+ if (!this._isModelSupportedByAccount(currentAccount, 'claude-console', requestedModel)) {
559
  continue
560
  }
561
 
562
  // 检查订阅是否过期
563
+ if (claudeConsoleAccountService.isSubscriptionExpired(currentAccount)) {
564
  logger.debug(
565
+ `⏰ Claude Console account ${currentAccount.name} (${currentAccount.id}) expired at ${currentAccount.subscriptionExpiresAt}`
566
  )
567
  continue
568
  }
569
 
570
  // 主动触发一次额度检查,确保状态即时生效
571
  try {
572
+ await claudeConsoleAccountService.checkQuotaUsage(currentAccount.id)
573
  } catch (e) {
574
  logger.warn(
575
+ `Failed to check quota for Claude Console account ${currentAccount.name}: ${e.message}`
576
  )
577
  // 继续处理该账号
578
  }
579
 
580
  // 检查是否被限流
581
+ const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
582
+ currentAccount.id
583
+ )
584
+ const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(
585
+ currentAccount.id
586
+ )
587
 
588
  if (!isRateLimited && !isQuotaExceeded) {
589
  availableAccounts.push({
590
+ ...currentAccount,
591
+ accountId: currentAccount.id,
592
  accountType: 'claude-console',
593
+ priority: parseInt(currentAccount.priority) || 50,
594
+ lastUsedAt: currentAccount.lastUsedAt || '0'
595
  })
596
  logger.info(
597
+ `✅ Added Claude Console account to available pool: ${currentAccount.name} (priority: ${currentAccount.priority})`
598
  )
599
  } else {
600
  if (isRateLimited) {
601
+ logger.warn(`⚠️ Claude Console account ${currentAccount.name} is rate limited`)
602
  }
603
  if (isQuotaExceeded) {
604
+ logger.warn(`💰 Claude Console account ${currentAccount.name} quota exceeded`)
605
  }
606
  }
607
  } else {
608
  logger.info(
609
+ `❌ Claude Console account ${currentAccount.name} not eligible - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}`
610
  )
611
  }
612
  }
src/utils/errorSanitizer.js ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 错误消息清理工具
3
+ * 用于移除上游错误中的供应商特定信息(如 URL、引用等)
4
+ */
5
+
6
+ /**
7
+ * 清理错误消息中的 URL 和供应商引用
8
+ * @param {string} message - 原始错误消息
9
+ * @returns {string} - 清理后的消息
10
+ */
11
+ function sanitizeErrorMessage(message) {
12
+ if (typeof message !== 'string') {
13
+ return message
14
+ }
15
+
16
+ // 移除 URL(http:// 或 https://)
17
+ let cleaned = message.replace(/https?:\/\/[^\s]+/gi, '')
18
+
19
+ // 移除常见的供应商引用模式
20
+ cleaned = cleaned.replace(/For more (?:details|information|help)[,\s]*/gi, '')
21
+ cleaned = cleaned.replace(/(?:please\s+)?visit\s+\S*/gi, '') // 移除 "visit xxx"
22
+ cleaned = cleaned.replace(/(?:see|check)\s+(?:our|the)\s+\S*/gi, '') // 移除 "see our xxx"
23
+ cleaned = cleaned.replace(/(?:contact|reach)\s+(?:us|support)\s+at\s+\S*/gi, '') // 移除联系信息
24
+
25
+ // 移除供应商特定关键词(包括整个单词)
26
+ cleaned = cleaned.replace(/88code\S*/gi, '')
27
+ cleaned = cleaned.replace(/duck\S*/gi, '')
28
+ cleaned = cleaned.replace(/packy\S*/gi, '')
29
+ cleaned = cleaned.replace(/ikun\S*/gi, '')
30
+ cleaned = cleaned.replace(/privnode\S*/gi, '')
31
+ cleaned = cleaned.replace(/yescode\S*/gi, '')
32
+ cleaned = cleaned.replace(/share\S*/gi, '')
33
+ cleaned = cleaned.replace(/yhlxj\S*/gi, '')
34
+ cleaned = cleaned.replace(/gac\S*/gi, '')
35
+ cleaned = cleaned.replace(/driod\S*/gi, '')
36
+
37
+ cleaned = cleaned.replace(/\s+/g, ' ').trim()
38
+
39
+ // 如果消息被清理得太短或为空,返回通用消息
40
+ if (cleaned.length < 5) {
41
+ return 'The requested model is currently unavailable'
42
+ }
43
+
44
+ return cleaned
45
+ }
46
+
47
+ /**
48
+ * 递归清理对象中的所有错误消息字段
49
+ * @param {Object} errorData - 原始错误数据对象
50
+ * @returns {Object} - 清理后的错误数据
51
+ */
52
+ function sanitizeUpstreamError(errorData) {
53
+ if (!errorData || typeof errorData !== 'object') {
54
+ return errorData
55
+ }
56
+
57
+ // 深拷贝避免修改原始对象
58
+ const sanitized = JSON.parse(JSON.stringify(errorData))
59
+
60
+ // 递归清理嵌套的错误对象
61
+ const sanitizeObject = (obj) => {
62
+ if (!obj || typeof obj !== 'object') {
63
+ return obj
64
+ }
65
+
66
+ for (const key in obj) {
67
+ if (key === 'message' && typeof obj[key] === 'string') {
68
+ obj[key] = sanitizeErrorMessage(obj[key])
69
+ } else if (typeof obj[key] === 'object') {
70
+ sanitizeObject(obj[key])
71
+ }
72
+ }
73
+
74
+ return obj
75
+ }
76
+
77
+ return sanitizeObject(sanitized)
78
+ }
79
+
80
+ /**
81
+ * 提取错误消息(支持多种错误格式)
82
+ * @param {*} body - 错误响应体(字符串或对象)
83
+ * @returns {string} - 提取的错误消息
84
+ */
85
+ function extractErrorMessage(body) {
86
+ if (!body) {
87
+ return ''
88
+ }
89
+
90
+ // 处理字符串类型
91
+ if (typeof body === 'string') {
92
+ const trimmed = body.trim()
93
+ if (!trimmed) {
94
+ return ''
95
+ }
96
+ try {
97
+ const parsed = JSON.parse(trimmed)
98
+ return extractErrorMessage(parsed)
99
+ } catch (error) {
100
+ return trimmed
101
+ }
102
+ }
103
+
104
+ // 处理对象类型
105
+ if (typeof body === 'object') {
106
+ // 常见错误格式: { error: "message" }
107
+ if (typeof body.error === 'string') {
108
+ return body.error
109
+ }
110
+ // 嵌套错误格式: { error: { message: "..." } }
111
+ if (body.error && typeof body.error === 'object') {
112
+ if (typeof body.error.message === 'string') {
113
+ return body.error.message
114
+ }
115
+ if (typeof body.error.error === 'string') {
116
+ return body.error.error
117
+ }
118
+ }
119
+ // 直接消息格式: { message: "..." }
120
+ if (typeof body.message === 'string') {
121
+ return body.message
122
+ }
123
+ }
124
+
125
+ return ''
126
+ }
127
+
128
+ /**
129
+ * 检测是否为账户被禁用或不可用的 400 错误
130
+ * @param {number} statusCode - HTTP 状态码
131
+ * @param {*} body - 响应体
132
+ * @returns {boolean} - 是否为账户禁用错误
133
+ */
134
+ function isAccountDisabledError(statusCode, body) {
135
+ if (statusCode !== 400) {
136
+ return false
137
+ }
138
+
139
+ const message = extractErrorMessage(body)
140
+ if (!message) {
141
+ return false
142
+ }
143
+ // 将消息全部转换为小写,进行模糊匹配(避免大小写问题)
144
+ const lowerMessage = message.toLowerCase()
145
+ // 检测常见的账户禁用/不可用模式
146
+ return (
147
+ lowerMessage.includes('organization has been disabled') ||
148
+ lowerMessage.includes('account has been disabled') ||
149
+ lowerMessage.includes('account is disabled') ||
150
+ lowerMessage.includes('no account supporting') ||
151
+ lowerMessage.includes('account not found') ||
152
+ lowerMessage.includes('invalid account') ||
153
+ lowerMessage.includes('too many active sessions')
154
+ )
155
+ }
156
+
157
+ module.exports = {
158
+ sanitizeErrorMessage,
159
+ sanitizeUpstreamError,
160
+ extractErrorMessage,
161
+ isAccountDisabledError
162
+ }
src/utils/oauthHelper.js CHANGED
@@ -173,7 +173,7 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
173
  proxyType: proxyConfig?.type || 'none'
174
  })
175
 
176
- const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, {
177
  headers: {
178
  'Content-Type': 'application/json',
179
  'User-Agent': 'claude-cli/1.0.56 (external, cli)',
@@ -182,9 +182,16 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
182
  Referer: 'https://claude.ai/',
183
  Origin: 'https://claude.ai'
184
  },
185
- httpsAgent: agent,
186
  timeout: 30000
187
- })
 
 
 
 
 
 
 
 
188
 
189
  // 记录完整的响应数据到专门的认证详细日志
190
  logger.authDetail('OAuth token exchange response', response.data)
@@ -378,7 +385,7 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr
378
  proxyType: proxyConfig?.type || 'none'
379
  })
380
 
381
- const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, {
382
  headers: {
383
  'Content-Type': 'application/json',
384
  'User-Agent': 'claude-cli/1.0.56 (external, cli)',
@@ -387,9 +394,16 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr
387
  Referer: 'https://claude.ai/',
388
  Origin: 'https://claude.ai'
389
  },
390
- httpsAgent: agent,
391
  timeout: 30000
392
- })
 
 
 
 
 
 
 
 
393
 
394
  // 记录完整的响应数据到专门的认证详细日志
395
  logger.authDetail('Setup Token exchange response', response.data)
 
173
  proxyType: proxyConfig?.type || 'none'
174
  })
175
 
176
+ const axiosConfig = {
177
  headers: {
178
  'Content-Type': 'application/json',
179
  'User-Agent': 'claude-cli/1.0.56 (external, cli)',
 
182
  Referer: 'https://claude.ai/',
183
  Origin: 'https://claude.ai'
184
  },
 
185
  timeout: 30000
186
+ }
187
+
188
+ if (agent) {
189
+ axiosConfig.httpAgent = agent
190
+ axiosConfig.httpsAgent = agent
191
+ axiosConfig.proxy = false
192
+ }
193
+
194
+ const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, axiosConfig)
195
 
196
  // 记录完整的响应数据到专门的认证详细日志
197
  logger.authDetail('OAuth token exchange response', response.data)
 
385
  proxyType: proxyConfig?.type || 'none'
386
  })
387
 
388
+ const axiosConfig = {
389
  headers: {
390
  'Content-Type': 'application/json',
391
  'User-Agent': 'claude-cli/1.0.56 (external, cli)',
 
394
  Referer: 'https://claude.ai/',
395
  Origin: 'https://claude.ai'
396
  },
 
397
  timeout: 30000
398
+ }
399
+
400
+ if (agent) {
401
+ axiosConfig.httpAgent = agent
402
+ axiosConfig.httpsAgent = agent
403
+ axiosConfig.proxy = false
404
+ }
405
+
406
+ const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, axiosConfig)
407
 
408
  // 记录完整的响应数据到专门的认证详细日志
409
  logger.authDetail('Setup Token exchange response', response.data)
src/utils/workosOAuthHelper.js CHANGED
@@ -40,13 +40,20 @@ async function startDeviceAuthorization(proxyConfig = null) {
40
  hasProxy: !!agent
41
  })
42
 
43
- const response = await axios.post(WORKOS_DEVICE_AUTHORIZE_URL, form.toString(), {
44
  headers: {
45
  'Content-Type': 'application/x-www-form-urlencoded'
46
  },
47
- httpsAgent: agent,
48
  timeout: 15000
49
- })
 
 
 
 
 
 
 
 
50
 
51
  const data = response.data || {}
52
 
@@ -108,13 +115,20 @@ async function pollDeviceAuthorization(deviceCode, proxyConfig = null) {
108
  const agent = ProxyHelper.createProxyAgent(proxyConfig)
109
 
110
  try {
111
- const response = await axios.post(WORKOS_TOKEN_URL, form.toString(), {
112
  headers: {
113
  'Content-Type': 'application/x-www-form-urlencoded'
114
  },
115
- httpsAgent: agent,
116
  timeout: 15000
117
- })
 
 
 
 
 
 
 
 
118
 
119
  const data = response.data || {}
120
 
 
40
  hasProxy: !!agent
41
  })
42
 
43
+ const axiosConfig = {
44
  headers: {
45
  'Content-Type': 'application/x-www-form-urlencoded'
46
  },
 
47
  timeout: 15000
48
+ }
49
+
50
+ if (agent) {
51
+ axiosConfig.httpAgent = agent
52
+ axiosConfig.httpsAgent = agent
53
+ axiosConfig.proxy = false
54
+ }
55
+
56
+ const response = await axios.post(WORKOS_DEVICE_AUTHORIZE_URL, form.toString(), axiosConfig)
57
 
58
  const data = response.data || {}
59
 
 
115
  const agent = ProxyHelper.createProxyAgent(proxyConfig)
116
 
117
  try {
118
+ const axiosConfig = {
119
  headers: {
120
  'Content-Type': 'application/x-www-form-urlencoded'
121
  },
 
122
  timeout: 15000
123
+ }
124
+
125
+ if (agent) {
126
+ axiosConfig.httpAgent = agent
127
+ axiosConfig.httpsAgent = agent
128
+ axiosConfig.proxy = false
129
+ }
130
+
131
+ const response = await axios.post(WORKOS_TOKEN_URL, form.toString(), axiosConfig)
132
 
133
  const data = response.data || {}
134
 
src/validators/clients/claudeCodeValidator.js CHANGED
@@ -131,7 +131,7 @@ class ClaudeCodeValidator {
131
  const userAgent = req.headers['user-agent'] || ''
132
  const path = req.path || ''
133
 
134
- const claudeCodePattern = /^claude-cli\/\d+\.\d+\.\d+/i;
135
 
136
  if (!claudeCodePattern.test(userAgent)) {
137
  // 不是 Claude Code 的请求,此验证器不处理
 
131
  const userAgent = req.headers['user-agent'] || ''
132
  const path = req.path || ''
133
 
134
+ const claudeCodePattern = /^claude-cli\/\d+\.\d+\.\d+/i
135
 
136
  if (!claudeCodePattern.test(userAgent)) {
137
  // 不是 Claude Code 的请求,此验证器不处理
src/validators/clients/codexCliValidator.js CHANGED
@@ -53,7 +53,8 @@ class CodexCliValidator {
53
  // 2. 对于特定路径,进行额外的严格验证
54
  // 对于 /openai 和 /azure 路径需要完整验证
55
  const strictValidationPaths = ['/openai', '/azure']
56
- const needsStrictValidation = req.path && strictValidationPaths.some(path => req.path.startsWith(path))
 
57
 
58
  if (!needsStrictValidation) {
59
  // 其他路径,只要 User-Agent 匹配就认为是 Codex CLI
 
53
  // 2. 对于特定路径,进行额外的严格验证
54
  // 对于 /openai 和 /azure 路径需要完整验证
55
  const strictValidationPaths = ['/openai', '/azure']
56
+ const needsStrictValidation =
57
+ req.path && strictValidationPaths.some((path) => req.path.startsWith(path))
58
 
59
  if (!needsStrictValidation) {
60
  // 其他路径,只要 User-Agent 匹配就认为是 Codex CLI
src/validators/clients/geminiCliValidator.js CHANGED
@@ -55,7 +55,9 @@ class GeminiCliValidator {
55
  // 包含 generateContent 的路径需要验证 User-Agent
56
  const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i
57
  if (!geminiCliPattern.test(userAgent)) {
58
- logger.debug(`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`)
 
 
59
  return false
60
  }
61
  }
 
55
  // 包含 generateContent 的路径需要验证 User-Agent
56
  const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i
57
  if (!geminiCliPattern.test(userAgent)) {
58
+ logger.debug(
59
+ `Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`
60
+ )
61
  return false
62
  }
63
  }
web/admin-spa/src/views/ApiKeysView.vue CHANGED
@@ -686,15 +686,33 @@
686
  class="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-gray-300"
687
  style="font-size: 13px"
688
  >
689
- <span
690
- v-if="key.lastUsedAt"
691
- class="cursor-help"
692
- style="font-size: 13px"
693
- :title="new Date(key.lastUsedAt).toLocaleString('zh-CN')"
694
- >
695
- {{ formatLastUsed(key.lastUsedAt) }}
696
- </span>
697
- <span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
698
  </td>
699
  <!-- 创建时间 -->
700
  <td
@@ -1258,11 +1276,31 @@
1258
  <p class="text-xs text-gray-500 dark:text-gray-400">费用</p>
1259
  </div>
1260
  </div>
1261
- <div class="mt-2 flex items-center justify-between">
1262
- <span class="text-xs text-gray-600 dark:text-gray-400">最后使用</span>
1263
- <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
1264
- formatLastUsed(key.lastUsedAt)
1265
- }}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1266
  </div>
1267
  </div>
1268
 
@@ -1765,10 +1803,33 @@
1765
  class="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-gray-300"
1766
  style="font-size: 13px"
1767
  >
1768
- <span v-if="key.lastUsedAt" style="font-size: 13px">
1769
- {{ formatLastUsed(key.lastUsedAt) }}
1770
- </span>
1771
- <span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1772
  </td>
1773
  <td class="operations-column operations-cell px-3 py-3">
1774
  <div class="flex items-center gap-2">
@@ -3676,6 +3737,100 @@ const formatLastUsed = (dateString) => {
3676
  return date.toLocaleDateString('zh-CN')
3677
  }
3678
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3679
  // 清除搜索
3680
  const clearSearch = () => {
3681
  searchKeyword.value = ''
@@ -3785,7 +3940,9 @@ const exportToExcel = () => {
3785
  Token数: formatTokenCount(periodTokens),
3786
  输入Token: formatTokenCount(periodInputTokens),
3787
  输出Token: formatTokenCount(periodOutputTokens),
3788
- 最后使用时间: key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用'
 
 
3789
  }
3790
 
3791
  // 添加分模型统计
 
686
  class="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-gray-300"
687
  style="font-size: 13px"
688
  >
689
+ <div class="flex flex-col leading-tight">
690
+ <span
691
+ v-if="key.lastUsedAt"
692
+ class="cursor-help"
693
+ style="font-size: 13px"
694
+ :title="new Date(key.lastUsedAt).toLocaleString('zh-CN')"
695
+ >
696
+ {{ formatLastUsed(key.lastUsedAt) }}
697
+ </span>
698
+ <span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
699
+ <span
700
+ v-if="hasLastUsageAccount(key)"
701
+ class="mt-1 text-xs text-gray-500 dark:text-gray-400"
702
+ :title="getLastUsageFullName(key)"
703
+ >
704
+ {{ getLastUsageDisplayName(key) }}
705
+ <span
706
+ v-if="!isLastUsageDeleted(key)"
707
+ class="ml-1 text-gray-400 dark:text-gray-500"
708
+ >
709
+ ({{ getLastUsageTypeLabel(key) }})
710
+ </span>
711
+ </span>
712
+ <span v-else class="mt-1 text-xs text-gray-400 dark:text-gray-500">
713
+ 暂无使用账号
714
+ </span>
715
+ </div>
716
  </td>
717
  <!-- 创建时间 -->
718
  <td
 
1276
  <p class="text-xs text-gray-500 dark:text-gray-400">费用</p>
1277
  </div>
1278
  </div>
1279
+ <div class="mt-2 text-xs text-gray-600 dark:text-gray-400">
1280
+ <div class="flex items-center justify-between">
1281
+ <span>最后使用</span>
1282
+ <span class="font-medium text-gray-700 dark:text-gray-300">
1283
+ {{ key.lastUsedAt ? formatLastUsed(key.lastUsedAt) : '从未使用' }}
1284
+ </span>
1285
+ </div>
1286
+ <div class="mt-1 flex items-center justify-between">
1287
+ <span>账号</span>
1288
+ <span
1289
+ v-if="hasLastUsageAccount(key)"
1290
+ class="truncate text-gray-500 dark:text-gray-400"
1291
+ style="max-width: 180px"
1292
+ :title="getLastUsageFullName(key)"
1293
+ >
1294
+ {{ getLastUsageDisplayName(key) }}
1295
+ <span
1296
+ v-if="!isLastUsageDeleted(key)"
1297
+ class="ml-1 text-gray-400 dark:text-gray-500"
1298
+ >
1299
+ ({{ getLastUsageTypeLabel(key) }})
1300
+ </span>
1301
+ </span>
1302
+ <span v-else class="text-gray-400 dark:text-gray-500">暂无使用账号</span>
1303
+ </div>
1304
  </div>
1305
  </div>
1306
 
 
1803
  class="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-gray-300"
1804
  style="font-size: 13px"
1805
  >
1806
+ <div class="flex flex-col leading-tight">
1807
+ <span
1808
+ v-if="key.lastUsedAt"
1809
+ class="cursor-help"
1810
+ style="font-size: 13px"
1811
+ :title="new Date(key.lastUsedAt).toLocaleString('zh-CN')"
1812
+ >
1813
+ {{ formatLastUsed(key.lastUsedAt) }}
1814
+ </span>
1815
+ <span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
1816
+ <span
1817
+ v-if="hasLastUsageAccount(key)"
1818
+ class="mt-1 text-xs text-gray-500 dark:text-gray-400"
1819
+ :title="getLastUsageFullName(key)"
1820
+ >
1821
+ {{ getLastUsageDisplayName(key) }}
1822
+ <span
1823
+ v-if="!isLastUsageDeleted(key)"
1824
+ class="ml-1 text-gray-400 dark:text-gray-500"
1825
+ >
1826
+ ({{ getLastUsageTypeLabel(key) }})
1827
+ </span>
1828
+ </span>
1829
+ <span v-else class="mt-1 text-xs text-gray-400 dark:text-gray-500">
1830
+ 暂无使用账号
1831
+ </span>
1832
+ </div>
1833
  </td>
1834
  <td class="operations-column operations-cell px-3 py-3">
1835
  <div class="flex items-center gap-2">
 
3737
  return date.toLocaleDateString('zh-CN')
3738
  }
3739
 
3740
+ const ACCOUNT_TYPE_LABELS = {
3741
+ claude: 'Claude',
3742
+ openai: 'OpenAI',
3743
+ gemini: 'Gemini',
3744
+ droid: 'Droid',
3745
+ deleted: '已删除',
3746
+ other: '其他'
3747
+ }
3748
+
3749
+ const MAX_LAST_USAGE_NAME_LENGTH = 16
3750
+
3751
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
3752
+
3753
+ const normalizeFrontendAccountCategory = (type) => {
3754
+ if (!type) return 'other'
3755
+ const lower = String(type).toLowerCase()
3756
+ if (lower === 'claude-console' || lower === 'claude_console' || lower === 'claude') {
3757
+ return 'claude'
3758
+ }
3759
+ if (
3760
+ lower === 'openai' ||
3761
+ lower === 'openai-responses' ||
3762
+ lower === 'openai_responses' ||
3763
+ lower === 'azure-openai' ||
3764
+ lower === 'azure_openai'
3765
+ ) {
3766
+ return 'openai'
3767
+ }
3768
+ if (lower === 'gemini') {
3769
+ return 'gemini'
3770
+ }
3771
+ if (lower === 'droid') {
3772
+ return 'droid'
3773
+ }
3774
+ return 'other'
3775
+ }
3776
+
3777
+ const getLastUsageInfo = (apiKey) => apiKey?.lastUsage || null
3778
+
3779
+ const hasLastUsageAccount = (apiKey) => {
3780
+ const info = getLastUsageInfo(apiKey)
3781
+ return !!(info && (info.accountName || info.accountId || info.rawAccountId))
3782
+ }
3783
+
3784
+ const isLikelyDeletedUsage = (info) => {
3785
+ if (!info) return false
3786
+ if (info.accountCategory === 'deleted') return true
3787
+
3788
+ const rawId = typeof info.rawAccountId === 'string' ? info.rawAccountId.trim() : ''
3789
+ const accountName = typeof info.accountName === 'string' ? info.accountName.trim() : ''
3790
+ const accountType =
3791
+ typeof info.accountType === 'string' ? info.accountType.trim().toLowerCase() : ''
3792
+
3793
+ if (!rawId) return false
3794
+
3795
+ const looksLikeUuid = UUID_PATTERN.test(rawId)
3796
+ const nameMissingOrSame = !accountName || accountName === rawId
3797
+ const typeUnknown =
3798
+ !accountType || accountType === 'unknown' || ACCOUNT_TYPE_LABELS[accountType] === undefined
3799
+
3800
+ return looksLikeUuid && nameMissingOrSame && typeUnknown
3801
+ }
3802
+
3803
+ const getLastUsageBaseName = (info) => {
3804
+ if (!info) return '未知账号'
3805
+ if (isLikelyDeletedUsage(info)) {
3806
+ return '已删除'
3807
+ }
3808
+ return info.accountName || info.accountId || info.rawAccountId || '未知账号'
3809
+ }
3810
+
3811
+ const getLastUsageFullName = (apiKey) => getLastUsageBaseName(getLastUsageInfo(apiKey))
3812
+
3813
+ const getLastUsageDisplayName = (apiKey) => {
3814
+ const full = getLastUsageFullName(apiKey)
3815
+ return full.length > MAX_LAST_USAGE_NAME_LENGTH
3816
+ ? `${full.slice(0, MAX_LAST_USAGE_NAME_LENGTH)}...`
3817
+ : full
3818
+ }
3819
+
3820
+ const getLastUsageTypeLabel = (apiKey) => {
3821
+ const info = getLastUsageInfo(apiKey)
3822
+ if (isLikelyDeletedUsage(info)) {
3823
+ return ACCOUNT_TYPE_LABELS.deleted
3824
+ }
3825
+ const category = info?.accountCategory || normalizeFrontendAccountCategory(info?.accountType)
3826
+ return ACCOUNT_TYPE_LABELS[category] || ACCOUNT_TYPE_LABELS.other
3827
+ }
3828
+
3829
+ const isLastUsageDeleted = (apiKey) => {
3830
+ const info = getLastUsageInfo(apiKey)
3831
+ return isLikelyDeletedUsage(info)
3832
+ }
3833
+
3834
  // 清除搜索
3835
  const clearSearch = () => {
3836
  searchKeyword.value = ''
 
3940
  Token数: formatTokenCount(periodTokens),
3941
  输入Token: formatTokenCount(periodInputTokens),
3942
  输出Token: formatTokenCount(periodOutputTokens),
3943
+ 最后使用时间: key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用',
3944
+ 最后使用账号: getLastUsageFullName(key),
3945
+ 最后使用类型: getLastUsageTypeLabel(key)
3946
  }
3947
 
3948
  // 添加分模型统计
web/admin-spa/src/views/DashboardView.vue CHANGED
@@ -726,7 +726,8 @@ let accountUsageTrendChartInstance = null
726
  const accountGroupOptions = [
727
  { value: 'claude', label: 'Claude' },
728
  { value: 'openai', label: 'OpenAI' },
729
- { value: 'gemini', label: 'Gemini' }
 
730
  ]
731
 
732
  const accountTrendUpdating = ref(false)
 
726
  const accountGroupOptions = [
727
  { value: 'claude', label: 'Claude' },
728
  { value: 'openai', label: 'OpenAI' },
729
+ { value: 'gemini', label: 'Gemini' },
730
+ { value: 'droid', label: 'Droid' }
731
  ]
732
 
733
  const accountTrendUpdating = ref(false)