| const { |
| BedrockRuntimeClient, |
| InvokeModelCommand, |
| InvokeModelWithResponseStreamCommand |
| } = require('@aws-sdk/client-bedrock-runtime') |
| const { fromEnv } = require('@aws-sdk/credential-providers') |
| const logger = require('../utils/logger') |
| const config = require('../../config/config') |
|
|
| class BedrockRelayService { |
| constructor() { |
| this.defaultRegion = process.env.AWS_REGION || config.bedrock?.defaultRegion || 'us-east-1' |
| this.smallFastModelRegion = |
| process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION || this.defaultRegion |
|
|
| |
| this.defaultModel = process.env.ANTHROPIC_MODEL || 'us.anthropic.claude-sonnet-4-20250514-v1:0' |
| this.defaultSmallModel = |
| process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0' |
|
|
| |
| this.maxOutputTokens = parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) || 4096 |
| this.maxThinkingTokens = parseInt(process.env.MAX_THINKING_TOKENS) || 1024 |
| this.enablePromptCaching = process.env.DISABLE_PROMPT_CACHING !== '1' |
|
|
| |
| this.clients = new Map() |
| } |
|
|
| |
| _getBedrockClient(region = null, bedrockAccount = null) { |
| const targetRegion = region || this.defaultRegion |
| const clientKey = `${targetRegion}-${bedrockAccount?.id || 'default'}` |
|
|
| if (this.clients.has(clientKey)) { |
| return this.clients.get(clientKey) |
| } |
|
|
| const clientConfig = { |
| region: targetRegion |
| } |
|
|
| |
| if (bedrockAccount?.awsCredentials) { |
| clientConfig.credentials = { |
| accessKeyId: bedrockAccount.awsCredentials.accessKeyId, |
| secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey, |
| sessionToken: bedrockAccount.awsCredentials.sessionToken |
| } |
| } else { |
| |
| if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { |
| clientConfig.credentials = fromEnv() |
| } else { |
| throw new Error( |
| 'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY' |
| ) |
| } |
| } |
|
|
| const client = new BedrockRuntimeClient(clientConfig) |
| this.clients.set(clientKey, client) |
|
|
| logger.debug( |
| `🔧 Created Bedrock client for region: ${targetRegion}, account: ${bedrockAccount?.name || 'default'}` |
| ) |
| return client |
| } |
|
|
| |
| async handleNonStreamRequest(requestBody, bedrockAccount = null) { |
| try { |
| const modelId = this._selectModel(requestBody, bedrockAccount) |
| const region = this._selectRegion(modelId, bedrockAccount) |
| const client = this._getBedrockClient(region, bedrockAccount) |
|
|
| |
| const bedrockPayload = this._convertToBedrockFormat(requestBody) |
|
|
| const command = new InvokeModelCommand({ |
| modelId, |
| body: JSON.stringify(bedrockPayload), |
| contentType: 'application/json', |
| accept: 'application/json' |
| }) |
|
|
| logger.debug(`🚀 Bedrock非流式请求 - 模型: ${modelId}, 区域: ${region}`) |
|
|
| const startTime = Date.now() |
| const response = await client.send(command) |
| const duration = Date.now() - startTime |
|
|
| |
| const responseBody = JSON.parse(new TextDecoder().decode(response.body)) |
| const claudeResponse = this._convertFromBedrockFormat(responseBody) |
|
|
| logger.info(`✅ Bedrock请求完成 - 模型: ${modelId}, 耗时: ${duration}ms`) |
|
|
| return { |
| success: true, |
| data: claudeResponse, |
| usage: claudeResponse.usage, |
| model: modelId, |
| duration |
| } |
| } catch (error) { |
| logger.error('❌ Bedrock非流式请求失败:', error) |
| throw this._handleBedrockError(error) |
| } |
| } |
|
|
| |
| async handleStreamRequest(requestBody, bedrockAccount = null, res) { |
| try { |
| const modelId = this._selectModel(requestBody, bedrockAccount) |
| const region = this._selectRegion(modelId, bedrockAccount) |
| const client = this._getBedrockClient(region, bedrockAccount) |
|
|
| |
| const bedrockPayload = this._convertToBedrockFormat(requestBody) |
|
|
| const command = new InvokeModelWithResponseStreamCommand({ |
| modelId, |
| body: JSON.stringify(bedrockPayload), |
| contentType: 'application/json', |
| accept: 'application/json' |
| }) |
|
|
| logger.debug(`🌊 Bedrock流式请求 - 模型: ${modelId}, 区域: ${region}`) |
|
|
| const startTime = Date.now() |
| const response = await client.send(command) |
|
|
| |
| res.writeHead(200, { |
| 'Content-Type': 'text/event-stream', |
| 'Cache-Control': 'no-cache', |
| Connection: 'keep-alive', |
| 'Access-Control-Allow-Origin': '*', |
| 'Access-Control-Allow-Headers': 'Content-Type, Authorization' |
| }) |
|
|
| let totalUsage = null |
| let isFirstChunk = true |
|
|
| |
| for await (const chunk of response.body) { |
| if (chunk.chunk) { |
| const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes)) |
| const claudeEvent = this._convertBedrockStreamToClaudeFormat(chunkData, isFirstChunk) |
|
|
| if (claudeEvent) { |
| |
| res.write(`event: ${claudeEvent.type}\n`) |
| res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`) |
|
|
| |
| if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) { |
| totalUsage = claudeEvent.data.usage |
| } |
|
|
| isFirstChunk = false |
| } |
| } |
| } |
|
|
| const duration = Date.now() - startTime |
| logger.info(`✅ Bedrock流式请求完成 - 模型: ${modelId}, 耗时: ${duration}ms`) |
|
|
| |
| res.write('event: done\n') |
| res.write('data: [DONE]\n\n') |
| res.end() |
|
|
| return { |
| success: true, |
| usage: totalUsage, |
| model: modelId, |
| duration |
| } |
| } catch (error) { |
| logger.error('❌ Bedrock流式请求失败:', error) |
|
|
| |
| if (!res.headersSent) { |
| res.writeHead(500, { 'Content-Type': 'application/json' }) |
| } |
|
|
| res.write('event: error\n') |
| res.write(`data: ${JSON.stringify({ error: this._handleBedrockError(error).message })}\n\n`) |
| res.end() |
|
|
| throw this._handleBedrockError(error) |
| } |
| } |
|
|
| |
| _selectModel(requestBody, bedrockAccount) { |
| let selectedModel |
|
|
| |
| if (bedrockAccount?.defaultModel) { |
| selectedModel = bedrockAccount.defaultModel |
| logger.info(`🎯 使用账户配置的模型: ${selectedModel}`, { |
| metadata: { source: 'account', accountId: bedrockAccount.id } |
| }) |
| } |
| |
| else if (requestBody.model) { |
| selectedModel = requestBody.model |
| logger.info(`🎯 使用请求指定的模型: ${selectedModel}`, { metadata: { source: 'request' } }) |
| } |
| |
| else { |
| selectedModel = this.defaultModel |
| logger.info(`🎯 使用系统默认模型: ${selectedModel}`, { metadata: { source: 'default' } }) |
| } |
|
|
| |
| const bedrockModel = this._mapToBedrockModel(selectedModel) |
| if (bedrockModel !== selectedModel) { |
| logger.info(`🔄 模型映射: ${selectedModel} → ${bedrockModel}`, { |
| metadata: { originalModel: selectedModel, bedrockModel } |
| }) |
| } |
|
|
| return bedrockModel |
| } |
|
|
| |
| _mapToBedrockModel(modelName) { |
| |
| const modelMapping = { |
| |
| 'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0', |
| 'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0', |
|
|
| |
| 'claude-opus-4': 'us.anthropic.claude-opus-4-1-20250805-v1:0', |
| 'claude-opus-4-1': 'us.anthropic.claude-opus-4-1-20250805-v1:0', |
| 'claude-opus-4-1-20250805': 'us.anthropic.claude-opus-4-1-20250805-v1:0', |
|
|
| |
| 'claude-3-7-sonnet': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', |
| 'claude-3-7-sonnet-20250219': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', |
|
|
| |
| 'claude-3-5-sonnet': 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', |
| 'claude-3-5-sonnet-20241022': 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', |
|
|
| |
| 'claude-3-5-haiku': 'us.anthropic.claude-3-5-haiku-20241022-v1:0', |
| 'claude-3-5-haiku-20241022': 'us.anthropic.claude-3-5-haiku-20241022-v1:0', |
|
|
| |
| 'claude-3-sonnet': 'us.anthropic.claude-3-sonnet-20240229-v1:0', |
| 'claude-3-sonnet-20240229': 'us.anthropic.claude-3-sonnet-20240229-v1:0', |
|
|
| |
| 'claude-3-haiku': 'us.anthropic.claude-3-haiku-20240307-v1:0', |
| 'claude-3-haiku-20240307': 'us.anthropic.claude-3-haiku-20240307-v1:0' |
| } |
|
|
| |
| |
| if (modelName.includes('.anthropic.') || modelName.startsWith('anthropic.')) { |
| return modelName |
| } |
|
|
| |
| const mappedModel = modelMapping[modelName] |
| if (mappedModel) { |
| return mappedModel |
| } |
|
|
| |
| logger.warn(`⚠️ 未找到模型映射: ${modelName},使用原始模型名`, { |
| metadata: { originalModel: modelName } |
| }) |
| return modelName |
| } |
|
|
| |
| _selectRegion(modelId, bedrockAccount) { |
| |
| if (bedrockAccount?.region) { |
| return bedrockAccount.region |
| } |
|
|
| |
| if (modelId.includes('haiku')) { |
| return this.smallFastModelRegion |
| } |
|
|
| return this.defaultRegion |
| } |
|
|
| |
| _convertToBedrockFormat(requestBody) { |
| const bedrockPayload = { |
| anthropic_version: 'bedrock-2023-05-31', |
| max_tokens: Math.min(requestBody.max_tokens || this.maxOutputTokens, this.maxOutputTokens), |
| messages: requestBody.messages || [] |
| } |
|
|
| |
| if (requestBody.system) { |
| bedrockPayload.system = requestBody.system |
| } |
|
|
| |
| if (requestBody.temperature !== undefined) { |
| bedrockPayload.temperature = requestBody.temperature |
| } |
|
|
| if (requestBody.top_p !== undefined) { |
| bedrockPayload.top_p = requestBody.top_p |
| } |
|
|
| if (requestBody.top_k !== undefined) { |
| bedrockPayload.top_k = requestBody.top_k |
| } |
|
|
| if (requestBody.stop_sequences) { |
| bedrockPayload.stop_sequences = requestBody.stop_sequences |
| } |
|
|
| |
| if (requestBody.tools) { |
| bedrockPayload.tools = requestBody.tools |
| } |
|
|
| if (requestBody.tool_choice) { |
| bedrockPayload.tool_choice = requestBody.tool_choice |
| } |
|
|
| return bedrockPayload |
| } |
|
|
| |
| _convertFromBedrockFormat(bedrockResponse) { |
| return { |
| id: `msg_${Date.now()}_bedrock`, |
| type: 'message', |
| role: 'assistant', |
| content: bedrockResponse.content || [], |
| model: bedrockResponse.model || this.defaultModel, |
| stop_reason: bedrockResponse.stop_reason || 'end_turn', |
| stop_sequence: bedrockResponse.stop_sequence || null, |
| usage: bedrockResponse.usage || { |
| input_tokens: 0, |
| output_tokens: 0 |
| } |
| } |
| } |
|
|
| |
| _convertBedrockStreamToClaudeFormat(bedrockChunk) { |
| if (bedrockChunk.type === 'message_start') { |
| return { |
| type: 'message_start', |
| data: { |
| type: 'message', |
| id: `msg_${Date.now()}_bedrock`, |
| role: 'assistant', |
| content: [], |
| model: this.defaultModel, |
| stop_reason: null, |
| stop_sequence: null, |
| usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 } |
| } |
| } |
| } |
|
|
| if (bedrockChunk.type === 'content_block_delta') { |
| return { |
| type: 'content_block_delta', |
| data: { |
| index: bedrockChunk.index || 0, |
| delta: bedrockChunk.delta || {} |
| } |
| } |
| } |
|
|
| if (bedrockChunk.type === 'message_delta') { |
| return { |
| type: 'message_delta', |
| data: { |
| delta: bedrockChunk.delta || {}, |
| usage: bedrockChunk.usage || {} |
| } |
| } |
| } |
|
|
| if (bedrockChunk.type === 'message_stop') { |
| return { |
| type: 'message_stop', |
| data: { |
| usage: bedrockChunk.usage || {} |
| } |
| } |
| } |
|
|
| return null |
| } |
|
|
| |
| _handleBedrockError(error) { |
| const errorMessage = error.message || 'Unknown Bedrock error' |
|
|
| if (error.name === 'ValidationException') { |
| return new Error(`Bedrock参数验证失败: ${errorMessage}`) |
| } |
|
|
| if (error.name === 'ThrottlingException') { |
| return new Error('Bedrock请求限流,请稍后重试') |
| } |
|
|
| if (error.name === 'AccessDeniedException') { |
| return new Error('Bedrock访问被拒绝,请检查IAM权限') |
| } |
|
|
| if (error.name === 'ModelNotReadyException') { |
| return new Error('Bedrock模型未就绪,请稍后重试') |
| } |
|
|
| return new Error(`Bedrock服务错误: ${errorMessage}`) |
| } |
|
|
| |
| async getAvailableModels(bedrockAccount = null) { |
| try { |
| const region = bedrockAccount?.region || this.defaultRegion |
|
|
| |
| const models = [ |
| { |
| id: 'us.anthropic.claude-sonnet-4-20250514-v1:0', |
| name: 'Claude Sonnet 4', |
| provider: 'anthropic', |
| type: 'bedrock' |
| }, |
| { |
| id: 'us.anthropic.claude-opus-4-1-20250805-v1:0', |
| name: 'Claude Opus 4.1', |
| provider: 'anthropic', |
| type: 'bedrock' |
| }, |
| { |
| id: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', |
| name: 'Claude 3.7 Sonnet', |
| provider: 'anthropic', |
| type: 'bedrock' |
| }, |
| { |
| id: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', |
| name: 'Claude 3.5 Sonnet v2', |
| provider: 'anthropic', |
| type: 'bedrock' |
| }, |
| { |
| id: 'us.anthropic.claude-3-5-haiku-20241022-v1:0', |
| name: 'Claude 3.5 Haiku', |
| provider: 'anthropic', |
| type: 'bedrock' |
| } |
| ] |
|
|
| logger.debug(`📋 返回Bedrock可用模型 ${models.length} 个, 区域: ${region}`) |
| return models |
| } catch (error) { |
| logger.error('❌ 获取Bedrock模型列表失败:', error) |
| return [] |
| } |
| } |
| } |
|
|
| module.exports = new BedrockRelayService() |
|
|