fix: 统一格式化claude参数传递

This commit is contained in:
shaw
2025-12-08 14:23:13 +08:00
parent bab7073822
commit 659072075d
3 changed files with 179 additions and 164 deletions

View File

@@ -18,7 +18,7 @@ const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
class ClaudeRelayService { class ClaudeRelayService {
constructor() { constructor() {
this.claudeApiUrl = config.claude.apiUrl this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
this.apiVersion = config.claude.apiVersion this.apiVersion = config.claude.apiVersion
this.betaHeader = config.claude.betaHeader this.betaHeader = config.claude.betaHeader
this.systemPrompt = config.claude.systemPrompt this.systemPrompt = config.claude.systemPrompt
@@ -878,11 +878,102 @@ class ClaudeRelayService {
// 🔧 过滤客户端请求头 // 🔧 过滤客户端请求头
_filterClientHeaders(clientHeaders) { _filterClientHeaders(clientHeaders) {
// 使用统一的 headerFilter 工具类 - 移除 CDN、浏览器和代理相关 headers // 使用统一的 headerFilter 工具类
// 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查 // 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查
return filterForClaude(clientHeaders) return filterForClaude(clientHeaders)
} }
// 🔧 准备请求头和 payload抽离公共逻辑
async _prepareRequestHeadersAndPayload(
body,
clientHeaders,
accountId,
accessToken,
options = {}
) {
const { account, accountType, sessionHash, requestOptions = {}, isStream = false } = options
// 获取统一的 User-Agent
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
// 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders)
// 判断是否是真实的 Claude Code 请求
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
let finalHeaders = { ...filteredHeaders }
let requestPayload = body
if (!isRealClaudeCode) {
// 获取该账号存储的 Claude Code headers
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
// 只添加客户端没有提供的 headers
Object.keys(claudeCodeHeaders).forEach((key) => {
const lowerKey = key.toLowerCase()
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
finalHeaders[key] = claudeCodeHeaders[key]
}
})
}
// 应用请求身份转换
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
account,
accountId,
accountType,
sessionHash,
clientHeaders,
requestOptions,
isStream
})
if (extensionResult.abortResponse) {
return { abortResponse: extensionResult.abortResponse }
}
requestPayload = extensionResult.body
finalHeaders = extensionResult.headers
// 序列化请求体,计算 content-length
const bodyString = JSON.stringify(requestPayload)
const contentLength = Buffer.byteLength(bodyString, 'utf8')
// 构建最终请求头包含认证、版本、User-Agent、Beta 等)
const headers = {
host: 'api.anthropic.com',
connection: 'keep-alive',
'content-type': 'application/json',
'content-length': String(contentLength),
authorization: `Bearer ${accessToken}`,
'anthropic-version': this.apiVersion,
...finalHeaders
}
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
const userAgent = unifiedUA || headers['user-agent'] || 'claude-cli/1.0.119 (external, cli)'
const acceptHeader = headers['accept'] || 'application/json'
delete headers['user-agent']
delete headers['accept']
headers['User-Agent'] = userAgent
headers['Accept'] = acceptHeader
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
const modelId = requestPayload?.model || body?.model
const clientBetaHeader = clientHeaders?.['anthropic-beta']
headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
return {
requestPayload,
bodyString,
headers,
isRealClaudeCode
}
}
_applyRequestIdentityTransform(body, headers, context = {}) { _applyRequestIdentityTransform(body, headers, context = {}) {
const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {} const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {}
@@ -928,46 +1019,24 @@ class ClaudeRelayService {
// 获取账户信息用于统一 User-Agent // 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId) const account = await claudeAccountService.getAccount(accountId)
// 获取统一的 User-Agent // 使用公共方法准备请求头和 payload
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account) const prepared = await this._prepareRequestHeadersAndPayload(
body,
// 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders)
// 判断是否是真实的 Claude Code 请求
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
let finalHeaders = { ...filteredHeaders }
let requestPayload = body
if (!isRealClaudeCode) {
// 获取该账号存储的 Claude Code headers
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
// 只添加客户端没有提供的 headers
Object.keys(claudeCodeHeaders).forEach((key) => {
const lowerKey = key.toLowerCase()
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
finalHeaders[key] = claudeCodeHeaders[key]
}
})
}
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
account,
accountId,
clientHeaders, clientHeaders,
requestOptions, accountId,
isStream: false accessToken,
}) {
account,
requestOptions,
isStream: false
}
)
if (extensionResult.abortResponse) { if (prepared.abortResponse) {
return extensionResult.abortResponse return prepared.abortResponse
} }
requestPayload = extensionResult.body const { bodyString, headers } = prepared
finalHeaders = extensionResult.headers
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 支持自定义路径(如 count_tokens // 支持自定义路径(如 count_tokens
@@ -981,30 +1050,14 @@ class ClaudeRelayService {
const options = { const options = {
hostname: url.hostname, hostname: url.hostname,
port: url.port || 443, port: url.port || 443,
path: requestPath, path: requestPath + (url.search || ''),
method: 'POST', method: 'POST',
headers: { headers,
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
'anthropic-version': this.apiVersion,
...finalHeaders
},
agent: proxyAgent, agent: proxyAgent,
timeout: config.requestTimeout || 600000 timeout: config.requestTimeout || 600000
} }
// 使用统一 User-Agent 或客户端提供的,最后使用默认值 console.log(options.path)
if (!options.headers['user-agent'] || unifiedUA !== null) {
const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)'
options.headers['user-agent'] = userAgent
}
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
const modelId = requestPayload?.model || body?.model
const clientBetaHeader = clientHeaders?.['anthropic-beta']
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
const req = https.request(options, (res) => { const req = https.request(options, (res) => {
let responseData = Buffer.alloc(0) let responseData = Buffer.alloc(0)
@@ -1015,32 +1068,32 @@ class ClaudeRelayService {
res.on('end', () => { res.on('end', () => {
try { try {
let bodyString = '' let responseBody = ''
// 根据Content-Encoding处理响应数据 // 根据Content-Encoding处理响应数据
const contentEncoding = res.headers['content-encoding'] const contentEncoding = res.headers['content-encoding']
if (contentEncoding === 'gzip') { if (contentEncoding === 'gzip') {
try { try {
bodyString = zlib.gunzipSync(responseData).toString('utf8') responseBody = zlib.gunzipSync(responseData).toString('utf8')
} catch (unzipError) { } catch (unzipError) {
logger.error('❌ Failed to decompress gzip response:', unzipError) logger.error('❌ Failed to decompress gzip response:', unzipError)
bodyString = responseData.toString('utf8') responseBody = responseData.toString('utf8')
} }
} else if (contentEncoding === 'deflate') { } else if (contentEncoding === 'deflate') {
try { try {
bodyString = zlib.inflateSync(responseData).toString('utf8') responseBody = zlib.inflateSync(responseData).toString('utf8')
} catch (unzipError) { } catch (unzipError) {
logger.error('❌ Failed to decompress deflate response:', unzipError) logger.error('❌ Failed to decompress deflate response:', unzipError)
bodyString = responseData.toString('utf8') responseBody = responseData.toString('utf8')
} }
} else { } else {
bodyString = responseData.toString('utf8') responseBody = responseData.toString('utf8')
} }
const response = { const response = {
statusCode: res.statusCode, statusCode: res.statusCode,
headers: res.headers, headers: res.headers,
body: bodyString body: responseBody
} }
logger.debug(`🔗 Claude API response: ${res.statusCode}`) logger.debug(`🔗 Claude API response: ${res.statusCode}`)
@@ -1095,7 +1148,7 @@ class ClaudeRelayService {
}) })
// 写入请求体 // 写入请求体
req.write(JSON.stringify(requestPayload)) req.write(bodyString)
req.end() req.end()
}) })
} }
@@ -1248,79 +1301,39 @@ class ClaudeRelayService {
const isOpusModelRequest = const isOpusModelRequest =
typeof body?.model === 'string' && body.model.toLowerCase().includes('opus') typeof body?.model === 'string' && body.model.toLowerCase().includes('opus')
// 获取统一的 User-Agent // 使用公共方法准备请求头和 payload
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account) const prepared = await this._prepareRequestHeadersAndPayload(
body,
// 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders)
// 判断是否是真实的 Claude Code 请求
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
let finalHeaders = { ...filteredHeaders }
let requestPayload = body
if (!isRealClaudeCode) {
// 获取该账号存储的 Claude Code headers
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
// 只添加客户端没有提供的 headers
Object.keys(claudeCodeHeaders).forEach((key) => {
const lowerKey = key.toLowerCase()
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
finalHeaders[key] = claudeCodeHeaders[key]
}
})
}
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
account,
accountId,
accountType,
sessionHash,
clientHeaders, clientHeaders,
requestOptions, accountId,
isStream: true accessToken,
}) {
account,
accountType,
sessionHash,
requestOptions,
isStream: true
}
)
if (extensionResult.abortResponse) { if (prepared.abortResponse) {
return extensionResult.abortResponse return prepared.abortResponse
} }
requestPayload = extensionResult.body const { bodyString, headers } = prepared
finalHeaders = extensionResult.headers
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = new URL(this.claudeApiUrl) const url = new URL(this.claudeApiUrl)
const options = { const options = {
hostname: url.hostname, hostname: url.hostname,
port: url.port || 443, port: url.port || 443,
path: url.pathname, path: url.pathname + (url.search || ''),
method: 'POST', method: 'POST',
headers: { headers,
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
'anthropic-version': this.apiVersion,
...finalHeaders
},
agent: proxyAgent, agent: proxyAgent,
timeout: config.requestTimeout || 600000 timeout: config.requestTimeout || 600000
} }
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
if (!options.headers['user-agent'] || unifiedUA !== null) {
const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)'
options.headers['user-agent'] = userAgent
}
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
const modelId = body?.model
const clientBetaHeader = clientHeaders?.['anthropic-beta']
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
const req = https.request(options, async (res) => { const req = https.request(options, async (res) => {
logger.debug(`🌊 Claude stream response status: ${res.statusCode}`) logger.debug(`🌊 Claude stream response status: ${res.statusCode}`)
@@ -1766,15 +1779,15 @@ class ClaudeRelayService {
// 提取5小时会话窗口状态 // 提取5小时会话窗口状态
// 使用大小写不敏感的方式获取响应头 // 使用大小写不敏感的方式获取响应头
const get5hStatus = (headers) => { const get5hStatus = (resHeaders) => {
if (!headers) { if (!resHeaders) {
return null return null
} }
// HTTP头部名称不区分大小写需要处理不同情况 // HTTP头部名称不区分大小写需要处理不同情况
return ( return (
headers['anthropic-ratelimit-unified-5h-status'] || resHeaders['anthropic-ratelimit-unified-5h-status'] ||
headers['Anthropic-Ratelimit-Unified-5h-Status'] || resHeaders['Anthropic-Ratelimit-Unified-5h-Status'] ||
headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS'] resHeaders['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
) )
} }
@@ -1942,7 +1955,7 @@ class ClaudeRelayService {
}) })
// 写入请求体 // 写入请求体
req.write(JSON.stringify(requestPayload)) req.write(bodyString)
req.end() req.end()
}) })
} }

