mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 增强claude转发特征模拟
This commit is contained in:
@@ -12,9 +12,7 @@ const claudeCodeHeadersService = require('./claudeCodeHeadersService')
|
||||
const redis = require('../models/redis')
|
||||
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||
const { formatDateWithTimezone } = require('../utils/dateHelper')
|
||||
const runtimeAddon = require('../utils/runtimeAddon')
|
||||
|
||||
const RUNTIME_EVENT_FMT_CLAUDE_REQ = 'fmtClaudeReq'
|
||||
const requestIdentityService = require('./requestIdentityService')
|
||||
|
||||
class ClaudeRelayService {
|
||||
constructor() {
|
||||
@@ -941,7 +939,7 @@ class ClaudeRelayService {
|
||||
return filteredHeaders
|
||||
}
|
||||
|
||||
_applyLocalRequestFormatters(body, headers, context = {}) {
|
||||
_applyRequestIdentityTransform(body, headers, context = {}) {
|
||||
const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {}
|
||||
|
||||
try {
|
||||
@@ -951,7 +949,7 @@ class ClaudeRelayService {
|
||||
...context
|
||||
}
|
||||
|
||||
const result = runtimeAddon.emitSync(RUNTIME_EVENT_FMT_CLAUDE_REQ, payload)
|
||||
const result = requestIdentityService.transform(payload)
|
||||
if (!result || typeof result !== 'object') {
|
||||
return { body, headers: normalizedHeaders }
|
||||
}
|
||||
@@ -966,7 +964,7 @@ class ClaudeRelayService {
|
||||
|
||||
return { body: nextBody, headers: nextHeaders, abortResponse }
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ 应用本地 fmtClaudeReq 插件失败:', error)
|
||||
logger.warn('⚠️ 应用请求身份转换失败:', error)
|
||||
return { body, headers: normalizedHeaders }
|
||||
}
|
||||
}
|
||||
@@ -1012,7 +1010,7 @@ class ClaudeRelayService {
|
||||
})
|
||||
}
|
||||
|
||||
const extensionResult = this._applyLocalRequestFormatters(requestPayload, finalHeaders, {
|
||||
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
|
||||
account,
|
||||
accountId,
|
||||
clientHeaders,
|
||||
@@ -1332,7 +1330,7 @@ class ClaudeRelayService {
|
||||
})
|
||||
}
|
||||
|
||||
const extensionResult = this._applyLocalRequestFormatters(requestPayload, finalHeaders, {
|
||||
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
|
||||
account,
|
||||
accountId,
|
||||
accountType,
|
||||
|
||||
416
src/services/requestIdentityService.js
Normal file
416
src/services/requestIdentityService.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,7 @@
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas fa-gem mt-[2px] text-[10px] text-purple-500"></i>
|
||||
<span class="font-medium text-white dark:text-gray-900"
|
||||
>Opus 窗口:7天Opus模型专用限额。</span
|
||||
>Sonnet窗口:7天Sonnet模型专用限额。</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
|
||||
Reference in New Issue
Block a user