|
|
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() |
|
|
|