Compare commits

...

20 Commits

Author SHA1 Message Date
github-actions[bot]
3000632d4e chore: sync VERSION file with release v1.1.258 [skip ci] 2026-01-15 01:25:03 +00:00
Wesley Liddick
9e3a4cf45a Merge pull request #899 from UncleJ-h/fix/remove-unused-heapdump
fix: remove unused heapdump dependency
2026-01-15 09:24:51 +08:00
UncleJ-h
eb992697b6 fix: remove unused heapdump dependency
The heapdump package was added in v1.1.257 but is not actually used anywhere in the codebase.

This causes build failures on platforms without Python (e.g., Zeabur) because heapdump requires node-gyp compilation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:43:45 +08:00
github-actions[bot]
35ab34d687 chore: sync VERSION file with release v1.1.257 [skip ci] 2026-01-14 07:41:16 +00:00
Wesley Liddick
bc4b050c69 Merge pull request #895 from wayfind/fix/memory-simple
fix(memory): reduce memory retention in request handling
2026-01-14 15:40:59 +08:00
root
189d53d793 style: fix ESLint prefer-const and formatting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:46:08 +00:00
root
b148537428 style: fix prettier formatting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:42:39 +00:00
root
9d1a451027 fix(memory): comprehensive req closure capture fixes
Additional fixes for memory leaks:
- Bedrock stream: extract _apiKeyIdBedrock, _rateLimitInfoBedrock, _requestBodyBedrock
- Non-stream requests: extract variables at block start
- Non-stream service calls: use extracted variables
- Non-stream usage recording: use extracted variables

All async callbacks now use local variables instead of req.* references,
preventing the entire request object (including large req.body with images)
from being retained by closures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:29:29 +00:00
root
ba815de08f fix(memory): extract req properties to avoid closure capturing entire request object
Problem:
- usageCallback closures referenced req.apiKey.id and req.rateLimitInfo
- This caused entire req object (including req.body with images) to be retained
- Base64 images in messages accumulated in memory (290 images = 26MB)

Solution:
- Extract needed properties before callback: _apiKeyId, _rateLimitInfo, etc.
- Closures now capture small local variables instead of entire req object
- Enables proper GC of request bodies after stream completion

Results verified via heapdump analysis:
- String memory: 144MB -> 24MB (-83%)
- Base64 images: 290 -> 0 (-100%)
- Heapdump size: 57MB -> 28MB (-51%)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:53:20 +00:00
root
b26027731e fix(memory): clear bodyString after req.write() to prevent closure capture
Additional memory optimizations:
- Set bodyString = null after req.write() in both stream and non-stream requests
- Use let instead of const for bodyString to allow nullifying
- Store non-stream originalBodyString in bodyStore to avoid closure capture
- Clean up bodyStore in finally block for non-stream requests

This prevents V8 closures (res.on handlers) from retaining large request
body strings until stream completion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:57:54 +00:00
root
f535b35a1c fix(memory): use bodyStore to avoid closure capturing request body
Problem:
- Stream response handlers (res.on) captured requestOptions in closures
- requestOptions contained originalBodyString (~800KB per request)
- These strings couldn't be GC'd until stream completed
- With concurrent requests, memory accumulated rapidly

Solution:
- Store request body strings in this.bodyStore Map with unique ID
- Pass only bodyStoreId in requestOptions (not the 800KB string)
- Closures capture small ID, not large string
- Clean up bodyStore on request completion (success/error/timeout)
- Extract needed values before closures to avoid capturing body object
2026-01-12 08:31:47 +00:00
github-actions[bot]
962e01b080 chore: sync VERSION file with release v1.1.256 [skip ci] 2026-01-10 08:56:29 +00:00
Wesley Liddick
fcc6ac4e22 Merge pull request #885 from junejuneli/feat/add_claude_aws_type
为 AWS Bedrock 账户添加 Bearer Token 认证支持
2026-01-10 16:56:13 +08:00
Wesley Liddick
3a03147ac9 Merge pull request #884 from moonsphere/fix_claude_auth [skip ci]
fix: claude subscription detection
2026-01-10 16:55:59 +08:00
juenjunli
94f239b56a feat: 添加 Claude 4.5 系列模型支持到 AWS Bedrock 映射表
为 AWS Bedrock 服务添加 Claude 4.5 系列模型的映射支持,包括:
- Claude 4.5 Opus (claude-opus-4-5)
- Claude 4.5 Sonnet (claude-sonnet-4-5)
- Claude 4.5 Haiku (claude-haiku-4-5)

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

Co-Authored-By: Claude Haiku 3.5 <noreply@anthropic.com>
2026-01-10 16:04:58 +08:00
juenjunli
b07873772c fix 2026-01-10 14:19:59 +08:00
juenjunli
549c95eb80 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>
2026-01-10 14:13:36 +08:00
jett
b397954ea4 fix lint 2026-01-10 11:41:35 +08:00
jett
ed835d0c28 using TTL-based random suffix 2026-01-10 00:56:21 +08:00
jett
28b27e6a7b fix: claude subscription detection 2026-01-10 00:31:17 +08:00
14 changed files with 1226 additions and 234 deletions

View File

@@ -1 +1 @@
1.1.255 1.1.258

20
package-lock.json generated
View File

@@ -20,6 +20,7 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"google-auth-library": "^10.1.0", "google-auth-library": "^10.1.0",
"heapdump": "^0.3.15",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.2",
"inquirer": "^8.2.6", "inquirer": "^8.2.6",
@@ -5398,6 +5399,19 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/heapdump": {
"version": "0.3.15",
"resolved": "https://registry.npmjs.org/heapdump/-/heapdump-0.3.15.tgz",
"integrity": "sha512-n8aSFscI9r3gfhOcAECAtXFaQ1uy4QSke6bnaL+iymYZ/dWs9cqDqHM+rALfsHUwukUbxsdlECZ0pKmJdQ/4OA==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"nan": "^2.13.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/helmet": { "node_modules/helmet": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz", "resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz",
@@ -7013,6 +7027,12 @@
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/nan": {
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz",
"integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==",
"license": "MIT"
},
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",

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

