mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: droid apikey异常自动移除
This commit is contained in:
@@ -308,6 +308,46 @@ class DroidAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定的 Droid API Key 条目
|
||||||
|
*/
|
||||||
|
async removeApiKeyEntry(accountId, keyId) {
|
||||||
|
if (!accountId || !keyId) {
|
||||||
|
return { removed: false, remainingCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountData = await redis.getDroidAccount(accountId)
|
||||||
|
if (!accountData) {
|
||||||
|
return { removed: false, remainingCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = this._parseApiKeyEntries(accountData.apiKeys)
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
return { removed: false, remainingCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = entries.filter((entry) => entry && entry.id !== keyId)
|
||||||
|
if (filtered.length === entries.length) {
|
||||||
|
return { removed: false, remainingCount: entries.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
accountData.apiKeys = filtered.length ? JSON.stringify(filtered) : ''
|
||||||
|
accountData.apiKeyCount = String(filtered.length)
|
||||||
|
|
||||||
|
await redis.setDroidAccount(accountId, accountData)
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`🚫 已删除 Droid API Key ${keyId}(Account: ${accountId}),剩余 ${filtered.length}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return { removed: true, remainingCount: filtered.length }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 删除 Droid API Key 失败:${keyId}(Account: ${accountId})`, error)
|
||||||
|
return { removed: false, remainingCount: 0, error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 WorkOS Refresh Token 刷新并验证凭证
|
* 使用 WorkOS Refresh Token 刷新并验证凭证
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -192,8 +192,12 @@ class DroidRelayService {
|
|||||||
disableStreaming = false
|
disableStreaming = false
|
||||||
} = options
|
} = options
|
||||||
const keyInfo = apiKeyData || {}
|
const keyInfo = apiKeyData || {}
|
||||||
|
const clientApiKeyId = keyInfo.id || null
|
||||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||||
const normalizedRequestBody = this._normalizeRequestBody(requestBody, normalizedEndpoint)
|
const normalizedRequestBody = this._normalizeRequestBody(requestBody, normalizedEndpoint)
|
||||||
|
let account = null
|
||||||
|
let selectedApiKey = null
|
||||||
|
let accessToken = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -203,16 +207,13 @@ class DroidRelayService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 选择一个可用的 Droid 账户(支持粘性会话和分组调度)
|
// 选择一个可用的 Droid 账户(支持粘性会话和分组调度)
|
||||||
const account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash)
|
account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash)
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`)
|
throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取认证凭据:支持 Access Token 和 API Key 两种模式
|
// 获取认证凭据:支持 Access Token 和 API Key 两种模式
|
||||||
let selectedApiKey = null
|
|
||||||
let accessToken = null
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof account.authenticationMethod === 'string' &&
|
typeof account.authenticationMethod === 'string' &&
|
||||||
account.authenticationMethod.toLowerCase().trim() === 'api_key'
|
account.authenticationMethod.toLowerCase().trim() === 'api_key'
|
||||||
@@ -281,7 +282,10 @@ class DroidRelayService {
|
|||||||
keyInfo,
|
keyInfo,
|
||||||
normalizedRequestBody,
|
normalizedRequestBody,
|
||||||
normalizedEndpoint,
|
normalizedEndpoint,
|
||||||
skipUsageRecord
|
skipUsageRecord,
|
||||||
|
selectedApiKey,
|
||||||
|
sessionHash,
|
||||||
|
clientApiKeyId
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 非流式响应:使用 axios
|
// 非流式响应:使用 axios
|
||||||
@@ -316,6 +320,21 @@ class DroidRelayService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Droid relay error: ${error.message}`, error)
|
logger.error(`❌ Droid relay error: ${error.message}`, error)
|
||||||
|
|
||||||
|
const status = error?.response?.status
|
||||||
|
if (status >= 400 && status < 500) {
|
||||||
|
try {
|
||||||
|
await this._handleUpstreamClientError(status, {
|
||||||
|
account,
|
||||||
|
selectedAccountApiKey: selectedApiKey,
|
||||||
|
endpointType: normalizedEndpoint,
|
||||||
|
sessionHash,
|
||||||
|
clientApiKeyId
|
||||||
|
})
|
||||||
|
} catch (handlingError) {
|
||||||
|
logger.error('❌ 处理 Droid 4xx 异常失败:', handlingError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
// HTTP 错误响应
|
// HTTP 错误响应
|
||||||
return {
|
return {
|
||||||
@@ -354,7 +373,10 @@ class DroidRelayService {
|
|||||||
apiKeyData,
|
apiKeyData,
|
||||||
requestBody,
|
requestBody,
|
||||||
endpointType,
|
endpointType,
|
||||||
skipUsageRecord = false
|
skipUsageRecord = false,
|
||||||
|
selectedAccountApiKey = null,
|
||||||
|
sessionHash = null,
|
||||||
|
clientApiKeyId = null
|
||||||
) {
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = new URL(apiUrl)
|
const url = new URL(apiUrl)
|
||||||
@@ -470,6 +492,17 @@ class DroidRelayService {
|
|||||||
logger.info('✅ res.end() reached')
|
logger.info('✅ res.end() reached')
|
||||||
const body = Buffer.concat(chunks).toString()
|
const body = Buffer.concat(chunks).toString()
|
||||||
logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`)
|
logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`)
|
||||||
|
if (res.statusCode >= 400 && res.statusCode < 500) {
|
||||||
|
this._handleUpstreamClientError(res.statusCode, {
|
||||||
|
account,
|
||||||
|
selectedAccountApiKey,
|
||||||
|
endpointType,
|
||||||
|
sessionHash,
|
||||||
|
clientApiKeyId
|
||||||
|
}).catch((handlingError) => {
|
||||||
|
logger.error('❌ 处理 Droid 流式4xx 异常失败:', handlingError)
|
||||||
|
})
|
||||||
|
}
|
||||||
if (!clientResponse.headersSent) {
|
if (!clientResponse.headersSent) {
|
||||||
clientResponse.status(res.statusCode).json({
|
clientResponse.status(res.statusCode).json({
|
||||||
error: 'upstream_error',
|
error: 'upstream_error',
|
||||||
@@ -1123,6 +1156,152 @@ class DroidRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理上游 4xx 响应,移除问题 API Key 或停止账号调度
|
||||||
|
*/
|
||||||
|
async _handleUpstreamClientError(statusCode, context = {}) {
|
||||||
|
if (!statusCode || statusCode < 400 || statusCode >= 500) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
account,
|
||||||
|
selectedAccountApiKey = null,
|
||||||
|
endpointType = null,
|
||||||
|
sessionHash = null,
|
||||||
|
clientApiKeyId = null
|
||||||
|
} = context
|
||||||
|
|
||||||
|
const accountId = this._extractAccountId(account)
|
||||||
|
if (!accountId) {
|
||||||
|
logger.warn('⚠️ 上游 4xx 处理被跳过:缺少有效的账户信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEndpoint = this._normalizeEndpointType(
|
||||||
|
endpointType || account?.endpointType || 'anthropic'
|
||||||
|
)
|
||||||
|
const authMethod =
|
||||||
|
typeof account?.authenticationMethod === 'string'
|
||||||
|
? account.authenticationMethod.toLowerCase().trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (authMethod === 'api_key') {
|
||||||
|
if (selectedAccountApiKey?.id) {
|
||||||
|
let removalResult = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
removalResult = await droidAccountService.removeApiKeyEntry(
|
||||||
|
accountId,
|
||||||
|
selectedAccountApiKey.id
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})失败:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash)
|
||||||
|
|
||||||
|
if (removalResult?.removed) {
|
||||||
|
logger.warn(
|
||||||
|
`🚫 上游返回 ${statusCode},已移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 上游返回 ${statusCode},但未能移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!removalResult || removalResult.remainingCount === 0) {
|
||||||
|
await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key 已全部失效')
|
||||||
|
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`ℹ️ Droid 账号 ${accountId} 仍有 ${removalResult.remainingCount} 个 API Key 可用`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 上游返回 ${statusCode},但未获取到对应的 Droid API Key(Account: ${accountId})`
|
||||||
|
)
|
||||||
|
await this._stopDroidAccountScheduling(accountId, statusCode, '缺少可用 API Key')
|
||||||
|
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._stopDroidAccountScheduling(accountId, statusCode, '凭证不可用')
|
||||||
|
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止指定 Droid 账号的调度
|
||||||
|
*/
|
||||||
|
async _stopDroidAccountScheduling(accountId, statusCode, reason = '') {
|
||||||
|
if (!accountId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = reason ? `${reason}` : '上游返回 4xx 错误'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await droidAccountService.updateAccount(accountId, {
|
||||||
|
schedulable: 'false',
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: `上游返回 ${statusCode}:${message}`
|
||||||
|
})
|
||||||
|
logger.warn(`🚫 已停止调度 Droid 账号 ${accountId}(状态码 ${statusCode},原因:${message})`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 停止调度 Droid 账号失败:${accountId}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理账号层面的粘性调度映射
|
||||||
|
*/
|
||||||
|
async _clearAccountStickyMapping(endpointType, sessionHash, clientApiKeyId) {
|
||||||
|
if (!sessionHash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||||
|
const apiKeyPart = clientApiKeyId || 'default'
|
||||||
|
const stickyKey = `droid:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await redis.deleteSessionAccountMapping(stickyKey)
|
||||||
|
logger.debug(`🧹 已清理 Droid 粘性会话映射:${stickyKey}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ 清理 Droid 粘性会话映射失败:${stickyKey}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 API Key 级别的粘性映射
|
||||||
|
*/
|
||||||
|
async _clearApiKeyStickyMapping(accountId, endpointType, sessionHash) {
|
||||||
|
if (!accountId || !sessionHash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stickyKey = this._composeApiKeyStickyKey(accountId, endpointType, sessionHash)
|
||||||
|
if (stickyKey) {
|
||||||
|
await redis.deleteSessionAccountMapping(stickyKey)
|
||||||
|
logger.debug(`🧹 已清理 Droid API Key 粘性映射:${stickyKey}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 清理 Droid API Key 粘性映射失败:${accountId}(endpoint: ${endpointType})`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_mapNetworkErrorStatus(error) {
|
_mapNetworkErrorStatus(error) {
|
||||||
const code = (error && error.code ? String(error.code) : '').toUpperCase()
|
const code = (error && error.code ? String(error.code) : '').toUpperCase()
|
||||||
|
|
||||||
|
|||||||
@@ -1773,6 +1773,9 @@
|
|||||||
<ul class="mt-1 list-disc space-y-1 pl-4">
|
<ul class="mt-1 list-disc space-y-1 pl-4">
|
||||||
<li>新会话将随机命中一个 Key,并在会话有效期内保持粘性。</li>
|
<li>新会话将随机命中一个 Key,并在会话有效期内保持粘性。</li>
|
||||||
<li>若某 Key 失效,会自动切换到剩余可用 Key,最大化成功率。</li>
|
<li>若某 Key 失效,会自动切换到剩余可用 Key,最大化成功率。</li>
|
||||||
|
<li>
|
||||||
|
若上游返回 4xx 错误码,该 Key 会被自动移除;全部 Key 清空后账号将暂停调度。
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user