diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 8fb90685..4b64102b 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -28,44 +28,38 @@ class ClaudeRelayService { } // 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header - // 规则: - // 1. 如果客户端传递了 anthropic-beta,检查是否包含 oauth-2025-04-20 - // 2. 如果没有 oauth-2025-04-20,则添加到 claude-code-20250219 后面(如果有的话),否则放在第一位 - // 3. 如果客户端没传递,则根据模型判断:haiku 不需要 claude-code,其他模型需要 _getBetaHeader(modelId, clientBetaHeader) { const OAUTH_BETA = 'oauth-2025-04-20' const CLAUDE_CODE_BETA = 'claude-code-20250219' + const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14' + const TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14' - // 如果客户端传递了 anthropic-beta - if (clientBetaHeader) { - // 检查是否已包含 oauth-2025-04-20 - if (clientBetaHeader.includes(OAUTH_BETA)) { - return clientBetaHeader - } - - // 需要添加 oauth-2025-04-20 - const parts = clientBetaHeader.split(',').map((p) => p.trim()) - - // 找到 claude-code-20250219 的位置 - const claudeCodeIndex = parts.findIndex((p) => p === CLAUDE_CODE_BETA) - - if (claudeCodeIndex !== -1) { - // 在 claude-code-20250219 后面插入 - parts.splice(claudeCodeIndex + 1, 0, OAUTH_BETA) - } else { - // 放在第一位 - parts.unshift(OAUTH_BETA) - } - - return parts.join(',') - } - - // 客户端没有传递,根据模型判断 const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku') - if (isHaikuModel) { - return 'oauth-2025-04-20,interleaved-thinking-2025-05-14' + const baseBetas = isHaikuModel + ? [OAUTH_BETA, INTERLEAVED_THINKING_BETA] + : [CLAUDE_CODE_BETA, OAUTH_BETA, INTERLEAVED_THINKING_BETA, TOOL_STREAMING_BETA] + + const betaList = [] + const seen = new Set() + const addBeta = (beta) => { + if (!beta || seen.has(beta)) { + return + } + seen.add(beta) + betaList.push(beta) } - return 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' + + baseBetas.forEach(addBeta) + + if (clientBetaHeader) { + clientBetaHeader + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + .forEach(addBeta) + } + + return betaList.join(',') } _buildStandardRateLimitMessage(resetTime) { @@ -140,6 +134,226 @@ class ClaudeRelayService { return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1) } + _isClaudeCodeUserAgent(clientHeaders) { + const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] + return typeof userAgent === 'string' && /^claude-cli\/[^\s]+\s+\(/i.test(userAgent) + } + + _isActualClaudeCodeRequest(requestBody, clientHeaders) { + return this.isRealClaudeCodeRequest(requestBody) && this._isClaudeCodeUserAgent(clientHeaders) + } + + _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 + } + + _isClaudeCodeCredentialError(body) { + const message = this._extractErrorMessage(body) + if (!message) { + return false + } + const lower = message.toLowerCase() + return ( + lower.includes('only authorized for use with claude code') || + lower.includes('cannot be used for other api requests') + ) + } + + _toPascalCaseToolName(name) { + const parts = name.split(/[_-]/).filter(Boolean) + if (parts.length === 0) { + return name + } + const pascal = parts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join('') + return `${pascal}_tool` + } + + _toRandomizedToolName(name) { + const suffix = Math.random().toString(36).substring(2, 8) + return `${name}_${suffix}` + } + + _transformToolNamesInRequestBody(body, options = {}) { + if (!body || typeof body !== 'object') { + return null + } + + const useRandomized = options.useRandomizedToolNames === true + const forwardMap = new Map() + const reverseMap = new Map() + + const transformName = (name) => { + if (typeof name !== 'string' || name.length === 0) { + return name + } + if (forwardMap.has(name)) { + return forwardMap.get(name) + } + const transformed = useRandomized + ? this._toRandomizedToolName(name) + : this._toPascalCaseToolName(name) + if (transformed !== name) { + forwardMap.set(name, transformed) + reverseMap.set(transformed, name) + } + return transformed + } + + if (Array.isArray(body.tools)) { + body.tools.forEach((tool) => { + if (tool && typeof tool.name === 'string') { + tool.name = transformName(tool.name) + } + }) + } + + if (body.tool_choice && typeof body.tool_choice === 'object') { + if (typeof body.tool_choice.name === 'string') { + body.tool_choice.name = transformName(body.tool_choice.name) + } + } + + if (Array.isArray(body.messages)) { + body.messages.forEach((message) => { + const content = message?.content + if (Array.isArray(content)) { + content.forEach((block) => { + if (block?.type === 'tool_use' && typeof block.name === 'string') { + block.name = transformName(block.name) + } + }) + } + }) + } + + return reverseMap.size > 0 ? reverseMap : null + } + + _restoreToolName(name, toolNameMap) { + if (!toolNameMap || toolNameMap.size === 0) { + return name + } + return toolNameMap.get(name) || name + } + + _restoreToolNamesInContentBlocks(content, toolNameMap) { + if (!Array.isArray(content)) { + return + } + + content.forEach((block) => { + if (block?.type === 'tool_use' && typeof block.name === 'string') { + block.name = this._restoreToolName(block.name, toolNameMap) + } + }) + } + + _restoreToolNamesInResponseObject(responseBody, toolNameMap) { + if (!responseBody || typeof responseBody !== 'object') { + return + } + + if (Array.isArray(responseBody.content)) { + this._restoreToolNamesInContentBlocks(responseBody.content, toolNameMap) + } + + if (responseBody.message && Array.isArray(responseBody.message.content)) { + this._restoreToolNamesInContentBlocks(responseBody.message.content, toolNameMap) + } + } + + _restoreToolNamesInResponseBody(responseBody, toolNameMap) { + if (!responseBody || !toolNameMap || toolNameMap.size === 0) { + return responseBody + } + + if (typeof responseBody === 'string') { + try { + const parsed = JSON.parse(responseBody) + this._restoreToolNamesInResponseObject(parsed, toolNameMap) + return JSON.stringify(parsed) + } catch (error) { + return responseBody + } + } + + if (typeof responseBody === 'object') { + this._restoreToolNamesInResponseObject(responseBody, toolNameMap) + } + + return responseBody + } + + _restoreToolNamesInStreamEvent(event, toolNameMap) { + if (!event || typeof event !== 'object') { + return + } + + if (event.content_block && event.content_block.type === 'tool_use') { + if (typeof event.content_block.name === 'string') { + event.content_block.name = this._restoreToolName(event.content_block.name, toolNameMap) + } + } + + if (event.delta && event.delta.type === 'tool_use') { + if (typeof event.delta.name === 'string') { + event.delta.name = this._restoreToolName(event.delta.name, toolNameMap) + } + } + + if (event.message && Array.isArray(event.message.content)) { + this._restoreToolNamesInContentBlocks(event.message.content, toolNameMap) + } + + if (Array.isArray(event.content)) { + this._restoreToolNamesInContentBlocks(event.content, toolNameMap) + } + } + + _createToolNameStripperStreamTransformer(streamTransformer, toolNameMap) { + if (!toolNameMap || toolNameMap.size === 0) { + return streamTransformer + } + + return (payload) => { + const transformed = streamTransformer ? streamTransformer(payload) : payload + if (!transformed || typeof transformed !== 'string') { + return transformed + } + + const lines = transformed.split('\n') + const updated = lines.map((line) => { + if (!line.startsWith('data:')) { + return line + } + const jsonStr = line.slice(5).trimStart() + if (!jsonStr || jsonStr === '[DONE]') { + return line + } + try { + const data = JSON.parse(jsonStr) + this._restoreToolNamesInStreamEvent(data, toolNameMap) + return `data: ${JSON.stringify(data)}` + } catch (error) { + return line + } + }) + + return updated.join('\n') + } + } + // 🚀 转发请求到Claude API async relayRequest( requestBody, @@ -311,7 +525,9 @@ class ClaudeRelayService { // 获取有效的访问token const accessToken = await claudeAccountService.getValidAccessToken(accountId) + const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders) const processedBody = this._processRequestBody(requestBody, account) + const baseRequestBody = JSON.parse(JSON.stringify(processedBody)) // 获取代理配置 const proxyAgent = await this._getProxyAgent(accountId) @@ -332,36 +548,53 @@ class ClaudeRelayService { clientResponse.once('close', handleClientDisconnect) } - // 发送请求到Claude API(传入回调以获取请求对象) - // 🔄 403 重试机制:仅对 claude-official 类型账户(OAuth 或 Setup Token) - const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0 - let retryCount = 0 - let response - let shouldRetry = false + const makeRequestWithRetries = async (requestOptions) => { + const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0 + let retryCount = 0 + let response + let shouldRetry = false - do { - response = await this._makeClaudeRequest( - processedBody, - accessToken, - proxyAgent, - clientHeaders, - accountId, - (req) => { - upstreamRequest = req - }, - options - ) - - // 检查是否需要重试 403 - shouldRetry = response.statusCode === 403 && retryCount < maxRetries - if (shouldRetry) { - retryCount++ - logger.warn( - `🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s` + do { + response = await this._makeClaudeRequest( + JSON.parse(JSON.stringify(baseRequestBody)), + accessToken, + proxyAgent, + clientHeaders, + accountId, + (req) => { + upstreamRequest = req + }, + { + ...requestOptions, + isRealClaudeCodeRequest + } ) - await this._sleep(2000) - } - } while (shouldRetry) + + shouldRetry = response.statusCode === 403 && retryCount < maxRetries + if (shouldRetry) { + retryCount++ + logger.warn( + `🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s` + ) + await this._sleep(2000) + } + } while (shouldRetry) + + return { response, retryCount } + } + + let requestOptions = options + let { response, retryCount } = await makeRequestWithRetries(requestOptions) + + if ( + this._isClaudeCodeCredentialError(response.body) && + requestOptions.useRandomizedToolNames !== true + ) { + requestOptions = { ...requestOptions, useRandomizedToolNames: true } + const retryResult = await makeRequestWithRetries(requestOptions) + response = retryResult.response + retryCount = retryResult.retryCount + } // 如果进行了重试,记录最终结果 if (retryCount > 0) { @@ -1035,23 +1268,19 @@ class ClaudeRelayService { // 获取过滤后的客户端 headers const filteredHeaders = this._filterClientHeaders(clientHeaders) - // 判断是否是真实的 Claude Code 请求 - const isRealClaudeCode = this.isRealClaudeCodeRequest(body) + const isRealClaudeCode = + requestOptions.isRealClaudeCodeRequest === undefined + ? this.isRealClaudeCodeRequest(body) + : requestOptions.isRealClaudeCodeRequest === true // 如果不是真实的 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] - } + finalHeaders[key] = claudeCodeHeaders[key] }) } @@ -1073,6 +1302,13 @@ class ClaudeRelayService { requestPayload = extensionResult.body finalHeaders = extensionResult.headers + let toolNameMap = null + if (!isRealClaudeCode) { + toolNameMap = this._transformToolNamesInRequestBody(requestPayload, { + useRandomizedToolNames: requestOptions.useRandomizedToolNames === true + }) + } + // 序列化请求体,计算 content-length const bodyString = JSON.stringify(requestPayload) const contentLength = Buffer.byteLength(bodyString, 'utf8') @@ -1098,17 +1334,16 @@ class ClaudeRelayService { logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`) - logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`) - // 根据模型和客户端传递的 anthropic-beta 动态设置 header const modelId = requestPayload?.model || body?.model - const clientBetaHeader = clientHeaders?.['anthropic-beta'] + const clientBetaHeader = this._getHeaderValueCaseInsensitive(clientHeaders, 'anthropic-beta') headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader) return { requestPayload, bodyString, headers, - isRealClaudeCode + isRealClaudeCode, + toolNameMap } } @@ -1174,7 +1409,7 @@ class ClaudeRelayService { return prepared.abortResponse } - const { bodyString, headers } = prepared + const { bodyString, headers, isRealClaudeCode, toolNameMap } = prepared return new Promise((resolve, reject) => { // 支持自定义路径(如 count_tokens) @@ -1226,6 +1461,10 @@ class ClaudeRelayService { responseBody = responseData.toString('utf8') } + if (!isRealClaudeCode) { + responseBody = this._restoreToolNamesInResponseBody(responseBody, toolNameMap) + } + const response = { statusCode: res.statusCode, headers: res.headers, @@ -1465,14 +1704,16 @@ class ClaudeRelayService { // 获取有效的访问token const accessToken = await claudeAccountService.getValidAccessToken(accountId) + const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders) const processedBody = this._processRequestBody(requestBody, account) + const baseRequestBody = JSON.parse(JSON.stringify(processedBody)) // 获取代理配置 const proxyAgent = await this._getProxyAgent(accountId) // 发送流式请求并捕获usage数据 await this._makeClaudeStreamRequestWithUsageCapture( - processedBody, + JSON.parse(JSON.stringify(baseRequestBody)), accessToken, proxyAgent, clientHeaders, @@ -1487,7 +1728,11 @@ class ClaudeRelayService { accountType, sessionHash, streamTransformer, - options, + { + ...options, + originalRequestBody: baseRequestBody, + isRealClaudeCodeRequest + }, isDedicatedOfficialAccount, // 📬 新增回调:在收到响应头时释放队列锁 async () => { @@ -1576,7 +1821,11 @@ class ClaudeRelayService { return prepared.abortResponse } - const { bodyString, headers } = prepared + const { bodyString, headers, isRealClaudeCode, toolNameMap } = prepared + const toolNameStreamTransformer = this._createToolNameStripperStreamTransformer( + streamTransformer, + toolNameMap + ) return new Promise((resolve, reject) => { const url = new URL(this.claudeApiUrl) @@ -1684,8 +1933,11 @@ class ClaudeRelayService { try { // 递归调用自身进行重试 + const retryBody = requestOptions.originalRequestBody + ? JSON.parse(JSON.stringify(requestOptions.originalRequestBody)) + : body const retryResult = await this._makeClaudeStreamRequestWithUsageCapture( - body, + retryBody, accessToken, proxyAgent, clientHeaders, @@ -1780,11 +2032,40 @@ class ClaudeRelayService { errorData += chunk.toString() }) - res.on('end', () => { + res.on('end', async () => { logger.error( `❌ Claude API error response (Account: ${account?.name || accountId}):`, errorData ) + if ( + this._isClaudeCodeCredentialError(errorData) && + requestOptions.useRandomizedToolNames !== true && + requestOptions.originalRequestBody + ) { + try { + const retryBody = JSON.parse(JSON.stringify(requestOptions.originalRequestBody)) + const retryResult = await this._makeClaudeStreamRequestWithUsageCapture( + retryBody, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + usageCallback, + accountId, + accountType, + sessionHash, + streamTransformer, + { ...requestOptions, useRandomizedToolNames: true }, + isDedicatedOfficialAccount, + onResponseStart, + retryCount + ) + resolve(retryResult) + } catch (retryError) { + reject(retryError) + } + return + } if (this._isOrganizationDisabledError(res.statusCode, errorData)) { ;(async () => { try { @@ -1819,7 +2100,7 @@ class ClaudeRelayService { } // 如果有 streamTransformer(如测试请求),使用前端期望的格式 - if (streamTransformer) { + if (toolNameStreamTransformer) { responseStream.write( `data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n` ) @@ -1873,8 +2154,8 @@ class ClaudeRelayService { if (isStreamWritable(responseStream)) { const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '') // 如果有流转换器,应用转换 - if (streamTransformer) { - const transformed = streamTransformer(linesToForward) + if (toolNameStreamTransformer) { + const transformed = toolNameStreamTransformer(linesToForward) if (transformed) { responseStream.write(transformed) } @@ -2007,8 +2288,8 @@ class ClaudeRelayService { try { // 处理缓冲区中剩余的数据 if (buffer.trim() && isStreamWritable(responseStream)) { - if (streamTransformer) { - const transformed = streamTransformer(buffer) + if (toolNameStreamTransformer) { + const transformed = toolNameStreamTransformer(buffer) if (transformed) { responseStream.write(transformed) }