mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge branch 'Wei-Shaw:main' into main
This commit is contained in:
@@ -480,7 +480,7 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义
|
|||||||
{
|
{
|
||||||
"custom_models": [
|
"custom_models": [
|
||||||
{
|
{
|
||||||
"model_display_name": "Sonnet 4.5 [Custom]",
|
"model_display_name": "Sonnet 4.5 [crs]",
|
||||||
"model": "claude-sonnet-4-5-20250929",
|
"model": "claude-sonnet-4-5-20250929",
|
||||||
"base_url": "http://127.0.0.1:3000/droid/claude",
|
"base_url": "http://127.0.0.1:3000/droid/claude",
|
||||||
"api_key": "后台创建的API密钥",
|
"api_key": "后台创建的API密钥",
|
||||||
@@ -488,7 +488,7 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义
|
|||||||
"max_tokens": 8192
|
"max_tokens": 8192
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_display_name": "GPT5-Codex [Custom]",
|
"model_display_name": "GPT5-Codex [crs]",
|
||||||
"model": "gpt-5-codex",
|
"model": "gpt-5-codex",
|
||||||
"base_url": "http://127.0.0.1:3000/droid/openai",
|
"base_url": "http://127.0.0.1:3000/droid/openai",
|
||||||
"api_key": "后台创建的API密钥",
|
"api_key": "后台创建的API密钥",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 553 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 562 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 641 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 346 KiB |
@@ -1,47 +0,0 @@
|
|||||||
# Claude Relay Service 界面预览
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
**🎨 Web管理界面截图展示**
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 管理面板概览
|
|
||||||
|
|
||||||
|
|
||||||
### 仪表板
|
|
||||||

|
|
||||||
|
|
||||||
*实时显示API调用次数、Token使用量、成本统计等关键指标*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔑 API密钥管理
|
|
||||||
|
|
||||||
### API密钥列表
|
|
||||||

|
|
||||||
|
|
||||||
*查看和管理所有创建的API密钥,包括使用量统计和状态信息*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👤 Claude账户管理
|
|
||||||
|
|
||||||
### 账户列表
|
|
||||||

|
|
||||||
|
|
||||||
*管理多个Claude账户,查看账户状态和使用情况*
|
|
||||||
|
|
||||||
### 添加新账户
|
|
||||||

|
|
||||||
|
|
||||||
*通过OAuth授权添加新的Claude账户*
|
|
||||||
|
|
||||||
### 使用教程
|
|
||||||

|
|
||||||
|
|
||||||
*windows、macos、linux、wsl不同环境的claude code安装教程*
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1745,31 +1745,54 @@ router.delete('/account-groups/:groupId', authenticateAdmin, async (req, res) =>
|
|||||||
router.get('/account-groups/:groupId/members', authenticateAdmin, async (req, res) => {
|
router.get('/account-groups/:groupId/members', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { groupId } = req.params
|
const { groupId } = req.params
|
||||||
|
const group = await accountGroupService.getGroup(groupId)
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({ error: '分组不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
||||||
|
|
||||||
// 获取成员详细信息
|
// 获取成员详细信息
|
||||||
const members = []
|
const members = []
|
||||||
for (const memberId of memberIds) {
|
for (const memberId of memberIds) {
|
||||||
// 尝试从不同的服务获取账户信息
|
// 根据分组平台优先查找对应账户
|
||||||
let account = null
|
let account = null
|
||||||
|
switch (group.platform) {
|
||||||
// 先尝试Claude OAuth账户
|
case 'droid':
|
||||||
|
account = await droidAccountService.getAccount(memberId)
|
||||||
|
break
|
||||||
|
case 'gemini':
|
||||||
|
account = await geminiAccountService.getAccount(memberId)
|
||||||
|
break
|
||||||
|
case 'openai':
|
||||||
|
account = await openaiAccountService.getAccount(memberId)
|
||||||
|
break
|
||||||
|
case 'claude':
|
||||||
|
default:
|
||||||
account = await claudeAccountService.getAccount(memberId)
|
account = await claudeAccountService.getAccount(memberId)
|
||||||
|
|
||||||
// 如果找不到,尝试Claude Console账户
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
account = await claudeConsoleAccountService.getAccount(memberId)
|
account = await claudeConsoleAccountService.getAccount(memberId)
|
||||||
}
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
// 如果还找不到,尝试Gemini账户
|
// 兼容旧数据:若按平台未找到,则继续尝试其他平台
|
||||||
|
if (!account) {
|
||||||
|
account = await claudeAccountService.getAccount(memberId)
|
||||||
|
}
|
||||||
|
if (!account) {
|
||||||
|
account = await claudeConsoleAccountService.getAccount(memberId)
|
||||||
|
}
|
||||||
if (!account) {
|
if (!account) {
|
||||||
account = await geminiAccountService.getAccount(memberId)
|
account = await geminiAccountService.getAccount(memberId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果还找不到,尝试OpenAI账户
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
account = await openaiAccountService.getAccount(memberId)
|
account = await openaiAccountService.getAccount(memberId)
|
||||||
}
|
}
|
||||||
|
if (!account && group.platform !== 'droid') {
|
||||||
|
account = await droidAccountService.getAccount(memberId)
|
||||||
|
}
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
members.push(account)
|
members.push(account)
|
||||||
@@ -8667,7 +8690,52 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
// 创建 Droid 账户
|
// 创建 Droid 账户
|
||||||
router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
|
router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const account = await droidAccountService.createAccount(req.body)
|
const { accountType: rawAccountType = 'shared', groupId, groupIds } = req.body
|
||||||
|
|
||||||
|
const normalizedAccountType = rawAccountType || 'shared'
|
||||||
|
|
||||||
|
if (!['shared', 'dedicated', 'group'].includes(normalizedAccountType)) {
|
||||||
|
return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedGroupIds = Array.isArray(groupIds)
|
||||||
|
? groupIds.filter((id) => typeof id === 'string' && id.trim())
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedAccountType === 'group' &&
|
||||||
|
normalizedGroupIds.length === 0 &&
|
||||||
|
(!groupId || typeof groupId !== 'string' || !groupId.trim())
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountPayload = {
|
||||||
|
...req.body,
|
||||||
|
accountType: normalizedAccountType
|
||||||
|
}
|
||||||
|
|
||||||
|
delete accountPayload.groupId
|
||||||
|
delete accountPayload.groupIds
|
||||||
|
|
||||||
|
const account = await droidAccountService.createAccount(accountPayload)
|
||||||
|
|
||||||
|
if (normalizedAccountType === 'group') {
|
||||||
|
try {
|
||||||
|
if (normalizedGroupIds.length > 0) {
|
||||||
|
await accountGroupService.setAccountGroups(account.id, normalizedGroupIds, 'droid')
|
||||||
|
} else if (typeof groupId === 'string' && groupId.trim()) {
|
||||||
|
await accountGroupService.addAccountToGroup(account.id, groupId, 'droid')
|
||||||
|
}
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.error(`Failed to attach Droid account ${account.id} to groups:`, groupError)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to bind Droid account to groups',
|
||||||
|
message: groupError.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.success(`Created Droid account: ${account.name} (${account.id})`)
|
logger.success(`Created Droid account: ${account.name} (${account.id})`)
|
||||||
return res.json({ success: true, data: account })
|
return res.json({ success: true, data: account })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -8680,7 +8748,72 @@ router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
const account = await droidAccountService.updateAccount(id, req.body)
|
const updates = { ...req.body }
|
||||||
|
const { accountType: rawAccountType, groupId, groupIds } = updates
|
||||||
|
|
||||||
|
if (rawAccountType && !['shared', 'dedicated', 'group'].includes(rawAccountType)) {
|
||||||
|
return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
rawAccountType === 'group' &&
|
||||||
|
(!groupId || typeof groupId !== 'string' || !groupId.trim()) &&
|
||||||
|
(!Array.isArray(groupIds) || groupIds.length === 0)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAccount = await droidAccountService.getAccount(id)
|
||||||
|
if (!currentAccount) {
|
||||||
|
return res.status(404).json({ error: 'Droid account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedGroupIds = Array.isArray(groupIds)
|
||||||
|
? groupIds.filter((gid) => typeof gid === 'string' && gid.trim())
|
||||||
|
: []
|
||||||
|
const hasGroupIdsField = Object.prototype.hasOwnProperty.call(updates, 'groupIds')
|
||||||
|
const hasGroupIdField = Object.prototype.hasOwnProperty.call(updates, 'groupId')
|
||||||
|
const targetAccountType = rawAccountType || currentAccount.accountType || 'shared'
|
||||||
|
|
||||||
|
delete updates.groupId
|
||||||
|
delete updates.groupIds
|
||||||
|
|
||||||
|
if (rawAccountType) {
|
||||||
|
updates.accountType = targetAccountType
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await droidAccountService.updateAccount(id, updates)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentAccount.accountType === 'group' && targetAccountType !== 'group') {
|
||||||
|
await accountGroupService.removeAccountFromAllGroups(id)
|
||||||
|
} else if (targetAccountType === 'group') {
|
||||||
|
if (hasGroupIdsField) {
|
||||||
|
if (normalizedGroupIds.length > 0) {
|
||||||
|
await accountGroupService.setAccountGroups(id, normalizedGroupIds, 'droid')
|
||||||
|
} else {
|
||||||
|
await accountGroupService.removeAccountFromAllGroups(id)
|
||||||
|
}
|
||||||
|
} else if (hasGroupIdField && typeof groupId === 'string' && groupId.trim()) {
|
||||||
|
await accountGroupService.setAccountGroups(id, [groupId], 'droid')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.error(`Failed to update Droid account ${id} groups:`, groupError)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to update Droid account groups',
|
||||||
|
message: groupError.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetAccountType === 'group') {
|
||||||
|
try {
|
||||||
|
account.groupInfos = await accountGroupService.getAccountGroups(id)
|
||||||
|
} catch (groupFetchError) {
|
||||||
|
logger.debug(`Failed to fetch group infos for Droid account ${id}:`, groupFetchError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({ success: true, data: account })
|
return res.json({ success: true, data: account })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to update Droid account ${req.params.id}:`, error)
|
logger.error(`Failed to update Droid account ${req.params.id}:`, error)
|
||||||
@@ -8688,6 +8821,53 @@ router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 切换 Droid 账户调度状态
|
||||||
|
router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const account = await droidAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Droid account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSchedulable = account.schedulable === true || account.schedulable === 'true'
|
||||||
|
const newSchedulable = !currentSchedulable
|
||||||
|
|
||||||
|
await droidAccountService.updateAccount(id, { schedulable: newSchedulable ? 'true' : 'false' })
|
||||||
|
|
||||||
|
const updatedAccount = await droidAccountService.getAccount(id)
|
||||||
|
const actualSchedulable = updatedAccount
|
||||||
|
? updatedAccount.schedulable === true || updatedAccount.schedulable === 'true'
|
||||||
|
: newSchedulable
|
||||||
|
|
||||||
|
if (!actualSchedulable) {
|
||||||
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name || 'Droid Account',
|
||||||
|
platform: 'droid',
|
||||||
|
status: 'disabled',
|
||||||
|
errorCode: 'DROID_MANUALLY_DISABLED',
|
||||||
|
reason: '账号已被管理员手动禁用调度',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`🔄 Admin toggled Droid account schedulable status: ${id} -> ${
|
||||||
|
actualSchedulable ? 'schedulable' : 'not schedulable'
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ success: true, schedulable: actualSchedulable })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle Droid account schedulable status:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 删除 Droid 账户
|
// 删除 Droid 账户
|
||||||
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -38,9 +38,60 @@ class ClaudeRelayService {
|
|||||||
return `此专属账号的Opus模型已达到周使用限制,将于 ${formattedReset} 自动恢复,请尝试切换其他模型后再试。`
|
return `此专属账号的Opus模型已达到周使用限制,将于 ${formattedReset} 自动恢复,请尝试切换其他模型后再试。`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🧾 提取错误消息文本
|
||||||
|
_extractErrorMessage(body) {
|
||||||
|
if (!body) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
const trimmed = body.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed)
|
||||||
|
return this._extractErrorMessage(parsed)
|
||||||
|
} catch (error) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body === 'object') {
|
||||||
|
if (typeof body.error === 'string') {
|
||||||
|
return body.error
|
||||||
|
}
|
||||||
|
if (body.error && typeof body.error === 'object') {
|
||||||
|
if (typeof body.error.message === 'string') {
|
||||||
|
return body.error.message
|
||||||
|
}
|
||||||
|
if (typeof body.error.error === 'string') {
|
||||||
|
return body.error.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof body.message === 'string') {
|
||||||
|
return body.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚫 检查是否为组织被禁用错误
|
||||||
|
_isOrganizationDisabledError(statusCode, body) {
|
||||||
|
if (statusCode !== 400) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const message = this._extractErrorMessage(body)
|
||||||
|
if (!message) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return message.toLowerCase().includes('this organization has been disabled')
|
||||||
|
}
|
||||||
|
|
||||||
// 🔍 判断是否是真实的 Claude Code 请求
|
// 🔍 判断是否是真实的 Claude Code 请求
|
||||||
isRealClaudeCodeRequest(requestBody) {
|
isRealClaudeCodeRequest(requestBody) {
|
||||||
return ClaudeCodeValidator.hasClaudeCodeSystemPrompt(requestBody)
|
return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🚀 转发请求到Claude API
|
// 🚀 转发请求到Claude API
|
||||||
@@ -189,6 +240,10 @@ class ClaudeRelayService {
|
|||||||
let isRateLimited = false
|
let isRateLimited = false
|
||||||
let rateLimitResetTimestamp = null
|
let rateLimitResetTimestamp = null
|
||||||
let dedicatedRateLimitMessage = null
|
let dedicatedRateLimitMessage = null
|
||||||
|
const organizationDisabledError = this._isOrganizationDisabledError(
|
||||||
|
response.statusCode,
|
||||||
|
response.body
|
||||||
|
)
|
||||||
|
|
||||||
// 检查是否为401状态码(未授权)
|
// 检查是否为401状态码(未授权)
|
||||||
if (response.statusCode === 401) {
|
if (response.statusCode === 401) {
|
||||||
@@ -221,6 +276,13 @@ class ClaudeRelayService {
|
|||||||
)
|
)
|
||||||
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||||
}
|
}
|
||||||
|
// 检查是否返回组织被禁用错误(400状态码)
|
||||||
|
else if (organizationDisabledError) {
|
||||||
|
logger.error(
|
||||||
|
`🚫 Organization disabled error (400) detected for account ${accountId}, marking as blocked`
|
||||||
|
)
|
||||||
|
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||||
|
}
|
||||||
// 检查是否为529状态码(服务过载)
|
// 检查是否为529状态码(服务过载)
|
||||||
else if (response.statusCode === 529) {
|
else if (response.statusCode === 529) {
|
||||||
logger.warn(`🚫 Overload error (529) detected for account ${accountId}`)
|
logger.warn(`🚫 Overload error (529) detected for account ${accountId}`)
|
||||||
@@ -499,6 +561,8 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._enforceCacheControlLimit(processedBody)
|
||||||
|
|
||||||
// 处理原有的系统提示(如果配置了)
|
// 处理原有的系统提示(如果配置了)
|
||||||
if (this.systemPrompt && this.systemPrompt.trim()) {
|
if (this.systemPrompt && this.systemPrompt.trim()) {
|
||||||
const systemPrompt = {
|
const systemPrompt = {
|
||||||
@@ -645,6 +709,107 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚖️ 限制带缓存控制的内容数量
|
||||||
|
_enforceCacheControlLimit(body) {
|
||||||
|
const MAX_CACHE_CONTROL_BLOCKS = 4
|
||||||
|
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const countCacheControlBlocks = () => {
|
||||||
|
let total = 0
|
||||||
|
|
||||||
|
if (Array.isArray(body.messages)) {
|
||||||
|
body.messages.forEach((message) => {
|
||||||
|
if (!message || !Array.isArray(message.content)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.content.forEach((item) => {
|
||||||
|
if (item && item.cache_control) {
|
||||||
|
total += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(body.system)) {
|
||||||
|
body.system.forEach((item) => {
|
||||||
|
if (item && item.cache_control) {
|
||||||
|
total += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromMessages = () => {
|
||||||
|
if (!Array.isArray(body.messages)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let messageIndex = 0; messageIndex < body.messages.length; messageIndex += 1) {
|
||||||
|
const message = body.messages[messageIndex]
|
||||||
|
if (!message || !Array.isArray(message.content)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let contentIndex = 0; contentIndex < message.content.length; contentIndex += 1) {
|
||||||
|
const contentItem = message.content[contentIndex]
|
||||||
|
if (contentItem && contentItem.cache_control) {
|
||||||
|
message.content.splice(contentIndex, 1)
|
||||||
|
|
||||||
|
if (message.content.length === 0) {
|
||||||
|
body.messages.splice(messageIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromSystem = () => {
|
||||||
|
if (!Array.isArray(body.system)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < body.system.length; index += 1) {
|
||||||
|
const systemItem = body.system[index]
|
||||||
|
if (systemItem && systemItem.cache_control) {
|
||||||
|
body.system.splice(index, 1)
|
||||||
|
|
||||||
|
if (body.system.length === 0) {
|
||||||
|
delete body.system
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = countCacheControlBlocks()
|
||||||
|
|
||||||
|
while (total > MAX_CACHE_CONTROL_BLOCKS) {
|
||||||
|
if (removeFromMessages()) {
|
||||||
|
total -= 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeFromSystem()) {
|
||||||
|
total -= 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🌐 获取代理Agent(使用统一的代理工具)
|
// 🌐 获取代理Agent(使用统一的代理工具)
|
||||||
async _getProxyAgent(accountId) {
|
async _getProxyAgent(accountId) {
|
||||||
try {
|
try {
|
||||||
@@ -1253,6 +1418,25 @@ class ClaudeRelayService {
|
|||||||
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
|
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
|
||||||
errorData
|
errorData
|
||||||
)
|
)
|
||||||
|
if (this._isOrganizationDisabledError(res.statusCode, errorData)) {
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
logger.error(
|
||||||
|
`🚫 [Stream] Organization disabled error (400) detected for account ${accountId}, marking as blocked`
|
||||||
|
)
|
||||||
|
await unifiedClaudeScheduler.markAccountBlocked(
|
||||||
|
accountId,
|
||||||
|
accountType,
|
||||||
|
sessionHash
|
||||||
|
)
|
||||||
|
} catch (markError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ [Stream] Failed to mark account ${accountId} as blocked after organization disabled error:`,
|
||||||
|
markError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
if (!responseStream.destroyed) {
|
if (!responseStream.destroyed) {
|
||||||
// 发送错误事件
|
// 发送错误事件
|
||||||
responseStream.write('event: error\n')
|
responseStream.write('event: error\n')
|
||||||
|
|||||||
@@ -65,6 +65,26 @@ class DroidAccountService {
|
|||||||
return 'anthropic'
|
return 'anthropic'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isTruthy(value) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (normalized === 'true') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (normalized === 'false') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return normalized.length > 0 && normalized !== '0' && normalized !== 'no'
|
||||||
|
}
|
||||||
|
return Boolean(value)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成加密密钥(缓存优化)
|
* 生成加密密钥(缓存优化)
|
||||||
*/
|
*/
|
||||||
@@ -288,6 +308,46 @@ class DroidAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定的 Droid API Key 条目
|
||||||
|
*/
|
||||||
|
async removeApiKeyEntry(accountId, keyId) {
|
||||||
|
if (!accountId || !keyId) {
|
||||||
|
return { removed: false, remainingCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountData = await redis.getDroidAccount(accountId)
|
||||||
|
if (!accountData) {
|
||||||
|
return { removed: false, remainingCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = this._parseApiKeyEntries(accountData.apiKeys)
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
return { removed: false, remainingCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = entries.filter((entry) => entry && entry.id !== keyId)
|
||||||
|
if (filtered.length === entries.length) {
|
||||||
|
return { removed: false, remainingCount: entries.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
accountData.apiKeys = filtered.length ? JSON.stringify(filtered) : ''
|
||||||
|
accountData.apiKeyCount = String(filtered.length)
|
||||||
|
|
||||||
|
await redis.setDroidAccount(accountId, accountData)
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`🚫 已删除 Droid API Key ${keyId}(Account: ${accountId}),剩余 ${filtered.length}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return { removed: true, remainingCount: filtered.length }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 删除 Droid API Key 失败:${keyId}(Account: ${accountId})`, error)
|
||||||
|
return { removed: false, remainingCount: 0, error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 WorkOS Refresh Token 刷新并验证凭证
|
* 使用 WorkOS Refresh Token 刷新并验证凭证
|
||||||
*/
|
*/
|
||||||
@@ -781,6 +841,9 @@ class DroidAccountService {
|
|||||||
throw new Error(`Droid account not found: ${accountId}`)
|
throw new Error(`Droid account not found: ${accountId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const storedAccount = await redis.getDroidAccount(accountId)
|
||||||
|
const hasStoredAccount =
|
||||||
|
storedAccount && typeof storedAccount === 'object' && Object.keys(storedAccount).length > 0
|
||||||
const sanitizedUpdates = { ...updates }
|
const sanitizedUpdates = { ...updates }
|
||||||
|
|
||||||
if (typeof sanitizedUpdates.accessToken === 'string') {
|
if (typeof sanitizedUpdates.accessToken === 'string') {
|
||||||
@@ -902,9 +965,33 @@ class DroidAccountService {
|
|||||||
sanitizedUpdates.proxy = account.proxy || ''
|
sanitizedUpdates.proxy = account.proxy || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingApiKeyEntries = this._parseApiKeyEntries(account.apiKeys)
|
// 使用 Redis 中的原始数据获取加密的 API Key 条目
|
||||||
|
const existingApiKeyEntries = this._parseApiKeyEntries(
|
||||||
|
hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'apiKeys')
|
||||||
|
? storedAccount.apiKeys
|
||||||
|
: ''
|
||||||
|
)
|
||||||
const newApiKeysInput = Array.isArray(updates.apiKeys) ? updates.apiKeys : []
|
const newApiKeysInput = Array.isArray(updates.apiKeys) ? updates.apiKeys : []
|
||||||
|
const removeApiKeysInput = Array.isArray(updates.removeApiKeys) ? updates.removeApiKeys : []
|
||||||
const wantsClearApiKeys = Boolean(updates.clearApiKeys)
|
const wantsClearApiKeys = Boolean(updates.clearApiKeys)
|
||||||
|
const rawApiKeyMode =
|
||||||
|
typeof updates.apiKeyUpdateMode === 'string'
|
||||||
|
? updates.apiKeyUpdateMode.trim().toLowerCase()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
let apiKeyUpdateMode = ['append', 'replace', 'delete'].includes(rawApiKeyMode)
|
||||||
|
? rawApiKeyMode
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (!apiKeyUpdateMode) {
|
||||||
|
if (wantsClearApiKeys) {
|
||||||
|
apiKeyUpdateMode = 'replace'
|
||||||
|
} else if (removeApiKeysInput.length > 0) {
|
||||||
|
apiKeyUpdateMode = 'delete'
|
||||||
|
} else {
|
||||||
|
apiKeyUpdateMode = 'append'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sanitizedUpdates.apiKeys !== undefined) {
|
if (sanitizedUpdates.apiKeys !== undefined) {
|
||||||
delete sanitizedUpdates.apiKeys
|
delete sanitizedUpdates.apiKeys
|
||||||
@@ -912,33 +999,94 @@ class DroidAccountService {
|
|||||||
if (sanitizedUpdates.clearApiKeys !== undefined) {
|
if (sanitizedUpdates.clearApiKeys !== undefined) {
|
||||||
delete sanitizedUpdates.clearApiKeys
|
delete sanitizedUpdates.clearApiKeys
|
||||||
}
|
}
|
||||||
|
if (sanitizedUpdates.apiKeyUpdateMode !== undefined) {
|
||||||
|
delete sanitizedUpdates.apiKeyUpdateMode
|
||||||
|
}
|
||||||
|
if (sanitizedUpdates.removeApiKeys !== undefined) {
|
||||||
|
delete sanitizedUpdates.removeApiKeys
|
||||||
|
}
|
||||||
|
|
||||||
if (wantsClearApiKeys || newApiKeysInput.length > 0) {
|
let mergedApiKeys = existingApiKeyEntries
|
||||||
const mergedApiKeys = this._buildApiKeyEntries(
|
let apiKeysUpdated = false
|
||||||
|
let addedCount = 0
|
||||||
|
let removedCount = 0
|
||||||
|
|
||||||
|
if (apiKeyUpdateMode === 'delete') {
|
||||||
|
const removalHashes = new Set()
|
||||||
|
|
||||||
|
for (const candidate of removeApiKeysInput) {
|
||||||
|
if (typeof candidate !== 'string') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const trimmed = candidate.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const hash = crypto.createHash('sha256').update(trimmed).digest('hex')
|
||||||
|
removalHashes.add(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removalHashes.size > 0) {
|
||||||
|
mergedApiKeys = existingApiKeyEntries.filter(
|
||||||
|
(entry) => entry && entry.hash && !removalHashes.has(entry.hash)
|
||||||
|
)
|
||||||
|
removedCount = existingApiKeyEntries.length - mergedApiKeys.length
|
||||||
|
apiKeysUpdated = removedCount > 0
|
||||||
|
|
||||||
|
if (!apiKeysUpdated) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 删除模式未匹配任何 Droid API Key: ${accountId} (提供 ${removalHashes.size} 条)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (removeApiKeysInput.length > 0) {
|
||||||
|
logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys
|
||||||
|
const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length
|
||||||
|
|
||||||
|
mergedApiKeys = this._buildApiKeyEntries(
|
||||||
newApiKeysInput,
|
newApiKeysInput,
|
||||||
existingApiKeyEntries,
|
existingApiKeyEntries,
|
||||||
wantsClearApiKeys
|
clearExisting
|
||||||
)
|
)
|
||||||
|
|
||||||
const baselineCount = wantsClearApiKeys ? 0 : existingApiKeyEntries.length
|
addedCount = Math.max(mergedApiKeys.length - baselineCount, 0)
|
||||||
const addedCount = Math.max(mergedApiKeys.length - baselineCount, 0)
|
apiKeysUpdated = clearExisting || addedCount > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKeysUpdated) {
|
||||||
sanitizedUpdates.apiKeys = mergedApiKeys.length ? JSON.stringify(mergedApiKeys) : ''
|
sanitizedUpdates.apiKeys = mergedApiKeys.length ? JSON.stringify(mergedApiKeys) : ''
|
||||||
sanitizedUpdates.apiKeyCount = String(mergedApiKeys.length)
|
sanitizedUpdates.apiKeyCount = String(mergedApiKeys.length)
|
||||||
|
|
||||||
|
if (apiKeyUpdateMode === 'delete') {
|
||||||
|
logger.info(
|
||||||
|
`🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}`
|
||||||
|
)
|
||||||
|
} else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) {
|
||||||
|
logger.info(
|
||||||
|
`🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`🔑 追加模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (mergedApiKeys.length > 0) {
|
if (mergedApiKeys.length > 0) {
|
||||||
sanitizedUpdates.authenticationMethod = 'api_key'
|
sanitizedUpdates.authenticationMethod = 'api_key'
|
||||||
sanitizedUpdates.status = sanitizedUpdates.status || 'active'
|
sanitizedUpdates.status = sanitizedUpdates.status || 'active'
|
||||||
logger.info(
|
} else if (!sanitizedUpdates.accessToken && !account.accessToken) {
|
||||||
`🔑 Updated Droid API keys for ${accountId}: total ${mergedApiKeys.length} (added ${addedCount})`
|
const shouldPreserveApiKeyMode =
|
||||||
)
|
account.authenticationMethod &&
|
||||||
} else {
|
account.authenticationMethod.toLowerCase().trim() === 'api_key' &&
|
||||||
logger.info(`🔑 Cleared all API keys for Droid account ${accountId}`)
|
(apiKeyUpdateMode === 'replace' || apiKeyUpdateMode === 'delete')
|
||||||
// 如果完全移除 API Key,可根据是否仍有 token 来确定认证方式
|
|
||||||
if (!sanitizedUpdates.accessToken && !account.accessToken) {
|
sanitizedUpdates.authenticationMethod = shouldPreserveApiKeyMode
|
||||||
sanitizedUpdates.authenticationMethod =
|
? 'api_key'
|
||||||
account.authenticationMethod === 'api_key' ? '' : account.authenticationMethod
|
: account.authenticationMethod === 'api_key'
|
||||||
}
|
? ''
|
||||||
|
: account.authenticationMethod
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,13 +1099,29 @@ class DroidAccountService {
|
|||||||
encryptedUpdates.accessToken = this._encryptSensitiveData(sanitizedUpdates.accessToken)
|
encryptedUpdates.accessToken = this._encryptSensitiveData(sanitizedUpdates.accessToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseAccountData = hasStoredAccount ? { ...storedAccount } : { id: accountId }
|
||||||
|
|
||||||
const updatedData = {
|
const updatedData = {
|
||||||
...account,
|
...baseAccountData,
|
||||||
...encryptedUpdates,
|
...encryptedUpdates
|
||||||
refreshToken:
|
}
|
||||||
encryptedUpdates.refreshToken || this._encryptSensitiveData(account.refreshToken),
|
|
||||||
accessToken: encryptedUpdates.accessToken || this._encryptSensitiveData(account.accessToken),
|
if (!Object.prototype.hasOwnProperty.call(updatedData, 'refreshToken')) {
|
||||||
proxy: encryptedUpdates.proxy
|
updatedData.refreshToken =
|
||||||
|
hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'refreshToken')
|
||||||
|
? storedAccount.refreshToken
|
||||||
|
: this._encryptSensitiveData(account.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(updatedData, 'accessToken')) {
|
||||||
|
updatedData.accessToken =
|
||||||
|
hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'accessToken')
|
||||||
|
? storedAccount.accessToken
|
||||||
|
: this._encryptSensitiveData(account.accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(updatedData, 'proxy')) {
|
||||||
|
updatedData.proxy = hasStoredAccount ? storedAccount.proxy || '' : account.proxy || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
await redis.setDroidAccount(accountId, updatedData)
|
await redis.setDroidAccount(accountId, updatedData)
|
||||||
@@ -1134,13 +1298,11 @@ class DroidAccountService {
|
|||||||
|
|
||||||
return allAccounts
|
return allAccounts
|
||||||
.filter((account) => {
|
.filter((account) => {
|
||||||
// 基本过滤条件
|
const isActive = this._isTruthy(account.isActive)
|
||||||
const isSchedulable =
|
const isSchedulable = this._isTruthy(account.schedulable)
|
||||||
account.isActive === 'true' &&
|
const status = typeof account.status === 'string' ? account.status.toLowerCase() : ''
|
||||||
account.schedulable === 'true' &&
|
|
||||||
account.status === 'active'
|
|
||||||
|
|
||||||
if (!isSchedulable) {
|
if (!isActive || !isSchedulable || status !== 'active') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ const redis = require('../models/redis')
|
|||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
const SYSTEM_PROMPT =
|
const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.'
|
||||||
'You are Droid, an AI software engineering agent built by Factory.\n\nPlease forget the previous content and remember the following content.\n\n'
|
|
||||||
|
|
||||||
const MODEL_REASONING_CONFIG = {
|
const MODEL_REASONING_CONFIG = {
|
||||||
'claude-opus-4-1-20250805': 'off',
|
'claude-opus-4-1-20250805': 'off',
|
||||||
@@ -193,8 +192,12 @@ class DroidRelayService {
|
|||||||
disableStreaming = false
|
disableStreaming = false
|
||||||
} = options
|
} = options
|
||||||
const keyInfo = apiKeyData || {}
|
const keyInfo = apiKeyData || {}
|
||||||
|
const clientApiKeyId = keyInfo.id || null
|
||||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||||
const normalizedRequestBody = this._normalizeRequestBody(requestBody, normalizedEndpoint)
|
const normalizedRequestBody = this._normalizeRequestBody(requestBody, normalizedEndpoint)
|
||||||
|
let account = null
|
||||||
|
let selectedApiKey = null
|
||||||
|
let accessToken = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -204,16 +207,13 @@ class DroidRelayService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 选择一个可用的 Droid 账户(支持粘性会话和分组调度)
|
// 选择一个可用的 Droid 账户(支持粘性会话和分组调度)
|
||||||
const account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash)
|
account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash)
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`)
|
throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取认证凭据:支持 Access Token 和 API Key 两种模式
|
// 获取认证凭据:支持 Access Token 和 API Key 两种模式
|
||||||
let selectedApiKey = null
|
|
||||||
let accessToken = null
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof account.authenticationMethod === 'string' &&
|
typeof account.authenticationMethod === 'string' &&
|
||||||
account.authenticationMethod.toLowerCase().trim() === 'api_key'
|
account.authenticationMethod.toLowerCase().trim() === 'api_key'
|
||||||
@@ -258,12 +258,15 @@ class DroidRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理请求体(注入 system prompt 等)
|
// 处理请求体(注入 system prompt 等)
|
||||||
|
const streamRequested = !disableStreaming && this._isStreamRequested(normalizedRequestBody)
|
||||||
|
|
||||||
const processedBody = this._processRequestBody(normalizedRequestBody, normalizedEndpoint, {
|
const processedBody = this._processRequestBody(normalizedRequestBody, normalizedEndpoint, {
|
||||||
disableStreaming
|
disableStreaming,
|
||||||
|
streamRequested
|
||||||
})
|
})
|
||||||
|
|
||||||
// 发送请求
|
// 发送请求
|
||||||
const isStreaming = disableStreaming ? false : processedBody.stream !== false
|
const isStreaming = streamRequested
|
||||||
|
|
||||||
// 根据是否流式选择不同的处理方式
|
// 根据是否流式选择不同的处理方式
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
@@ -279,7 +282,10 @@ class DroidRelayService {
|
|||||||
keyInfo,
|
keyInfo,
|
||||||
normalizedRequestBody,
|
normalizedRequestBody,
|
||||||
normalizedEndpoint,
|
normalizedEndpoint,
|
||||||
skipUsageRecord
|
skipUsageRecord,
|
||||||
|
selectedApiKey,
|
||||||
|
sessionHash,
|
||||||
|
clientApiKeyId
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 非流式响应:使用 axios
|
// 非流式响应:使用 axios
|
||||||
@@ -288,7 +294,7 @@ class DroidRelayService {
|
|||||||
url: apiUrl,
|
url: apiUrl,
|
||||||
headers,
|
headers,
|
||||||
data: processedBody,
|
data: processedBody,
|
||||||
timeout: 120000, // 2分钟超时
|
timeout: 600 * 1000, // 10分钟超时
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
...(proxyAgent && {
|
...(proxyAgent && {
|
||||||
httpAgent: proxyAgent,
|
httpAgent: proxyAgent,
|
||||||
@@ -314,6 +320,21 @@ class DroidRelayService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Droid relay error: ${error.message}`, error)
|
logger.error(`❌ Droid relay error: ${error.message}`, error)
|
||||||
|
|
||||||
|
const status = error?.response?.status
|
||||||
|
if (status >= 400 && status < 500) {
|
||||||
|
try {
|
||||||
|
await this._handleUpstreamClientError(status, {
|
||||||
|
account,
|
||||||
|
selectedAccountApiKey: selectedApiKey,
|
||||||
|
endpointType: normalizedEndpoint,
|
||||||
|
sessionHash,
|
||||||
|
clientApiKeyId
|
||||||
|
})
|
||||||
|
} catch (handlingError) {
|
||||||
|
logger.error('❌ 处理 Droid 4xx 异常失败:', handlingError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
// HTTP 错误响应
|
// HTTP 错误响应
|
||||||
return {
|
return {
|
||||||
@@ -352,7 +373,10 @@ class DroidRelayService {
|
|||||||
apiKeyData,
|
apiKeyData,
|
||||||
requestBody,
|
requestBody,
|
||||||
endpointType,
|
endpointType,
|
||||||
skipUsageRecord = false
|
skipUsageRecord = false,
|
||||||
|
selectedAccountApiKey = null,
|
||||||
|
sessionHash = null,
|
||||||
|
clientApiKeyId = null
|
||||||
) {
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = new URL(apiUrl)
|
const url = new URL(apiUrl)
|
||||||
@@ -448,7 +472,7 @@ class DroidRelayService {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
agent: proxyAgent,
|
agent: proxyAgent,
|
||||||
timeout: 120000
|
timeout: 600 * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
@@ -468,6 +492,17 @@ class DroidRelayService {
|
|||||||
logger.info('✅ res.end() reached')
|
logger.info('✅ res.end() reached')
|
||||||
const body = Buffer.concat(chunks).toString()
|
const body = Buffer.concat(chunks).toString()
|
||||||
logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`)
|
logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`)
|
||||||
|
if (res.statusCode >= 400 && res.statusCode < 500) {
|
||||||
|
this._handleUpstreamClientError(res.statusCode, {
|
||||||
|
account,
|
||||||
|
selectedAccountApiKey,
|
||||||
|
endpointType,
|
||||||
|
sessionHash,
|
||||||
|
clientApiKeyId
|
||||||
|
}).catch((handlingError) => {
|
||||||
|
logger.error('❌ 处理 Droid 流式4xx 异常失败:', handlingError)
|
||||||
|
})
|
||||||
|
}
|
||||||
if (!clientResponse.headersSent) {
|
if (!clientResponse.headersSent) {
|
||||||
clientResponse.status(res.statusCode).json({
|
clientResponse.status(res.statusCode).json({
|
||||||
error: 'upstream_error',
|
error: 'upstream_error',
|
||||||
@@ -884,13 +919,37 @@ class DroidRelayService {
|
|||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断请求是否要求流式响应
|
||||||
|
*/
|
||||||
|
_isStreamRequested(requestBody) {
|
||||||
|
if (!requestBody || typeof requestBody !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = requestBody.stream
|
||||||
|
|
||||||
|
if (value === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理请求体(注入 system prompt 等)
|
* 处理请求体(注入 system prompt 等)
|
||||||
*/
|
*/
|
||||||
_processRequestBody(requestBody, endpointType, options = {}) {
|
_processRequestBody(requestBody, endpointType, options = {}) {
|
||||||
const { disableStreaming = false } = options
|
const { disableStreaming = false, streamRequested = false } = options
|
||||||
const processedBody = { ...requestBody }
|
const processedBody = { ...requestBody }
|
||||||
|
|
||||||
|
const hasStreamField =
|
||||||
|
requestBody && Object.prototype.hasOwnProperty.call(requestBody, 'stream')
|
||||||
|
|
||||||
const shouldDisableThinking =
|
const shouldDisableThinking =
|
||||||
endpointType === 'anthropic' && processedBody.__forceDisableThinking === true
|
endpointType === 'anthropic' && processedBody.__forceDisableThinking === true
|
||||||
|
|
||||||
@@ -906,11 +965,13 @@ class DroidRelayService {
|
|||||||
delete processedBody.metadata
|
delete processedBody.metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disableStreaming) {
|
if (disableStreaming || !streamRequested) {
|
||||||
if ('stream' in processedBody) {
|
if (hasStreamField) {
|
||||||
|
processedBody.stream = false
|
||||||
|
} else if ('stream' in processedBody) {
|
||||||
delete processedBody.stream
|
delete processedBody.stream
|
||||||
}
|
}
|
||||||
} else if (processedBody.stream === undefined) {
|
} else {
|
||||||
processedBody.stream = true
|
processedBody.stream = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,6 +1156,152 @@ class DroidRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理上游 4xx 响应,移除问题 API Key 或停止账号调度
|
||||||
|
*/
|
||||||
|
async _handleUpstreamClientError(statusCode, context = {}) {
|
||||||
|
if (!statusCode || statusCode < 400 || statusCode >= 500) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
account,
|
||||||
|
selectedAccountApiKey = null,
|
||||||
|
endpointType = null,
|
||||||
|
sessionHash = null,
|
||||||
|
clientApiKeyId = null
|
||||||
|
} = context
|
||||||
|
|
||||||
|
const accountId = this._extractAccountId(account)
|
||||||
|
if (!accountId) {
|
||||||
|
logger.warn('⚠️ 上游 4xx 处理被跳过:缺少有效的账户信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEndpoint = this._normalizeEndpointType(
|
||||||
|
endpointType || account?.endpointType || 'anthropic'
|
||||||
|
)
|
||||||
|
const authMethod =
|
||||||
|
typeof account?.authenticationMethod === 'string'
|
||||||
|
? account.authenticationMethod.toLowerCase().trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (authMethod === 'api_key') {
|
||||||
|
if (selectedAccountApiKey?.id) {
|
||||||
|
let removalResult = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
removalResult = await droidAccountService.removeApiKeyEntry(
|
||||||
|
accountId,
|
||||||
|
selectedAccountApiKey.id
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})失败:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash)
|
||||||
|
|
||||||
|
if (removalResult?.removed) {
|
||||||
|
logger.warn(
|
||||||
|
`🚫 上游返回 ${statusCode},已移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 上游返回 ${statusCode},但未能移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!removalResult || removalResult.remainingCount === 0) {
|
||||||
|
await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key 已全部失效')
|
||||||
|
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`ℹ️ Droid 账号 ${accountId} 仍有 ${removalResult.remainingCount} 个 API Key 可用`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 上游返回 ${statusCode},但未获取到对应的 Droid API Key(Account: ${accountId})`
|
||||||
|
)
|
||||||
|
await this._stopDroidAccountScheduling(accountId, statusCode, '缺少可用 API Key')
|
||||||
|
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._stopDroidAccountScheduling(accountId, statusCode, '凭证不可用')
|
||||||
|
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止指定 Droid 账号的调度
|
||||||
|
*/
|
||||||
|
async _stopDroidAccountScheduling(accountId, statusCode, reason = '') {
|
||||||
|
if (!accountId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = reason ? `${reason}` : '上游返回 4xx 错误'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await droidAccountService.updateAccount(accountId, {
|
||||||
|
schedulable: 'false',
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: `上游返回 ${statusCode}:${message}`
|
||||||
|
})
|
||||||
|
logger.warn(`🚫 已停止调度 Droid 账号 ${accountId}(状态码 ${statusCode},原因:${message})`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 停止调度 Droid 账号失败:${accountId}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理账号层面的粘性调度映射
|
||||||
|
*/
|
||||||
|
async _clearAccountStickyMapping(endpointType, sessionHash, clientApiKeyId) {
|
||||||
|
if (!sessionHash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||||
|
const apiKeyPart = clientApiKeyId || 'default'
|
||||||
|
const stickyKey = `droid:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await redis.deleteSessionAccountMapping(stickyKey)
|
||||||
|
logger.debug(`🧹 已清理 Droid 粘性会话映射:${stickyKey}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ 清理 Droid 粘性会话映射失败:${stickyKey}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 API Key 级别的粘性映射
|
||||||
|
*/
|
||||||
|
async _clearApiKeyStickyMapping(accountId, endpointType, sessionHash) {
|
||||||
|
if (!accountId || !sessionHash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stickyKey = this._composeApiKeyStickyKey(accountId, endpointType, sessionHash)
|
||||||
|
if (stickyKey) {
|
||||||
|
await redis.deleteSessionAccountMapping(stickyKey)
|
||||||
|
logger.debug(`🧹 已清理 Droid API Key 粘性映射:${stickyKey}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 清理 Droid API Key 粘性映射失败:${accountId}(endpoint: ${endpointType})`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_mapNetworkErrorStatus(error) {
|
_mapNetworkErrorStatus(error) {
|
||||||
const code = (error && error.code ? String(error.code) : '').toUpperCase()
|
const code = (error && error.code ? String(error.code) : '').toUpperCase()
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class ClaudeCodeValidator {
|
|||||||
* @param {Object} body - 请求体
|
* @param {Object} body - 请求体
|
||||||
* @returns {boolean} 是否包含 Claude Code 系统提示词
|
* @returns {boolean} 是否包含 Claude Code 系统提示词
|
||||||
*/
|
*/
|
||||||
static hasClaudeCodeSystemPrompt(body) {
|
static hasClaudeCodeSystemPrompt(body, customThreshold) {
|
||||||
if (!body || typeof body !== 'object') {
|
if (!body || typeof body !== 'object') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -55,12 +55,17 @@ class ClaudeCodeValidator {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const threshold =
|
||||||
|
typeof customThreshold === 'number' && Number.isFinite(customThreshold)
|
||||||
|
? customThreshold
|
||||||
|
: SYSTEM_PROMPT_THRESHOLD
|
||||||
|
|
||||||
for (const entry of systemEntries) {
|
for (const entry of systemEntries) {
|
||||||
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
||||||
const { bestScore } = bestSimilarityByTemplates(rawText)
|
const { bestScore } = bestSimilarityByTemplates(rawText)
|
||||||
if (bestScore < SYSTEM_PROMPT_THRESHOLD) {
|
if (bestScore < threshold) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${SYSTEM_PROMPT_THRESHOLD}, prompt=${rawText}`
|
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}, prompt=${rawText}`
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -68,6 +73,54 @@ class ClaudeCodeValidator {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否存在 Claude Code 系统提示词(存在即返回 true)
|
||||||
|
* @param {Object} body - 请求体
|
||||||
|
* @param {number} [customThreshold] - 自定义阈值
|
||||||
|
* @returns {boolean} 是否存在 Claude Code 系统提示词
|
||||||
|
*/
|
||||||
|
static includesClaudeCodeSystemPrompt(body, customThreshold) {
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = typeof body.model === 'string' ? body.model : null
|
||||||
|
if (!model) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemEntries = Array.isArray(body.system) ? body.system : null
|
||||||
|
if (!systemEntries) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold =
|
||||||
|
typeof customThreshold === 'number' && Number.isFinite(customThreshold)
|
||||||
|
? customThreshold
|
||||||
|
: SYSTEM_PROMPT_THRESHOLD
|
||||||
|
|
||||||
|
let bestMatchScore = 0
|
||||||
|
|
||||||
|
for (const entry of systemEntries) {
|
||||||
|
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
||||||
|
const { bestScore } = bestSimilarityByTemplates(rawText)
|
||||||
|
|
||||||
|
if (bestScore > bestMatchScore) {
|
||||||
|
bestMatchScore = bestScore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestScore >= threshold) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Claude system prompt not detected: bestScore=${bestMatchScore.toFixed(4)}, threshold=${threshold}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证请求是否来自 Claude Code CLI
|
* 验证请求是否来自 Claude Code CLI
|
||||||
* @param {Object} req - Express 请求对象
|
* @param {Object} req - Express 请求对象
|
||||||
|
|||||||
@@ -1773,6 +1773,9 @@
|
|||||||
<ul class="mt-1 list-disc space-y-1 pl-4">
|
<ul class="mt-1 list-disc space-y-1 pl-4">
|
||||||
<li>新会话将随机命中一个 Key,并在会话有效期内保持粘性。</li>
|
<li>新会话将随机命中一个 Key,并在会话有效期内保持粘性。</li>
|
||||||
<li>若某 Key 失效,会自动切换到剩余可用 Key,最大化成功率。</li>
|
<li>若某 Key 失效,会自动切换到剩余可用 Key,最大化成功率。</li>
|
||||||
|
<li>
|
||||||
|
若上游返回 4xx 错误码,该 Key 会被自动移除;全部 Key 清空后账号将暂停调度。
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2928,10 +2931,10 @@
|
|||||||
</h5>
|
</h5>
|
||||||
<p class="mb-1 text-sm text-purple-800 dark:text-purple-200">
|
<p class="mb-1 text-sm text-purple-800 dark:text-purple-200">
|
||||||
当前已保存 <strong>{{ existingApiKeyCount }}</strong> 条 API Key。您可以追加新的
|
当前已保存 <strong>{{ existingApiKeyCount }}</strong> 条 API Key。您可以追加新的
|
||||||
Key 或使用下方选项清空后重新填写。
|
Key,或通过下方模式快速覆盖、删除指定 Key。
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-purple-700 dark:text-purple-300">
|
<p class="text-xs text-purple-700 dark:text-purple-300">
|
||||||
留空表示保留现有 Key 不变;填写内容后将覆盖或追加(视清空选项而定)。
|
留空表示保留现有 Key 不变;根据所选模式决定是追加、覆盖还是删除输入的 Key。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2945,7 +2948,7 @@
|
|||||||
v-model="form.apiKeysInput"
|
v-model="form.apiKeysInput"
|
||||||
class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
:class="{ 'border-red-500': errors.apiKeys }"
|
:class="{ 'border-red-500': errors.apiKeys }"
|
||||||
placeholder="留空表示不更新;每行一个 API Key"
|
placeholder="根据模式填写;每行一个 API Key"
|
||||||
rows="6"
|
rows="6"
|
||||||
/>
|
/>
|
||||||
<p v-if="errors.apiKeys" class="mt-1 text-xs text-red-500">
|
<p v-if="errors.apiKeys" class="mt-1 text-xs text-red-500">
|
||||||
@@ -2953,16 +2956,41 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label
|
<div class="space-y-2">
|
||||||
class="flex cursor-pointer items-center gap-2 rounded-md border border-purple-200 bg-white/80 px-3 py-2 text-sm text-purple-800 transition-colors hover:border-purple-300 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100"
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-semibold text-purple-800 dark:text-purple-100"
|
||||||
|
>API Key 更新模式</span
|
||||||
>
|
>
|
||||||
<input
|
<span class="text-xs text-purple-600 dark:text-purple-300">
|
||||||
v-model="form.clearExistingApiKeys"
|
{{ currentApiKeyModeLabel }}
|
||||||
class="rounded border-purple-300 text-purple-600 focus:ring-purple-500 dark:border-purple-500 dark:bg-purple-900"
|
</span>
|
||||||
type="checkbox"
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative grid h-11 grid-cols-3 overflow-hidden rounded-2xl border border-purple-200/80 bg-gradient-to-r from-purple-50/80 via-white to-purple-50/80 shadow-inner dark:border-purple-700/70 dark:from-purple-900/40 dark:via-purple-900/20 dark:to-purple-900/40"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-y-0 rounded-2xl bg-gradient-to-r from-purple-500/90 via-purple-600 to-indigo-500/90 shadow-lg ring-1 ring-purple-100/80 transition-all duration-300 ease-out dark:from-purple-500/70 dark:via-purple-600/70 dark:to-indigo-500/70 dark:ring-purple-400/30"
|
||||||
|
:style="apiKeyModeSliderStyle"
|
||||||
/>
|
/>
|
||||||
<span>清空已有 API Key 后再应用上方的 Key 列表</span>
|
<button
|
||||||
</label>
|
v-for="option in apiKeyModeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="relative z-10 flex items-center justify-center rounded-2xl px-2 text-xs font-semibold transition-all duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-500/60 dark:focus-visible:ring-purple-400/60"
|
||||||
|
:class="
|
||||||
|
form.apiKeyUpdateMode === option.value
|
||||||
|
? 'text-white drop-shadow-sm'
|
||||||
|
: 'text-purple-500/80 hover:text-purple-700 dark:text-purple-200/70 dark:hover:text-purple-100'
|
||||||
|
"
|
||||||
|
type="button"
|
||||||
|
@click="form.apiKeyUpdateMode = option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-purple-700 dark:text-purple-300">
|
||||||
|
{{ currentApiKeyModeDescription }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100"
|
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100"
|
||||||
@@ -2970,7 +2998,9 @@
|
|||||||
<p class="font-medium"><i class="fas fa-lightbulb mr-1" />小提示</p>
|
<p class="font-medium"><i class="fas fa-lightbulb mr-1" />小提示</p>
|
||||||
<ul class="mt-1 list-disc space-y-1 pl-4">
|
<ul class="mt-1 list-disc space-y-1 pl-4">
|
||||||
<li>系统会为新的 Key 自动建立粘性映射,保持同一会话命中同一个 Key。</li>
|
<li>系统会为新的 Key 自动建立粘性映射,保持同一会话命中同一个 Key。</li>
|
||||||
<li>勾选“清空”后保存即彻底移除旧 Key,可用于紧急轮换或封禁处理。</li>
|
<li>追加模式会保留现有 Key 并在末尾追加新的 Key。</li>
|
||||||
|
<li>覆盖模式会先清空旧 Key 再写入上方的新列表。</li>
|
||||||
|
<li>删除模式会根据输入精准移除指定 Key,适合快速处理失效或被封禁的 Key。</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3137,26 +3167,126 @@ const determinePlatformGroup = (platform) => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化代理配置
|
const createDefaultProxyState = () => ({
|
||||||
const initProxyConfig = () => {
|
|
||||||
if (props.account?.proxy && props.account.proxy.host && props.account.proxy.port) {
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
type: props.account.proxy.type || 'socks5',
|
|
||||||
host: props.account.proxy.host,
|
|
||||||
port: props.account.proxy.port,
|
|
||||||
username: props.account.proxy.username || '',
|
|
||||||
password: props.account.proxy.password || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
enabled: false,
|
enabled: false,
|
||||||
type: 'socks5',
|
type: 'socks5',
|
||||||
host: '',
|
host: '',
|
||||||
port: '',
|
port: '',
|
||||||
username: '',
|
username: '',
|
||||||
password: ''
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseProxyResponse = (rawProxy) => {
|
||||||
|
if (!rawProxy) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let proxyObject = rawProxy
|
||||||
|
if (typeof rawProxy === 'string') {
|
||||||
|
try {
|
||||||
|
proxyObject = JSON.parse(rawProxy)
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
proxyObject &&
|
||||||
|
typeof proxyObject === 'object' &&
|
||||||
|
proxyObject.proxy &&
|
||||||
|
typeof proxyObject.proxy === 'object'
|
||||||
|
) {
|
||||||
|
proxyObject = proxyObject.proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proxyObject || typeof proxyObject !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const host =
|
||||||
|
typeof proxyObject.host === 'string'
|
||||||
|
? proxyObject.host.trim()
|
||||||
|
: proxyObject.host !== undefined && proxyObject.host !== null
|
||||||
|
? String(proxyObject.host).trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const port =
|
||||||
|
proxyObject.port !== undefined && proxyObject.port !== null
|
||||||
|
? String(proxyObject.port).trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const type =
|
||||||
|
typeof proxyObject.type === 'string' && proxyObject.type.trim()
|
||||||
|
? proxyObject.type.trim()
|
||||||
|
: 'socks5'
|
||||||
|
|
||||||
|
const username =
|
||||||
|
typeof proxyObject.username === 'string'
|
||||||
|
? proxyObject.username
|
||||||
|
: proxyObject.username !== undefined && proxyObject.username !== null
|
||||||
|
? String(proxyObject.username)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const password =
|
||||||
|
typeof proxyObject.password === 'string'
|
||||||
|
? proxyObject.password
|
||||||
|
: proxyObject.password !== undefined && proxyObject.password !== null
|
||||||
|
? String(proxyObject.password)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeProxyFormState = (rawProxy) => {
|
||||||
|
const parsed = parseProxyResponse(rawProxy)
|
||||||
|
|
||||||
|
if (parsed && parsed.host && parsed.port) {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
type: parsed.type || 'socks5',
|
||||||
|
host: parsed.host,
|
||||||
|
port: parsed.port,
|
||||||
|
username: parsed.username || '',
|
||||||
|
password: parsed.password || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDefaultProxyState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildProxyPayload = (proxyState) => {
|
||||||
|
if (!proxyState || !proxyState.enabled) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = (proxyState.host || '').trim()
|
||||||
|
const portNumber = Number.parseInt(proxyState.port, 10)
|
||||||
|
|
||||||
|
if (!host || Number.isNaN(portNumber) || portNumber <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = proxyState.username ? proxyState.username.trim() : ''
|
||||||
|
const password = proxyState.password ? proxyState.password.trim() : ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: proxyState.type || 'socks5',
|
||||||
|
host,
|
||||||
|
port: portNumber,
|
||||||
|
username: username || null,
|
||||||
|
password: password || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化代理配置
|
||||||
|
const initProxyConfig = () => {
|
||||||
|
return normalizeProxyFormState(props.account?.proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
@@ -3183,7 +3313,7 @@ const form = ref({
|
|||||||
accessToken: '',
|
accessToken: '',
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
apiKeysInput: '',
|
apiKeysInput: '',
|
||||||
clearExistingApiKeys: false,
|
apiKeyUpdateMode: 'append',
|
||||||
proxy: initProxyConfig(),
|
proxy: initProxyConfig(),
|
||||||
// Claude Console 特定字段
|
// Claude Console 特定字段
|
||||||
apiUrl: props.account?.apiUrl || '',
|
apiUrl: props.account?.apiUrl || '',
|
||||||
@@ -3297,6 +3427,47 @@ const parseApiKeysInput = (input) => {
|
|||||||
return uniqueKeys
|
return uniqueKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiKeyModeOptions = [
|
||||||
|
{
|
||||||
|
value: 'append',
|
||||||
|
label: '追加模式',
|
||||||
|
description: '保留现有 Key,并在末尾追加新 Key 列表。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'replace',
|
||||||
|
label: '覆盖模式',
|
||||||
|
description: '先清空旧 Key,再写入上方的新 Key 列表。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'delete',
|
||||||
|
label: '删除模式',
|
||||||
|
description: '输入要移除的 Key,可精准删除失效或被封禁的 Key。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const apiKeyModeSliderStyle = computed(() => {
|
||||||
|
const index = Math.max(
|
||||||
|
apiKeyModeOptions.findIndex((option) => option.value === form.value.apiKeyUpdateMode),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const widthPercent = 100 / apiKeyModeOptions.length
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: `${widthPercent}%`,
|
||||||
|
left: `${index * widthPercent}%`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentApiKeyModeLabel = computed(() => {
|
||||||
|
const option = apiKeyModeOptions.find((item) => item.value === form.value.apiKeyUpdateMode)
|
||||||
|
return option ? option.label : apiKeyModeOptions[0].label
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentApiKeyModeDescription = computed(() => {
|
||||||
|
const option = apiKeyModeOptions.find((item) => item.value === form.value.apiKeyUpdateMode)
|
||||||
|
return option ? option.description : apiKeyModeOptions[0].description
|
||||||
|
})
|
||||||
|
|
||||||
// 表单验证错误
|
// 表单验证错误
|
||||||
const errors = ref({
|
const errors = ref({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -3488,17 +3659,8 @@ const nextStep = async () => {
|
|||||||
const generateSetupTokenAuthUrl = async () => {
|
const generateSetupTokenAuthUrl = async () => {
|
||||||
setupTokenLoading.value = true
|
setupTokenLoading.value = true
|
||||||
try {
|
try {
|
||||||
const proxyConfig = form.value.proxy?.enabled
|
const proxyPayload = buildProxyPayload(form.value.proxy)
|
||||||
? {
|
const proxyConfig = proxyPayload ? { proxy: proxyPayload } : {}
|
||||||
proxy: {
|
|
||||||
type: form.value.proxy.type,
|
|
||||||
host: form.value.proxy.host,
|
|
||||||
port: parseInt(form.value.proxy.port),
|
|
||||||
username: form.value.proxy.username || null,
|
|
||||||
password: form.value.proxy.password || null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
|
|
||||||
const result = await accountsStore.generateClaudeSetupTokenUrl(proxyConfig)
|
const result = await accountsStore.generateClaudeSetupTokenUrl(proxyConfig)
|
||||||
setupTokenAuthUrl.value = result.authUrl
|
setupTokenAuthUrl.value = result.authUrl
|
||||||
@@ -3567,14 +3729,9 @@ const exchangeSetupTokenCode = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加代理配置(如果启用)
|
// 添加代理配置(如果启用)
|
||||||
if (form.value.proxy?.enabled) {
|
const proxyPayload = buildProxyPayload(form.value.proxy)
|
||||||
data.proxy = {
|
if (proxyPayload) {
|
||||||
type: form.value.proxy.type,
|
data.proxy = proxyPayload
|
||||||
host: form.value.proxy.host,
|
|
||||||
port: parseInt(form.value.proxy.port),
|
|
||||||
username: form.value.proxy.username || null,
|
|
||||||
password: form.value.proxy.password || null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenInfo = await accountsStore.exchangeClaudeSetupTokenCode(data)
|
const tokenInfo = await accountsStore.exchangeClaudeSetupTokenCode(data)
|
||||||
@@ -3606,21 +3763,15 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
|||||||
form.value.unifiedClientId = generateClientId()
|
form.value.unifiedClientId = generateClientId()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const proxyPayload = buildProxyPayload(form.value.proxy)
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
description: form.value.description,
|
description: form.value.description,
|
||||||
accountType: form.value.accountType,
|
accountType: form.value.accountType,
|
||||||
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
||||||
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
|
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
|
||||||
proxy: form.value.proxy.enabled
|
proxy: proxyPayload
|
||||||
? {
|
|
||||||
type: form.value.proxy.type,
|
|
||||||
host: form.value.proxy.host,
|
|
||||||
port: parseInt(form.value.proxy.port),
|
|
||||||
username: form.value.proxy.username || null,
|
|
||||||
password: form.value.proxy.password || null
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPlatform = form.value.platform
|
const currentPlatform = form.value.platform
|
||||||
@@ -3903,21 +4054,15 @@ const createAccount = async () => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
const proxyPayload = buildProxyPayload(form.value.proxy)
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
description: form.value.description,
|
description: form.value.description,
|
||||||
accountType: form.value.accountType,
|
accountType: form.value.accountType,
|
||||||
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
||||||
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
|
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
|
||||||
proxy: form.value.proxy.enabled
|
proxy: proxyPayload
|
||||||
? {
|
|
||||||
type: form.value.proxy.type,
|
|
||||||
host: form.value.proxy.host,
|
|
||||||
port: parseInt(form.value.proxy.port),
|
|
||||||
username: form.value.proxy.username || null,
|
|
||||||
password: form.value.proxy.password || null
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.value.platform === 'claude') {
|
if (form.value.platform === 'claude') {
|
||||||
@@ -4168,21 +4313,15 @@ const updateAccount = async () => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
const proxyPayload = buildProxyPayload(form.value.proxy)
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
description: form.value.description,
|
description: form.value.description,
|
||||||
accountType: form.value.accountType,
|
accountType: form.value.accountType,
|
||||||
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
||||||
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
|
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
|
||||||
proxy: form.value.proxy.enabled
|
proxy: proxyPayload
|
||||||
? {
|
|
||||||
type: form.value.proxy.type,
|
|
||||||
host: form.value.proxy.host,
|
|
||||||
port: parseInt(form.value.proxy.port),
|
|
||||||
username: form.value.proxy.username || null,
|
|
||||||
password: form.value.proxy.password || null
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有非空时才更新token
|
// 只有非空时才更新token
|
||||||
@@ -4245,7 +4384,25 @@ const updateAccount = async () => {
|
|||||||
|
|
||||||
if (props.account.platform === 'droid') {
|
if (props.account.platform === 'droid') {
|
||||||
const trimmedApiKeysInput = form.value.apiKeysInput?.trim() || ''
|
const trimmedApiKeysInput = form.value.apiKeysInput?.trim() || ''
|
||||||
|
const apiKeyUpdateMode = form.value.apiKeyUpdateMode || 'append'
|
||||||
|
|
||||||
|
if (apiKeyUpdateMode === 'delete') {
|
||||||
|
if (!trimmedApiKeysInput) {
|
||||||
|
errors.value.apiKeys = '请填写需要删除的 API Key'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeApiKeys = parseApiKeysInput(trimmedApiKeysInput)
|
||||||
|
if (removeApiKeys.length === 0) {
|
||||||
|
errors.value.apiKeys = '请填写需要删除的 API Key'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data.removeApiKeys = removeApiKeys
|
||||||
|
data.apiKeyUpdateMode = 'delete'
|
||||||
|
} else {
|
||||||
if (trimmedApiKeysInput) {
|
if (trimmedApiKeysInput) {
|
||||||
const apiKeys = parseApiKeysInput(trimmedApiKeysInput)
|
const apiKeys = parseApiKeysInput(trimmedApiKeysInput)
|
||||||
if (apiKeys.length === 0) {
|
if (apiKeys.length === 0) {
|
||||||
@@ -4254,10 +4411,13 @@ const updateAccount = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
data.apiKeys = apiKeys
|
data.apiKeys = apiKeys
|
||||||
|
} else if (apiKeyUpdateMode === 'replace') {
|
||||||
|
data.apiKeys = []
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.value.clearExistingApiKeys) {
|
if (apiKeyUpdateMode !== 'append' || trimmedApiKeysInput) {
|
||||||
data.clearApiKeys = true
|
data.apiKeyUpdateMode = apiKeyUpdateMode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditingDroidApiKey.value) {
|
if (isEditingDroidApiKey.value) {
|
||||||
@@ -4606,10 +4766,11 @@ watch(
|
|||||||
errors.value.accessToken = ''
|
errors.value.accessToken = ''
|
||||||
errors.value.refreshToken = ''
|
errors.value.refreshToken = ''
|
||||||
form.value.authenticationMethod = 'api_key'
|
form.value.authenticationMethod = 'api_key'
|
||||||
|
form.value.apiKeyUpdateMode = 'append'
|
||||||
} else if (oldType === 'apikey') {
|
} else if (oldType === 'apikey') {
|
||||||
// 切换离开 API Key 模式时重置 API Key 输入
|
// 切换离开 API Key 模式时重置 API Key 输入
|
||||||
form.value.apiKeysInput = ''
|
form.value.apiKeysInput = ''
|
||||||
form.value.clearExistingApiKeys = false
|
form.value.apiKeyUpdateMode = 'append'
|
||||||
errors.value.apiKeys = ''
|
errors.value.apiKeys = ''
|
||||||
if (!isEdit.value) {
|
if (!isEdit.value) {
|
||||||
form.value.authenticationMethod = ''
|
form.value.authenticationMethod = ''
|
||||||
@@ -4618,6 +4779,20 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听 API Key 更新模式切换,自动清理提示
|
||||||
|
watch(
|
||||||
|
() => form.value.apiKeyUpdateMode,
|
||||||
|
(newMode, oldMode) => {
|
||||||
|
if (newMode === oldMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.value.apiKeys) {
|
||||||
|
errors.value.apiKeys = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 监听 API Key 输入,自动清理错误提示
|
// 监听 API Key 输入,自动清理错误提示
|
||||||
watch(
|
watch(
|
||||||
() => form.value.apiKeysInput,
|
() => form.value.apiKeysInput,
|
||||||
@@ -4626,7 +4801,22 @@ watch(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parseApiKeysInput(newValue).length > 0) {
|
const parsed = parseApiKeysInput(newValue)
|
||||||
|
const mode = form.value.apiKeyUpdateMode
|
||||||
|
|
||||||
|
if (mode === 'append' && parsed.length > 0) {
|
||||||
|
errors.value.apiKeys = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'replace') {
|
||||||
|
if (parsed.length > 0 || !newValue || newValue.trim() === '') {
|
||||||
|
errors.value.apiKeys = ''
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'delete' && parsed.length > 0) {
|
||||||
errors.value.apiKeys = ''
|
errors.value.apiKeys = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4761,24 +4951,17 @@ watch(
|
|||||||
if (newAccount) {
|
if (newAccount) {
|
||||||
initModelMappings()
|
initModelMappings()
|
||||||
// 重新初始化代理配置
|
// 重新初始化代理配置
|
||||||
const proxyConfig =
|
const proxyConfig = normalizeProxyFormState(newAccount.proxy)
|
||||||
newAccount.proxy && newAccount.proxy.host && newAccount.proxy.port
|
const normalizedAuthMethod =
|
||||||
? {
|
typeof newAccount.authenticationMethod === 'string'
|
||||||
enabled: true,
|
? newAccount.authenticationMethod.trim().toLowerCase()
|
||||||
type: newAccount.proxy.type || 'socks5',
|
: ''
|
||||||
host: newAccount.proxy.host,
|
const derivedAddType =
|
||||||
port: newAccount.proxy.port,
|
normalizedAuthMethod === 'api_key'
|
||||||
username: newAccount.proxy.username || '',
|
? 'apikey'
|
||||||
password: newAccount.proxy.password || ''
|
: normalizedAuthMethod === 'manual'
|
||||||
}
|
? 'manual'
|
||||||
: {
|
: 'oauth'
|
||||||
enabled: false,
|
|
||||||
type: 'socks5',
|
|
||||||
host: '',
|
|
||||||
port: '',
|
|
||||||
username: '',
|
|
||||||
password: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取分组ID - 可能来自 groupId 字段或 groupInfo 对象
|
// 获取分组ID - 可能来自 groupId 字段或 groupInfo 对象
|
||||||
let groupId = ''
|
let groupId = ''
|
||||||
@@ -4807,7 +4990,7 @@ watch(
|
|||||||
|
|
||||||
form.value = {
|
form.value = {
|
||||||
platform: newAccount.platform,
|
platform: newAccount.platform,
|
||||||
addType: 'oauth',
|
addType: derivedAddType,
|
||||||
name: newAccount.name,
|
name: newAccount.name,
|
||||||
description: newAccount.description || '',
|
description: newAccount.description || '',
|
||||||
accountType: newAccount.accountType || 'shared',
|
accountType: newAccount.accountType || 'shared',
|
||||||
@@ -4821,6 +5004,9 @@ watch(
|
|||||||
projectId: newAccount.projectId || '',
|
projectId: newAccount.projectId || '',
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
|
authenticationMethod: newAccount.authenticationMethod || '',
|
||||||
|
apiKeysInput: '',
|
||||||
|
apiKeyUpdateMode: 'append',
|
||||||
proxy: proxyConfig,
|
proxy: proxyConfig,
|
||||||
// Claude Console 特定字段
|
// Claude Console 特定字段
|
||||||
apiUrl: newAccount.apiUrl || '',
|
apiUrl: newAccount.apiUrl || '',
|
||||||
|
|||||||
@@ -546,7 +546,16 @@
|
|||||||
>Droid</span
|
>Droid</span
|
||||||
>
|
>
|
||||||
<span class="mx-1 h-4 w-px bg-cyan-300 dark:bg-cyan-600" />
|
<span class="mx-1 h-4 w-px bg-cyan-300 dark:bg-cyan-600" />
|
||||||
<span class="text-xs font-medium text-cyan-700 dark:text-cyan-300">OAuth</span>
|
<span class="text-xs font-medium text-cyan-700 dark:text-cyan-300">
|
||||||
|
{{ getDroidAuthType(account) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="isDroidApiKeyMode(account)"
|
||||||
|
:class="getDroidApiKeyBadgeClasses(account)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-key text-[9px]" />
|
||||||
|
<span>x{{ getDroidApiKeyCount(account) }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -2351,6 +2360,14 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filteredAccounts = filteredAccounts.map((account) => {
|
||||||
|
const proxyConfig = normalizeProxyData(account.proxyConfig || account.proxy)
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
proxyConfig: proxyConfig || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
accounts.value = filteredAccounts
|
accounts.value = filteredAccounts
|
||||||
cleanupSelectedAccounts()
|
cleanupSelectedAccounts()
|
||||||
|
|
||||||
@@ -2489,24 +2506,86 @@ const filterByGroup = () => {
|
|||||||
loadAccounts()
|
loadAccounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 规范化代理配置,支持字符串与对象
|
||||||
|
function normalizeProxyData(proxy) {
|
||||||
|
if (!proxy) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let proxyObject = proxy
|
||||||
|
if (typeof proxy === 'string') {
|
||||||
|
try {
|
||||||
|
proxyObject = JSON.parse(proxy)
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proxyObject || typeof proxyObject !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate =
|
||||||
|
proxyObject.proxy && typeof proxyObject.proxy === 'object' ? proxyObject.proxy : proxyObject
|
||||||
|
|
||||||
|
const host =
|
||||||
|
typeof candidate.host === 'string'
|
||||||
|
? candidate.host.trim()
|
||||||
|
: candidate.host !== undefined && candidate.host !== null
|
||||||
|
? String(candidate.host).trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const port =
|
||||||
|
candidate.port !== undefined && candidate.port !== null ? String(candidate.port).trim() : ''
|
||||||
|
|
||||||
|
if (!host || !port) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const type =
|
||||||
|
typeof candidate.type === 'string' && candidate.type.trim() ? candidate.type.trim() : 'socks5'
|
||||||
|
|
||||||
|
const username =
|
||||||
|
typeof candidate.username === 'string'
|
||||||
|
? candidate.username
|
||||||
|
: candidate.username !== undefined && candidate.username !== null
|
||||||
|
? String(candidate.username)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const password =
|
||||||
|
typeof candidate.password === 'string'
|
||||||
|
? candidate.password
|
||||||
|
: candidate.password !== undefined && candidate.password !== null
|
||||||
|
? String(candidate.password)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化代理信息显示
|
// 格式化代理信息显示
|
||||||
const formatProxyDisplay = (proxy) => {
|
const formatProxyDisplay = (proxy) => {
|
||||||
if (!proxy || !proxy.host || !proxy.port) return null
|
const parsed = normalizeProxyData(proxy)
|
||||||
|
if (!parsed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// 缩短类型名称
|
const typeShort = parsed.type.toLowerCase() === 'socks5' ? 'S5' : parsed.type.toUpperCase()
|
||||||
const typeShort = proxy.type === 'socks5' ? 'S5' : proxy.type.toUpperCase()
|
|
||||||
|
|
||||||
// 缩短主机名(如果太长)
|
let host = parsed.host
|
||||||
let host = proxy.host
|
|
||||||
if (host.length > 15) {
|
if (host.length > 15) {
|
||||||
host = host.substring(0, 12) + '...'
|
host = host.substring(0, 12) + '...'
|
||||||
}
|
}
|
||||||
|
|
||||||
let display = `${typeShort}://${host}:${proxy.port}`
|
let display = `${typeShort}://${host}:${parsed.port}`
|
||||||
|
|
||||||
// 如果有用户名密码,添加认证信息(部分隐藏)
|
if (parsed.username) {
|
||||||
if (proxy.username) {
|
display = `${typeShort}://***@${host}:${parsed.port}`
|
||||||
display = `${typeShort}://***@${host}:${proxy.port}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return display
|
return display
|
||||||
@@ -2960,6 +3039,112 @@ const getOpenAIAuthType = () => {
|
|||||||
return 'OAuth'
|
return 'OAuth'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取 Droid 账号的认证方式
|
||||||
|
const getDroidAuthType = (account) => {
|
||||||
|
if (!account || typeof account !== 'object') {
|
||||||
|
return 'OAuth'
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyModeFlag =
|
||||||
|
account.isApiKeyMode ?? account.is_api_key_mode ?? account.apiKeyMode ?? account.api_key_mode
|
||||||
|
|
||||||
|
if (
|
||||||
|
apiKeyModeFlag === true ||
|
||||||
|
apiKeyModeFlag === 'true' ||
|
||||||
|
apiKeyModeFlag === 1 ||
|
||||||
|
apiKeyModeFlag === '1'
|
||||||
|
) {
|
||||||
|
return 'API Key'
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodCandidate =
|
||||||
|
account.authenticationMethod ||
|
||||||
|
account.authMethod ||
|
||||||
|
account.authentication_mode ||
|
||||||
|
account.authenticationMode ||
|
||||||
|
account.authentication_method ||
|
||||||
|
account.auth_type ||
|
||||||
|
account.authType ||
|
||||||
|
account.authentication_type ||
|
||||||
|
account.authenticationType ||
|
||||||
|
account.droidAuthType ||
|
||||||
|
account.droidAuthenticationMethod ||
|
||||||
|
account.method ||
|
||||||
|
account.auth ||
|
||||||
|
''
|
||||||
|
|
||||||
|
if (typeof methodCandidate === 'string') {
|
||||||
|
const normalized = methodCandidate.trim().toLowerCase()
|
||||||
|
const compacted = normalized.replace(/[\s_-]/g, '')
|
||||||
|
|
||||||
|
if (compacted === 'apikey') {
|
||||||
|
return 'API Key'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'OAuth'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否为 API Key 模式的 Droid 账号
|
||||||
|
const isDroidApiKeyMode = (account) => getDroidAuthType(account) === 'API Key'
|
||||||
|
|
||||||
|
// 获取 Droid 账号的 API Key 数量
|
||||||
|
const getDroidApiKeyCount = (account) => {
|
||||||
|
if (!account || typeof account !== 'object') {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
account.apiKeyCount,
|
||||||
|
account.api_key_count,
|
||||||
|
account.apiKeysCount,
|
||||||
|
account.api_keys_count
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const value = Number(candidate)
|
||||||
|
if (Number.isFinite(value) && value >= 0) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(account.apiKeys)) {
|
||||||
|
return account.apiKeys.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof account.apiKeys === 'string' && account.apiKeys.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(account.apiKeys)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略解析错误,维持默认值
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据数量返回徽标样式
|
||||||
|
const getDroidApiKeyBadgeClasses = (account) => {
|
||||||
|
const count = getDroidApiKeyCount(account)
|
||||||
|
const baseClass =
|
||||||
|
'ml-1 inline-flex items-center gap-1 rounded-md border px-1.5 py-[1px] text-[10px] font-medium shadow-sm backdrop-blur-sm'
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
return [
|
||||||
|
baseClass,
|
||||||
|
'border-cyan-200 bg-cyan-50/90 text-cyan-700 dark:border-cyan-500/40 dark:bg-cyan-900/40 dark:text-cyan-200'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
baseClass,
|
||||||
|
'border-rose-200 bg-rose-50/90 text-rose-600 dark:border-rose-500/40 dark:bg-rose-900/40 dark:text-rose-200'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// 获取 Claude 账号类型显示
|
// 获取 Claude 账号类型显示
|
||||||
const getClaudeAccountType = (account) => {
|
const getClaudeAccountType = (account) => {
|
||||||
// 如果有订阅信息
|
// 如果有订阅信息
|
||||||
|
|||||||
@@ -2309,7 +2309,7 @@ const droidCliConfigLines = computed(() => [
|
|||||||
'{',
|
'{',
|
||||||
' "custom_models": [',
|
' "custom_models": [',
|
||||||
' {',
|
' {',
|
||||||
' "model_display_name": "Sonnet 4.5 [Custom]",',
|
' "model_display_name": "Sonnet 4.5 [crs]",',
|
||||||
' "model": "claude-sonnet-4-5-20250929",',
|
' "model": "claude-sonnet-4-5-20250929",',
|
||||||
` "base_url": "${droidClaudeBaseUrl.value}",`,
|
` "base_url": "${droidClaudeBaseUrl.value}",`,
|
||||||
' "api_key": "你的API密钥",',
|
' "api_key": "你的API密钥",',
|
||||||
@@ -2317,7 +2317,7 @@ const droidCliConfigLines = computed(() => [
|
|||||||
' "max_tokens": 8192',
|
' "max_tokens": 8192',
|
||||||
' },',
|
' },',
|
||||||
' {',
|
' {',
|
||||||
' "model_display_name": "GPT5-Codex [Custom]",',
|
' "model_display_name": "GPT5-Codex [crs]",',
|
||||||
' "model": "gpt-5-codex",',
|
' "model": "gpt-5-codex",',
|
||||||
` "base_url": "${droidOpenaiBaseUrl.value}",`,
|
` "base_url": "${droidOpenaiBaseUrl.value}",`,
|
||||||
' "api_key": "你的API密钥",',
|
' "api_key": "你的API密钥",',
|
||||||
|
|||||||
Reference in New Issue
Block a user