feat: 增强claude转发特征模拟

This commit is contained in:
shaw
2025-11-28 13:54:42 +08:00
parent 7db70e2dc0
commit 49645e8a50
3 changed files with 423 additions and 9 deletions

View File

@@ -12,9 +12,7 @@ const claudeCodeHeadersService = require('./claudeCodeHeadersService')
const redis = require('../models/redis') const redis = require('../models/redis')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator') const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const { formatDateWithTimezone } = require('../utils/dateHelper') const { formatDateWithTimezone } = require('../utils/dateHelper')
const runtimeAddon = require('../utils/runtimeAddon') const requestIdentityService = require('./requestIdentityService')
const RUNTIME_EVENT_FMT_CLAUDE_REQ = 'fmtClaudeReq'
class ClaudeRelayService { class ClaudeRelayService {
constructor() { constructor() {
@@ -941,7 +939,7 @@ class ClaudeRelayService {
return filteredHeaders return filteredHeaders
} }
_applyLocalRequestFormatters(body, headers, context = {}) { _applyRequestIdentityTransform(body, headers, context = {}) {
const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {} const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {}
try { try {
@@ -951,7 +949,7 @@ class ClaudeRelayService {
...context ...context
} }
const result = runtimeAddon.emitSync(RUNTIME_EVENT_FMT_CLAUDE_REQ, payload) const result = requestIdentityService.transform(payload)
if (!result || typeof result !== 'object') { if (!result || typeof result !== 'object') {
return { body, headers: normalizedHeaders } return { body, headers: normalizedHeaders }
} }
@@ -966,7 +964,7 @@ class ClaudeRelayService {
return { body: nextBody, headers: nextHeaders, abortResponse } return { body: nextBody, headers: nextHeaders, abortResponse }
} catch (error) { } catch (error) {
logger.warn('⚠️ 应用本地 fmtClaudeReq 插件失败:', error) logger.warn('⚠️ 应用请求身份转换失败:', error)
return { body, headers: normalizedHeaders } return { body, headers: normalizedHeaders }
} }
} }
@@ -1012,7 +1010,7 @@ class ClaudeRelayService {
}) })
} }
const extensionResult = this._applyLocalRequestFormatters(requestPayload, finalHeaders, { const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
account, account,
accountId, accountId,
clientHeaders, clientHeaders,
@@ -1332,7 +1330,7 @@ class ClaudeRelayService {
}) })
} }
const extensionResult = this._applyLocalRequestFormatters(requestPayload, finalHeaders, { const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
account, account,
accountId, accountId,
accountType, accountType,

View File

@@ -0,0 +1,416 @@
/**
* Request Identity Service
*
* 处理 Claude 请求的身份信息规范化:
* 1. Stainless 指纹管理 - 收集、持久化和应用 x-stainless-* 系列请求头
* 2. User ID 规范化 - 重写 metadata.user_id使其与真实账户保持一致
*/
const crypto = require('crypto')
const logger = require('../utils/logger')
const redisService = require('../models/redis')
const SESSION_PREFIX = 'session_'
const ACCOUNT_MARKER = '_account_'
const STAINLESS_HEADER_KEYS = [
'x-stainless-retry-count',
'x-stainless-timeout',
'x-stainless-lang',
'x-stainless-package-version',
'x-stainless-os',
'x-stainless-arch',
'x-stainless-runtime',
'x-stainless-runtime-version'
]
const MIN_FINGERPRINT_FIELDS = 4
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
function formatUuidFromSeed(seed) {
const digest = crypto.createHash('sha256').update(String(seed)).digest()
const bytes = Buffer.from(digest.subarray(0, 16))
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
function safeParseJson(value) {
if (typeof value !== 'string' || !value.trim()) {
return null
}
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' ? parsed : null
} catch (error) {
return null
}
}
function getRedisClient() {
if (!redisService || typeof redisService.getClientSafe !== 'function') {
throw new Error('requestIdentityService: Redis 服务未初始化')
}
return redisService.getClientSafe()
}
function hasFingerprintValues(fingerprint) {
return fingerprint && typeof fingerprint === 'object' && Object.keys(fingerprint).length > 0
}
function sanitizeFingerprint(source) {
if (!source || typeof source !== 'object') {
return {}
}
const normalized = {}
const lowerCaseSource = {}
Object.keys(source).forEach((key) => {
const value = source[key]
if (value === undefined || value === null || String(value).trim() === '') {
return
}
lowerCaseSource[key.toLowerCase()] = String(value)
})
STAINLESS_HEADER_KEYS.forEach((key) => {
if (lowerCaseSource[key]) {
normalized[key] = lowerCaseSource[key]
}
})
return normalized
}
function collectFingerprintFromHeaders(headers) {
if (!headers || typeof headers !== 'object') {
return {}
}
const subset = {}
Object.keys(headers).forEach((key) => {
const lowerKey = key.toLowerCase()
if (STAINLESS_HEADER_KEYS.includes(lowerKey)) {
subset[lowerKey] = headers[key]
}
})
return sanitizeFingerprint(subset)
}
function removeHeaderCaseInsensitive(target, key) {
if (!target || typeof target !== 'object') {
return
}
const lowerKey = key.toLowerCase()
Object.keys(target).forEach((candidate) => {
if (candidate.toLowerCase() === lowerKey) {
delete target[candidate]
}
})
}
function applyFingerprintToHeaders(headers, fingerprint) {
if (!headers || typeof headers !== 'object') {
return headers
}
if (!hasFingerprintValues(fingerprint)) {
return { ...headers }
}
const nextHeaders = { ...headers }
STAINLESS_HEADER_KEYS.forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(fingerprint, key)) {
return
}
removeHeaderCaseInsensitive(nextHeaders, key)
nextHeaders[key] = fingerprint[key]
})
return nextHeaders
}
function persistFingerprint(accountId, fingerprint) {
if (!accountId || !hasFingerprintValues(fingerprint)) {
return
}
const client = getRedisClient()
const key = `${REDIS_KEY_PREFIX}${accountId}`
const serialized = JSON.stringify(fingerprint)
const command = client.set(key, serialized, 'NX')
if (command && typeof command.catch === 'function') {
command.catch((error) => {
logger.error(`requestIdentityService: Redis 持久化指纹失败 (${accountId}): ${error.message}`)
})
}
}
function getHeaderValueCaseInsensitive(headers, key) {
if (!headers || typeof headers !== 'object') {
return undefined
}
const lowerKey = key.toLowerCase()
for (const candidate of Object.keys(headers)) {
if (candidate.toLowerCase() === lowerKey) {
return headers[candidate]
}
}
return undefined
}
function headersChanged(original, updated) {
if (original === updated) {
return false
}
for (const key of STAINLESS_HEADER_KEYS) {
if (
getHeaderValueCaseInsensitive(original, key) !== getHeaderValueCaseInsensitive(updated, key)
) {
return true
}
}
return false
}
function resolveAccountId(payload) {
if (!payload || typeof payload !== 'object') {
return null
}
const account = payload.account && typeof payload.account === 'object' ? payload.account : null
const candidates = [
payload.accountId,
payload.account_id,
payload.accountID,
account && (account.accountId || account.account_id || account.accountID),
account && (account.id || account.uuid),
account && (account.account_uuid || account.accountUuid),
account && (account.schedulerAccountId || account.scheduler_account_id)
]
for (const candidate of candidates) {
if (candidate === undefined || candidate === null) {
continue
}
const stringified = String(candidate).trim()
if (stringified) {
return stringified
}
}
return null
}
function rewriteHeaders(headers, accountId) {
if (!headers || typeof headers !== 'object') {
return { nextHeaders: headers, changed: false }
}
if (!accountId) {
return { nextHeaders: { ...headers }, changed: false }
}
const workingHeaders = { ...headers }
const fingerprint = collectFingerprintFromHeaders(workingHeaders)
const fieldCount = Object.keys(fingerprint).length
if (fieldCount < MIN_FINGERPRINT_FIELDS) {
logger.warn(
`requestIdentityService: 账号 ${accountId} 提供的 Stainless 指纹字段不足,已保持原样`
)
return { nextHeaders: workingHeaders, changed: false }
}
try {
persistFingerprint(accountId, fingerprint)
} catch (error) {
logger.error(`requestIdentityService: 持久化指纹失败 (${accountId}): ${error.message}`)
return {
abortResponse: {
statusCode: 500,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ error: 'fingerprint_persist_failed', message: '指纹信息持久化失败' })
}
}
}
const appliedHeaders = applyFingerprintToHeaders(workingHeaders, fingerprint)
const changed = headersChanged(workingHeaders, appliedHeaders)
return { nextHeaders: appliedHeaders, changed }
}
function normalizeAccountUuid(candidate) {
if (typeof candidate !== 'string') {
return null
}
const trimmed = candidate.trim()
return trimmed || null
}
function extractAccountUuid(account) {
if (!account || typeof account !== 'object') {
return null
}
const extInfoRaw = account.extInfo
if (!extInfoRaw) {
return null
}
const extInfoObject = typeof extInfoRaw === 'string' ? safeParseJson(extInfoRaw) : null
if (!extInfoObject || typeof extInfoObject !== 'object') {
return null
}
const extUuid = normalizeAccountUuid(extInfoObject.account_uuid)
return extUuid || null
}
function rewriteUserId(body, accountId, accountUuid) {
if (!body || typeof body !== 'object') {
return { nextBody: body, changed: false }
}
const { metadata } = body
if (!metadata || typeof metadata !== 'object') {
return { nextBody: body, changed: false }
}
const userId = metadata.user_id
if (typeof userId !== 'string') {
return { nextBody: body, changed: false }
}
const pivot = userId.lastIndexOf(SESSION_PREFIX)
if (pivot === -1) {
return { nextBody: body, changed: false }
}
const prefixBeforeSession = userId.slice(0, pivot)
const sessionTail = userId.slice(pivot + SESSION_PREFIX.length)
const seedTail = sessionTail || 'default'
const effectiveScheduler = accountId ? String(accountId) : 'unknown-scheduler'
const hashed = formatUuidFromSeed(`${effectiveScheduler}::${seedTail}`)
let normalizedPrefix = prefixBeforeSession
if (accountUuid) {
const trimmedUuid = normalizeAccountUuid(accountUuid)
if (trimmedUuid) {
const accountIndex = normalizedPrefix.indexOf(ACCOUNT_MARKER)
if (accountIndex === -1) {
const base = normalizedPrefix.replace(/_+$/, '')
const baseWithMarker = /_account$/.test(base) ? base : `${base}_account`
normalizedPrefix = `${baseWithMarker}_${trimmedUuid}_`
} else {
const valueStart = accountIndex + ACCOUNT_MARKER.length
let separatorIndex = normalizedPrefix.indexOf('_', valueStart)
if (separatorIndex === -1) {
separatorIndex = normalizedPrefix.length
}
const head = normalizedPrefix.slice(0, valueStart)
let tail = '_'
if (separatorIndex < normalizedPrefix.length) {
tail = normalizedPrefix.slice(separatorIndex)
if (/^_+$/.test(tail)) {
tail = '_'
}
}
normalizedPrefix = `${head}${trimmedUuid}${tail}`
}
}
}
const nextUserId = `${normalizedPrefix}${SESSION_PREFIX}${hashed}`
if (nextUserId === userId) {
return { nextBody: body, changed: false }
}
const nextBody = {
...body,
metadata: {
...metadata,
user_id: nextUserId
}
}
return { nextBody, changed: true }
}
/**
* 转换请求身份信息
* @param {Object} payload - 请求载荷
* @param {Object} payload.body - 请求体
* @param {Object} payload.headers - 请求头
* @param {string} payload.accountId - 账户ID
* @param {Object} payload.account - 账户对象
* @returns {Object} 转换后的 { body, headers, abortResponse? }
*/
function transform(payload = {}) {
const currentBody = payload.body
const currentHeaders = payload.headers
if (!payload.accountId) {
return {
body: currentBody,
headers: currentHeaders
}
}
const accountUuid = extractAccountUuid(payload.account)
const accountIdForHeaders = resolveAccountId(payload)
const { nextBody } = rewriteUserId(currentBody, payload.accountId, accountUuid)
const headerResult = rewriteHeaders(currentHeaders, accountIdForHeaders)
const nextHeaders = headerResult ? headerResult.nextHeaders : currentHeaders
const abortResponse =
headerResult && headerResult.abortResponse ? headerResult.abortResponse : null
return {
body: nextBody,
headers: nextHeaders,
abortResponse
}
}
module.exports = {
transform,
// 导出内部函数供测试使用
_internal: {
formatUuidFromSeed,
collectFingerprintFromHeaders,
rewriteHeaders,
rewriteUserId,
extractAccountUuid,
resolveAccountId
}
}

View File

@@ -362,7 +362,7 @@
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<i class="fas fa-gem mt-[2px] text-[10px] text-purple-500"></i> <i class="fas fa-gem mt-[2px] text-[10px] text-purple-500"></i>
<span class="font-medium text-white dark:text-gray-900" <span class="font-medium text-white dark:text-gray-900"
>Opus 窗口7Opus模型专用限额</span >Sonnet窗口7Sonnet模型专用限额</span
> >
</div> </div>
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">