@@ -8,6 +8,7 @@ const geminiApiAccountService = require('../../services/geminiApiAccountService'
const openaiAccountService = require('../../services/openaiAccountService') const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService') const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const droidAccountService = require('../../services/droidAccountService') const droidAccountService = require('../../services/droidAccountService')
const bedrockAccountService = require('../../services/bedrockAccountService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth') const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger') const logger = require('../../utils/logger')
@@ -25,6 +26,7 @@ const accountTypeNames = {
gemini: 'Gemini', gemini: 'Gemini',
'gemini-api': 'Gemini API', 'gemini-api': 'Gemini API',
droid: 'Droid', droid: 'Droid',
bedrock: 'AWS Bedrock',
unknown: '未知渠道' unknown: '未知渠道'
} }
@@ -37,7 +39,8 @@ const resolveAccountByPlatform = async (accountId, platform) => {
openai: openaiAccountService, openai: openaiAccountService,
'openai-responses': openaiResponsesAccountService, 'openai-responses': openaiResponsesAccountService,
droid: droidAccountService, droid: droidAccountService,
ccr: ccrAccountService ccr: ccrAccountService,
bedrock: bedrockAccountService
} }
if (platform && serviceMap[platform]) { if (platform && serviceMap[platform]) {
@@ -161,7 +164,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
'openai-responses', 'openai-responses',
'gemini', 'gemini',
'gemini-api', 'gemini-api',
'droid' 'droid',
'bedrock'
] ]
if (!allowedPlatforms.includes(platform)) { if (!allowedPlatforms.includes(platform)) {
return res.status(400).json({ return res.status(400).json({
@@ -174,7 +178,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
openai: 'openai', openai: 'openai',
'openai-responses': 'openai-responses', 'openai-responses': 'openai-responses',
'gemini-api': 'gemini-api', 'gemini-api': 'gemini-api',
droid: 'droid' droid: 'droid',
bedrock: 'bedrock'
} }
const fallbackModelMap = { const fallbackModelMap = {
@@ -184,7 +189,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
'openai-responses': 'gpt-4o-mini-2024-07-18', 'openai-responses': 'gpt-4o-mini-2024-07-18',
gemini: 'gemini-1.5-flash', gemini: 'gemini-1.5-flash',
'gemini-api': 'gemini-2.0-flash', 'gemini-api': 'gemini-2.0-flash',
droid: 'unknown' droid: 'unknown',
bedrock: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0'
} }
// 获取账户信息以获取创建时间 // 获取账户信息以获取创建时间
@@ -215,6 +221,11 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
case 'droid': case 'droid':
accountData = await droidAccountService.getAccount(accountId) accountData = await droidAccountService.getAccount(accountId)
break break
case 'bedrock': {
const result = await bedrockAccountService.getAccount(accountId)
accountData = result?.success ? result.data : null
break
}
} }
if (accountData && accountData.createdAt) { if (accountData && accountData.createdAt) {
@@ -882,7 +893,7 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
try { try {
const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
const allowedGroups = ['claude', 'openai', 'gemini', 'droid'] const allowedGroups = ['claude', 'openai', 'gemini', 'droid', 'bedrock']
if (!allowedGroups.includes(group)) { if (!allowedGroups.includes(group)) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@@ -894,7 +905,8 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
claude: 'Claude账户', claude: 'Claude账户',
openai: 'OpenAI账户', openai: 'OpenAI账户',
gemini: 'Gemini账户', gemini: 'Gemini账户',
droid: 'Droid账户' droid: 'Droid账户',
bedrock: 'Bedrock账户'
} }
// 拉取各平台账号列表 // 拉取各平台账号列表
@@ -988,6 +1000,18 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
platform: 'droid' platform: 'droid'
} }
}) })
} else if (group === 'bedrock') {
const result = await bedrockAccountService.getAllAccounts()
const bedrockAccounts = result?.success ? result.data : []
accounts = bedrockAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || `Bedrock账号 ${shortId}`,
platform: 'bedrock'
}
})
} }
if (!accounts || accounts.length === 0) { if (!accounts || accounts.length === 0) {

View File

@@ -416,11 +416,18 @@ async function handleMessagesRequest(req, res) {
// 根据账号类型选择对应的转发服务并调用 // 根据账号类型选择对应的转发服务并调用
if (accountType === 'claude-official') { if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务会自己选择账号 // 官方Claude账号使用原有的转发服务会自己选择账号
// 🧹 内存优化:提取需要的值,避免闭包捕获整个 req 对象
const _apiKeyId = req.apiKey.id
const _rateLimitInfo = req.rateLimitInfo
const _requestBody = req.body // 传递后清除引用
const _apiKey = req.apiKey
const _headers = req.headers
await claudeRelayService.relayStreamRequestWithUsageCapture( await claudeRelayService.relayStreamRequestWithUsageCapture(
req.body, _requestBody,
req.apiKey, _apiKey,
res, res,
req.headers, _headers,
(usageData) => { (usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量 // 回调函数当检测到完整usage数据时记录真实token使用量
logger.info( logger.info(
@@ -470,13 +477,13 @@ async function handleMessagesRequest(req, res) {
} }
apiKeyService apiKeyService
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude') .recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, 'claude')
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record stream usage:', error) logger.error('❌ Failed to record stream usage:', error)
}) })
queueRateLimitUpdate( queueRateLimitUpdate(
req.rateLimitInfo, _rateLimitInfo,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,
@@ -501,11 +508,18 @@ async function handleMessagesRequest(req, res) {
) )
} else if (accountType === 'claude-console') { } else if (accountType === 'claude-console') {
// Claude Console账号使用Console转发服务需要传递accountId // Claude Console账号使用Console转发服务需要传递accountId
// 🧹 内存优化:提取需要的值
const _apiKeyIdConsole = req.apiKey.id
const _rateLimitInfoConsole = req.rateLimitInfo
const _requestBodyConsole = req.body
const _apiKeyConsole = req.apiKey
const _headersConsole = req.headers
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture( await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
req.body, _requestBodyConsole,
req.apiKey, _apiKeyConsole,
res, res,
req.headers, _headersConsole,
(usageData) => { (usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量 // 回调函数当检测到完整usage数据时记录真实token使用量
logger.info( logger.info(
@@ -556,7 +570,7 @@ async function handleMessagesRequest(req, res) {
apiKeyService apiKeyService
.recordUsageWithDetails( .recordUsageWithDetails(
req.apiKey.id, _apiKeyIdConsole,
usageObject, usageObject,
model, model,
usageAccountId, usageAccountId,
@@ -567,7 +581,7 @@ async function handleMessagesRequest(req, res) {
}) })
queueRateLimitUpdate( queueRateLimitUpdate(
req.rateLimitInfo, _rateLimitInfoConsole,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,
@@ -593,6 +607,11 @@ async function handleMessagesRequest(req, res) {
) )
} else if (accountType === 'bedrock') { } else if (accountType === 'bedrock') {
// Bedrock账号使用Bedrock转发服务 // Bedrock账号使用Bedrock转发服务
// 🧹 内存优化:提取需要的值
const _apiKeyIdBedrock = req.apiKey.id
const _rateLimitInfoBedrock = req.rateLimitInfo
const _requestBodyBedrock = req.body
try { try {
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId) const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
if (!bedrockAccountResult.success) { if (!bedrockAccountResult.success) {
@@ -600,7 +619,7 @@ async function handleMessagesRequest(req, res) {
} }
const result = await bedrockRelayService.handleStreamRequest( const result = await bedrockRelayService.handleStreamRequest(
req.body, _requestBodyBedrock,
bedrockAccountResult.data, bedrockAccountResult.data,
res res
) )
@@ -611,13 +630,21 @@ async function handleMessagesRequest(req, res) {
const outputTokens = result.usage.output_tokens || 0 const outputTokens = result.usage.output_tokens || 0
apiKeyService apiKeyService
.recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId) .recordUsage(
_apiKeyIdBedrock,
inputTokens,
outputTokens,
0,
0,
result.model,
accountId
)
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record Bedrock stream usage:', error) logger.error('❌ Failed to record Bedrock stream usage:', error)
}) })
queueRateLimitUpdate( queueRateLimitUpdate(
req.rateLimitInfo, _rateLimitInfoBedrock,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,
@@ -642,11 +669,18 @@ async function handleMessagesRequest(req, res) {
} }
} else if (accountType === 'ccr') { } else if (accountType === 'ccr') {
// CCR账号使用CCR转发服务需要传递accountId // CCR账号使用CCR转发服务需要传递accountId
// 🧹 内存优化:提取需要的值
const _apiKeyIdCcr = req.apiKey.id
const _rateLimitInfoCcr = req.rateLimitInfo
const _requestBodyCcr = req.body
const _apiKeyCcr = req.apiKey
const _headersCcr = req.headers
await ccrRelayService.relayStreamRequestWithUsageCapture( await ccrRelayService.relayStreamRequestWithUsageCapture(
req.body, _requestBodyCcr,
req.apiKey, _apiKeyCcr,
res, res,
req.headers, _headersCcr,
(usageData) => { (usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量 // 回调函数当检测到完整usage数据时记录真实token使用量
logger.info( logger.info(
@@ -696,13 +730,13 @@ async function handleMessagesRequest(req, res) {
} }
apiKeyService apiKeyService
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr') .recordUsageWithDetails(_apiKeyIdCcr, usageObject, model, usageAccountId, 'ccr')
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record CCR stream usage:', error) logger.error('❌ Failed to record CCR stream usage:', error)
}) })
queueRateLimitUpdate( queueRateLimitUpdate(
req.rateLimitInfo, _rateLimitInfoCcr,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,
@@ -737,18 +771,26 @@ async function handleMessagesRequest(req, res) {
} }
}, 1000) // 1秒后检查 }, 1000) // 1秒后检查
} else { } else {
// 🧹 内存优化:提取需要的值,避免后续回调捕获整个 req
const _apiKeyIdNonStream = req.apiKey.id
const _apiKeyNameNonStream = req.apiKey.name
const _rateLimitInfoNonStream = req.rateLimitInfo
const _requestBodyNonStream = req.body
const _apiKeyNonStream = req.apiKey
const _headersNonStream = req.headers
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开) // 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) { if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn( logger.warn(
`⚠️ Client disconnected before non-stream request could start for key: ${req.apiKey?.name || 'unknown'}` `⚠️ Client disconnected before non-stream request could start for key: ${_apiKeyNameNonStream || 'unknown'}`
) )
return undefined return undefined
} }
// 非流式响应 - 只使用官方真实usage数据 // 非流式响应 - 只使用官方真实usage数据
logger.info('📄 Starting non-streaming request', { logger.info('📄 Starting non-streaming request', {
apiKeyId: req.apiKey.id, apiKeyId: _apiKeyIdNonStream,
apiKeyName: req.apiKey.name apiKeyName: _apiKeyNameNonStream
}) })
// 📊 监听 socket 事件以追踪连接状态变化 // 📊 监听 socket 事件以追踪连接状态变化
@@ -919,11 +961,11 @@ async function handleMessagesRequest(req, res) {
? await claudeAccountService.getAccount(accountId) ? await claudeAccountService.getAccount(accountId)
: await claudeConsoleAccountService.getAccount(accountId) : await claudeConsoleAccountService.getAccount(accountId)
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) { if (account?.interceptWarmup === 'true' && isWarmupRequest(_requestBodyNonStream)) {
logger.api( logger.api(
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})` `🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
) )
return res.json(buildMockWarmupResponse(req.body.model)) return res.json(buildMockWarmupResponse(_requestBodyNonStream.model))
} }
} }
@@ -936,11 +978,11 @@ async function handleMessagesRequest(req, res) {
if (accountType === 'claude-official') { if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务 // 官方Claude账号使用原有的转发服务
response = await claudeRelayService.relayRequest( response = await claudeRelayService.relayRequest(
req.body, _requestBodyNonStream,
req.apiKey, _apiKeyNonStream,
req, req, // clientRequest 用于断开检测,保留但服务层已优化
res, res,
req.headers _headersNonStream
) )
} else if (accountType === 'claude-console') { } else if (accountType === 'claude-console') {
// Claude Console账号使用Console转发服务 // Claude Console账号使用Console转发服务
@@ -948,11 +990,11 @@ async function handleMessagesRequest(req, res) {
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}` `[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
) )
response = await claudeConsoleRelayService.relayRequest( response = await claudeConsoleRelayService.relayRequest(
req.body, _requestBodyNonStream,
req.apiKey, _apiKeyNonStream,
req, req, // clientRequest 保留用于断开检测
res, res,
req.headers, _headersNonStream,
accountId accountId
) )
} else if (accountType === 'bedrock') { } else if (accountType === 'bedrock') {
@@ -964,9 +1006,9 @@ async function handleMessagesRequest(req, res) {
} }
const result = await bedrockRelayService.handleNonStreamRequest( const result = await bedrockRelayService.handleNonStreamRequest(
req.body, _requestBodyNonStream,
bedrockAccountResult.data, bedrockAccountResult.data,
req.headers _headersNonStream
) )
// 构建标准响应格式 // 构建标准响应格式
@@ -996,11 +1038,11 @@ async function handleMessagesRequest(req, res) {
// CCR账号使用CCR转发服务 // CCR账号使用CCR转发服务
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`) logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
response = await ccrRelayService.relayRequest( response = await ccrRelayService.relayRequest(
req.body, _requestBodyNonStream,
req.apiKey, _apiKeyNonStream,
req, req, // clientRequest 保留用于断开检测
res, res,
req.headers, _headersNonStream,
accountId accountId
) )
} }
@@ -1049,14 +1091,14 @@ async function handleMessagesRequest(req, res) {
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0 const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0 const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro") // Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
const rawModel = jsonData.model || req.body.model || 'unknown' const rawModel = jsonData.model || _requestBodyNonStream.model || 'unknown'
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel) const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
const model = usageBaseModel || rawModel const model = usageBaseModel || rawModel
// 记录真实的token使用量包含模型信息和所有4种token以及账户ID // 记录真实的token使用量包含模型信息和所有4种token以及账户ID
const { accountId: responseAccountId } = response const { accountId: responseAccountId } = response
await apiKeyService.recordUsage( await apiKeyService.recordUsage(
req.apiKey.id, _apiKeyIdNonStream,
inputTokens, inputTokens,
outputTokens, outputTokens,
cacheCreateTokens, cacheCreateTokens,
@@ -1066,7 +1108,7 @@ async function handleMessagesRequest(req, res) {
) )
await queueRateLimitUpdate( await queueRateLimitUpdate(
req.rateLimitInfo, _rateLimitInfoNonStream,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,

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凭证用于内部使用 // 根据凭证类型解密对应的凭证
if (account.awsCredentials) { // 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底)
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) 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) {
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'
) )
} }
} }
@@ -431,6 +435,18 @@ class BedrockRelayService {
_mapToBedrockModel(modelName) { _mapToBedrockModel(modelName) {
// 标准Claude模型名到Bedrock模型名的映射表 // 标准Claude模型名到Bedrock模型名的映射表
const modelMapping = { const modelMapping = {
// Claude 4.5 Opus
'claude-opus-4-5': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
'claude-opus-4-5-20251101': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
// Claude 4.5 Sonnet
'claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
'claude-sonnet-4-5-20250929': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
// Claude 4.5 Haiku
'claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
'claude-haiku-4-5-20251001': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
// Claude Sonnet 4 // Claude Sonnet 4
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0', 'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0', 'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',

View File

@@ -21,51 +21,51 @@ const { isStreamWritable } = require('../utils/streamHelper')
class ClaudeRelayService { class ClaudeRelayService {
constructor() { constructor() {
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true' this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
// 🧹 内存优化:用于存储请求体字符串,避免闭包捕获
this.bodyStore = new Map()
this._bodyStoreIdCounter = 0
this.apiVersion = config.claude.apiVersion this.apiVersion = config.claude.apiVersion
this.betaHeader = config.claude.betaHeader this.betaHeader = config.claude.betaHeader
this.systemPrompt = config.claude.systemPrompt this.systemPrompt = config.claude.systemPrompt
this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
this.toolNameSuffix = null
this.toolNameSuffixGeneratedAt = 0
this.toolNameSuffixTtlMs = 60 * 60 * 1000
} }
// 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header // 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header
// 规则:
// 1. 如果客户端传递了 anthropic-beta检查是否包含 oauth-2025-04-20
// 2. 如果没有 oauth-2025-04-20则添加到 claude-code-20250219 后面(如果有的话),否则放在第一位
// 3. 如果客户端没传递则根据模型判断haiku 不需要 claude-code其他模型需要
_getBetaHeader(modelId, clientBetaHeader) { _getBetaHeader(modelId, clientBetaHeader) {
const OAUTH_BETA = 'oauth-2025-04-20' const OAUTH_BETA = 'oauth-2025-04-20'
const CLAUDE_CODE_BETA = 'claude-code-20250219' const CLAUDE_CODE_BETA = 'claude-code-20250219'
const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'
const TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14'
// 如果客户端传递了 anthropic-beta
if (clientBetaHeader) {
// 检查是否已包含 oauth-2025-04-20
if (clientBetaHeader.includes(OAUTH_BETA)) {
return clientBetaHeader
}
// 需要添加 oauth-2025-04-20
const parts = clientBetaHeader.split(',').map((p) => p.trim())
// 找到 claude-code-20250219 的位置
const claudeCodeIndex = parts.findIndex((p) => p === CLAUDE_CODE_BETA)
if (claudeCodeIndex !== -1) {
// 在 claude-code-20250219 后面插入
parts.splice(claudeCodeIndex + 1, 0, OAUTH_BETA)
} else {
// 放在第一位
parts.unshift(OAUTH_BETA)
}
return parts.join(',')
}
// 客户端没有传递,根据模型判断
const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku') const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku')
if (isHaikuModel) { const baseBetas = isHaikuModel
return 'oauth-2025-04-20,interleaved-thinking-2025-05-14' ? [OAUTH_BETA, INTERLEAVED_THINKING_BETA]
: [CLAUDE_CODE_BETA, OAUTH_BETA, INTERLEAVED_THINKING_BETA, TOOL_STREAMING_BETA]
const betaList = []
const seen = new Set()
const addBeta = (beta) => {
if (!beta || seen.has(beta)) {
return
}
seen.add(beta)
betaList.push(beta)
} }
return 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
baseBetas.forEach(addBeta)
if (clientBetaHeader) {
clientBetaHeader
.split(',')
.map((p) => p.trim())
.filter(Boolean)
.forEach(addBeta)
}
return betaList.join(',')
} }
_buildStandardRateLimitMessage(resetTime) { _buildStandardRateLimitMessage(resetTime) {
@@ -140,6 +140,235 @@ class ClaudeRelayService {
return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1) return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1)
} }
_isClaudeCodeUserAgent(clientHeaders) {
const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent']
return typeof userAgent === 'string' && /^claude-cli\/[^\s]+\s+\(/i.test(userAgent)
}
_isActualClaudeCodeRequest(requestBody, clientHeaders) {
return this.isRealClaudeCodeRequest(requestBody) && this._isClaudeCodeUserAgent(clientHeaders)
}
_getHeaderValueCaseInsensitive(headers, key) {
if (!headers || typeof headers !== 'object') {
return undefined
}
const lowerKey = key.toLowerCase()
for (const candidate of Object.keys(headers)) {
if (candidate.toLowerCase() === lowerKey) {
return headers[candidate]
}
}
return undefined
}
_isClaudeCodeCredentialError(body) {
const message = this._extractErrorMessage(body)
if (!message) {
return false
}
const lower = message.toLowerCase()
return (
lower.includes('only authorized for use with claude code') ||
lower.includes('cannot be used for other api requests')
)
}
_toPascalCaseToolName(name) {
const parts = name.split(/[_-]/).filter(Boolean)
if (parts.length === 0) {
return name
}
const pascal = parts
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join('')
return `${pascal}_tool`
}
_getToolNameSuffix() {
const now = Date.now()
if (!this.toolNameSuffix || now - this.toolNameSuffixGeneratedAt > this.toolNameSuffixTtlMs) {
this.toolNameSuffix = Math.random().toString(36).substring(2, 8)
this.toolNameSuffixGeneratedAt = now
}
return this.toolNameSuffix
}
_toRandomizedToolName(name) {
const suffix = this._getToolNameSuffix()
return `${name}_${suffix}`
}
_transformToolNamesInRequestBody(body, options = {}) {
if (!body || typeof body !== 'object') {
return null
}
const useRandomized = options.useRandomizedToolNames === true
const forwardMap = new Map()
const reverseMap = new Map()
const transformName = (name) => {
if (typeof name !== 'string' || name.length === 0) {
return name
}
if (forwardMap.has(name)) {
return forwardMap.get(name)
}
const transformed = useRandomized
? this._toRandomizedToolName(name)
: this._toPascalCaseToolName(name)
if (transformed !== name) {
forwardMap.set(name, transformed)
reverseMap.set(transformed, name)
}
return transformed
}
if (Array.isArray(body.tools)) {
body.tools.forEach((tool) => {
if (tool && typeof tool.name === 'string') {
tool.name = transformName(tool.name)
}
})
}
if (body.tool_choice && typeof body.tool_choice === 'object') {
if (typeof body.tool_choice.name === 'string') {
body.tool_choice.name = transformName(body.tool_choice.name)
}
}
if (Array.isArray(body.messages)) {
body.messages.forEach((message) => {
const content = message?.content
if (Array.isArray(content)) {
content.forEach((block) => {
if (block?.type === 'tool_use' && typeof block.name === 'string') {
block.name = transformName(block.name)
}
})
}
})
}
return reverseMap.size > 0 ? reverseMap : null
}
_restoreToolName(name, toolNameMap) {
if (!toolNameMap || toolNameMap.size === 0) {
return name
}
return toolNameMap.get(name) || name
}
_restoreToolNamesInContentBlocks(content, toolNameMap) {
if (!Array.isArray(content)) {
return
}
content.forEach((block) => {
if (block?.type === 'tool_use' && typeof block.name === 'string') {
block.name = this._restoreToolName(block.name, toolNameMap)
}
})
}
_restoreToolNamesInResponseObject(responseBody, toolNameMap) {
if (!responseBody || typeof responseBody !== 'object') {
return
}
if (Array.isArray(responseBody.content)) {
this._restoreToolNamesInContentBlocks(responseBody.content, toolNameMap)
}
if (responseBody.message && Array.isArray(responseBody.message.content)) {
this._restoreToolNamesInContentBlocks(responseBody.message.content, toolNameMap)
}
}
_restoreToolNamesInResponseBody(responseBody, toolNameMap) {
if (!responseBody || !toolNameMap || toolNameMap.size === 0) {
return responseBody
}
if (typeof responseBody === 'string') {
try {
const parsed = JSON.parse(responseBody)
this._restoreToolNamesInResponseObject(parsed, toolNameMap)
return JSON.stringify(parsed)
} catch (error) {
return responseBody
}
}
if (typeof responseBody === 'object') {
this._restoreToolNamesInResponseObject(responseBody, toolNameMap)
}
return responseBody
}
_restoreToolNamesInStreamEvent(event, toolNameMap) {
if (!event || typeof event !== 'object') {
return
}
if (event.content_block && event.content_block.type === 'tool_use') {
if (typeof event.content_block.name === 'string') {
event.content_block.name = this._restoreToolName(event.content_block.name, toolNameMap)
}
}
if (event.delta && event.delta.type === 'tool_use') {
if (typeof event.delta.name === 'string') {
event.delta.name = this._restoreToolName(event.delta.name, toolNameMap)
}
}
if (event.message && Array.isArray(event.message.content)) {
this._restoreToolNamesInContentBlocks(event.message.content, toolNameMap)
}
if (Array.isArray(event.content)) {
this._restoreToolNamesInContentBlocks(event.content, toolNameMap)
}
}
_createToolNameStripperStreamTransformer(streamTransformer, toolNameMap) {
if (!toolNameMap || toolNameMap.size === 0) {
return streamTransformer
}
return (payload) => {
const transformed = streamTransformer ? streamTransformer(payload) : payload
if (!transformed || typeof transformed !== 'string') {
return transformed
}
const lines = transformed.split('\n')
const updated = lines.map((line) => {
if (!line.startsWith('data:')) {
return line
}
const jsonStr = line.slice(5).trimStart()
if (!jsonStr || jsonStr === '[DONE]') {
return line
}
try {
const data = JSON.parse(jsonStr)
this._restoreToolNamesInStreamEvent(data, toolNameMap)
return `data: ${JSON.stringify(data)}`
} catch (error) {
return line
}
})
return updated.join('\n')
}
}
// 🚀 转发请求到Claude API // 🚀 转发请求到Claude API
async relayRequest( async relayRequest(
requestBody, requestBody,
@@ -153,6 +382,7 @@ class ClaudeRelayService {
let queueLockAcquired = false let queueLockAcquired = false
let queueRequestId = null let queueRequestId = null
let selectedAccountId = null let selectedAccountId = null
let bodyStoreIdNonStream = null // 🧹 在 try 块外声明,以便 finally 清理
try { try {
// 调试日志查看API Key数据 // 调试日志查看API Key数据
@@ -311,7 +541,12 @@ class ClaudeRelayService {
// 获取有效的访问token // 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId) const accessToken = await claudeAccountService.getValidAccessToken(accountId)
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
const processedBody = this._processRequestBody(requestBody, account) const processedBody = this._processRequestBody(requestBody, account)
// 🧹 内存优化:存储到 bodyStore避免闭包捕获
const originalBodyString = JSON.stringify(processedBody)
bodyStoreIdNonStream = ++this._bodyStoreIdCounter
this.bodyStore.set(bodyStoreIdNonStream, originalBodyString)
// 获取代理配置 // 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId) const proxyAgent = await this._getProxyAgent(accountId)
@@ -332,36 +567,59 @@ class ClaudeRelayService {
clientResponse.once('close', handleClientDisconnect) clientResponse.once('close', handleClientDisconnect)
} }
// 发送请求到Claude API传入回调以获取请求对象 const makeRequestWithRetries = async (requestOptions) => {
// 🔄 403 重试机制:仅对 claude-official 类型账户OAuth 或 Setup Token const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0 let retryCount = 0
let retryCount = 0 let response
let response let shouldRetry = false
let shouldRetry = false
do { do {
response = await this._makeClaudeRequest( // 🧹 每次重试从 bodyStore 解析新对象,避免闭包捕获
processedBody, let retryRequestBody
accessToken, try {
proxyAgent, retryRequestBody = JSON.parse(this.bodyStore.get(bodyStoreIdNonStream))
clientHeaders, } catch (parseError) {
accountId, logger.error(`❌ Failed to parse body for retry: ${parseError.message}`)
(req) => { throw new Error(`Request body parse failed: ${parseError.message}`)
upstreamRequest = req }
}, response = await this._makeClaudeRequest(
options retryRequestBody,
) accessToken,
proxyAgent,
// 检查是否需要重试 403 clientHeaders,
shouldRetry = response.statusCode === 403 && retryCount < maxRetries accountId,
if (shouldRetry) { (req) => {
retryCount++ upstreamRequest = req
logger.warn( },
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s` {
...requestOptions,
isRealClaudeCodeRequest
}
) )
await this._sleep(2000)
} shouldRetry = response.statusCode === 403 && retryCount < maxRetries
} while (shouldRetry) if (shouldRetry) {
retryCount++
logger.warn(
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
)
await this._sleep(2000)
}
} while (shouldRetry)
return { response, retryCount }
}
let requestOptions = options
let { response, retryCount } = await makeRequestWithRetries(requestOptions)
if (
this._isClaudeCodeCredentialError(response.body) &&
requestOptions.useRandomizedToolNames !== true
) {
requestOptions = { ...requestOptions, useRandomizedToolNames: true }
;({ response, retryCount } = await makeRequestWithRetries(requestOptions))
}
// 如果进行了重试,记录最终结果 // 如果进行了重试,记录最终结果
if (retryCount > 0) { if (retryCount > 0) {
@@ -661,6 +919,10 @@ class ClaudeRelayService {
) )
throw error throw error
} finally { } finally {
// 🧹 清理 bodyStore
if (bodyStoreIdNonStream !== null) {
this.bodyStore.delete(bodyStoreIdNonStream)
}
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放) // 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && selectedAccountId) { if (queueLockAcquired && queueRequestId && selectedAccountId) {
try { try {
@@ -1035,23 +1297,19 @@ class ClaudeRelayService {
// 获取过滤后的客户端 headers // 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders) const filteredHeaders = this._filterClientHeaders(clientHeaders)
// 判断是否是真实的 Claude Code 请求 const isRealClaudeCode =
const isRealClaudeCode = this.isRealClaudeCodeRequest(body) requestOptions.isRealClaudeCodeRequest === undefined
? this.isRealClaudeCodeRequest(body)
: requestOptions.isRealClaudeCodeRequest === true
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
let finalHeaders = { ...filteredHeaders } let finalHeaders = { ...filteredHeaders }
let requestPayload = body let requestPayload = body
if (!isRealClaudeCode) { if (!isRealClaudeCode) {
// 获取该账号存储的 Claude Code headers
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
// 只添加客户端没有提供的 headers
Object.keys(claudeCodeHeaders).forEach((key) => { Object.keys(claudeCodeHeaders).forEach((key) => {
const lowerKey = key.toLowerCase() finalHeaders[key] = claudeCodeHeaders[key]
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
finalHeaders[key] = claudeCodeHeaders[key]
}
}) })
} }
@@ -1073,6 +1331,13 @@ class ClaudeRelayService {
requestPayload = extensionResult.body requestPayload = extensionResult.body
finalHeaders = extensionResult.headers finalHeaders = extensionResult.headers
let toolNameMap = null
if (!isRealClaudeCode) {
toolNameMap = this._transformToolNamesInRequestBody(requestPayload, {
useRandomizedToolNames: requestOptions.useRandomizedToolNames === true
})
}
// 序列化请求体,计算 content-length // 序列化请求体,计算 content-length
const bodyString = JSON.stringify(requestPayload) const bodyString = JSON.stringify(requestPayload)
const contentLength = Buffer.byteLength(bodyString, 'utf8') const contentLength = Buffer.byteLength(bodyString, 'utf8')
@@ -1098,17 +1363,16 @@ class ClaudeRelayService {
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`) logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
// 根据模型和客户端传递的 anthropic-beta 动态设置 header // 根据模型和客户端传递的 anthropic-beta 动态设置 header
const modelId = requestPayload?.model || body?.model const modelId = requestPayload?.model || body?.model
const clientBetaHeader = clientHeaders?.['anthropic-beta'] const clientBetaHeader = this._getHeaderValueCaseInsensitive(clientHeaders, 'anthropic-beta')
headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader) headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
return { return {
requestPayload, requestPayload,
bodyString, bodyString,
headers, headers,
isRealClaudeCode isRealClaudeCode,
toolNameMap
} }
} }
@@ -1174,7 +1438,8 @@ class ClaudeRelayService {
return prepared.abortResponse return prepared.abortResponse
} }
const { bodyString, headers } = prepared let { bodyString } = prepared
const { headers, isRealClaudeCode, toolNameMap } = prepared
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 支持自定义路径(如 count_tokens // 支持自定义路径(如 count_tokens
@@ -1226,6 +1491,10 @@ class ClaudeRelayService {
responseBody = responseData.toString('utf8') responseBody = responseData.toString('utf8')
} }
if (!isRealClaudeCode) {
responseBody = this._restoreToolNamesInResponseBody(responseBody, toolNameMap)
}
const response = { const response = {
statusCode: res.statusCode, statusCode: res.statusCode,
headers: res.headers, headers: res.headers,
@@ -1284,6 +1553,8 @@ class ClaudeRelayService {
// 写入请求体 // 写入请求体
req.write(bodyString) req.write(bodyString)
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
bodyString = null
req.end() req.end()
}) })
} }
@@ -1465,7 +1736,12 @@ class ClaudeRelayService {
// 获取有效的访问token // 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId) const accessToken = await claudeAccountService.getValidAccessToken(accountId)
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
const processedBody = this._processRequestBody(requestBody, account) const processedBody = this._processRequestBody(requestBody, account)
// 🧹 内存优化:存储到 bodyStore不放入 requestOptions 避免闭包捕获
const originalBodyString = JSON.stringify(processedBody)
const bodyStoreId = ++this._bodyStoreIdCounter
this.bodyStore.set(bodyStoreId, originalBodyString)
// 获取代理配置 // 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId) const proxyAgent = await this._getProxyAgent(accountId)
@@ -1487,7 +1763,11 @@ class ClaudeRelayService {
accountType, accountType,
sessionHash, sessionHash,
streamTransformer, streamTransformer,
options, {
...options,
bodyStoreId,
isRealClaudeCodeRequest
},
isDedicatedOfficialAccount, isDedicatedOfficialAccount,
// 📬 新增回调:在收到响应头时释放队列锁 // 📬 新增回调:在收到响应头时释放队列锁
async () => { async () => {
@@ -1576,7 +1856,12 @@ class ClaudeRelayService {
return prepared.abortResponse return prepared.abortResponse
} }
const { bodyString, headers } = prepared let { bodyString } = prepared
const { headers, toolNameMap } = prepared
const toolNameStreamTransformer = this._createToolNameStripperStreamTransformer(
streamTransformer,
toolNameMap
)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = new URL(this.claudeApiUrl) const url = new URL(this.claudeApiUrl)
@@ -1684,8 +1969,22 @@ class ClaudeRelayService {
try { try {
// 递归调用自身进行重试 // 递归调用自身进行重试
// 🧹 从 bodyStore 获取字符串用于重试
if (
!requestOptions.bodyStoreId ||
!this.bodyStore.has(requestOptions.bodyStoreId)
) {
throw new Error('529 retry requires valid bodyStoreId')
}
let retryBody
try {
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
} catch (parseError) {
logger.error(`❌ Failed to parse body for 529 retry: ${parseError.message}`)
throw new Error(`529 retry body parse failed: ${parseError.message}`)
}
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture( const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
body, retryBody,
accessToken, accessToken,
proxyAgent, proxyAgent,
clientHeaders, clientHeaders,
@@ -1780,11 +2079,48 @@ class ClaudeRelayService {
errorData += chunk.toString() errorData += chunk.toString()
}) })
res.on('end', () => { res.on('end', async () => {
logger.error( logger.error(
`❌ Claude API error response (Account: ${account?.name || accountId}):`, `❌ Claude API error response (Account: ${account?.name || accountId}):`,
errorData errorData
) )
if (
this._isClaudeCodeCredentialError(errorData) &&
requestOptions.useRandomizedToolNames !== true &&
requestOptions.bodyStoreId &&
this.bodyStore.has(requestOptions.bodyStoreId)
) {
let retryBody
try {
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
} catch (parseError) {
logger.error(`❌ Failed to parse body for 403 retry: ${parseError.message}`)
reject(new Error(`403 retry body parse failed: ${parseError.message}`))
return
}
try {
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
retryBody,
accessToken,
proxyAgent,
clientHeaders,
responseStream,
usageCallback,
accountId,
accountType,
sessionHash,
streamTransformer,
{ ...requestOptions, useRandomizedToolNames: true },
isDedicatedOfficialAccount,
onResponseStart,
retryCount
)
resolve(retryResult)
} catch (retryError) {
reject(retryError)
}
return
}
if (this._isOrganizationDisabledError(res.statusCode, errorData)) { if (this._isOrganizationDisabledError(res.statusCode, errorData)) {
;(async () => { ;(async () => {
try { try {
@@ -1819,7 +2155,7 @@ class ClaudeRelayService {
} }
// 如果有 streamTransformer如测试请求使用前端期望的格式 // 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) { if (toolNameStreamTransformer) {
responseStream.write( responseStream.write(
`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n` `data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`
) )
@@ -1858,6 +2194,11 @@ class ClaudeRelayService {
let rateLimitDetected = false // 限流检测标志 let rateLimitDetected = false // 限流检测标志
// 监听数据块解析SSE并寻找usage信息 // 监听数据块解析SSE并寻找usage信息
// 🧹 内存优化:在闭包创建前提取需要的值,避免闭包捕获 body 和 requestOptions
// body 和 requestOptions 只在闭包外使用,闭包内只引用基本类型
const requestedModel = body?.model || 'unknown'
const { isRealClaudeCodeRequest } = requestOptions
res.on('data', (chunk) => { res.on('data', (chunk) => {
try { try {
const chunkStr = chunk.toString() const chunkStr = chunk.toString()
@@ -1873,8 +2214,8 @@ class ClaudeRelayService {
if (isStreamWritable(responseStream)) { if (isStreamWritable(responseStream)) {
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '') const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
// 如果有流转换器,应用转换 // 如果有流转换器,应用转换
if (streamTransformer) { if (toolNameStreamTransformer) {
const transformed = streamTransformer(linesToForward) const transformed = toolNameStreamTransformer(linesToForward)
if (transformed) { if (transformed) {
responseStream.write(transformed) responseStream.write(transformed)
} }
@@ -2007,8 +2348,8 @@ class ClaudeRelayService {
try { try {
// 处理缓冲区中剩余的数据 // 处理缓冲区中剩余的数据
if (buffer.trim() && isStreamWritable(responseStream)) { if (buffer.trim() && isStreamWritable(responseStream)) {
if (streamTransformer) { if (toolNameStreamTransformer) {
const transformed = streamTransformer(buffer) const transformed = toolNameStreamTransformer(buffer)
if (transformed) { if (transformed) {
responseStream.write(transformed) responseStream.write(transformed)
} }
@@ -2063,7 +2404,7 @@ class ClaudeRelayService {
// 打印原始的usage数据为JSON字符串避免嵌套问题 // 打印原始的usage数据为JSON字符串避免嵌套问题
logger.info( logger.info(
`📊 === Stream Request Usage Summary === Model: ${body.model}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}` `📊 === Stream Request Usage Summary === Model: ${requestedModel}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
) )
// 一般一个请求只会使用一个模型即使有多个usage事件也应该合并 // 一般一个请求只会使用一个模型即使有多个usage事件也应该合并
@@ -2073,7 +2414,7 @@ class ClaudeRelayService {
output_tokens: totalUsage.output_tokens, output_tokens: totalUsage.output_tokens,
cache_creation_input_tokens: totalUsage.cache_creation_input_tokens, cache_creation_input_tokens: totalUsage.cache_creation_input_tokens,
cache_read_input_tokens: totalUsage.cache_read_input_tokens, cache_read_input_tokens: totalUsage.cache_read_input_tokens,
model: allUsageData[allUsageData.length - 1].model || body.model // 使用最后一个模型或请求模型 model: allUsageData[allUsageData.length - 1].model || requestedModel // 使用最后一个模型或请求模型
} }
// 如果有详细的cache_creation数据合并它们 // 如果有详细的cache_creation数据合并它们
@@ -2182,15 +2523,15 @@ class ClaudeRelayService {
} }
// 只有真实的 Claude Code 请求才更新 headers流式请求 // 只有真实的 Claude Code 请求才更新 headers流式请求
if ( if (clientHeaders && Object.keys(clientHeaders).length > 0 && isRealClaudeCodeRequest) {
clientHeaders &&
Object.keys(clientHeaders).length > 0 &&
this.isRealClaudeCodeRequest(body)
) {
await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders)
} }
} }
// 🧹 清理 bodyStore
if (requestOptions.bodyStoreId) {
this.bodyStore.delete(requestOptions.bodyStoreId)
}
logger.debug('🌊 Claude stream response with usage capture completed') logger.debug('🌊 Claude stream response with usage capture completed')
resolve() resolve()
}) })
@@ -2247,6 +2588,10 @@ class ClaudeRelayService {
) )
responseStream.end() responseStream.end()
} }
// 🧹 清理 bodyStore
if (requestOptions.bodyStoreId) {
this.bodyStore.delete(requestOptions.bodyStoreId)
}
reject(error) reject(error)
}) })
@@ -2276,6 +2621,10 @@ class ClaudeRelayService {
) )
responseStream.end() responseStream.end()
} }
// 🧹 清理 bodyStore
if (requestOptions.bodyStoreId) {
this.bodyStore.delete(requestOptions.bodyStoreId)
}
reject(new Error('Request timeout')) reject(new Error('Request timeout'))
}) })
@@ -2289,6 +2638,8 @@ class ClaudeRelayService {
// 写入请求体 // 写入请求体
req.write(bodyString) req.write(bodyString)
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
bodyString = null
req.end() req.end()
}) })
} }

