mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge branch 'pr-503' into dev
This commit is contained in:
@@ -2103,6 +2103,61 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 批量获取 Claude 账户的 OAuth Usage 数据
|
||||
router.get('/claude-accounts/usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accounts = await redis.getAllClaudeAccounts()
|
||||
|
||||
// 批量并发获取所有活跃 OAuth 账户的 Usage
|
||||
const usagePromises = accounts.map(async (account) => {
|
||||
// 检查是否为 OAuth 账户:scopes 包含 OAuth 相关权限
|
||||
const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : []
|
||||
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||
|
||||
// 仅为 OAuth 授权的活跃账户调用 usage API
|
||||
if (
|
||||
isOAuth &&
|
||||
account.isActive === 'true' &&
|
||||
account.accessToken &&
|
||||
account.status === 'active'
|
||||
) {
|
||||
try {
|
||||
const usageData = await claudeAccountService.fetchOAuthUsage(account.id)
|
||||
if (usageData) {
|
||||
await claudeAccountService.updateClaudeUsageSnapshot(account.id, usageData)
|
||||
}
|
||||
// 重新读取更新后的数据
|
||||
const updatedAccount = await redis.getClaudeAccount(account.id)
|
||||
return {
|
||||
accountId: account.id,
|
||||
claudeUsage: claudeAccountService.buildClaudeUsageSnapshot(updatedAccount)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to fetch OAuth usage for ${account.id}:`, error.message)
|
||||
return { accountId: account.id, claudeUsage: null }
|
||||
}
|
||||
}
|
||||
// Setup Token 账户不调用 usage API,直接返回 null
|
||||
return { accountId: account.id, claudeUsage: null }
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(usagePromises)
|
||||
|
||||
// 转换为 { accountId: usage } 映射
|
||||
const usageMap = {}
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
usageMap[result.value.accountId] = result.value.claudeUsage
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: usageMap })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to fetch Claude accounts usage:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch usage data', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建新的Claude账户
|
||||
router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -451,6 +451,14 @@ class ClaudeAccountService {
|
||||
// 获取会话窗口信息
|
||||
const sessionWindowInfo = await this.getSessionWindowInfo(account.id)
|
||||
|
||||
// 构建 Claude Usage 快照(从 Redis 读取)
|
||||
const claudeUsage = this.buildClaudeUsageSnapshot(account)
|
||||
|
||||
// 判断授权类型:检查 scopes 是否包含 OAuth 相关权限
|
||||
const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : []
|
||||
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||
const authType = isOAuth ? 'oauth' : 'setup-token'
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
@@ -463,6 +471,7 @@ class ClaudeAccountService {
|
||||
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
|
||||
priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50
|
||||
platform: account.platform || 'claude', // 添加平台标识,用于前端区分
|
||||
authType, // OAuth 或 Setup Token
|
||||
createdAt: account.createdAt,
|
||||
lastUsedAt: account.lastUsedAt,
|
||||
lastRefreshAt: account.lastRefreshAt,
|
||||
@@ -493,6 +502,8 @@ class ClaudeAccountService {
|
||||
remainingTime: null,
|
||||
lastRequestTime: null
|
||||
},
|
||||
// 添加 Claude Usage 信息(三窗口)
|
||||
claudeUsage: claudeUsage || null,
|
||||
// 添加调度状态
|
||||
schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据
|
||||
// 添加自动停止调度设置
|
||||
@@ -1131,6 +1142,16 @@ class ClaudeAccountService {
|
||||
return `${maskedUsername}@${domain}`
|
||||
}
|
||||
|
||||
// 🔢 安全转换为数字或null
|
||||
_toNumberOrNull(value) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
// 🧹 清理错误账户
|
||||
async cleanupErrorAccounts() {
|
||||
try {
|
||||
@@ -1578,6 +1599,173 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取 OAuth Usage 数据
|
||||
async fetchOAuthUsage(accountId, accessToken = null, agent = null) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId)
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
// 如果没有提供 accessToken,使用 getValidAccessToken 自动检查过期并刷新
|
||||
if (!accessToken) {
|
||||
accessToken = await this.getValidAccessToken(accountId)
|
||||
}
|
||||
|
||||
// 如果没有提供 agent,创建代理
|
||||
if (!agent) {
|
||||
agent = this._createProxyAgent(accountData.proxy)
|
||||
}
|
||||
|
||||
logger.debug(`📊 Fetching OAuth usage for account: ${accountData.name} (${accountId})`)
|
||||
|
||||
// 请求 OAuth usage 接口
|
||||
const response = await axios.get('https://api.anthropic.com/api/oauth/usage', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'anthropic-beta': 'oauth-2025-04-20',
|
||||
'User-Agent': 'claude-cli/1.0.56 (external, cli)',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
},
|
||||
httpsAgent: agent,
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
logger.debug('✅ Successfully fetched OAuth usage data:', {
|
||||
accountId,
|
||||
fiveHour: response.data.five_hour?.utilization,
|
||||
sevenDay: response.data.seven_day?.utilization,
|
||||
sevenDayOpus: response.data.seven_day_opus?.utilization
|
||||
})
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
logger.warn(`⚠️ Failed to fetch OAuth usage for account ${accountId}: ${response.status}`)
|
||||
return null
|
||||
} catch (error) {
|
||||
// 403 错误通常表示使用的是 Setup Token 而非 OAuth
|
||||
if (error.response?.status === 403) {
|
||||
logger.debug(
|
||||
`⚠️ OAuth usage API returned 403 for account ${accountId}. This account likely uses Setup Token instead of OAuth.`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// 其他错误正常记录
|
||||
logger.error(
|
||||
`❌ Failed to fetch OAuth usage for account ${accountId}:`,
|
||||
error.response?.data || error.message
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 构建 Claude Usage 快照(从 Redis 数据)
|
||||
buildClaudeUsageSnapshot(accountData) {
|
||||
const updatedAt = accountData.claudeUsageUpdatedAt
|
||||
|
||||
const fiveHourUtilization = this._toNumberOrNull(accountData.claudeFiveHourUtilization)
|
||||
const fiveHourResetsAt = accountData.claudeFiveHourResetsAt
|
||||
const sevenDayUtilization = this._toNumberOrNull(accountData.claudeSevenDayUtilization)
|
||||
const sevenDayResetsAt = accountData.claudeSevenDayResetsAt
|
||||
const sevenDayOpusUtilization = this._toNumberOrNull(accountData.claudeSevenDayOpusUtilization)
|
||||
const sevenDayOpusResetsAt = accountData.claudeSevenDayOpusResetsAt
|
||||
|
||||
const hasFiveHourData = fiveHourUtilization !== null || fiveHourResetsAt
|
||||
const hasSevenDayData = sevenDayUtilization !== null || sevenDayResetsAt
|
||||
const hasSevenDayOpusData = sevenDayOpusUtilization !== null || sevenDayOpusResetsAt
|
||||
|
||||
if (!updatedAt && !hasFiveHourData && !hasSevenDayData && !hasSevenDayOpusData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
updatedAt,
|
||||
fiveHour: {
|
||||
utilization: fiveHourUtilization,
|
||||
resetsAt: fiveHourResetsAt,
|
||||
remainingSeconds: fiveHourResetsAt
|
||||
? Math.max(0, Math.floor((new Date(fiveHourResetsAt).getTime() - now) / 1000))
|
||||
: null
|
||||
},
|
||||
sevenDay: {
|
||||
utilization: sevenDayUtilization,
|
||||
resetsAt: sevenDayResetsAt,
|
||||
remainingSeconds: sevenDayResetsAt
|
||||
? Math.max(0, Math.floor((new Date(sevenDayResetsAt).getTime() - now) / 1000))
|
||||
: null
|
||||
},
|
||||
sevenDayOpus: {
|
||||
utilization: sevenDayOpusUtilization,
|
||||
resetsAt: sevenDayOpusResetsAt,
|
||||
remainingSeconds: sevenDayOpusResetsAt
|
||||
? Math.max(0, Math.floor((new Date(sevenDayOpusResetsAt).getTime() - now) / 1000))
|
||||
: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 更新 Claude Usage 快照到 Redis
|
||||
async updateClaudeUsageSnapshot(accountId, usageData) {
|
||||
if (!usageData || typeof usageData !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const updates = {}
|
||||
|
||||
// 5小时窗口
|
||||
if (usageData.five_hour) {
|
||||
if (usageData.five_hour.utilization !== undefined) {
|
||||
updates.claudeFiveHourUtilization = String(usageData.five_hour.utilization)
|
||||
}
|
||||
if (usageData.five_hour.resets_at) {
|
||||
updates.claudeFiveHourResetsAt = usageData.five_hour.resets_at
|
||||
}
|
||||
}
|
||||
|
||||
// 7天窗口
|
||||
if (usageData.seven_day) {
|
||||
if (usageData.seven_day.utilization !== undefined) {
|
||||
updates.claudeSevenDayUtilization = String(usageData.seven_day.utilization)
|
||||
}
|
||||
if (usageData.seven_day.resets_at) {
|
||||
updates.claudeSevenDayResetsAt = usageData.seven_day.resets_at
|
||||
}
|
||||
}
|
||||
|
||||
// 7天Opus窗口
|
||||
if (usageData.seven_day_opus) {
|
||||
if (usageData.seven_day_opus.utilization !== undefined) {
|
||||
updates.claudeSevenDayOpusUtilization = String(usageData.seven_day_opus.utilization)
|
||||
}
|
||||
if (usageData.seven_day_opus.resets_at) {
|
||||
updates.claudeSevenDayOpusResetsAt = usageData.seven_day_opus.resets_at
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
updates.claudeUsageUpdatedAt = new Date().toISOString()
|
||||
|
||||
const accountData = await redis.getClaudeAccount(accountId)
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
Object.assign(accountData, updates)
|
||||
await redis.setClaudeAccount(accountId, accountData)
|
||||
logger.debug(
|
||||
`📊 Updated Claude usage snapshot for account ${accountId}:`,
|
||||
Object.keys(updates)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取账号 Profile 信息并更新账号类型
|
||||
async fetchAndUpdateAccountProfile(accountId, accessToken = null, agent = null) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user