mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
fix: 调整Claude Code相似度检测并恢复401处理
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -30,7 +30,6 @@
|
|||||||
"ora": "^5.4.1",
|
"ora": "^5.4.1",
|
||||||
"rate-limiter-flexible": "^5.0.5",
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
"socks-proxy-agent": "^8.0.2",
|
"socks-proxy-agent": "^8.0.2",
|
||||||
"string-similarity": "^4.0.4",
|
|
||||||
"table": "^6.8.1",
|
"table": "^6.8.1",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
@@ -8426,13 +8425,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/string-similarity": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==",
|
|
||||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
|||||||
@@ -69,7 +69,6 @@
|
|||||||
"ora": "^5.4.1",
|
"ora": "^5.4.1",
|
||||||
"rate-limiter-flexible": "^5.0.5",
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
"socks-proxy-agent": "^8.0.2",
|
"socks-proxy-agent": "^8.0.2",
|
||||||
"string-similarity": "^4.0.4",
|
|
||||||
"table": "^6.8.1",
|
"table": "^6.8.1",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
|
|||||||
@@ -135,6 +135,13 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
|
|||||||
// 主处理函数,供两个路由共享
|
// 主处理函数,供两个路由共享
|
||||||
const handleResponses = async (req, res) => {
|
const handleResponses = async (req, res) => {
|
||||||
let upstream = null
|
let upstream = null
|
||||||
|
let accountId = null
|
||||||
|
let accountType = 'openai'
|
||||||
|
let sessionHash = null
|
||||||
|
let account = null
|
||||||
|
let proxy = null
|
||||||
|
let accessToken = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 从中间件获取 API Key 数据
|
// 从中间件获取 API Key 数据
|
||||||
const apiKeyData = req.apiKey || {}
|
const apiKeyData = req.apiKey || {}
|
||||||
@@ -147,6 +154,8 @@ const handleResponses = async (req, res) => {
|
|||||||
req.body?.conversation_id ||
|
req.body?.conversation_id ||
|
||||||
null
|
null
|
||||||
|
|
||||||
|
sessionHash = sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null
|
||||||
|
|
||||||
// 从请求体中提取模型和流式标志
|
// 从请求体中提取模型和流式标志
|
||||||
let requestedModel = req.body?.model || null
|
let requestedModel = req.body?.model || null
|
||||||
|
|
||||||
@@ -191,14 +200,11 @@ const handleResponses = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用调度器选择账户
|
// 使用调度器选择账户
|
||||||
const {
|
;({ accessToken, accountId, accountType, proxy, account } = await getOpenAIAuthToken(
|
||||||
accessToken,
|
apiKeyData,
|
||||||
accountId,
|
sessionId,
|
||||||
accountName: _accountName,
|
requestedModel
|
||||||
accountType,
|
))
|
||||||
proxy,
|
|
||||||
account
|
|
||||||
} = await getOpenAIAuthToken(apiKeyData, sessionId, requestedModel)
|
|
||||||
|
|
||||||
// 如果是 OpenAI-Responses 账户,使用专门的中继服务处理
|
// 如果是 OpenAI-Responses 账户,使用专门的中继服务处理
|
||||||
if (accountType === 'openai-responses') {
|
if (accountType === 'openai-responses') {
|
||||||
@@ -312,7 +318,7 @@ const handleResponses = async (req, res) => {
|
|||||||
await unifiedOpenAIScheduler.markAccountRateLimited(
|
await unifiedOpenAIScheduler.markAccountRateLimited(
|
||||||
accountId,
|
accountId,
|
||||||
'openai',
|
'openai',
|
||||||
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
|
sessionHash,
|
||||||
resetsInSeconds
|
resetsInSeconds
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -337,6 +343,77 @@ const handleResponses = async (req, res) => {
|
|||||||
res.status(429).json(errorResponse)
|
res.status(429).json(errorResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
} else if (upstream.status === 401) {
|
||||||
|
logger.warn(`🔐 Unauthorized error detected for OpenAI account ${accountId} (Codex API)`)
|
||||||
|
|
||||||
|
let errorData = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isStream && upstream.data && typeof upstream.data.on === 'function') {
|
||||||
|
const chunks = []
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
upstream.data.on('data', (chunk) => chunks.push(chunk))
|
||||||
|
upstream.data.on('end', resolve)
|
||||||
|
upstream.data.on('error', reject)
|
||||||
|
setTimeout(resolve, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fullResponse = Buffer.concat(chunks).toString()
|
||||||
|
try {
|
||||||
|
errorData = JSON.parse(fullResponse)
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error('Failed to parse 401 error response:', parseError)
|
||||||
|
logger.debug('Raw 401 response:', fullResponse)
|
||||||
|
errorData = { error: { message: fullResponse || 'Unauthorized' } }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorData = upstream.data
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error('⚠️ Failed to handle 401 error response:', parseError)
|
||||||
|
}
|
||||||
|
|
||||||
|
let reason = 'OpenAI账号认证失败(401错误)'
|
||||||
|
if (errorData) {
|
||||||
|
const messageCandidate =
|
||||||
|
errorData.error &&
|
||||||
|
typeof errorData.error.message === 'string' &&
|
||||||
|
errorData.error.message.trim()
|
||||||
|
? errorData.error.message.trim()
|
||||||
|
: typeof errorData.message === 'string' && errorData.message.trim()
|
||||||
|
? errorData.message.trim()
|
||||||
|
: null
|
||||||
|
if (messageCandidate) {
|
||||||
|
reason = `OpenAI账号认证失败(401错误):${messageCandidate}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unifiedOpenAIScheduler.markAccountUnauthorized(
|
||||||
|
accountId,
|
||||||
|
'openai',
|
||||||
|
sessionHash,
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
} catch (markError) {
|
||||||
|
logger.error('❌ Failed to mark OpenAI account unauthorized after 401:', markError)
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorResponse = errorData
|
||||||
|
if (!errorResponse || typeof errorResponse !== 'object' || Buffer.isBuffer(errorResponse)) {
|
||||||
|
const fallbackMessage =
|
||||||
|
typeof errorData === 'string' && errorData.trim() ? errorData.trim() : 'Unauthorized'
|
||||||
|
errorResponse = {
|
||||||
|
error: {
|
||||||
|
message: fallbackMessage,
|
||||||
|
type: 'unauthorized',
|
||||||
|
code: 'unauthorized'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json(errorResponse)
|
||||||
return
|
return
|
||||||
} else if (upstream.status === 200 || upstream.status === 201) {
|
} else if (upstream.status === 200 || upstream.status === 201) {
|
||||||
// 请求成功,检查并移除限流状态
|
// 请求成功,检查并移除限流状态
|
||||||
@@ -553,7 +630,7 @@ const handleResponses = async (req, res) => {
|
|||||||
await unifiedOpenAIScheduler.markAccountRateLimited(
|
await unifiedOpenAIScheduler.markAccountRateLimited(
|
||||||
accountId,
|
accountId,
|
||||||
'openai',
|
'openai',
|
||||||
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
|
sessionHash,
|
||||||
rateLimitResetsInSeconds
|
rateLimitResetsInSeconds
|
||||||
)
|
)
|
||||||
} else if (upstream.status === 200) {
|
} else if (upstream.status === 200) {
|
||||||
@@ -594,9 +671,51 @@ const handleResponses = async (req, res) => {
|
|||||||
logger.error('Proxy to ChatGPT codex/responses failed:', error)
|
logger.error('Proxy to ChatGPT codex/responses failed:', error)
|
||||||
// 优先使用主动设置的 statusCode,然后是上游响应的状态码,最后默认 500
|
// 优先使用主动设置的 statusCode,然后是上游响应的状态码,最后默认 500
|
||||||
const status = error.statusCode || error.response?.status || 500
|
const status = error.statusCode || error.response?.status || 500
|
||||||
const message = error.response?.data || error.message || 'Internal server error'
|
|
||||||
|
if (status === 401 && accountId) {
|
||||||
|
let reason = 'OpenAI账号认证失败(401错误)'
|
||||||
|
const errorData = error.response?.data
|
||||||
|
if (errorData) {
|
||||||
|
if (typeof errorData === 'string' && errorData.trim()) {
|
||||||
|
reason = `OpenAI账号认证失败(401错误):${errorData.trim()}`
|
||||||
|
} else if (
|
||||||
|
errorData.error &&
|
||||||
|
typeof errorData.error.message === 'string' &&
|
||||||
|
errorData.error.message.trim()
|
||||||
|
) {
|
||||||
|
reason = `OpenAI账号认证失败(401错误):${errorData.error.message.trim()}`
|
||||||
|
} else if (typeof errorData.message === 'string' && errorData.message.trim()) {
|
||||||
|
reason = `OpenAI账号认证失败(401错误):${errorData.message.trim()}`
|
||||||
|
}
|
||||||
|
} else if (error.message) {
|
||||||
|
reason = `OpenAI账号认证失败(401错误):${error.message}`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unifiedOpenAIScheduler.markAccountUnauthorized(
|
||||||
|
accountId,
|
||||||
|
accountType || 'openai',
|
||||||
|
sessionHash,
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
} catch (markError) {
|
||||||
|
logger.error('❌ Failed to mark OpenAI account unauthorized in catch handler:', markError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let responsePayload = error.response?.data
|
||||||
|
if (!responsePayload) {
|
||||||
|
responsePayload = { error: { message: error.message || 'Internal server error' } }
|
||||||
|
} else if (typeof responsePayload === 'string') {
|
||||||
|
responsePayload = { error: { message: responsePayload } }
|
||||||
|
} else if (typeof responsePayload === 'object' && !responsePayload.error) {
|
||||||
|
responsePayload = {
|
||||||
|
error: { message: responsePayload.message || error.message || 'Internal server error' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(status).json({ error: { message } })
|
res.status(status).json(responsePayload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@ class ClaudeAccountService {
|
|||||||
throw new Error('Account not found')
|
throw new Error('Account not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountKey = `claude:account:${accountId}`
|
||||||
|
|
||||||
// 清除限流状态
|
// 清除限流状态
|
||||||
delete accountData.rateLimitedAt
|
delete accountData.rateLimitedAt
|
||||||
delete accountData.rateLimitStatus
|
delete accountData.rateLimitStatus
|
||||||
@@ -1210,6 +1212,15 @@ class ClaudeAccountService {
|
|||||||
}
|
}
|
||||||
await redis.setClaudeAccount(accountId, accountData)
|
await redis.setClaudeAccount(accountId, accountData)
|
||||||
|
|
||||||
|
// 显式删除Redis中的限流字段,避免旧标记阻止账号恢复调度
|
||||||
|
await redis.client.hdel(
|
||||||
|
accountKey,
|
||||||
|
'rateLimitedAt',
|
||||||
|
'rateLimitStatus',
|
||||||
|
'rateLimitEndAt',
|
||||||
|
'rateLimitAutoStopped'
|
||||||
|
)
|
||||||
|
|
||||||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
|
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
|
|||||||
@@ -715,9 +715,7 @@ class ClaudeRelayService {
|
|||||||
options.headers['user-agent'] = userAgent
|
options.headers['user-agent'] = userAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
||||||
`🔗 指纹是这个: ${options.headers['user-agent']}`
|
|
||||||
)
|
|
||||||
|
|
||||||
// 使用自定义的 betaHeader 或默认值
|
// 使用自定义的 betaHeader 或默认值
|
||||||
const betaHeader =
|
const betaHeader =
|
||||||
@@ -956,9 +954,7 @@ class ClaudeRelayService {
|
|||||||
options.headers['user-agent'] = userAgent
|
options.headers['user-agent'] = userAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
||||||
`🔗 指纹是这个: ${options.headers['user-agent']}`
|
|
||||||
)
|
|
||||||
// 使用自定义的 betaHeader 或默认值
|
// 使用自定义的 betaHeader 或默认值
|
||||||
const betaHeader =
|
const betaHeader =
|
||||||
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
|
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
|
||||||
@@ -1619,7 +1615,7 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🛠️ 统一的错误处理方法
|
// 🛠️ 统一的错误处理方法
|
||||||
async _handleServerError(accountId, statusCode, sessionHash = null, context = '') {
|
async _handleServerError(accountId, statusCode, _sessionHash = null, context = '') {
|
||||||
try {
|
try {
|
||||||
await claudeAccountService.recordServerError(accountId, statusCode)
|
await claudeAccountService.recordServerError(accountId, statusCode)
|
||||||
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
||||||
@@ -1635,10 +1631,10 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
if (errorCount > threshold) {
|
if (errorCount > threshold) {
|
||||||
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
|
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
|
||||||
|
// ⚠️ 只记录5xx/504告警,不再自动停止调度,避免上游抖动导致误停
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ ${prefix}Account ${accountId} exceeded ${errorTypeLabel} error threshold (${errorCount} errors), marking as temp_error`
|
`❌ ${prefix}Account ${accountId} exceeded ${errorTypeLabel} error threshold (${errorCount} errors), please investigate upstream stability`
|
||||||
)
|
)
|
||||||
await claudeAccountService.markAccountTempError(accountId, sessionHash)
|
|
||||||
}
|
}
|
||||||
} catch (handlingError) {
|
} catch (handlingError) {
|
||||||
logger.error(`❌ Failed to handle ${context} server error:`, handlingError)
|
logger.error(`❌ Failed to handle ${context} server error:`, handlingError)
|
||||||
|
|||||||
@@ -865,6 +865,49 @@ async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = nul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🚫 标记账户为未授权状态(401错误)
|
||||||
|
async function markAccountUnauthorized(accountId, reason = 'OpenAI账号认证失败(401错误)') {
|
||||||
|
const account = await getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const currentCount = parseInt(account.unauthorizedCount || '0', 10)
|
||||||
|
const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
status: 'unauthorized',
|
||||||
|
schedulable: 'false',
|
||||||
|
errorMessage: reason,
|
||||||
|
unauthorizedAt: now,
|
||||||
|
unauthorizedCount: unauthorizedCount.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAccount(accountId, updates)
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Marked OpenAI account ${account.name || accountId} as unauthorized due to 401 error`
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const webhookNotifier = require('../utils/webhookNotifier')
|
||||||
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
|
accountId,
|
||||||
|
accountName: account.name || accountId,
|
||||||
|
platform: 'openai',
|
||||||
|
status: 'unauthorized',
|
||||||
|
errorCode: 'OPENAI_UNAUTHORIZED',
|
||||||
|
reason,
|
||||||
|
timestamp: now
|
||||||
|
})
|
||||||
|
logger.info(
|
||||||
|
`📢 Webhook notification sent for OpenAI account ${account.name} unauthorized state`
|
||||||
|
)
|
||||||
|
} catch (webhookError) {
|
||||||
|
logger.error('Failed to send unauthorized webhook notification:', webhookError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔄 重置账户所有异常状态
|
// 🔄 重置账户所有异常状态
|
||||||
async function resetAccountStatus(accountId) {
|
async function resetAccountStatus(accountId) {
|
||||||
const account = await getAccount(accountId)
|
const account = await getAccount(accountId)
|
||||||
@@ -1001,6 +1044,7 @@ module.exports = {
|
|||||||
refreshAccountToken,
|
refreshAccountToken,
|
||||||
isTokenExpired,
|
isTokenExpired,
|
||||||
setAccountRateLimited,
|
setAccountRateLimited,
|
||||||
|
markAccountUnauthorized,
|
||||||
resetAccountStatus,
|
resetAccountStatus,
|
||||||
toggleSchedulable,
|
toggleSchedulable,
|
||||||
getAccountRateLimitInfo,
|
getAccountRateLimitInfo,
|
||||||
|
|||||||
@@ -293,6 +293,48 @@ class OpenAIResponsesAccountService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🚫 标记账户为未授权状态(401错误)
|
||||||
|
async markAccountUnauthorized(accountId, reason = 'OpenAI Responses账号认证失败(401错误)') {
|
||||||
|
const account = await this.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const currentCount = parseInt(account.unauthorizedCount || '0', 10)
|
||||||
|
const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1
|
||||||
|
|
||||||
|
await this.updateAccount(accountId, {
|
||||||
|
status: 'unauthorized',
|
||||||
|
schedulable: 'false',
|
||||||
|
errorMessage: reason,
|
||||||
|
unauthorizedAt: now,
|
||||||
|
unauthorizedCount: unauthorizedCount.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`🚫 OpenAI-Responses account ${account.name || accountId} marked as unauthorized due to 401 error`
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const webhookNotifier = require('../utils/webhookNotifier')
|
||||||
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
|
accountId,
|
||||||
|
accountName: account.name || accountId,
|
||||||
|
platform: 'openai',
|
||||||
|
status: 'unauthorized',
|
||||||
|
errorCode: 'OPENAI_UNAUTHORIZED',
|
||||||
|
reason,
|
||||||
|
timestamp: now
|
||||||
|
})
|
||||||
|
logger.info(
|
||||||
|
`📢 Webhook notification sent for OpenAI-Responses account ${account.name || accountId} unauthorized state`
|
||||||
|
)
|
||||||
|
} catch (webhookError) {
|
||||||
|
logger.error('Failed to send unauthorized webhook notification:', webhookError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查并清除过期的限流状态
|
// 检查并清除过期的限流状态
|
||||||
async checkAndClearRateLimit(accountId) {
|
async checkAndClearRateLimit(accountId) {
|
||||||
const account = await this.getAccount(accountId)
|
const account = await this.getAccount(accountId)
|
||||||
|
|||||||
@@ -169,6 +169,61 @@ class OpenAIResponsesRelayService {
|
|||||||
errorData
|
errorData
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
let reason = 'OpenAI Responses账号认证失败(401错误)'
|
||||||
|
if (errorData) {
|
||||||
|
if (typeof errorData === 'string' && errorData.trim()) {
|
||||||
|
reason = `OpenAI Responses账号认证失败(401错误):${errorData.trim()}`
|
||||||
|
} else if (
|
||||||
|
errorData.error &&
|
||||||
|
typeof errorData.error.message === 'string' &&
|
||||||
|
errorData.error.message.trim()
|
||||||
|
) {
|
||||||
|
reason = `OpenAI Responses账号认证失败(401错误):${errorData.error.message.trim()}`
|
||||||
|
} else if (typeof errorData.message === 'string' && errorData.message.trim()) {
|
||||||
|
reason = `OpenAI Responses账号认证失败(401错误):${errorData.message.trim()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unifiedOpenAIScheduler.markAccountUnauthorized(
|
||||||
|
account.id,
|
||||||
|
'openai-responses',
|
||||||
|
sessionHash,
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
} catch (markError) {
|
||||||
|
logger.error(
|
||||||
|
'❌ Failed to mark OpenAI-Responses account unauthorized after 401:',
|
||||||
|
markError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let unauthorizedResponse = errorData
|
||||||
|
if (
|
||||||
|
!unauthorizedResponse ||
|
||||||
|
typeof unauthorizedResponse !== 'object' ||
|
||||||
|
unauthorizedResponse.pipe ||
|
||||||
|
Buffer.isBuffer(unauthorizedResponse)
|
||||||
|
) {
|
||||||
|
const fallbackMessage =
|
||||||
|
typeof errorData === 'string' && errorData.trim() ? errorData.trim() : 'Unauthorized'
|
||||||
|
unauthorizedResponse = {
|
||||||
|
error: {
|
||||||
|
message: fallbackMessage,
|
||||||
|
type: 'unauthorized',
|
||||||
|
code: 'unauthorized'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理监听器
|
||||||
|
req.removeListener('close', handleClientDisconnect)
|
||||||
|
res.removeListener('close', handleClientDisconnect)
|
||||||
|
|
||||||
|
return res.status(401).json(unauthorizedResponse)
|
||||||
|
}
|
||||||
|
|
||||||
// 清理监听器
|
// 清理监听器
|
||||||
req.removeListener('close', handleClientDisconnect)
|
req.removeListener('close', handleClientDisconnect)
|
||||||
res.removeListener('close', handleClientDisconnect)
|
res.removeListener('close', handleClientDisconnect)
|
||||||
@@ -250,6 +305,57 @@ class OpenAIResponsesRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
let reason = 'OpenAI Responses账号认证失败(401错误)'
|
||||||
|
if (errorData) {
|
||||||
|
if (typeof errorData === 'string' && errorData.trim()) {
|
||||||
|
reason = `OpenAI Responses账号认证失败(401错误):${errorData.trim()}`
|
||||||
|
} else if (
|
||||||
|
errorData.error &&
|
||||||
|
typeof errorData.error.message === 'string' &&
|
||||||
|
errorData.error.message.trim()
|
||||||
|
) {
|
||||||
|
reason = `OpenAI Responses账号认证失败(401错误):${errorData.error.message.trim()}`
|
||||||
|
} else if (typeof errorData.message === 'string' && errorData.message.trim()) {
|
||||||
|
reason = `OpenAI Responses账号认证失败(401错误):${errorData.message.trim()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unifiedOpenAIScheduler.markAccountUnauthorized(
|
||||||
|
account.id,
|
||||||
|
'openai-responses',
|
||||||
|
sessionHash,
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
} catch (markError) {
|
||||||
|
logger.error(
|
||||||
|
'❌ Failed to mark OpenAI-Responses account unauthorized in catch handler:',
|
||||||
|
markError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let unauthorizedResponse = errorData
|
||||||
|
if (
|
||||||
|
!unauthorizedResponse ||
|
||||||
|
typeof unauthorizedResponse !== 'object' ||
|
||||||
|
unauthorizedResponse.pipe ||
|
||||||
|
Buffer.isBuffer(unauthorizedResponse)
|
||||||
|
) {
|
||||||
|
const fallbackMessage =
|
||||||
|
typeof errorData === 'string' && errorData.trim() ? errorData.trim() : 'Unauthorized'
|
||||||
|
unauthorizedResponse = {
|
||||||
|
error: {
|
||||||
|
message: fallbackMessage,
|
||||||
|
type: 'unauthorized',
|
||||||
|
code: 'unauthorized'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).json(unauthorizedResponse)
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(status).json(errorData)
|
return res.status(status).json(errorData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -356,7 +356,12 @@ class UnifiedOpenAIScheduler {
|
|||||||
try {
|
try {
|
||||||
if (accountType === 'openai') {
|
if (accountType === 'openai') {
|
||||||
const account = await openaiAccountService.getAccount(accountId)
|
const account = await openaiAccountService.getAccount(accountId)
|
||||||
if (!account || !account.isActive || account.status === 'error') {
|
if (
|
||||||
|
!account ||
|
||||||
|
!account.isActive ||
|
||||||
|
account.status === 'error' ||
|
||||||
|
account.status === 'unauthorized'
|
||||||
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// 检查是否可调度
|
// 检查是否可调度
|
||||||
@@ -370,7 +375,8 @@ class UnifiedOpenAIScheduler {
|
|||||||
if (
|
if (
|
||||||
!account ||
|
!account ||
|
||||||
(account.isActive !== true && account.isActive !== 'true') ||
|
(account.isActive !== true && account.isActive !== 'true') ||
|
||||||
account.status === 'error'
|
account.status === 'error' ||
|
||||||
|
account.status === 'unauthorized'
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -500,6 +506,39 @@ class UnifiedOpenAIScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🚫 标记账户为未授权状态
|
||||||
|
async markAccountUnauthorized(
|
||||||
|
accountId,
|
||||||
|
accountType,
|
||||||
|
sessionHash = null,
|
||||||
|
reason = 'OpenAI账号认证失败(401错误)'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (accountType === 'openai') {
|
||||||
|
await openaiAccountService.markAccountUnauthorized(accountId, reason)
|
||||||
|
} else if (accountType === 'openai-responses') {
|
||||||
|
await openaiResponsesAccountService.markAccountUnauthorized(accountId, reason)
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Unsupported account type ${accountType} when marking unauthorized for account ${accountId}`
|
||||||
|
)
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionHash) {
|
||||||
|
await this._deleteSessionMapping(sessionHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to mark account as unauthorized: ${accountId} (${accountType})`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 移除账户的限流状态
|
// ✅ 移除账户的限流状态
|
||||||
async removeAccountRateLimit(accountId, accountType) {
|
async removeAccountRateLimit(accountId, accountType) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const haikuSystemPrompt = `Analyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title that captures the new topic. Format your response as a JSON object with two fields: 'isNewTopic' (boolean) and 'title' (string, or null if isNewTopic is false). Only include these fields, no other text.`
|
const haikuSystemPrompt = `Analyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title that captures the new topic. Format your response as a JSON object with two fields: 'isNewTopic' (boolean) and 'title' (string, or null if isNewTopic is false). Only include these fields, no other text.`
|
||||||
export const claudeOtherSystemPrompt1 = `You are Claude Code, Anthropic's official CLI for Claude.`
|
const claudeOtherSystemPrompt1 = `You are Claude Code, Anthropic's official CLI for Claude.`
|
||||||
export const claudeOtherSystemPrompt2 = `
|
const claudeOtherSystemPrompt2 = `
|
||||||
You are an interactive CLI tool that helps users 'according to your "Output Style" below, which describes how you should respond to user queries.' : 'with software engineering tasks.'} Use the instructions below and the tools available to you to assist the user.
|
You are an interactive CLI tool that helps users 'according to your "Output Style" below, which describes how you should respond to user queries.' : 'with software engineering tasks.'} Use the instructions below and the tools available to you to assist the user.
|
||||||
|
|
||||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||||
@@ -163,3 +163,9 @@ user: Where are errors from the client handled?
|
|||||||
assistant: Clients are marked as failed in the \`connectToServer\` function in src/services/process.ts:712.
|
assistant: Clients are marked as failed in the \`connectToServer\` function in src/services/process.ts:712.
|
||||||
</example>
|
</example>
|
||||||
`
|
`
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
haikuSystemPrompt,
|
||||||
|
claudeOtherSystemPrompt1,
|
||||||
|
claudeOtherSystemPrompt2
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,81 @@
|
|||||||
import stringSimilarity from 'string-similarity'
|
const MAX_TEXT_LENGTH = 4000
|
||||||
|
|
||||||
function normalize(value) {
|
function normalize(value) {
|
||||||
return value.replace(/\s+/g, ' ').trim()
|
return value.replace(/\s+/g, ' ').trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function simple(actual, expected, threshold) {
|
function clamp(value) {
|
||||||
|
if (value.length <= MAX_TEXT_LENGTH) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 截断极长文本,避免耗时的相似度计算
|
||||||
|
return value.slice(0, MAX_TEXT_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBigramStats(text) {
|
||||||
|
const stats = new Map()
|
||||||
|
|
||||||
|
for (let index = 0; index < text.length - 1; index += 1) {
|
||||||
|
const gram = text[index] + text[index + 1]
|
||||||
|
stats.set(gram, (stats.get(gram) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats,
|
||||||
|
total: Math.max(text.length - 1, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diceCoefficient(left, right) {
|
||||||
|
if (left === right) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.length < 2 || right.length < 2) {
|
||||||
|
return left === right && left.length > 0 ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stats: leftStats, total: leftTotal } = buildBigramStats(left)
|
||||||
|
const { stats: rightStats, total: rightTotal } = buildBigramStats(right)
|
||||||
|
|
||||||
|
let intersection = 0
|
||||||
|
const [smaller, larger] =
|
||||||
|
leftStats.size <= rightStats.size ? [leftStats, rightStats] : [rightStats, leftStats]
|
||||||
|
|
||||||
|
smaller.forEach((count, gram) => {
|
||||||
|
if (larger.has(gram)) {
|
||||||
|
intersection += Math.min(count, larger.get(gram))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (leftTotal + rightTotal === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return (2 * intersection) / (leftTotal + rightTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
function simple(actual, expected, threshold = 0.9) {
|
||||||
if (typeof expected !== 'string' || !expected.trim()) {
|
if (typeof expected !== 'string' || !expected.trim()) {
|
||||||
throw new Error('Expected prompt text must be a non-empty string')
|
throw new Error('期望的提示词必须是非空字符串')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof actual !== 'string' || !actual.trim()) {
|
if (typeof actual !== 'string' || !actual.trim()) {
|
||||||
return { score: 0, threshold, passed: false }
|
return { score: 0, threshold, passed: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const score = stringSimilarity.compareTwoStrings(normalize(actual), normalize(expected))
|
const normalizedExpected = clamp(normalize(expected))
|
||||||
return { score, threshold, passed: score >= threshold }
|
const normalizedActual = clamp(normalize(actual))
|
||||||
|
const score = diceCoefficient(normalizedActual, normalizedExpected)
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
threshold,
|
||||||
|
passed: score >= threshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
simple
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
const logger = require('../../utils/logger')
|
const logger = require('../../utils/logger')
|
||||||
const { CLIENT_DEFINITIONS } = require('../clientDefinitions')
|
const { CLIENT_DEFINITIONS } = require('../clientDefinitions')
|
||||||
import {
|
const {
|
||||||
haikuSystemPrompt,
|
haikuSystemPrompt,
|
||||||
claudeOtherSystemPrompt1,
|
claudeOtherSystemPrompt1,
|
||||||
claudeOtherSystemPrompt2
|
claudeOtherSystemPrompt2
|
||||||
} from '../../utils/contents'
|
} = require('../../utils/contents')
|
||||||
import { simple as similaritySimple } from '../../utils/text-similarity'
|
const { simple: similaritySimple } = require('../../utils/text-similarity')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude Code CLI 验证器
|
* Claude Code CLI 验证器
|
||||||
@@ -56,23 +56,30 @@ class ClaudeCodeValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const systemEntries = Array.isArray(body.system) ? body.system : []
|
const systemEntries = Array.isArray(body.system) ? body.system : []
|
||||||
const system0Text = systemEntries?.[0]?.text
|
const system0Text =
|
||||||
const system1Text = systemEntries?.[1]?.text
|
systemEntries.length > 0 && typeof systemEntries[0]?.text === 'string'
|
||||||
|
? systemEntries[0].text
|
||||||
|
: null
|
||||||
|
const system1Text =
|
||||||
|
systemEntries.length > 1 && typeof systemEntries[1]?.text === 'string'
|
||||||
|
? systemEntries[1].text
|
||||||
|
: null
|
||||||
|
|
||||||
if (model.startsWith('claude-3-5-haiku')) {
|
if (model.startsWith('claude-3-5-haiku')) {
|
||||||
const messages = Array.isArray(body.messages) ? body.messages : []
|
const messages = Array.isArray(body.messages) ? body.messages : []
|
||||||
const isSingleUserMessage =
|
const isSingleUserMessage =
|
||||||
messages.length === 1 && messages.every((item) => item?.role === 'user')
|
messages.length === 1 && messages.every((item) => item?.role === 'user')
|
||||||
|
|
||||||
if (!isSingleUserMessage) {
|
if (!isSingleUserMessage || !system0Text) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const similarity = similaritySimple(system0Text, haikuSystemPrompt, 0.9)
|
const similarity = similaritySimple(system0Text, haikuSystemPrompt, 0.9)
|
||||||
if (!similarity.passed) {
|
return similarity.passed
|
||||||
return false
|
}
|
||||||
}
|
|
||||||
return
|
if (!system0Text || !system1Text) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const sys0 = similaritySimple(system0Text, claudeOtherSystemPrompt1, 0.9)
|
const sys0 = similaritySimple(system0Text, claudeOtherSystemPrompt1, 0.9)
|
||||||
@@ -81,11 +88,7 @@ class ClaudeCodeValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sys1 = similaritySimple(system1Text, claudeOtherSystemPrompt2, 0.5)
|
const sys1 = similaritySimple(system1Text, claudeOtherSystemPrompt2, 0.5)
|
||||||
if (!sys1.passed) {
|
return sys1.passed
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user