| const https = require('https') |
| const axios = require('axios') |
| const ProxyHelper = require('../utils/proxyHelper') |
| const droidScheduler = require('./droidScheduler') |
| const droidAccountService = require('./droidAccountService') |
| const apiKeyService = require('./apiKeyService') |
| const redis = require('../models/redis') |
| const { updateRateLimitCounters } = require('../utils/rateLimitHelper') |
| const logger = require('../utils/logger') |
| const runtimeAddon = require('../utils/runtimeAddon') |
|
|
| const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.' |
| const RUNTIME_EVENT_FMT_PAYLOAD = 'fmtPayload' |
|
|
| |
| |
| |
|
|
| class DroidRelayService { |
| constructor() { |
| this.factoryApiBaseUrl = 'https://app.factory.ai/api/llm' |
|
|
| this.endpoints = { |
| anthropic: '/a/v1/messages', |
| openai: '/o/v1/responses' |
| } |
|
|
| this.userAgent = 'factory-cli/0.19.12' |
| this.systemPrompt = SYSTEM_PROMPT |
| this.API_KEY_STICKY_PREFIX = 'droid_api_key' |
| } |
|
|
| _normalizeEndpointType(endpointType) { |
| if (!endpointType) { |
| return 'anthropic' |
| } |
|
|
| const normalized = String(endpointType).toLowerCase() |
| if (normalized === 'openai' || normalized === 'common') { |
| return 'openai' |
| } |
|
|
| if (normalized === 'anthropic') { |
| return 'anthropic' |
| } |
|
|
| return 'anthropic' |
| } |
|
|
| _normalizeRequestBody(requestBody, endpointType) { |
| if (!requestBody || typeof requestBody !== 'object') { |
| return requestBody |
| } |
|
|
| const normalizedBody = { ...requestBody } |
|
|
| if (endpointType === 'anthropic' && typeof normalizedBody.model === 'string') { |
| const originalModel = normalizedBody.model |
| const trimmedModel = originalModel.trim() |
| const lowerModel = trimmedModel.toLowerCase() |
|
|
| if (lowerModel.includes('haiku')) { |
| const mappedModel = 'claude-sonnet-4-20250514' |
| if (originalModel !== mappedModel) { |
| logger.info(`🔄 将请求模型从 ${originalModel} 映射为 ${mappedModel}`) |
| } |
| normalizedBody.model = mappedModel |
| } |
| } |
|
|
| if (endpointType === 'openai' && typeof normalizedBody.model === 'string') { |
| const originalModel = normalizedBody.model |
| const trimmedModel = originalModel.trim() |
| const lowerModel = trimmedModel.toLowerCase() |
|
|
| if (lowerModel === 'gpt-5') { |
| const mappedModel = 'gpt-5-2025-08-07' |
| if (originalModel !== mappedModel) { |
| logger.info(`🔄 将请求模型从 ${originalModel} 映射为 ${mappedModel}`) |
| } |
| normalizedBody.model = mappedModel |
| } |
| } |
|
|
| return normalizedBody |
| } |
|
|
| async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') { |
| if (!rateLimitInfo) { |
| return |
| } |
|
|
| try { |
| const { totalTokens, totalCost } = await updateRateLimitCounters( |
| rateLimitInfo, |
| usageSummary, |
| model |
| ) |
|
|
| if (totalTokens > 0) { |
| logger.api(`📊 Updated rate limit token count${context}: +${totalTokens}`) |
| } |
| if (typeof totalCost === 'number' && totalCost > 0) { |
| logger.api(`💰 Updated rate limit cost count${context}: +$${totalCost.toFixed(6)}`) |
| } |
| } catch (error) { |
| logger.error(`❌ Failed to update rate limit counters${context}:`, error) |
| } |
| } |
|
|
| _composeApiKeyStickyKey(accountId, endpointType, sessionHash) { |
| if (!accountId || !sessionHash) { |
| return null |
| } |
|
|
| const normalizedEndpoint = this._normalizeEndpointType(endpointType) |
| return `${this.API_KEY_STICKY_PREFIX}:${accountId}:${normalizedEndpoint}:${sessionHash}` |
| } |
|
|
| async _selectApiKey(account, endpointType, sessionHash) { |
| const entries = await droidAccountService.getDecryptedApiKeyEntries(account.id) |
| if (!entries || entries.length === 0) { |
| throw new Error(`Droid account ${account.id} 未配置任何 API Key`) |
| } |
|
|
| |
| const activeEntries = entries.filter((entry) => entry.status !== 'error') |
| if (!activeEntries || activeEntries.length === 0) { |
| throw new Error(`Droid account ${account.id} 没有可用的 API Key(所有API Key均已异常)`) |
| } |
|
|
| const stickyKey = this._composeApiKeyStickyKey(account.id, endpointType, sessionHash) |
|
|
| if (stickyKey) { |
| const mappedKeyId = await redis.getSessionAccountMapping(stickyKey) |
| if (mappedKeyId) { |
| const mappedEntry = activeEntries.find((entry) => entry.id === mappedKeyId) |
| if (mappedEntry) { |
| await redis.extendSessionAccountMappingTTL(stickyKey) |
| await droidAccountService.touchApiKeyUsage(account.id, mappedEntry.id) |
| logger.info(`🔐 使用已绑定的 Droid API Key ${mappedEntry.id}(Account: ${account.id})`) |
| return mappedEntry |
| } |
|
|
| await redis.deleteSessionAccountMapping(stickyKey) |
| } |
| } |
|
|
| const selectedEntry = activeEntries[Math.floor(Math.random() * activeEntries.length)] |
| if (!selectedEntry) { |
| throw new Error(`Droid account ${account.id} 没有可用的 API Key`) |
| } |
|
|
| if (stickyKey) { |
| await redis.setSessionAccountMapping(stickyKey, selectedEntry.id) |
| } |
|
|
| await droidAccountService.touchApiKeyUsage(account.id, selectedEntry.id) |
|
|
| logger.info( |
| `🔐 随机选取 Droid API Key ${selectedEntry.id}(Account: ${account.id}, Active Keys: ${activeEntries.length}/${entries.length})` |
| ) |
|
|
| return selectedEntry |
| } |
|
|
| async relayRequest( |
| requestBody, |
| apiKeyData, |
| clientRequest, |
| clientResponse, |
| clientHeaders, |
| options = {} |
| ) { |
| const { |
| endpointType = 'anthropic', |
| sessionHash = null, |
| customPath = null, |
| skipUsageRecord = false, |
| disableStreaming = false |
| } = options |
| const keyInfo = apiKeyData || {} |
| const clientApiKeyId = keyInfo.id || null |
| const normalizedEndpoint = this._normalizeEndpointType(endpointType) |
| const normalizedRequestBody = this._normalizeRequestBody(requestBody, normalizedEndpoint) |
| let account = null |
| let selectedApiKey = null |
| let accessToken = null |
|
|
| try { |
| logger.info( |
| `📤 Processing Droid API request for key: ${ |
| keyInfo.name || keyInfo.id || 'unknown' |
| }, endpoint: ${normalizedEndpoint}${sessionHash ? `, session: ${sessionHash}` : ''}` |
| ) |
|
|
| |
| account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash) |
|
|
| if (!account) { |
| throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`) |
| } |
|
|
| |
| if ( |
| typeof account.authenticationMethod === 'string' && |
| account.authenticationMethod.toLowerCase().trim() === 'api_key' |
| ) { |
| selectedApiKey = await this._selectApiKey(account, normalizedEndpoint, sessionHash) |
| accessToken = selectedApiKey.key |
| } else { |
| accessToken = await droidAccountService.getValidAccessToken(account.id) |
| } |
|
|
| |
| let endpointPath = this.endpoints[normalizedEndpoint] |
|
|
| if (typeof customPath === 'string' && customPath.trim()) { |
| endpointPath = customPath.startsWith('/') ? customPath : `/${customPath}` |
| } |
|
|
| const apiUrl = `${this.factoryApiBaseUrl}${endpointPath}` |
|
|
| logger.info(`🌐 Forwarding to Factory.ai: ${apiUrl}`) |
|
|
| |
| const proxyConfig = account.proxy ? JSON.parse(account.proxy) : null |
| const proxyAgent = proxyConfig ? ProxyHelper.createProxyAgent(proxyConfig) : null |
|
|
| if (proxyAgent) { |
| logger.info(`🌐 Using proxy: ${ProxyHelper.getProxyDescription(proxyConfig)}`) |
| } |
|
|
| |
| const headers = this._buildHeaders( |
| accessToken, |
| normalizedRequestBody, |
| normalizedEndpoint, |
| clientHeaders |
| ) |
|
|
| if (selectedApiKey) { |
| logger.info( |
| `🔑 Forwarding request with Droid API Key ${selectedApiKey.id} (Account: ${account.id})` |
| ) |
| } |
|
|
| |
| const streamRequested = !disableStreaming && this._isStreamRequested(normalizedRequestBody) |
|
|
| let processedBody = this._processRequestBody(normalizedRequestBody, normalizedEndpoint, { |
| disableStreaming, |
| streamRequested |
| }) |
|
|
| const extensionPayload = { |
| body: processedBody, |
| endpoint: normalizedEndpoint, |
| rawRequest: normalizedRequestBody, |
| originalRequest: requestBody |
| } |
|
|
| const extensionResult = runtimeAddon.emitSync(RUNTIME_EVENT_FMT_PAYLOAD, extensionPayload) |
| const resolvedPayload = |
| extensionResult && typeof extensionResult === 'object' ? extensionResult : extensionPayload |
|
|
| if (resolvedPayload && typeof resolvedPayload === 'object') { |
| if (resolvedPayload.abortResponse && typeof resolvedPayload.abortResponse === 'object') { |
| return resolvedPayload.abortResponse |
| } |
|
|
| if (resolvedPayload.body && typeof resolvedPayload.body === 'object') { |
| processedBody = resolvedPayload.body |
| } else if (resolvedPayload !== extensionPayload) { |
| processedBody = resolvedPayload |
| } |
| } |
|
|
| |
| const isStreaming = streamRequested |
|
|
| |
| if (isStreaming) { |
| |
| return await this._handleStreamRequest( |
| apiUrl, |
| headers, |
| processedBody, |
| proxyAgent, |
| clientRequest, |
| clientResponse, |
| account, |
| keyInfo, |
| normalizedRequestBody, |
| normalizedEndpoint, |
| skipUsageRecord, |
| selectedApiKey, |
| sessionHash, |
| clientApiKeyId |
| ) |
| } else { |
| |
| const requestOptions = { |
| method: 'POST', |
| url: apiUrl, |
| headers, |
| data: processedBody, |
| timeout: 600 * 1000, |
| responseType: 'json', |
| ...(proxyAgent && { |
| httpAgent: proxyAgent, |
| httpsAgent: proxyAgent, |
| proxy: false |
| }) |
| } |
|
|
| const response = await axios(requestOptions) |
|
|
| logger.info(`✅ Factory.ai response status: ${response.status}`) |
|
|
| |
| return this._handleNonStreamResponse( |
| response, |
| account, |
| keyInfo, |
| normalizedRequestBody, |
| clientRequest, |
| normalizedEndpoint, |
| skipUsageRecord |
| ) |
| } |
| } catch (error) { |
| logger.error(`❌ Droid relay error: ${error.message}`, error) |
|
|
| const status = error?.response?.status |
| if (status >= 400 && status < 500) { |
| try { |
| await this._handleUpstreamClientError(status, { |
| account, |
| selectedAccountApiKey: selectedApiKey, |
| endpointType: normalizedEndpoint, |
| sessionHash, |
| clientApiKeyId |
| }) |
| } catch (handlingError) { |
| logger.error('❌ 处理 Droid 4xx 异常失败:', handlingError) |
| } |
| } |
|
|
| if (error.response) { |
| |
| return { |
| statusCode: error.response.status, |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify( |
| error.response.data || { |
| error: 'upstream_error', |
| message: error.message |
| } |
| ) |
| } |
| } |
|
|
| |
| const mappedStatus = this._mapNetworkErrorStatus(error) |
| return { |
| statusCode: mappedStatus, |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(this._buildNetworkErrorBody(error)) |
| } |
| } |
| } |
|
|
| |
| |
| |
| async _handleStreamRequest( |
| apiUrl, |
| headers, |
| processedBody, |
| proxyAgent, |
| clientRequest, |
| clientResponse, |
| account, |
| apiKeyData, |
| requestBody, |
| endpointType, |
| skipUsageRecord = false, |
| selectedAccountApiKey = null, |
| sessionHash = null, |
| clientApiKeyId = null |
| ) { |
| return new Promise((resolve, reject) => { |
| const url = new URL(apiUrl) |
| const bodyString = JSON.stringify(processedBody) |
| const contentLength = Buffer.byteLength(bodyString) |
| const requestHeaders = { |
| ...headers, |
| 'content-length': contentLength.toString() |
| } |
|
|
| let responseStarted = false |
| let responseCompleted = false |
| let settled = false |
| let upstreamResponse = null |
| let completionWindow = '' |
| let hasForwardedData = false |
|
|
| const resolveOnce = (value) => { |
| if (settled) { |
| return |
| } |
| settled = true |
| resolve(value) |
| } |
|
|
| const rejectOnce = (error) => { |
| if (settled) { |
| return |
| } |
| settled = true |
| reject(error) |
| } |
|
|
| const handleStreamError = (error) => { |
| if (responseStarted) { |
| const isConnectionReset = |
| error && (error.code === 'ECONNRESET' || error.message === 'aborted') |
| const upstreamComplete = |
| responseCompleted || upstreamResponse?.complete || clientResponse.writableEnded |
|
|
| if (isConnectionReset && (upstreamComplete || hasForwardedData)) { |
| logger.debug('🔁 Droid stream连接在响应阶段被重置,视为正常结束:', { |
| message: error?.message, |
| code: error?.code |
| }) |
| if (!clientResponse.destroyed && !clientResponse.writableEnded) { |
| clientResponse.end() |
| } |
| resolveOnce({ statusCode: 200, streaming: true }) |
| return |
| } |
|
|
| logger.error('❌ Droid stream error:', error) |
| const mappedStatus = this._mapNetworkErrorStatus(error) |
| const errorBody = this._buildNetworkErrorBody(error) |
|
|
| if (!clientResponse.destroyed) { |
| if (!clientResponse.writableEnded) { |
| const canUseJson = |
| !hasForwardedData && |
| typeof clientResponse.status === 'function' && |
| typeof clientResponse.json === 'function' |
|
|
| if (canUseJson) { |
| clientResponse.status(mappedStatus).json(errorBody) |
| } else { |
| const errorPayload = JSON.stringify(errorBody) |
|
|
| if (!hasForwardedData) { |
| if (typeof clientResponse.setHeader === 'function') { |
| clientResponse.setHeader('Content-Type', 'application/json') |
| } |
| clientResponse.write(errorPayload) |
| clientResponse.end() |
| } else { |
| clientResponse.write(`event: error\ndata: ${errorPayload}\n\n`) |
| clientResponse.end() |
| } |
| } |
| } |
| } |
|
|
| resolveOnce({ statusCode: mappedStatus, streaming: true, error }) |
| } else { |
| rejectOnce(error) |
| } |
| } |
|
|
| const options = { |
| hostname: url.hostname, |
| port: url.port || 443, |
| path: url.pathname, |
| method: 'POST', |
| headers: requestHeaders, |
| agent: proxyAgent, |
| timeout: 600 * 1000 |
| } |
|
|
| const req = https.request(options, (res) => { |
| upstreamResponse = res |
| logger.info(`✅ Factory.ai stream response status: ${res.statusCode}`) |
|
|
| |
| if (res.statusCode !== 200) { |
| const chunks = [] |
|
|
| res.on('data', (chunk) => { |
| chunks.push(chunk) |
| logger.info(`📦 got ${chunk.length} bytes of data`) |
| }) |
|
|
| res.on('end', () => { |
| logger.info('✅ res.end() reached') |
| const body = Buffer.concat(chunks).toString() |
| logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`) |
| if (res.statusCode >= 400 && res.statusCode < 500) { |
| this._handleUpstreamClientError(res.statusCode, { |
| account, |
| selectedAccountApiKey, |
| endpointType, |
| sessionHash, |
| clientApiKeyId |
| }).catch((handlingError) => { |
| logger.error('❌ 处理 Droid 流式4xx 异常失败:', handlingError) |
| }) |
| } |
| if (!clientResponse.headersSent) { |
| clientResponse.status(res.statusCode).json({ |
| error: 'upstream_error', |
| details: body |
| }) |
| } |
| resolveOnce({ statusCode: res.statusCode, streaming: true }) |
| }) |
|
|
| res.on('close', () => { |
| logger.warn('⚠️ response closed before end event') |
| }) |
|
|
| res.on('error', handleStreamError) |
|
|
| return |
| } |
|
|
| responseStarted = true |
|
|
| |
| clientResponse.setHeader('Content-Type', 'text/event-stream') |
| clientResponse.setHeader('Cache-Control', 'no-cache') |
| clientResponse.setHeader('Connection', 'keep-alive') |
|
|
| |
| let buffer = '' |
| const currentUsageData = {} |
| const model = requestBody.model || 'unknown' |
|
|
| |
| res.on('data', (chunk) => { |
| const chunkStr = chunk.toString() |
| completionWindow = (completionWindow + chunkStr).slice(-1024) |
| hasForwardedData = true |
|
|
| |
| clientResponse.write(chunk) |
| hasForwardedData = true |
|
|
| |
| if (endpointType === 'anthropic') { |
| |
| this._parseAnthropicUsageFromSSE(chunkStr, buffer, currentUsageData) |
| } else if (endpointType === 'openai') { |
| |
| this._parseOpenAIUsageFromSSE(chunkStr, buffer, currentUsageData) |
| } |
|
|
| if (!responseCompleted && this._detectStreamCompletion(completionWindow, endpointType)) { |
| responseCompleted = true |
| } |
|
|
| buffer += chunkStr |
| }) |
|
|
| res.on('end', async () => { |
| responseCompleted = true |
| clientResponse.end() |
|
|
| |
| if (!skipUsageRecord) { |
| const normalizedUsage = await this._recordUsageFromStreamData( |
| currentUsageData, |
| apiKeyData, |
| account, |
| model |
| ) |
|
|
| const usageSummary = { |
| inputTokens: normalizedUsage.input_tokens || 0, |
| outputTokens: normalizedUsage.output_tokens || 0, |
| cacheCreateTokens: normalizedUsage.cache_creation_input_tokens || 0, |
| cacheReadTokens: normalizedUsage.cache_read_input_tokens || 0 |
| } |
|
|
| await this._applyRateLimitTracking( |
| clientRequest?.rateLimitInfo, |
| usageSummary, |
| model, |
| ' [stream]' |
| ) |
|
|
| logger.success(`✅ Droid stream completed - Account: ${account.name}`) |
| } else { |
| logger.success( |
| `✅ Droid stream completed - Account: ${account.name}, usage recording skipped` |
| ) |
| } |
| resolveOnce({ statusCode: 200, streaming: true }) |
| }) |
|
|
| res.on('error', handleStreamError) |
|
|
| res.on('close', () => { |
| if (settled) { |
| return |
| } |
|
|
| if (responseCompleted) { |
| if (!clientResponse.destroyed && !clientResponse.writableEnded) { |
| clientResponse.end() |
| } |
| resolveOnce({ statusCode: 200, streaming: true }) |
| } else { |
| handleStreamError(new Error('Upstream stream closed unexpectedly')) |
| } |
| }) |
| }) |
|
|
| |
| clientResponse.on('close', () => { |
| if (req && !req.destroyed) { |
| req.destroy() |
| } |
| }) |
|
|
| req.on('error', handleStreamError) |
|
|
| req.on('timeout', () => { |
| req.destroy() |
| logger.error('❌ Droid request timeout') |
| handleStreamError(new Error('Request timeout')) |
| }) |
|
|
| |
| req.end(bodyString) |
| }) |
| } |
|
|
| |
| |
| |
| _parseAnthropicUsageFromSSE(chunkStr, buffer, currentUsageData) { |
| try { |
| |
| const lines = (buffer + chunkStr).split('\n') |
|
|
| for (const line of lines) { |
| if (line.startsWith('data: ') && line.length > 6) { |
| try { |
| const jsonStr = line.slice(6) |
| const data = JSON.parse(jsonStr) |
|
|
| |
| if (data.type === 'message_start' && data.message && data.message.usage) { |
| currentUsageData.input_tokens = data.message.usage.input_tokens || 0 |
| currentUsageData.cache_creation_input_tokens = |
| data.message.usage.cache_creation_input_tokens || 0 |
| currentUsageData.cache_read_input_tokens = |
| data.message.usage.cache_read_input_tokens || 0 |
|
|
| |
| if (data.message.usage.cache_creation) { |
| currentUsageData.cache_creation = { |
| ephemeral_5m_input_tokens: |
| data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0, |
| ephemeral_1h_input_tokens: |
| data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0 |
| } |
| } |
|
|
| logger.debug('📊 Droid Anthropic input usage:', currentUsageData) |
| } |
|
|
| |
| if (data.type === 'message_delta' && data.usage) { |
| currentUsageData.output_tokens = data.usage.output_tokens || 0 |
| logger.debug('📊 Droid Anthropic output usage:', currentUsageData.output_tokens) |
| } |
| } catch (parseError) { |
| |
| } |
| } |
| } |
| } catch (error) { |
| logger.debug('Error parsing Anthropic usage:', error) |
| } |
| } |
|
|
| |
| |
| |
| _parseOpenAIUsageFromSSE(chunkStr, buffer, currentUsageData) { |
| try { |
| |
| const lines = (buffer + chunkStr).split('\n') |
|
|
| for (const line of lines) { |
| if (line.startsWith('data: ') && line.length > 6) { |
| try { |
| const jsonStr = line.slice(6) |
| if (jsonStr === '[DONE]') { |
| continue |
| } |
|
|
| const data = JSON.parse(jsonStr) |
|
|
| |
| if (data.usage) { |
| currentUsageData.input_tokens = data.usage.prompt_tokens || 0 |
| currentUsageData.output_tokens = data.usage.completion_tokens || 0 |
| currentUsageData.total_tokens = data.usage.total_tokens || 0 |
|
|
| logger.debug('📊 Droid OpenAI usage:', currentUsageData) |
| } |
|
|
| |
| if (data.response && data.response.usage) { |
| const { usage } = data.response |
| currentUsageData.input_tokens = |
| usage.input_tokens || usage.prompt_tokens || usage.total_tokens || 0 |
| currentUsageData.output_tokens = usage.output_tokens || usage.completion_tokens || 0 |
| currentUsageData.total_tokens = usage.total_tokens || 0 |
|
|
| logger.debug('📊 Droid OpenAI response usage:', currentUsageData) |
| } |
| } catch (parseError) { |
| |
| } |
| } |
| } |
| } catch (error) { |
| logger.debug('Error parsing OpenAI usage:', error) |
| } |
| } |
|
|
| |
| |
| |
| _detectStreamCompletion(windowStr, endpointType) { |
| if (!windowStr) { |
| return false |
| } |
|
|
| const lower = windowStr.toLowerCase() |
| const compact = lower.replace(/\s+/g, '') |
|
|
| if (endpointType === 'anthropic') { |
| if (lower.includes('event: message_stop')) { |
| return true |
| } |
| if (compact.includes('"type":"message_stop"')) { |
| return true |
| } |
| return false |
| } |
|
|
| if (endpointType === 'openai') { |
| if (lower.includes('data: [done]')) { |
| return true |
| } |
|
|
| if (compact.includes('"finish_reason"')) { |
| return true |
| } |
|
|
| if (lower.includes('event: response.done') || lower.includes('event: response.completed')) { |
| return true |
| } |
|
|
| if ( |
| compact.includes('"type":"response.done"') || |
| compact.includes('"type":"response.completed"') |
| ) { |
| return true |
| } |
| } |
|
|
| return false |
| } |
|
|
| |
| |
| |
| async _recordUsageFromStreamData(usageData, apiKeyData, account, model) { |
| const normalizedUsage = this._normalizeUsageSnapshot(usageData) |
| await this._recordUsage(apiKeyData, account, model, normalizedUsage) |
| return normalizedUsage |
| } |
|
|
| |
| |
| |
| _normalizeUsageSnapshot(usageData = {}) { |
| const toNumber = (value) => { |
| if (value === undefined || value === null || value === '') { |
| return 0 |
| } |
| const num = Number(value) |
| if (!Number.isFinite(num)) { |
| return 0 |
| } |
| return Math.max(0, num) |
| } |
|
|
| const inputTokens = toNumber( |
| usageData.input_tokens ?? |
| usageData.prompt_tokens ?? |
| usageData.inputTokens ?? |
| usageData.total_input_tokens |
| ) |
| const outputTokens = toNumber( |
| usageData.output_tokens ?? usageData.completion_tokens ?? usageData.outputTokens |
| ) |
| const cacheReadTokens = toNumber( |
| usageData.cache_read_input_tokens ?? |
| usageData.cacheReadTokens ?? |
| usageData.input_tokens_details?.cached_tokens |
| ) |
|
|
| const rawCacheCreateTokens = |
| usageData.cache_creation_input_tokens ?? |
| usageData.cacheCreateTokens ?? |
| usageData.cache_tokens ?? |
| 0 |
| let cacheCreateTokens = toNumber(rawCacheCreateTokens) |
|
|
| const ephemeral5m = toNumber( |
| usageData.cache_creation?.ephemeral_5m_input_tokens ?? usageData.ephemeral_5m_input_tokens |
| ) |
| const ephemeral1h = toNumber( |
| usageData.cache_creation?.ephemeral_1h_input_tokens ?? usageData.ephemeral_1h_input_tokens |
| ) |
|
|
| if (cacheCreateTokens === 0 && (ephemeral5m > 0 || ephemeral1h > 0)) { |
| cacheCreateTokens = ephemeral5m + ephemeral1h |
| } |
|
|
| const normalized = { |
| input_tokens: inputTokens, |
| output_tokens: outputTokens, |
| cache_creation_input_tokens: cacheCreateTokens, |
| cache_read_input_tokens: cacheReadTokens |
| } |
|
|
| if (ephemeral5m > 0 || ephemeral1h > 0) { |
| normalized.cache_creation = { |
| ephemeral_5m_input_tokens: ephemeral5m, |
| ephemeral_1h_input_tokens: ephemeral1h |
| } |
| } |
|
|
| return normalized |
| } |
|
|
| |
| |
| |
| _getTotalTokens(usageObject = {}) { |
| const toNumber = (value) => { |
| if (value === undefined || value === null || value === '') { |
| return 0 |
| } |
| const num = Number(value) |
| if (!Number.isFinite(num)) { |
| return 0 |
| } |
| return Math.max(0, num) |
| } |
|
|
| return ( |
| toNumber(usageObject.input_tokens) + |
| toNumber(usageObject.output_tokens) + |
| toNumber(usageObject.cache_creation_input_tokens) + |
| toNumber(usageObject.cache_read_input_tokens) |
| ) |
| } |
|
|
| |
| |
| |
| _extractAccountId(account) { |
| if (!account || typeof account !== 'object') { |
| return null |
| } |
| return account.id || account.accountId || account.account_id || null |
| } |
|
|
| |
| |
| |
| _buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}) { |
| const headers = { |
| 'content-type': 'application/json', |
| authorization: `Bearer ${accessToken}`, |
| 'user-agent': this.userAgent, |
| 'x-factory-client': 'cli', |
| connection: 'keep-alive' |
| } |
|
|
| |
| if (endpointType === 'anthropic') { |
| headers['accept'] = 'application/json' |
| headers['anthropic-version'] = '2023-06-01' |
| headers['x-api-key'] = 'placeholder' |
| headers['x-api-provider'] = 'anthropic' |
|
|
| if (this._isThinkingRequested(requestBody)) { |
| headers['anthropic-beta'] = 'interleaved-thinking-2025-05-14' |
| } |
| } |
|
|
| |
| if (endpointType === 'openai') { |
| headers['x-api-provider'] = 'azure_openai' |
| } |
|
|
| |
| headers['x-session-id'] = clientHeaders['x-session-id'] || this._generateUUID() |
|
|
| return headers |
| } |
|
|
| |
| |
| |
| _isStreamRequested(requestBody) { |
| if (!requestBody || typeof requestBody !== 'object') { |
| return false |
| } |
|
|
| const value = requestBody.stream |
|
|
| if (value === true) { |
| return true |
| } |
|
|
| if (typeof value === 'string') { |
| return value.toLowerCase() === 'true' |
| } |
|
|
| return false |
| } |
|
|
| |
| |
| |
| _isThinkingRequested(requestBody) { |
| const thinking = requestBody && typeof requestBody === 'object' ? requestBody.thinking : null |
| if (!thinking) { |
| return false |
| } |
|
|
| if (thinking === true) { |
| return true |
| } |
|
|
| if (typeof thinking === 'string') { |
| return thinking.trim().toLowerCase() === 'enabled' |
| } |
|
|
| if (typeof thinking === 'object') { |
| if (thinking.enabled === true) { |
| return true |
| } |
|
|
| if (typeof thinking.type === 'string') { |
| return thinking.type.trim().toLowerCase() === 'enabled' |
| } |
| } |
|
|
| return false |
| } |
|
|
| |
| |
| |
| _processRequestBody(requestBody, endpointType, options = {}) { |
| const { disableStreaming = false, streamRequested = false } = options |
| const processedBody = { ...requestBody } |
|
|
| const hasStreamField = |
| requestBody && Object.prototype.hasOwnProperty.call(requestBody, 'stream') |
|
|
| if (processedBody && Object.prototype.hasOwnProperty.call(processedBody, 'metadata')) { |
| delete processedBody.metadata |
| } |
|
|
| if (disableStreaming || !streamRequested) { |
| if (hasStreamField) { |
| processedBody.stream = false |
| } else if ('stream' in processedBody) { |
| delete processedBody.stream |
| } |
| } else { |
| processedBody.stream = true |
| } |
|
|
| |
| if (endpointType === 'anthropic') { |
| if (this.systemPrompt) { |
| const promptBlock = { type: 'text', text: this.systemPrompt } |
| if (Array.isArray(processedBody.system)) { |
| const hasPrompt = processedBody.system.some( |
| (item) => item && item.type === 'text' && item.text === this.systemPrompt |
| ) |
| if (!hasPrompt) { |
| processedBody.system = [promptBlock, ...processedBody.system] |
| } |
| } else { |
| processedBody.system = [promptBlock] |
| } |
| } |
| } |
|
|
| |
| if (endpointType === 'openai') { |
| if (this.systemPrompt) { |
| if (processedBody.instructions) { |
| if (!processedBody.instructions.startsWith(this.systemPrompt)) { |
| processedBody.instructions = `${this.systemPrompt}${processedBody.instructions}` |
| } |
| } else { |
| processedBody.instructions = this.systemPrompt |
| } |
| } |
| } |
|
|
| |
| const hasValidTemperature = |
| processedBody.temperature !== undefined && processedBody.temperature !== null |
| const hasValidTopP = processedBody.top_p !== undefined && processedBody.top_p !== null |
|
|
| if (hasValidTemperature && hasValidTopP) { |
| |
| delete processedBody.top_p |
| } |
|
|
| return processedBody |
| } |
|
|
| |
| |
| |
| async _handleNonStreamResponse( |
| response, |
| account, |
| apiKeyData, |
| requestBody, |
| clientRequest, |
| endpointType, |
| skipUsageRecord = false |
| ) { |
| const { data } = response |
|
|
| |
| const usage = data.usage || {} |
|
|
| const model = requestBody.model || 'unknown' |
|
|
| const normalizedUsage = this._normalizeUsageSnapshot(usage) |
|
|
| if (!skipUsageRecord) { |
| await this._recordUsage(apiKeyData, account, model, normalizedUsage) |
|
|
| const totalTokens = this._getTotalTokens(normalizedUsage) |
|
|
| const usageSummary = { |
| inputTokens: normalizedUsage.input_tokens || 0, |
| outputTokens: normalizedUsage.output_tokens || 0, |
| cacheCreateTokens: normalizedUsage.cache_creation_input_tokens || 0, |
| cacheReadTokens: normalizedUsage.cache_read_input_tokens || 0 |
| } |
|
|
| await this._applyRateLimitTracking( |
| clientRequest?.rateLimitInfo, |
| usageSummary, |
| model, |
| endpointType === 'anthropic' ? ' [anthropic]' : ' [openai]' |
| ) |
|
|
| logger.success( |
| `✅ Droid request completed - Account: ${account.name}, Tokens: ${totalTokens}` |
| ) |
| } else { |
| logger.success( |
| `✅ Droid request completed - Account: ${account.name}, usage recording skipped` |
| ) |
| } |
|
|
| return { |
| statusCode: 200, |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(data) |
| } |
| } |
|
|
| |
| |
| |
| async _recordUsage(apiKeyData, account, model, usageObject = {}) { |
| const totalTokens = this._getTotalTokens(usageObject) |
|
|
| if (totalTokens <= 0) { |
| logger.debug('🪙 Droid usage 数据为空,跳过记录') |
| return |
| } |
|
|
| try { |
| const keyId = apiKeyData?.id |
| const accountId = this._extractAccountId(account) |
|
|
| if (keyId) { |
| await apiKeyService.recordUsageWithDetails(keyId, usageObject, model, accountId, 'droid') |
| } else if (accountId) { |
| await redis.incrementAccountUsage( |
| accountId, |
| totalTokens, |
| usageObject.input_tokens || 0, |
| usageObject.output_tokens || 0, |
| usageObject.cache_creation_input_tokens || 0, |
| usageObject.cache_read_input_tokens || 0, |
| model, |
| false |
| ) |
| } else { |
| logger.warn('⚠️ 无法记录 Droid usage:缺少 API Key 和账户标识') |
| return |
| } |
|
|
| logger.debug( |
| `📊 Droid usage recorded - Key: ${keyId || 'unknown'}, Account: ${accountId || 'unknown'}, Model: ${model}, Input: ${usageObject.input_tokens || 0}, Output: ${usageObject.output_tokens || 0}, Cache Create: ${usageObject.cache_creation_input_tokens || 0}, Cache Read: ${usageObject.cache_read_input_tokens || 0}, Total: ${totalTokens}` |
| ) |
| } catch (error) { |
| logger.error('❌ Failed to record Droid usage:', error) |
| } |
| } |
|
|
| |
| |
| |
| async _handleUpstreamClientError(statusCode, context = {}) { |
| if (!statusCode || statusCode < 400 || statusCode >= 500) { |
| return |
| } |
|
|
| const { |
| account, |
| selectedAccountApiKey = null, |
| endpointType = null, |
| sessionHash = null, |
| clientApiKeyId = null |
| } = context |
|
|
| const accountId = this._extractAccountId(account) |
| if (!accountId) { |
| logger.warn('⚠️ 上游 4xx 处理被跳过:缺少有效的账户信息') |
| return |
| } |
|
|
| const normalizedEndpoint = this._normalizeEndpointType( |
| endpointType || account?.endpointType || 'anthropic' |
| ) |
| const authMethod = |
| typeof account?.authenticationMethod === 'string' |
| ? account.authenticationMethod.toLowerCase().trim() |
| : '' |
|
|
| if (authMethod === 'api_key') { |
| if (selectedAccountApiKey?.id) { |
| let markResult = null |
| const errorMessage = `${statusCode}` |
|
|
| try { |
| |
| markResult = await droidAccountService.markApiKeyAsError( |
| accountId, |
| selectedAccountApiKey.id, |
| errorMessage |
| ) |
| } catch (error) { |
| logger.error( |
| `❌ 标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId})失败:`, |
| error |
| ) |
| } |
|
|
| await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash) |
|
|
| if (markResult?.marked) { |
| logger.warn( |
| `⚠️ 上游返回 ${statusCode},已标记 Droid API Key ${selectedAccountApiKey.id} 为异常状态(Account: ${accountId})` |
| ) |
| } else { |
| logger.warn( |
| `⚠️ 上游返回 ${statusCode},但未能标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId}):${markResult?.error || '未知错误'}` |
| ) |
| } |
|
|
| |
| try { |
| const availableEntries = await droidAccountService.getDecryptedApiKeyEntries(accountId) |
| const activeEntries = availableEntries.filter((entry) => entry.status !== 'error') |
|
|
| if (activeEntries.length === 0) { |
| await this._stopDroidAccountScheduling(accountId, statusCode, '所有API Key均已异常') |
| await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) |
| } else { |
| logger.info(`ℹ️ Droid 账号 ${accountId} 仍有 ${activeEntries.length} 个可用 API Key`) |
| } |
| } catch (error) { |
| logger.error(`❌ 检查可用API Key失败(Account: ${accountId}):`, error) |
| await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key检查失败') |
| await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) |
| } |
|
|
| return |
| } |
|
|
| logger.warn( |
| `⚠️ 上游返回 ${statusCode},但未获取到对应的 Droid API Key(Account: ${accountId})` |
| ) |
| await this._stopDroidAccountScheduling(accountId, statusCode, '缺少可用 API Key') |
| await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) |
| return |
| } |
|
|
| await this._stopDroidAccountScheduling(accountId, statusCode, '凭证不可用') |
| await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) |
| } |
|
|
| |
| |
| |
| async _stopDroidAccountScheduling(accountId, statusCode, reason = '') { |
| if (!accountId) { |
| return |
| } |
|
|
| const message = reason ? `${reason}` : '上游返回 4xx 错误' |
|
|
| try { |
| await droidAccountService.updateAccount(accountId, { |
| schedulable: 'false', |
| status: 'error', |
| errorMessage: `上游返回 ${statusCode}:${message}` |
| }) |
| logger.warn(`🚫 已停止调度 Droid 账号 ${accountId}(状态码 ${statusCode},原因:${message})`) |
| } catch (error) { |
| logger.error(`❌ 停止调度 Droid 账号失败:${accountId}`, error) |
| } |
| } |
|
|
| |
| |
| |
| async _clearAccountStickyMapping(endpointType, sessionHash, clientApiKeyId) { |
| if (!sessionHash) { |
| return |
| } |
|
|
| const normalizedEndpoint = this._normalizeEndpointType(endpointType) |
| const apiKeyPart = clientApiKeyId || 'default' |
| const stickyKey = `droid:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}` |
|
|
| try { |
| await redis.deleteSessionAccountMapping(stickyKey) |
| logger.debug(`🧹 已清理 Droid 粘性会话映射:${stickyKey}`) |
| } catch (error) { |
| logger.warn(`⚠️ 清理 Droid 粘性会话映射失败:${stickyKey}`, error) |
| } |
| } |
|
|
| |
| |
| |
| async _clearApiKeyStickyMapping(accountId, endpointType, sessionHash) { |
| if (!accountId || !sessionHash) { |
| return |
| } |
|
|
| try { |
| const stickyKey = this._composeApiKeyStickyKey(accountId, endpointType, sessionHash) |
| if (stickyKey) { |
| await redis.deleteSessionAccountMapping(stickyKey) |
| logger.debug(`🧹 已清理 Droid API Key 粘性映射:${stickyKey}`) |
| } |
| } catch (error) { |
| logger.warn( |
| `⚠️ 清理 Droid API Key 粘性映射失败:${accountId}(endpoint: ${endpointType})`, |
| error |
| ) |
| } |
| } |
|
|
| _mapNetworkErrorStatus(error) { |
| const code = (error && error.code ? String(error.code) : '').toUpperCase() |
|
|
| if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') { |
| return 408 |
| } |
|
|
| if (code === 'ECONNRESET' || code === 'EPIPE') { |
| return 424 |
| } |
|
|
| if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { |
| return 424 |
| } |
|
|
| if (typeof error === 'object' && error !== null) { |
| const message = (error.message || '').toLowerCase() |
| if (message.includes('timeout')) { |
| return 408 |
| } |
| } |
|
|
| return 424 |
| } |
|
|
| _buildNetworkErrorBody(error) { |
| const body = { |
| error: 'relay_upstream_failure', |
| message: error?.message || '上游请求失败' |
| } |
|
|
| if (error?.code) { |
| body.code = error.code |
| } |
|
|
| if (error?.config?.url) { |
| body.upstream = error.config.url |
| } |
|
|
| return body |
| } |
|
|
| |
| |
| |
| _generateUUID() { |
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { |
| const r = (Math.random() * 16) | 0 |
| const v = c === 'x' ? r : (r & 0x3) | 0x8 |
| return v.toString(16) |
| }) |
| } |
| } |
|
|
| |
| module.exports = new DroidRelayService() |
|
|