View File

@@ -22,6 +22,18 @@ const STAINLESS_HEADER_KEYS = [
'x-stainless-runtime', 'x-stainless-runtime',
'x-stainless-runtime-version' 'x-stainless-runtime-version'
] ]
// 小写 key 到正确大小写格式的映射(用于返回给上游时)
const STAINLESS_HEADER_CASE_MAP = {
'x-stainless-retry-count': 'X-Stainless-Retry-Count',
'x-stainless-timeout': 'X-Stainless-Timeout',
'x-stainless-lang': 'X-Stainless-Lang',
'x-stainless-package-version': 'X-Stainless-Package-Version',
'x-stainless-os': 'X-Stainless-OS',
'x-stainless-arch': 'X-Stainless-Arch',
'x-stainless-runtime': 'X-Stainless-Runtime',
'x-stainless-runtime-version': 'X-Stainless-Runtime-Version'
}
const MIN_FINGERPRINT_FIELDS = 4 const MIN_FINGERPRINT_FIELDS = 4
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:' const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
@@ -135,7 +147,9 @@ function applyFingerprintToHeaders(headers, fingerprint) {
return return
} }
removeHeaderCaseInsensitive(nextHeaders, key) removeHeaderCaseInsensitive(nextHeaders, key)
nextHeaders[key] = fingerprint[key] // 使用正确的大小写格式返回给上游
const properCaseKey = STAINLESS_HEADER_CASE_MAP[key] || key
nextHeaders[properCaseKey] = fingerprint[key]
}) })
return nextHeaders return nextHeaders

