Merge branch 'Wei-Shaw:main' into main

This commit is contained in:
jft0m
2025-10-14 22:39:30 +08:00
committed by GitHub
14 changed files with 893 additions and 106 deletions

View File

@@ -65,6 +65,44 @@ const TOKEN_COUNT_PATHS = new Set([
'/droid/claude/v1/messages/count_tokens'
])
function extractApiKey(req) {
const candidates = [
req.headers['x-api-key'],
req.headers['x-goog-api-key'],
req.headers['authorization'],
req.headers['api-key'],
req.query?.key
]
for (const candidate of candidates) {
let value = candidate
if (Array.isArray(value)) {
value = value.find((item) => typeof item === 'string' && item.trim())
}
if (typeof value !== 'string') {
continue
}
let trimmed = value.trim()
if (!trimmed) {
continue
}
if (/^Bearer\s+/i.test(trimmed)) {
trimmed = trimmed.replace(/^Bearer\s+/i, '').trim()
if (!trimmed) {
continue
}
}
return trimmed
}
return ''
}
function normalizeRequestPath(value) {
if (!value) {
return '/'
@@ -95,18 +133,18 @@ const authenticateApiKey = async (req, res, next) => {
try {
// 安全提取API Key支持多种格式包括Gemini CLI支持
const apiKey =
req.headers['x-api-key'] ||
req.headers['x-goog-api-key'] ||
req.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
req.headers['api-key'] ||
req.query.key
const apiKey = extractApiKey(req)
if (apiKey) {
req.headers['x-api-key'] = apiKey
}
if (!apiKey) {
logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Missing API key',
message: 'Please provide an API key in the x-api-key header or Authorization header'
message:
'Please provide an API key in the x-api-key, x-goog-api-key, or Authorization header'
})
}
@@ -950,6 +988,7 @@ const corsMiddleware = (req, res, next) => {
'Accept',
'Authorization',
'x-api-key',
'x-goog-api-key',
'api-key',
'x-admin-token',
'anthropic-version',

View File

@@ -7,12 +7,38 @@ const logger = require('../utils/logger')
const browserFallbackMiddleware = (req, res, next) => {
const userAgent = req.headers['user-agent'] || ''
const origin = req.headers['origin'] || ''
const authHeader = req.headers['authorization'] || req.headers['x-api-key'] || ''
const extractHeader = (value) => {
let candidate = value
if (Array.isArray(candidate)) {
candidate = candidate.find((item) => typeof item === 'string' && item.trim())
}
if (typeof candidate !== 'string') {
return ''
}
let trimmed = candidate.trim()
if (!trimmed) {
return ''
}
if (/^Bearer\s+/i.test(trimmed)) {
trimmed = trimmed.replace(/^Bearer\s+/i, '').trim()
}
return trimmed
}
const apiKeyHeader =
extractHeader(req.headers['x-api-key']) || extractHeader(req.headers['x-goog-api-key'])
const normalizedKey = extractHeader(req.headers['authorization']) || apiKeyHeader
// 检查是否为Chrome插件或浏览器请求
const isChromeExtension = origin.startsWith('chrome-extension://')
const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/')
const hasApiKey = authHeader.startsWith('cr_') // 我们的API Key格式
const hasApiKey = normalizedKey.startsWith('cr_') // 我们的API Key格式
if ((isChromeExtension || isBrowserRequest) && hasApiKey) {
// 为Chrome插件请求添加特殊标记
@@ -23,8 +49,8 @@ const browserFallbackMiddleware = (req, res, next) => {
req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)'
// 确保设置正确的认证头
if (!req.headers['authorization'] && req.headers['x-api-key']) {
req.headers['authorization'] = `Bearer ${req.headers['x-api-key']}`
if (!req.headers['authorization'] && apiKeyHeader) {
req.headers['authorization'] = `Bearer ${apiKeyHeader}`
}
// 添加必要的Anthropic头

View File

@@ -9058,6 +9058,109 @@ router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (r
}
})
// 获取单个 Droid 账户详细信息
router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
// 获取账户基本信息
const account = await droidAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
error: 'Not Found',
message: 'Droid account not found'
})
}
// 获取使用统计信息
let usageStats
try {
usageStats = await redis.getAccountUsageStats(account.id, 'droid')
} catch (error) {
logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error)
usageStats = {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
// 获取分组信息
let groupInfos = []
try {
groupInfos = await accountGroupService.getAccountGroups(account.id)
} catch (error) {
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error)
groupInfos = []
}
// 获取绑定的 API Key 数量
const allApiKeys = await redis.getAllApiKeys()
const groupIds = groupInfos.map((group) => group.id)
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
const binding = key.droidAccountId
if (!binding) {
return count
}
if (binding === account.id) {
return count + 1
}
if (binding.startsWith('group:')) {
const groupId = binding.substring('group:'.length)
if (groupIds.includes(groupId)) {
return count + 1
}
}
return count
}, 0)
// 获取解密的 API Keys用于管理界面
let decryptedApiKeys = []
try {
decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id)
} catch (error) {
logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error)
decryptedApiKeys = []
}
// 返回完整的账户信息,包含实际的 API Keys
const accountDetails = {
...account,
// 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
expiresAt: account.subscriptionExpiresAt || null,
schedulable: account.schedulable === 'true',
boundApiKeysCount,
groupInfos,
// 包含实际的 API Keys用于管理界面
apiKeys: decryptedApiKeys.map((entry) => ({
key: entry.key,
id: entry.id,
usageCount: entry.usageCount || 0,
lastUsedAt: entry.lastUsedAt || null,
status: entry.status || 'active', // 使用实际的状态,默认为 active
errorMessage: entry.errorMessage || '', // 包含错误信息
createdAt: entry.createdAt || null
})),
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages
}
}
return res.json({
success: true,
data: accountDetails
})
} catch (error) {
logger.error(`Failed to get Droid account ${req.params.id}:`, error)
return res.status(500).json({
error: 'Failed to get Droid account',
message: error.message
})
}
})
// 删除 Droid 账户
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try {

View File

@@ -13,13 +13,10 @@ const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
// 生成会话哈希
function generateSessionHash(req) {
const sessionData = [
req.headers['user-agent'],
req.ip,
req.headers['x-api-key']?.substring(0, 10)
]
.filter(Boolean)
.join(':')
const apiKeyPrefix =
req.headers['x-api-key']?.substring(0, 10) || req.headers['x-goog-api-key']?.substring(0, 10)
const sessionData = [req.headers['user-agent'], req.ip, apiKeyPrefix].filter(Boolean).join(':')
return crypto.createHash('sha256').update(sessionData).digest('hex')
}

View File

@@ -9,11 +9,10 @@ const crypto = require('crypto')
// 生成会话哈希
function generateSessionHash(req) {
const sessionData = [
req.headers['user-agent'],
req.ip,
req.headers['authorization']?.substring(0, 20)
]
const authSource =
req.headers['authorization'] || req.headers['x-api-key'] || req.headers['x-goog-api-key']
const sessionData = [req.headers['user-agent'], req.ip, authSource?.substring(0, 20)]
.filter(Boolean)
.join(':')

View File

@@ -183,7 +183,10 @@ class DroidAccountService {
? []
: normalizedExisting
.filter((entry) => entry && entry.id && entry.encryptedKey)
.map((entry) => ({ ...entry }))
.map((entry) => ({
...entry,
status: entry.status || 'active' // 确保有默认状态
}))
const hashSet = new Set(entries.map((entry) => entry.hash).filter(Boolean))
@@ -214,7 +217,9 @@ class DroidAccountService {
encryptedKey: this._encryptSensitiveData(trimmed),
createdAt: now,
lastUsedAt: '',
usageCount: '0'
usageCount: '0',
status: 'active', // 新增状态字段
errorMessage: '' // 新增错误信息字段
})
}
@@ -230,7 +235,9 @@ class DroidAccountService {
id: entry.id,
createdAt: entry.createdAt || '',
lastUsedAt: entry.lastUsedAt || '',
usageCount: entry.usageCount || '0'
usageCount: entry.usageCount || '0',
status: entry.status || 'active', // 新增状态字段
errorMessage: entry.errorMessage || '' // 新增错误信息字段
}))
}
@@ -252,7 +259,9 @@ class DroidAccountService {
hash: entry.hash || '',
createdAt: entry.createdAt || '',
lastUsedAt: entry.lastUsedAt || '',
usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0
usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0,
status: entry.status || 'active', // 新增状态字段
errorMessage: entry.errorMessage || '' // 新增错误信息字段
}
}
@@ -348,6 +357,56 @@ class DroidAccountService {
}
}
/**
* 标记指定的 Droid API Key 条目为异常状态
*/
async markApiKeyAsError(accountId, keyId, errorMessage = '') {
if (!accountId || !keyId) {
return { marked: false, error: '参数无效' }
}
try {
const accountData = await redis.getDroidAccount(accountId)
if (!accountData) {
return { marked: false, error: '账户不存在' }
}
const entries = this._parseApiKeyEntries(accountData.apiKeys)
if (!entries || entries.length === 0) {
return { marked: false, error: '无API Key条目' }
}
let marked = false
const updatedEntries = entries.map((entry) => {
if (entry && entry.id === keyId) {
marked = true
return {
...entry,
status: 'error',
errorMessage: errorMessage || 'API Key异常'
}
}
return entry
})
if (!marked) {
return { marked: false, error: '未找到指定的API Key' }
}
accountData.apiKeys = JSON.stringify(updatedEntries)
await redis.setDroidAccount(accountId, accountData)
logger.warn(
`⚠️ 已标记 Droid API Key ${keyId} 为异常状态Account: ${accountId}${errorMessage}`
)
return { marked: true }
} catch (error) {
logger.error(`❌ 标记 Droid API Key 异常状态失败:${keyId}Account: ${accountId}`, error)
return { marked: false, error: error.message }
}
}
/**
* 使用 WorkOS Refresh Token 刷新并验证凭证
*/
@@ -979,7 +1038,7 @@ class DroidAccountService {
? updates.apiKeyUpdateMode.trim().toLowerCase()
: ''
let apiKeyUpdateMode = ['append', 'replace', 'delete'].includes(rawApiKeyMode)
let apiKeyUpdateMode = ['append', 'replace', 'delete', 'update'].includes(rawApiKeyMode)
? rawApiKeyMode
: ''
@@ -1041,6 +1100,60 @@ class DroidAccountService {
} else if (removeApiKeysInput.length > 0) {
logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`)
}
} else if (apiKeyUpdateMode === 'update') {
// 更新模式:根据提供的 key 匹配现有条目并更新状态
mergedApiKeys = [...existingApiKeyEntries]
const updatedHashes = new Set()
for (const updateItem of newApiKeysInput) {
if (!updateItem || typeof updateItem !== 'object') {
continue
}
const key = updateItem.key || updateItem.apiKey || ''
if (!key || typeof key !== 'string') {
continue
}
const trimmed = key.trim()
if (!trimmed) {
continue
}
const hash = crypto.createHash('sha256').update(trimmed).digest('hex')
updatedHashes.add(hash)
// 查找现有条目
const existingIndex = mergedApiKeys.findIndex((entry) => entry && entry.hash === hash)
if (existingIndex !== -1) {
// 更新现有条目的状态信息
const existingEntry = mergedApiKeys[existingIndex]
mergedApiKeys[existingIndex] = {
...existingEntry,
status: updateItem.status || existingEntry.status || 'active',
errorMessage:
updateItem.errorMessage !== undefined
? updateItem.errorMessage
: existingEntry.errorMessage || '',
lastUsedAt:
updateItem.lastUsedAt !== undefined
? updateItem.lastUsedAt
: existingEntry.lastUsedAt || '',
usageCount:
updateItem.usageCount !== undefined
? String(updateItem.usageCount)
: existingEntry.usageCount || '0'
}
apiKeysUpdated = true
}
}
if (!apiKeysUpdated) {
logger.warn(
`⚠️ 更新模式未匹配任何 Droid API Key: ${accountId} (提供 ${updatedHashes.size} 个哈希)`
)
}
} else {
const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys
const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length
@@ -1063,6 +1176,10 @@ class DroidAccountService {
logger.info(
`🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}`
)
} else if (apiKeyUpdateMode === 'update') {
logger.info(
`🔑 更新模式更新 Droid API keys for ${accountId}: 更新了 ${newApiKeysInput.length} 个 API Key 的状态信息`
)
} else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) {
logger.info(
`🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}`

View File

@@ -121,12 +121,18 @@ class DroidRelayService {
throw new Error(`Droid account ${account.id} 未配置任何 API Key`)
}
// 过滤掉异常状态的API Key
const activeEntries = entries.filter((entry) => entry.status !== 'error')
if (!activeEntries || activeEntries.length === 0) {
throw new Error(`Droid account ${account.id} 没有可用的 API Key所有API Key均已异常`)
}
const stickyKey = this._composeApiKeyStickyKey(account.id, endpointType, sessionHash)
if (stickyKey) {
const mappedKeyId = await redis.getSessionAccountMapping(stickyKey)
if (mappedKeyId) {
const mappedEntry = entries.find((entry) => entry.id === mappedKeyId)
const mappedEntry = activeEntries.find((entry) => entry.id === mappedKeyId)
if (mappedEntry) {
await redis.extendSessionAccountMappingTTL(stickyKey)
await droidAccountService.touchApiKeyUsage(account.id, mappedEntry.id)
@@ -138,7 +144,7 @@ class DroidRelayService {
}
}
const selectedEntry = entries[Math.floor(Math.random() * entries.length)]
const selectedEntry = activeEntries[Math.floor(Math.random() * activeEntries.length)]
if (!selectedEntry) {
throw new Error(`Droid account ${account.id} 没有可用的 API Key`)
}
@@ -150,7 +156,7 @@ class DroidRelayService {
await droidAccountService.touchApiKeyUsage(account.id, selectedEntry.id)
logger.info(
`🔐 随机选取 Droid API Key ${selectedEntry.id}Account: ${account.id}, Keys: ${entries.length}`
`🔐 随机选取 Droid API Key ${selectedEntry.id}Account: ${account.id}, Active Keys: ${activeEntries.length}/${entries.length}`
)
return selectedEntry
@@ -1144,39 +1150,50 @@ class DroidRelayService {
if (authMethod === 'api_key') {
if (selectedAccountApiKey?.id) {
let removalResult = null
let markResult = null
const errorMessage = `${statusCode}`
try {
removalResult = await droidAccountService.removeApiKeyEntry(
// 标记API Key为异常状态而不是删除
markResult = await droidAccountService.markApiKeyAsError(
accountId,
selectedAccountApiKey.id
selectedAccountApiKey.id,
errorMessage
)
} catch (error) {
logger.error(
`移除 Droid API Key ${selectedAccountApiKey.id}Account: ${accountId})失败:`,
`标记 Droid API Key ${selectedAccountApiKey.id} 异常状态Account: ${accountId})失败:`,
error
)
}
await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash)
if (removalResult?.removed) {
if (markResult?.marked) {
logger.warn(
`🚫 上游返回 ${statusCode},已移除 Droid API Key ${selectedAccountApiKey.id}Account: ${accountId}`
`⚠️ 上游返回 ${statusCode},已标记 Droid API Key ${selectedAccountApiKey.id} 为异常状态Account: ${accountId}`
)
} else {
logger.warn(
`⚠️ 上游返回 ${statusCode},但未能移除 Droid API Key ${selectedAccountApiKey.id}Account: ${accountId}`
`⚠️ 上游返回 ${statusCode},但未能标记 Droid API Key ${selectedAccountApiKey.id} 异常状态Account: ${accountId}${markResult?.error || '未知错误'}`
)
}
if (!removalResult || removalResult.remainingCount === 0) {
await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key 已全部失效')
// 检查是否还有可用的API Key
try {
const availableEntries = await droidAccountService.getDecryptedApiKeyEntries(accountId)
const activeEntries = availableEntries.filter((entry) => entry.status !== 'error')
if (activeEntries.length === 0) {
await this._stopDroidAccountScheduling(accountId, statusCode, '所有API Key均已异常')
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
} else {
logger.info(` Droid 账号 ${accountId} 仍有 ${activeEntries.length} 个可用 API Key`)
}
} catch (error) {
logger.error(`❌ 检查可用API Key失败Account: ${accountId}`, error)
await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key检查失败')
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
} else {
logger.info(
` Droid 账号 ${accountId} 仍有 ${removalResult.remainingCount} 个 API Key 可用`
)
}
return

View File

@@ -28,8 +28,25 @@ class UnifiedClaudeScheduler {
return true // 没有指定模型时,默认支持
}
// Claude OAuth 账户的 Opus 模型检查
// Claude OAuth 账户的模型检查
if (accountType === 'claude-official') {
// 1. 首先检查是否为 Claude 官方支持的模型
// Claude Official API 只支持 Anthropic 自己的模型,不支持第三方模型(如 deepseek-chat)
const isClaudeOfficialModel =
requestedModel.startsWith('claude-') ||
requestedModel.includes('claude') ||
requestedModel.includes('sonnet') ||
requestedModel.includes('opus') ||
requestedModel.includes('haiku')
if (!isClaudeOfficialModel) {
logger.info(
`🚫 Claude official account ${account.name} does not support non-Claude model ${requestedModel}${context ? ` ${context}` : ''}`
)
return false
}
// 2. Opus 模型的订阅级别检查
if (requestedModel.toLowerCase().includes('opus')) {
if (account.subscriptionInfo) {
try {