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 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,
|
||||||
|
|||||||
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">
|
<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 窗口:7天Opus模型专用限额。</span
|
>Sonnet窗口:7天Sonnet模型专用限额。</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user