View File

@@ -52,50 +52,38 @@ function filterForOpenAI(headers) {
/** /**
* 为 Claude/Anthropic API 过滤 headers * 为 Claude/Anthropic API 过滤 headers
* 在原有逻辑基础上添加 CDN headers 到敏感列表 * 使用白名单模式,只允许指定的 headers 通过
*/ */
function filterForClaude(headers) { function filterForClaude(headers) {
const sensitiveHeaders = [ // 白名单模式:只允许以下 headers
'content-type', const allowedHeaders = [
'user-agent',
'x-api-key',
'authorization',
'x-authorization',
'host',
'content-length',
'connection',
'proxy-authorization',
'content-encoding',
'transfer-encoding',
...cdnHeaders // 添加 CDN headers
]
const browserHeaders = [
'origin',
'referer',
'sec-fetch-mode',
'sec-fetch-site',
'sec-fetch-dest',
'sec-ch-ua',
'sec-ch-ua-mobile',
'sec-ch-ua-platform',
'accept-language',
'accept-encoding',
'accept', 'accept',
'cache-control', 'x-stainless-retry-count',
'pragma', 'x-stainless-timeout',
'anthropic-dangerous-direct-browser-access' 'x-stainless-lang',
'x-stainless-package-version',
'x-stainless-os',
'x-stainless-arch',
'x-stainless-runtime',
'x-stainless-runtime-version',
'x-stainless-helper-method',
'anthropic-dangerous-direct-browser-access',
'anthropic-version',
'x-app',
'anthropic-beta',
'accept-language',
'sec-fetch-mode',
'accept-encoding',
'user-agent',
'content-type',
'connection'
] ]
const allowedHeaders = ['x-request-id', 'anthropic-version', 'anthropic-beta']
const filtered = {} const filtered = {}
Object.keys(headers || {}).forEach((key) => { Object.keys(headers || {}).forEach((key) => {
const lowerKey = key.toLowerCase() const lowerKey = key.toLowerCase()
if (allowedHeaders.includes(lowerKey)) { if (allowedHeaders.includes(lowerKey)) {
filtered[key] = headers[key] filtered[key] = headers[key]
} else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
filtered[key] = headers[key]
} }
}) })