View File

@@ -1157,6 +1157,7 @@
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/lodash": "*" "@types/lodash": "*"
} }
@@ -1351,6 +1352,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1587,6 +1589,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001726", "caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173", "electron-to-chromium": "^1.5.173",
@@ -3060,13 +3063,15 @@
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash-unified": { "node_modules/lodash-unified": {
"version": "1.0.3", "version": "1.0.3",
@@ -3618,6 +3623,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -3764,6 +3770,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -4028,6 +4035,7 @@
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==", "integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -4525,6 +4533,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -4915,6 +4924,7 @@
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -5115,6 +5125,7 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.18", "@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18", "@vue/compiler-sfc": "3.5.18",

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
> >
<input <div v-if="!isEdit" class="flex gap-4">
v-model="form.accessKeyId" <label class="flex cursor-pointer items-center">
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" <input
:class="{ 'border-red-500': errors.accessKeyId }" v-model="form.credentialType"
placeholder="请输入 AWS Access Key ID" class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
required type="radio"
type="text" value="access_key"
/> />
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500"> <span class="text-sm text-gray-700 dark:text-gray-300"
{{ errors.accessKeyId }} >AWS Access Key(访问密钥)</span
</p> >
</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> </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
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="{ 'border-red-500': errors.accessKeyId }"
:placeholder="isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Access Key ID'"
:required="!isEdit"
type="text"
/>
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
{{ errors.accessKeyId }}
</p>
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
💡 编辑模式下,留空则保持原有 Access Key ID 不变
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 秘密访问密钥 {{ isEdit ? '' : '*' }}</label
>
<input
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="{ 'border-red-500': errors.secretAccessKey }"
:placeholder="
isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Secret Access Key'
"
:required="!isEdit"
type="password"
/>
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
{{ errors.secretAccessKey }}
</p>
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
💡 编辑模式下,留空则保持原有 Secret Access Key 不变
</p>
</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" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 秘密访问密钥 *</label >Bearer Token {{ isEdit ? '' : '*' }}</label
> >
<input <input
v-model="form.secretAccessKey" 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="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.bearerToken }"
placeholder="请输入 AWS Secret Access Key" :placeholder="
required isEdit ? '留空则保持原有 Bearer Token 不变' : '请输入 AWS Bearer Token'
"
:required="!isEdit"
type="password" type="password"
/> />
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500"> <p v-if="errors.bearerToken" class="mt-1 text-xs text-red-500">
{{ errors.secretAccessKey }} {{ errors.bearerToken }}
</p> </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> </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,27 +1070,14 @@
<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">
💡 请输入完整的区域代码,如 us-east-1
</p>
</div> </div>
</div> </div>
</div> </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>
</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"
>默认主模型 (可选)</label >默认主模型 (可选)</label
@@ -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,14 +5128,27 @@ const createAccount = async () => {
hasError = true hasError = true
} }
} else if (form.value.platform === 'bedrock') { } else if (form.value.platform === 'bedrock') {
// Bedrock 验证 // Bedrock 验证 - 根据凭证类型进行不同验证
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') { if (form.value.credentialType === 'access_key') {
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID' // Access Key 模式:创建时必填,编辑时可选(留空则保持原有凭证)
hasError = true if (!isEdit.value) {
} if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
if (!form.value.secretAccessKey || form.value.secretAccessKey.trim() === '') { errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥' hasError = true
hasError = true }
if (!form.value.secretAccessKey || form.value.secretAccessKey.trim() === '') {
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
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 区域'
@@ -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.awsCredentials = { data.credentialType = form.value.credentialType || 'access_key'
accessKeyId: form.value.accessKeyId,
secretAccessKey: form.value.secretAccessKey, // 根据凭证类型构造不同的凭证对象
sessionToken: form.value.sessionToken || null if (form.value.credentialType === 'access_key') {
data.awsCredentials = {
accessKeyId: form.value.accessKeyId,
secretAccessKey: form.value.secretAccessKey,
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,19 +5746,33 @@ const updateAccount = async () => {
// Bedrock 特定更新 // Bedrock 特定更新
if (props.account.platform === 'bedrock') { if (props.account.platform === 'bedrock') {
// 只有当有凭证变更时才构造 awsCredentials 对象 // 更新凭证类型
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) { if (form.value.credentialType) {
data.awsCredentials = {} data.credentialType = form.value.credentialType
if (form.value.accessKeyId) { }
data.awsCredentials.accessKeyId = form.value.accessKeyId
// 根据凭证类型更新凭证
if (form.value.credentialType === 'access_key') {
// 只有当有凭证变更时才构造 awsCredentials 对象
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
data.awsCredentials = {}
if (form.value.accessKeyId) {
data.awsCredentials.accessKeyId = form.value.accessKeyId
}
if (form.value.secretAccessKey) {
data.awsCredentials.secretAccessKey = form.value.secretAccessKey
}
if (form.value.sessionToken !== undefined) {
data.awsCredentials.sessionToken = form.value.sessionToken || null
}
} }
if (form.value.secretAccessKey) { } else if (form.value.credentialType === 'bearer_token') {
data.awsCredentials.secretAccessKey = form.value.secretAccessKey // Bearer Token 模式:更新 Bearer Token编辑时可选留空则保留原有凭证
} if (form.value.bearerToken && form.value.bearerToken.trim()) {
if (form.value.sessionToken !== undefined) { data.bearerToken = form.value.bearerToken
data.awsCredentials.sessionToken = form.value.sessionToken || null
} }
} }
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

@@ -364,7 +364,8 @@ const platformLabelMap = {
'openai-responses': 'OpenAI Responses', 'openai-responses': 'OpenAI Responses',
gemini: 'Gemini', gemini: 'Gemini',
'gemini-api': 'Gemini API', 'gemini-api': 'Gemini API',
droid: 'Droid' droid: 'Droid',
bedrock: 'Claude AWS Bedrock'
} }
const platformLabel = computed(() => platformLabelMap[props.account?.platform] || '未知平台') const platformLabel = computed(() => platformLabelMap[props.account?.platform] || '未知平台')

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)