feat: 为 AWS Bedrock 账户添加 Bearer Token 认证支持

- 新增 credentialType 字段支持 access_key 和 bearer_token 两种认证方式
- 实现 Bedrock 账户的 testAccountConnection 方法,支持 SSE 流式测试
- 前端账户表单增加认证类型选择器,自动切换输入字段
- 前端测试模态框根据账户类型自动选择测试模型(Bearer Token 使用 Sonnet 4.5,Access Key 使用 Haiku)
- 改进测试接口错误处理,避免响应流重复关闭

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
juenjunli
2026-01-10 14:13:36 +08:00
parent 810fe9fe90
commit 549c95eb80
7 changed files with 625 additions and 94 deletions

View File

@@ -122,6 +122,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
description, description,
region, region,
awsCredentials, awsCredentials,
bearerToken,
defaultModel, defaultModel,
priority, priority,
accountType, accountType,
@@ -145,9 +146,9 @@ router.post('/', authenticateAdmin, async (req, res) => {
} }
// 验证credentialType的有效性 // 验证credentialType的有效性
if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) { if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) {
return res.status(400).json({ return res.status(400).json({
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
}) })
} }
@@ -156,10 +157,11 @@ router.post('/', authenticateAdmin, async (req, res) => {
description: description || '', description: description || '',
region: region || 'us-east-1', region: region || 'us-east-1',
awsCredentials, awsCredentials,
bearerToken,
defaultModel, defaultModel,
priority: priority || 50, priority: priority || 50,
accountType: accountType || 'shared', accountType: accountType || 'shared',
credentialType: credentialType || 'default' credentialType: credentialType || 'access_key'
}) })
if (!result.success) { if (!result.success) {
@@ -206,10 +208,10 @@ router.put('/:accountId', authenticateAdmin, async (req, res) => {
// 验证credentialType的有效性 // 验证credentialType的有效性
if ( if (
mappedUpdates.credentialType && mappedUpdates.credentialType &&
!['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType) !['access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
) { ) {
return res.status(400).json({ return res.status(400).json({
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
}) })
} }
@@ -349,22 +351,15 @@ router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res)
} }
}) })
// 测试Bedrock账户连接 // 测试Bedrock账户连接SSE 流式)
router.post('/:accountId/test', authenticateAdmin, async (req, res) => { router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
try { try {
const { accountId } = req.params const { accountId } = req.params
const result = await bedrockAccountService.testAccount(accountId) await bedrockAccountService.testAccountConnection(accountId, res)
if (!result.success) {
return res.status(500).json({ error: 'Account test failed', message: result.error })
}
logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`)
return res.json({ success: true, data: result.data })
} catch (error) { } catch (error) {
logger.error('❌ Failed to test Bedrock account:', error) logger.error('❌ Failed to test Bedrock account:', error)
return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message }) // 错误已在服务层处理,这里仅做日志记录
} }
}) })

View File

@@ -226,7 +226,15 @@ class AccountBalanceService {
return null return null
} }
return await service.getAccount(accountId) const result = await service.getAccount(accountId)
// 处理不同服务返回格式的差异
// Bedrock/CCR/Droid 等服务返回 { success, data } 格式
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.success ? result.data : null
}
return result
} }
async getAllAccountsByPlatform(platform) { async getAllAccountsByPlatform(platform) {
@@ -275,10 +283,27 @@ class AccountBalanceService {
const accountId = account?.id const accountId = account?.id
if (!accountId) { if (!accountId) {
throw new Error('账户缺少 id') // 如果账户缺少 id返回空响应而不是抛出错误避免接口报错和UI错误
this.logger.warn('账户缺少 id返回空余额数据', { account, platform })
return this._buildResponse(
{
status: 'error',
errorMessage: '账户数据异常',
balance: null,
currency: 'USD',
quota: null,
statistics: {},
lastRefreshAt: new Date().toISOString()
},
'unknown',
platform,
'local',
null,
{ scriptEnabled: false, scriptConfigured: false }
)
} }
// 余额脚本配置状态(用于前端控制刷新余额按钮) // 余额脚本配置状态(用于前端控制"刷新余额"按钮)
let scriptConfig = null let scriptConfig = null
let scriptConfigured = false let scriptConfigured = false
if (typeof this.redis?.getBalanceScriptConfig === 'function') { if (typeof this.redis?.getBalanceScriptConfig === 'function') {

View File

@@ -35,12 +35,13 @@ class BedrockAccountService {
description = '', description = '',
region = process.env.AWS_REGION || 'us-east-1', region = process.env.AWS_REGION || 'us-east-1',
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken } awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
bearerToken = null, // AWS Bearer Token for Bedrock API Keys
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0', defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
isActive = true, isActive = true,
accountType = 'shared', // 'dedicated' or 'shared' accountType = 'shared', // 'dedicated' or 'shared'
priority = 50, // 调度优先级 (1-100数字越小优先级越高) priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
credentialType = 'default' // 'default', 'access_key', 'bearer_token' credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key
} = options } = options
const accountId = uuidv4() const accountId = uuidv4()
@@ -71,6 +72,11 @@ class BedrockAccountService {
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials) accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
} }
// 加密存储 Bearer Token
if (bearerToken) {
accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken })
}
const client = redis.getClientSafe() const client = redis.getClientSafe()
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData)) await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
@@ -106,9 +112,85 @@ class BedrockAccountService {
const account = JSON.parse(accountData) const account = JSON.parse(accountData)
// 解密AWS凭证用于内部使用 // 根据凭证类型解密对应的凭证
// 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底)
try {
let accessKeyDecrypted = false
let bearerTokenDecrypted = false
// 第一步:按照 credentialType 尝试解密对应的凭证
if (account.credentialType === 'access_key' && account.awsCredentials) {
// Access Key 模式:解密 AWS 凭证
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
logger.debug(
`🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
)
} else if (account.credentialType === 'bearer_token' && account.bearerToken) {
// Bearer Token 模式:解密 Bearer Token
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
logger.debug(
`🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
)
} else if (!account.credentialType || account.credentialType === 'default') {
// 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证
if (account.awsCredentials) { if (account.awsCredentials) {
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
}
if (account.bearerToken) {
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
}
logger.debug(
`🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}`
)
}
// 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
logger.warn(
`⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}`
)
if (account.awsCredentials) {
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
logger.warn(
`🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'`
)
}
if (account.bearerToken) {
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
logger.warn(
`🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'`
)
}
}
// 验证至少解密了一种凭证
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
logger.error(
`❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}`
)
return {
success: false,
error: 'No valid credentials found in account data'
}
}
} catch (decryptError) {
logger.error(
`❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`,
decryptError
)
return {
success: false,
error: `Credentials decryption failed: ${decryptError.message}`
}
} }
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`) logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
@@ -155,7 +237,11 @@ class BedrockAccountService {
updatedAt: account.updatedAt, updatedAt: account.updatedAt,
type: 'bedrock', type: 'bedrock',
platform: 'bedrock', platform: 'bedrock',
hasCredentials: !!account.awsCredentials // 根据凭证类型判断是否有凭证
hasCredentials:
account.credentialType === 'bearer_token'
? !!account.bearerToken
: !!account.awsCredentials
}) })
} }
} }
@@ -235,6 +321,15 @@ class BedrockAccountService {
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`) logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
} }
// 更新 Bearer Token
if (updates.bearerToken !== undefined) {
if (updates.bearerToken) {
account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken })
} else {
delete account.bearerToken
}
}
// ✅ 直接保存 subscriptionExpiresAt如果提供 // ✅ 直接保存 subscriptionExpiresAt如果提供
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段 // Bedrock 没有 token 刷新逻辑,不会覆盖此字段
if (updates.subscriptionExpiresAt !== undefined) { if (updates.subscriptionExpiresAt !== undefined) {
@@ -345,13 +440,45 @@ class BedrockAccountService {
const account = accountResult.data const account = accountResult.data
logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`) logger.info(
`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}`
)
// 尝试获取模型列表来测试连接 // 验证凭证是否已解密
const hasValidCredentials =
(account.credentialType === 'access_key' && account.awsCredentials) ||
(account.credentialType === 'bearer_token' && account.bearerToken) ||
(!account.credentialType && (account.awsCredentials || account.bearerToken))
if (!hasValidCredentials) {
logger.error(
`❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}`
)
return {
success: false,
error: 'No valid credentials found after decryption'
}
}
// 尝试创建 Bedrock 客户端来验证凭证格式
try {
bedrockRelayService._getBedrockClient(account.region, account)
logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`)
} catch (clientError) {
logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError)
return {
success: false,
error: `Failed to create Bedrock client: ${clientError.message}`
}
}
// 获取可用模型列表(硬编码,但至少验证了凭证格式正确)
const models = await bedrockRelayService.getAvailableModels(account) const models = await bedrockRelayService.getAvailableModels(account)
if (models && models.length > 0) { if (models && models.length > 0) {
logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`) logger.info(
`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}`
)
return { return {
success: true, success: true,
data: { data: {
@@ -376,6 +503,135 @@ class BedrockAccountService {
} }
} }
/**
* 🧪 测试 Bedrock 账户连接SSE 流式返回,供前端测试页面使用)
* @param {string} accountId - 账户ID
* @param {Object} res - Express response 对象
* @param {string} model - 测试使用的模型
*/
async testAccountConnection(accountId, res, model = null) {
const { InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime')
try {
// 获取账户信息
const accountResult = await this.getAccount(accountId)
if (!accountResult.success) {
throw new Error(accountResult.error || 'Account not found')
}
const account = accountResult.data
// 根据账户类型选择合适的测试模型
if (!model) {
// Access Key 模式使用 Haiku更快更便宜
model = account.defaultModel || 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
}
logger.info(
`🧪 Testing Bedrock account connection: ${account.name} (${accountId}), model: ${model}, credentialType: ${account.credentialType}`
)
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.status(200)
// 发送 test_start 事件
res.write(`data: ${JSON.stringify({ type: 'test_start' })}\n\n`)
// 构造测试请求体Bedrock 格式)
const bedrockPayload = {
anthropic_version: 'bedrock-2023-05-31',
max_tokens: 256,
messages: [
{
role: 'user',
content:
'Hello! Please respond with a simple greeting to confirm the connection is working. And tell me who are you?'
}
]
}
// 获取 Bedrock 客户端
const region = account.region || bedrockRelayService.defaultRegion
const client = bedrockRelayService._getBedrockClient(region, account)
// 创建流式调用命令
const command = new InvokeModelWithResponseStreamCommand({
modelId: model,
body: JSON.stringify(bedrockPayload),
contentType: 'application/json',
accept: 'application/json'
})
logger.debug(`🌊 Bedrock test stream - model: ${model}, region: ${region}`)
const startTime = Date.now()
const response = await client.send(command)
// 处理流式响应
// let responseText = ''
for await (const chunk of response.body) {
if (chunk.chunk) {
const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes))
// 提取文本内容
if (chunkData.type === 'content_block_delta' && chunkData.delta?.text) {
const { text } = chunkData.delta
// responseText += text
// 发送 content 事件
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
}
// 检测错误
if (chunkData.type === 'error') {
throw new Error(chunkData.error?.message || 'Bedrock API error')
}
}
}
const duration = Date.now() - startTime
logger.info(`✅ Bedrock test completed - model: ${model}, duration: ${duration}ms`)
// 发送 message_stop 事件(前端兼容)
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
// 发送 test_complete 事件
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
// 结束响应
res.end()
logger.info(`✅ Test request completed for Bedrock account: ${account.name}`)
} catch (error) {
logger.error(`❌ Test Bedrock account connection failed:`, error)
// 发送错误事件给前端
try {
// 检查响应流是否仍然可写
if (!res.writableEnded && !res.destroyed) {
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.status(200)
}
const errorMsg = error.message || '测试失败'
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
res.end()
}
} catch (writeError) {
logger.error('Failed to write error to response stream:', writeError)
}
// 不再重新抛出错误,避免路由层再次处理
// throw error
}
}
/** /**
* 检查账户订阅是否过期 * 检查账户订阅是否过期
* @param {Object} account - 账户对象 * @param {Object} account - 账户对象

View File

@@ -48,13 +48,17 @@ class BedrockRelayService {
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey, secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
sessionToken: bedrockAccount.awsCredentials.sessionToken sessionToken: bedrockAccount.awsCredentials.sessionToken
} }
} else if (bedrockAccount?.bearerToken) {
// Bearer Token 模式AWS SDK >= 3.400.0 会自动检测环境变量
clientConfig.token = { token: bedrockAccount.bearerToken }
logger.debug(`🔑 使用 Bearer Token 认证 - 账户: ${bedrockAccount.name || 'unknown'}`)
} else { } else {
// 检查是否有环境变量凭证 // 检查是否有环境变量凭证
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
clientConfig.credentials = fromEnv() clientConfig.credentials = fromEnv()
} else { } else {
throw new Error( throw new Error(
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY' 'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或Bearer Token或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
) )
} }
} }

View File

@@ -852,41 +852,194 @@
</div> </div>
<!-- Bedrock 特定字段 --> <!-- Bedrock 特定字段 -->
<div v-if="form.platform === 'bedrock' && !isEdit" class="space-y-4"> <div v-if="form.platform === 'bedrock'" class="space-y-4">
<!-- 凭证类型选择器 -->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 访问密钥 ID *</label >凭证类型 *</label
>
<div v-if="!isEdit" class="flex gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="access_key"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>AWS Access Key访问密钥</span
>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="bearer_token"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>Bearer Token长期令牌</span
>
</label>
</div>
<div v-else class="flex gap-4">
<label class="flex items-center opacity-60">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
disabled
type="radio"
value="access_key"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>AWS Access Key访问密钥</span
>
</label>
<label class="flex items-center opacity-60">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
disabled
type="radio"
value="bearer_token"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>Bearer Token长期令牌</span
>
</label>
</div>
<div
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
>
<div class="flex items-start gap-2">
<i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
<div class="text-xs text-blue-700 dark:text-blue-300">
<p v-if="form.credentialType === 'access_key'" class="font-medium">
使用 AWS Access Key ID 和 Secret Access Key 进行身份验证(支持临时凭证)
</p>
<p v-else class="font-medium">
使用 AWS Bedrock API Keys 生成的 Bearer Token
进行身份验证,更简单、权限范围更小
</p>
<p v-if="isEdit" class="mt-1 text-xs italic">
💡 编辑模式下凭证类型不可更改,如需切换类型请重新创建账户
</p>
</div>
</div>
</div>
</div>
<!-- AWS Access Key 字段(仅在 access_key 模式下显示)-->
<div v-if="form.credentialType === 'access_key'">
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 访问密钥 ID {{ isEdit ? '' : '*' }}</label
> >
<input <input
v-model="form.accessKeyId" v-model="form.accessKeyId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.accessKeyId }" :class="{ 'border-red-500': errors.accessKeyId }"
placeholder="请输入 AWS Access Key ID" :placeholder="isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Access Key ID'"
required :required="!isEdit"
type="text" type="text"
/> />
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500"> <p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
{{ errors.accessKeyId }} {{ errors.accessKeyId }}
</p> </p>
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
💡 编辑模式下,留空则保持原有 Access Key ID 不变
</p>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 秘密访问密钥 *</label >AWS 秘密访问密钥 {{ isEdit ? '' : '*' }}</label
> >
<input <input
v-model="form.secretAccessKey" v-model="form.secretAccessKey"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.secretAccessKey }" :class="{ 'border-red-500': errors.secretAccessKey }"
placeholder="请输入 AWS Secret Access Key" :placeholder="
required isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Secret Access Key'
"
:required="!isEdit"
type="password" type="password"
/> />
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500"> <p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
{{ errors.secretAccessKey }} {{ errors.secretAccessKey }}
</p> </p>
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
💡 编辑模式下,留空则保持原有 Secret Access Key 不变
</p>
</div> </div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>会话令牌 (可选)</label
>
<input
v-model="form.sessionToken"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:placeholder="
isEdit
? '留空则保持原有 Session Token 不变'
: '如果使用临时凭证,请输入会话令牌'
"
type="password"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
仅在使用临时 AWS 凭证时需要填写
</p>
</div>
</div>
<!-- Bearer Token 字段(仅在 bearer_token 模式下显示)-->
<div v-if="form.credentialType === 'bearer_token'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Bearer Token {{ isEdit ? '' : '*' }}</label
>
<input
v-model="form.bearerToken"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.bearerToken }"
:placeholder="
isEdit ? '留空则保持原有 Bearer Token 不变' : '请输入 AWS Bearer Token'
"
:required="!isEdit"
type="password"
/>
<p v-if="errors.bearerToken" class="mt-1 text-xs text-red-500">
{{ errors.bearerToken }}
</p>
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
💡 编辑模式下,留空则保持原有 Bearer Token 不变
</p>
<div
class="mt-2 rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-700 dark:bg-green-900/30"
>
<div class="flex items-start gap-2">
<i class="fas fa-key mt-0.5 text-green-600 dark:text-green-400" />
<div class="text-xs text-green-700 dark:text-green-300">
<p class="mb-1 font-medium">Bearer Token 说明:</p>
<ul class="list-inside list-disc space-y-1 text-xs">
<li>输入 AWS Bedrock API Keys 生成的 Bearer Token</li>
<li>Bearer Token 仅限 Bedrock 服务访问,权限范围更小</li>
<li>相比 Access Key 更简单,无需 Secret Key</li>
<li>
参考:<a
class="text-green-600 underline dark:text-green-400"
href="https://aws.amazon.com/cn/blogs/machine-learning/accelerate-ai-development-with-amazon-bedrock-api-keys/"
target="_blank"
>AWS 官方文档</a
>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- AWS 区域(两种凭证类型都需要)-->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 区域 *</label >AWS 区域 *</label
@@ -902,10 +1055,12 @@
<p v-if="errors.region" class="mt-1 text-xs text-red-500"> <p v-if="errors.region" class="mt-1 text-xs text-red-500">
{{ errors.region }} {{ errors.region }}
</p> </p>
<div class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3"> <div
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
>
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<i class="fas fa-info-circle mt-0.5 text-blue-600" /> <i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
<div class="text-xs text-blue-700"> <div class="text-xs text-blue-700 dark:text-blue-300">
<p class="mb-1 font-medium">常用 AWS 区域参考:</p> <p class="mb-1 font-medium">常用 AWS 区域参考:</p>
<div class="grid grid-cols-2 gap-1 text-xs"> <div class="grid grid-cols-2 gap-1 text-xs">
<span>• us-east-1 (美国东部)</span> <span>• us-east-1 (美国东部)</span>
@@ -915,26 +1070,13 @@
<span>• ap-northeast-1 (东京)</span> <span>• ap-northeast-1 (东京)</span>
<span>• eu-central-1 (法兰克福)</span> <span>• eu-central-1 (法兰克福)</span>
</div> </div>
<p class="mt-2 text-blue-600">💡 请输入完整的区域代码,如 us-east-1</p> <p class="mt-2 text-blue-600 dark:text-blue-400">
</div> 💡 请输入完整的区域代码,如 us-east-1
</div>
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>会话令牌 (可选)</label
>
<input
v-model="form.sessionToken"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="如果使用临时凭证,请输入会话令牌"
type="password"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
仅在使用临时 AWS 凭证时需要填写
</p> </p>
</div> </div>
</div>
</div>
</div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -4105,10 +4247,12 @@ const form = ref({
// 并发控制字段 // 并发控制字段
maxConcurrentTasks: props.account?.maxConcurrentTasks || 0, maxConcurrentTasks: props.account?.maxConcurrentTasks || 0,
// Bedrock 特定字段 // Bedrock 特定字段
credentialType: props.account?.credentialType || 'access_key', // 'access_key' 或 'bearer_token'
accessKeyId: props.account?.accessKeyId || '', accessKeyId: props.account?.accessKeyId || '',
secretAccessKey: props.account?.secretAccessKey || '', secretAccessKey: props.account?.secretAccessKey || '',
region: props.account?.region || '', region: props.account?.region || '',
sessionToken: props.account?.sessionToken || '', sessionToken: props.account?.sessionToken || '',
bearerToken: props.account?.bearerToken || '', // Bearer Token 字段
defaultModel: props.account?.defaultModel || '', defaultModel: props.account?.defaultModel || '',
smallFastModel: props.account?.smallFastModel || '', smallFastModel: props.account?.smallFastModel || '',
// Azure OpenAI 特定字段 // Azure OpenAI 特定字段
@@ -4271,6 +4415,7 @@ const errors = ref({
accessKeyId: '', accessKeyId: '',
secretAccessKey: '', secretAccessKey: '',
region: '', region: '',
bearerToken: '',
azureEndpoint: '', azureEndpoint: '',
deploymentName: '' deploymentName: ''
}) })
@@ -4983,7 +5128,10 @@ const createAccount = async () => {
hasError = true hasError = true
} }
} else if (form.value.platform === 'bedrock') { } else if (form.value.platform === 'bedrock') {
// Bedrock 验证 // Bedrock 验证 - 根据凭证类型进行不同验证
if (form.value.credentialType === 'access_key') {
// Access Key 模式:创建时必填,编辑时可选(留空则保持原有凭证)
if (!isEdit.value) {
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') { if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID' errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
hasError = true hasError = true
@@ -4992,6 +5140,16 @@ const createAccount = async () => {
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥' errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
hasError = true hasError = true
} }
}
} else if (form.value.credentialType === 'bearer_token') {
// Bearer Token 模式:创建时必填,编辑时可选(留空则保持原有凭证)
if (!isEdit.value) {
if (!form.value.bearerToken || form.value.bearerToken.trim() === '') {
errors.value.bearerToken = '请填写 Bearer Token'
hasError = true
}
}
}
if (!form.value.region || form.value.region.trim() === '') { if (!form.value.region || form.value.region.trim() === '') {
errors.value.region = '请选择 AWS 区域' errors.value.region = '请选择 AWS 区域'
hasError = true hasError = true
@@ -5246,12 +5404,21 @@ const createAccount = async () => {
? form.value.supportedModels ? form.value.supportedModels
: [] : []
} else if (form.value.platform === 'bedrock') { } else if (form.value.platform === 'bedrock') {
// Bedrock 账户特定数据 - 构造 awsCredentials 对象 // Bedrock 账户特定数据
data.credentialType = form.value.credentialType || 'access_key'
// 根据凭证类型构造不同的凭证对象
if (form.value.credentialType === 'access_key') {
data.awsCredentials = { data.awsCredentials = {
accessKeyId: form.value.accessKeyId, accessKeyId: form.value.accessKeyId,
secretAccessKey: form.value.secretAccessKey, secretAccessKey: form.value.secretAccessKey,
sessionToken: form.value.sessionToken || null sessionToken: form.value.sessionToken || null
} }
} else if (form.value.credentialType === 'bearer_token') {
// Bearer Token 模式:必须传递 Bearer Token
data.bearerToken = form.value.bearerToken
}
data.region = form.value.region data.region = form.value.region
data.defaultModel = form.value.defaultModel || null data.defaultModel = form.value.defaultModel || null
data.smallFastModel = form.value.smallFastModel || null data.smallFastModel = form.value.smallFastModel || null
@@ -5579,6 +5746,13 @@ const updateAccount = async () => {
// Bedrock 特定更新 // Bedrock 特定更新
if (props.account.platform === 'bedrock') { if (props.account.platform === 'bedrock') {
// 更新凭证类型
if (form.value.credentialType) {
data.credentialType = form.value.credentialType
}
// 根据凭证类型更新凭证
if (form.value.credentialType === 'access_key') {
// 只有当有凭证变更时才构造 awsCredentials 对象 // 只有当有凭证变更时才构造 awsCredentials 对象
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) { if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
data.awsCredentials = {} data.awsCredentials = {}
@@ -5592,6 +5766,13 @@ const updateAccount = async () => {
data.awsCredentials.sessionToken = form.value.sessionToken || null data.awsCredentials.sessionToken = form.value.sessionToken || null
} }
} }
} else if (form.value.credentialType === 'bearer_token') {
// Bearer Token 模式:更新 Bearer Token编辑时可选留空则保留原有凭证
if (form.value.bearerToken && form.value.bearerToken.trim()) {
data.bearerToken = form.value.bearerToken
}
}
if (form.value.region) { if (form.value.region) {
data.region = form.value.region data.region = form.value.region
} }

View File

@@ -68,6 +68,22 @@
{{ platformLabel }} {{ platformLabel }}
</span> </span>
</div> </div>
<!-- Bedrock 账号类型 -->
<div
v-if="props.account?.platform === 'bedrock'"
class="flex items-center justify-between text-sm"
>
<span class="text-gray-500 dark:text-gray-400">账号类型</span>
<span
:class="[
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
credentialTypeBadgeClass
]"
>
<i :class="credentialTypeIcon" />
{{ credentialTypeLabel }}
</span>
</div>
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">测试模型</span> <span class="text-gray-500 dark:text-gray-400">测试模型</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span> <span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
@@ -209,13 +225,15 @@ const platformLabel = computed(() => {
const platform = props.account.platform const platform = props.account.platform
if (platform === 'claude') return 'Claude OAuth' if (platform === 'claude') return 'Claude OAuth'
if (platform === 'claude-console') return 'Claude Console' if (platform === 'claude-console') return 'Claude Console'
if (platform === 'bedrock') return 'AWS Bedrock'
return platform return platform
}) })
const platformIcon = computed(() => { const platformIcon = computed(() => {
if (!props.account) return 'fas fa-question' if (!props.account) return 'fas fa-question'
const platform = props.account.platform const platform = props.account.platform
if (platform === 'claude' || platform === 'claude-console') return 'fas fa-brain' if (platform === 'claude' || platform === 'claude-console' || platform === 'bedrock')
return 'fas fa-brain'
return 'fas fa-robot' return 'fas fa-robot'
}) })
@@ -228,6 +246,39 @@ const platformBadgeClass = computed(() => {
if (platform === 'claude-console') { if (platform === 'claude-console') {
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300' return 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
} }
if (platform === 'bedrock') {
return 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
}
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
})
// Bedrock 账号类型相关
const credentialTypeLabel = computed(() => {
if (!props.account || props.account.platform !== 'bedrock') return ''
const credentialType = props.account.credentialType
if (credentialType === 'access_key') return 'Access Key'
if (credentialType === 'bearer_token') return 'Bearer Token'
return 'Unknown'
})
const credentialTypeIcon = computed(() => {
if (!props.account || props.account.platform !== 'bedrock') return ''
const credentialType = props.account.credentialType
if (credentialType === 'access_key') return 'fas fa-key'
if (credentialType === 'bearer_token') return 'fas fa-ticket'
return 'fas fa-question'
})
const credentialTypeBadgeClass = computed(() => {
if (!props.account || props.account.platform !== 'bedrock')
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
const credentialType = props.account.credentialType
if (credentialType === 'access_key') {
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
}
if (credentialType === 'bearer_token') {
return 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
}
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}) })
@@ -346,6 +397,9 @@ function getTestEndpoint() {
if (platform === 'claude-console') { if (platform === 'claude-console') {
return `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test` return `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`
} }
if (platform === 'bedrock') {
return `${API_PREFIX}/admin/bedrock-accounts/${props.account.id}/test`
}
return '' return ''
} }
@@ -469,7 +523,7 @@ function handleClose() {
emit('close') emit('close')
} }
// 监听show变化重置状态 // 监听show变化重置状态并设置测试模型
watch( watch(
() => props.show, () => props.show,
(newVal) => { (newVal) => {
@@ -478,6 +532,21 @@ watch(
responseText.value = '' responseText.value = ''
errorMessage.value = '' errorMessage.value = ''
testDuration.value = 0 testDuration.value = 0
// 根据平台和账号类型设置测试模型
if (props.account?.platform === 'bedrock') {
const credentialType = props.account.credentialType
if (credentialType === 'bearer_token') {
// Bearer Token 模式使用 Sonnet 4.5
testModel.value = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
} else {
// Access Key 模式使用 Haiku更快更便宜
testModel.value = 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
}
} else {
// 其他平台使用默认模型
testModel.value = 'claude-sonnet-4-5-20250929'
}
} }
} }
) )

View File

@@ -2203,7 +2203,8 @@ const supportedUsagePlatforms = [
'openai-responses', 'openai-responses',
'gemini', 'gemini',
'droid', 'droid',
'gemini-api' 'gemini-api',
'bedrock'
] ]
// 过期时间编辑弹窗状态 // 过期时间编辑弹窗状态
@@ -2547,7 +2548,7 @@ const closeAccountUsageModal = () => {
} }
// 测试账户连通性相关函数 // 测试账户连通性相关函数
const supportedTestPlatforms = ['claude', 'claude-console'] const supportedTestPlatforms = ['claude', 'claude-console', 'bedrock']
const canTestAccount = (account) => { const canTestAccount = (account) => {
return !!account && supportedTestPlatforms.includes(account.platform) return !!account && supportedTestPlatforms.includes(account.platform)