fix: 调整Claude Code相似度检测并恢复401处理

This commit is contained in:
shaw
2025-09-24 15:04:39 +08:00
parent b89305ad4d
commit ad443ea18a
12 changed files with 476 additions and 56 deletions

8
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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)
} }
} }
} }

View File

@@ -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 }

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
} }

View File

@@ -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
} }
/** /**