diff --git a/README.md b/README.md
index b3c37558..a5c8961b 100644
--- a/README.md
+++ b/README.md
@@ -480,7 +480,7 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义
{
"custom_models": [
{
- "model_display_name": "Sonnet 4.5 [Custom]",
+ "model_display_name": "Sonnet 4.5 [crs]",
"model": "claude-sonnet-4-5-20250929",
"base_url": "http://127.0.0.1:3000/droid/claude",
"api_key": "后台创建的API密钥",
@@ -488,7 +488,7 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义
"max_tokens": 8192
},
{
- "model_display_name": "GPT5-Codex [Custom]",
+ "model_display_name": "GPT5-Codex [crs]",
"model": "gpt-5-codex",
"base_url": "http://127.0.0.1:3000/droid/openai",
"api_key": "后台创建的API密钥",
diff --git a/VERSION b/VERSION
index 4cc87f7d..3e8f33bb 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.165
+1.1.169
diff --git a/docs/images/add-claude-account.png b/docs/images/add-claude-account.png
deleted file mode 100644
index 0dc89327..00000000
Binary files a/docs/images/add-claude-account.png and /dev/null differ
diff --git a/docs/images/api-keys-list.png b/docs/images/api-keys-list.png
deleted file mode 100644
index 3e18e920..00000000
Binary files a/docs/images/api-keys-list.png and /dev/null differ
diff --git a/docs/images/claude-accounts-list.png b/docs/images/claude-accounts-list.png
deleted file mode 100644
index 97f3a448..00000000
Binary files a/docs/images/claude-accounts-list.png and /dev/null differ
diff --git a/docs/images/dashboard-overview.png b/docs/images/dashboard-overview.png
deleted file mode 100644
index 4a9b9327..00000000
Binary files a/docs/images/dashboard-overview.png and /dev/null differ
diff --git a/docs/images/tutorial.png b/docs/images/tutorial.png
deleted file mode 100644
index 5da02086..00000000
Binary files a/docs/images/tutorial.png and /dev/null differ
diff --git a/docs/preview.md b/docs/preview.md
deleted file mode 100644
index 3f1ded55..00000000
--- a/docs/preview.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Claude Relay Service 界面预览
-
-
-
-**🎨 Web管理界面截图展示**
-
-
-
----
-
-## 📊 管理面板概览
-
-
-### 仪表板
-
-
-*实时显示API调用次数、Token使用量、成本统计等关键指标*
-
----
-
-## 🔑 API密钥管理
-
-### API密钥列表
-
-
-*查看和管理所有创建的API密钥,包括使用量统计和状态信息*
-
----
-
-## 👤 Claude账户管理
-
-### 账户列表
-
-
-*管理多个Claude账户,查看账户状态和使用情况*
-
-### 添加新账户
-
-
-*通过OAuth授权添加新的Claude账户*
-
-### 使用教程
-
-
-*windows、macos、linux、wsl不同环境的claude code安装教程*
-
----
diff --git a/src/routes/admin.js b/src/routes/admin.js
index bad940ba..f0a3035e 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -1745,31 +1745,54 @@ router.delete('/account-groups/:groupId', authenticateAdmin, async (req, res) =>
router.get('/account-groups/:groupId/members', authenticateAdmin, async (req, res) => {
try {
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 members = []
for (const memberId of memberIds) {
- // 尝试从不同的服务获取账户信息
+ // 根据分组平台优先查找对应账户
let account = null
+ switch (group.platform) {
+ 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)
+ if (!account) {
+ account = await claudeConsoleAccountService.getAccount(memberId)
+ }
+ break
+ }
- // 先尝试Claude OAuth账户
- account = await claudeAccountService.getAccount(memberId)
-
- // 如果找不到,尝试Claude Console账户
+ // 兼容旧数据:若按平台未找到,则继续尝试其他平台
+ if (!account) {
+ account = await claudeAccountService.getAccount(memberId)
+ }
if (!account) {
account = await claudeConsoleAccountService.getAccount(memberId)
}
-
- // 如果还找不到,尝试Gemini账户
if (!account) {
account = await geminiAccountService.getAccount(memberId)
}
-
- // 如果还找不到,尝试OpenAI账户
if (!account) {
account = await openaiAccountService.getAccount(memberId)
}
+ if (!account && group.platform !== 'droid') {
+ account = await droidAccountService.getAccount(memberId)
+ }
if (account) {
members.push(account)
@@ -8676,7 +8699,52 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
// 创建 Droid 账户
router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
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})`)
return res.json({ success: true, data: account })
} catch (error) {
@@ -8689,7 +8757,72 @@ router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try {
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 })
} catch (error) {
logger.error(`Failed to update Droid account ${req.params.id}:`, error)
@@ -8697,6 +8830,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 账户
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try {
diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js
index 8d8909b8..389ae26f 100644
--- a/src/services/claudeRelayService.js
+++ b/src/services/claudeRelayService.js
@@ -38,9 +38,60 @@ class ClaudeRelayService {
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 请求
isRealClaudeCodeRequest(requestBody) {
- return ClaudeCodeValidator.hasClaudeCodeSystemPrompt(requestBody)
+ return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1)
}
// 🚀 转发请求到Claude API
@@ -189,6 +240,10 @@ class ClaudeRelayService {
let isRateLimited = false
let rateLimitResetTimestamp = null
let dedicatedRateLimitMessage = null
+ const organizationDisabledError = this._isOrganizationDisabledError(
+ response.statusCode,
+ response.body
+ )
// 检查是否为401状态码(未授权)
if (response.statusCode === 401) {
@@ -221,6 +276,13 @@ class ClaudeRelayService {
)
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状态码(服务过载)
else if (response.statusCode === 529) {
logger.warn(`🚫 Overload error (529) detected for account ${accountId}`)
@@ -499,6 +561,8 @@ class ClaudeRelayService {
}
}
+ this._enforceCacheControlLimit(processedBody)
+
// 处理原有的系统提示(如果配置了)
if (this.systemPrompt && this.systemPrompt.trim()) {
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(使用统一的代理工具)
async _getProxyAgent(accountId) {
try {
@@ -1253,6 +1418,25 @@ class ClaudeRelayService {
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
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) {
// 发送错误事件
responseStream.write('event: error\n')
diff --git a/src/services/droidAccountService.js b/src/services/droidAccountService.js
index b79fd3bf..c6baecbf 100644
--- a/src/services/droidAccountService.js
+++ b/src/services/droidAccountService.js
@@ -65,6 +65,26 @@ class DroidAccountService {
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 刷新并验证凭证
*/
@@ -781,6 +841,9 @@ class DroidAccountService {
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 }
if (typeof sanitizedUpdates.accessToken === 'string') {
@@ -902,9 +965,33 @@ class DroidAccountService {
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 removeApiKeysInput = Array.isArray(updates.removeApiKeys) ? updates.removeApiKeys : []
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) {
delete sanitizedUpdates.apiKeys
@@ -912,33 +999,94 @@ class DroidAccountService {
if (sanitizedUpdates.clearApiKeys !== undefined) {
delete sanitizedUpdates.clearApiKeys
}
+ if (sanitizedUpdates.apiKeyUpdateMode !== undefined) {
+ delete sanitizedUpdates.apiKeyUpdateMode
+ }
+ if (sanitizedUpdates.removeApiKeys !== undefined) {
+ delete sanitizedUpdates.removeApiKeys
+ }
- if (wantsClearApiKeys || newApiKeysInput.length > 0) {
- const mergedApiKeys = this._buildApiKeyEntries(
+ let mergedApiKeys = existingApiKeyEntries
+ 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,
existingApiKeyEntries,
- wantsClearApiKeys
+ clearExisting
)
- const baselineCount = wantsClearApiKeys ? 0 : existingApiKeyEntries.length
- const addedCount = Math.max(mergedApiKeys.length - baselineCount, 0)
+ addedCount = Math.max(mergedApiKeys.length - baselineCount, 0)
+ apiKeysUpdated = clearExisting || addedCount > 0
+ }
+ if (apiKeysUpdated) {
sanitizedUpdates.apiKeys = mergedApiKeys.length ? JSON.stringify(mergedApiKeys) : ''
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) {
sanitizedUpdates.authenticationMethod = 'api_key'
sanitizedUpdates.status = sanitizedUpdates.status || 'active'
- logger.info(
- `🔑 Updated Droid API keys for ${accountId}: total ${mergedApiKeys.length} (added ${addedCount})`
- )
- } else {
- logger.info(`🔑 Cleared all API keys for Droid account ${accountId}`)
- // 如果完全移除 API Key,可根据是否仍有 token 来确定认证方式
- if (!sanitizedUpdates.accessToken && !account.accessToken) {
- sanitizedUpdates.authenticationMethod =
- account.authenticationMethod === 'api_key' ? '' : account.authenticationMethod
- }
+ } else if (!sanitizedUpdates.accessToken && !account.accessToken) {
+ const shouldPreserveApiKeyMode =
+ account.authenticationMethod &&
+ account.authenticationMethod.toLowerCase().trim() === 'api_key' &&
+ (apiKeyUpdateMode === 'replace' || apiKeyUpdateMode === 'delete')
+
+ sanitizedUpdates.authenticationMethod = shouldPreserveApiKeyMode
+ ? 'api_key'
+ : account.authenticationMethod === 'api_key'
+ ? ''
+ : account.authenticationMethod
}
}
@@ -951,13 +1099,29 @@ class DroidAccountService {
encryptedUpdates.accessToken = this._encryptSensitiveData(sanitizedUpdates.accessToken)
}
+ const baseAccountData = hasStoredAccount ? { ...storedAccount } : { id: accountId }
+
const updatedData = {
- ...account,
- ...encryptedUpdates,
- refreshToken:
- encryptedUpdates.refreshToken || this._encryptSensitiveData(account.refreshToken),
- accessToken: encryptedUpdates.accessToken || this._encryptSensitiveData(account.accessToken),
- proxy: encryptedUpdates.proxy
+ ...baseAccountData,
+ ...encryptedUpdates
+ }
+
+ if (!Object.prototype.hasOwnProperty.call(updatedData, 'refreshToken')) {
+ 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)
@@ -1134,13 +1298,11 @@ class DroidAccountService {
return allAccounts
.filter((account) => {
- // 基本过滤条件
- const isSchedulable =
- account.isActive === 'true' &&
- account.schedulable === 'true' &&
- account.status === 'active'
+ const isActive = this._isTruthy(account.isActive)
+ const isSchedulable = this._isTruthy(account.schedulable)
+ const status = typeof account.status === 'string' ? account.status.toLowerCase() : ''
- if (!isSchedulable) {
+ if (!isActive || !isSchedulable || status !== 'active') {
return false
}
diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js
index 73c6200d..604aa10f 100644
--- a/src/services/droidRelayService.js
+++ b/src/services/droidRelayService.js
@@ -8,8 +8,7 @@ const redis = require('../models/redis')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const logger = require('../utils/logger')
-const SYSTEM_PROMPT =
- '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 SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.'
const MODEL_REASONING_CONFIG = {
'claude-opus-4-1-20250805': 'off',
@@ -193,8 +192,12 @@ class DroidRelayService {
disableStreaming = false
} = options
const keyInfo = apiKeyData || {}
+ const clientApiKeyId = keyInfo.id || null
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const normalizedRequestBody = this._normalizeRequestBody(requestBody, normalizedEndpoint)
+ let account = null
+ let selectedApiKey = null
+ let accessToken = null
try {
logger.info(
@@ -204,16 +207,13 @@ class DroidRelayService {
)
// 选择一个可用的 Droid 账户(支持粘性会话和分组调度)
- const account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash)
+ account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash)
if (!account) {
throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`)
}
// 获取认证凭据:支持 Access Token 和 API Key 两种模式
- let selectedApiKey = null
- let accessToken = null
-
if (
typeof account.authenticationMethod === 'string' &&
account.authenticationMethod.toLowerCase().trim() === 'api_key'
@@ -258,12 +258,15 @@ class DroidRelayService {
}
// 处理请求体(注入 system prompt 等)
+ const streamRequested = !disableStreaming && this._isStreamRequested(normalizedRequestBody)
+
const processedBody = this._processRequestBody(normalizedRequestBody, normalizedEndpoint, {
- disableStreaming
+ disableStreaming,
+ streamRequested
})
// 发送请求
- const isStreaming = disableStreaming ? false : processedBody.stream !== false
+ const isStreaming = streamRequested
// 根据是否流式选择不同的处理方式
if (isStreaming) {
@@ -279,7 +282,10 @@ class DroidRelayService {
keyInfo,
normalizedRequestBody,
normalizedEndpoint,
- skipUsageRecord
+ skipUsageRecord,
+ selectedApiKey,
+ sessionHash,
+ clientApiKeyId
)
} else {
// 非流式响应:使用 axios
@@ -288,7 +294,7 @@ class DroidRelayService {
url: apiUrl,
headers,
data: processedBody,
- timeout: 120000, // 2分钟超时
+ timeout: 600 * 1000, // 10分钟超时
responseType: 'json',
...(proxyAgent && {
httpAgent: proxyAgent,
@@ -314,6 +320,21 @@ class DroidRelayService {
} catch (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) {
// HTTP 错误响应
return {
@@ -352,7 +373,10 @@ class DroidRelayService {
apiKeyData,
requestBody,
endpointType,
- skipUsageRecord = false
+ skipUsageRecord = false,
+ selectedAccountApiKey = null,
+ sessionHash = null,
+ clientApiKeyId = null
) {
return new Promise((resolve, reject) => {
const url = new URL(apiUrl)
@@ -448,7 +472,7 @@ class DroidRelayService {
method: 'POST',
headers: requestHeaders,
agent: proxyAgent,
- timeout: 120000
+ timeout: 600 * 1000
}
const req = https.request(options, (res) => {
@@ -468,6 +492,17 @@ class DroidRelayService {
logger.info('✅ res.end() reached')
const body = Buffer.concat(chunks).toString()
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) {
clientResponse.status(res.statusCode).json({
error: 'upstream_error',
@@ -884,13 +919,37 @@ class DroidRelayService {
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 等)
*/
_processRequestBody(requestBody, endpointType, options = {}) {
- const { disableStreaming = false } = options
+ const { disableStreaming = false, streamRequested = false } = options
const processedBody = { ...requestBody }
+ const hasStreamField =
+ requestBody && Object.prototype.hasOwnProperty.call(requestBody, 'stream')
+
const shouldDisableThinking =
endpointType === 'anthropic' && processedBody.__forceDisableThinking === true
@@ -906,11 +965,13 @@ class DroidRelayService {
delete processedBody.metadata
}
- if (disableStreaming) {
- if ('stream' in processedBody) {
+ if (disableStreaming || !streamRequested) {
+ if (hasStreamField) {
+ processedBody.stream = false
+ } else if ('stream' in processedBody) {
delete processedBody.stream
}
- } else if (processedBody.stream === undefined) {
+ } else {
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) {
const code = (error && error.code ? String(error.code) : '').toUpperCase()
diff --git a/src/validators/clients/claudeCodeValidator.js b/src/validators/clients/claudeCodeValidator.js
index b538024b..a72928f4 100644
--- a/src/validators/clients/claudeCodeValidator.js
+++ b/src/validators/clients/claudeCodeValidator.js
@@ -40,7 +40,7 @@ class ClaudeCodeValidator {
* @param {Object} body - 请求体
* @returns {boolean} 是否包含 Claude Code 系统提示词
*/
- static hasClaudeCodeSystemPrompt(body) {
+ static hasClaudeCodeSystemPrompt(body, customThreshold) {
if (!body || typeof body !== 'object') {
return false
}
@@ -55,12 +55,17 @@ class ClaudeCodeValidator {
return false
}
+ const threshold =
+ typeof customThreshold === 'number' && Number.isFinite(customThreshold)
+ ? customThreshold
+ : SYSTEM_PROMPT_THRESHOLD
+
for (const entry of systemEntries) {
const rawText = typeof entry?.text === 'string' ? entry.text : ''
const { bestScore } = bestSimilarityByTemplates(rawText)
- if (bestScore < SYSTEM_PROMPT_THRESHOLD) {
+ if (bestScore < threshold) {
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
}
@@ -68,6 +73,54 @@ class ClaudeCodeValidator {
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
* @param {Object} req - Express 请求对象
diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue
index fc817ca5..efbe8266 100644
--- a/web/admin-spa/src/components/accounts/AccountForm.vue
+++ b/web/admin-spa/src/components/accounts/AccountForm.vue
@@ -1816,6 +1816,9 @@
- 新会话将随机命中一个 Key,并在会话有效期内保持粘性。
- 若某 Key 失效,会自动切换到剩余可用 Key,最大化成功率。
+ -
+ 若上游返回 4xx 错误码,该 Key 会被自动移除;全部 Key 清空后账号将暂停调度。
+
@@ -3014,10 +3017,10 @@
当前已保存 {{ existingApiKeyCount }} 条 API Key。您可以追加新的
- Key 或使用下方选项清空后重新填写。
+ Key,或通过下方模式快速覆盖、删除指定 Key。
- 留空表示保留现有 Key 不变;填写内容后将覆盖或追加(视清空选项而定)。
+ 留空表示保留现有 Key 不变;根据所选模式决定是追加、覆盖还是删除输入的 Key。
@@ -3031,7 +3034,7 @@
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="{ 'border-red-500': errors.apiKeys }"
- placeholder="留空表示不更新;每行一个 API Key"
+ placeholder="根据模式填写;每行一个 API Key"
rows="6"
/>
@@ -3039,16 +3042,41 @@
-
+
+
+ API Key 更新模式
+
+ {{ currentApiKeyModeLabel }}
+
+
+
+
+
+
+
+ {{ currentApiKeyModeDescription }}
+
+
小提示
- 系统会为新的 Key 自动建立粘性映射,保持同一会话命中同一个 Key。
- - 勾选“清空”后保存即彻底移除旧 Key,可用于紧急轮换或封禁处理。
+ - 追加模式会保留现有 Key 并在末尾追加新的 Key。
+ - 覆盖模式会先清空旧 Key 再写入上方的新列表。
+ - 删除模式会根据输入精准移除指定 Key,适合快速处理失效或被封禁的 Key。
@@ -3223,26 +3253,126 @@ const determinePlatformGroup = (platform) => {
return ''
}
-// 初始化代理配置
-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 || ''
+const createDefaultProxyState = () => ({
+ enabled: false,
+ type: 'socks5',
+ host: '',
+ port: '',
+ username: '',
+ password: ''
+})
+
+const parseProxyResponse = (rawProxy) => {
+ if (!rawProxy) {
+ return null
+ }
+
+ let proxyObject = rawProxy
+ if (typeof rawProxy === 'string') {
+ try {
+ proxyObject = JSON.parse(rawProxy)
+ } catch (error) {
+ return null
}
}
- return {
- enabled: false,
- type: 'socks5',
- host: '',
- port: '',
- username: '',
- password: ''
+
+ 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)
}
// 表单数据
@@ -3269,7 +3399,7 @@ const form = ref({
accessToken: '',
refreshToken: '',
apiKeysInput: '',
- clearExistingApiKeys: false,
+ apiKeyUpdateMode: 'append',
proxy: initProxyConfig(),
// Claude Console 特定字段
apiUrl: props.account?.apiUrl || '',
@@ -3387,6 +3517,47 @@ const parseApiKeysInput = (input) => {
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({
name: '',
@@ -3578,17 +3749,8 @@ const nextStep = async () => {
const generateSetupTokenAuthUrl = async () => {
setupTokenLoading.value = true
try {
- const proxyConfig = form.value.proxy?.enabled
- ? {
- 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 proxyPayload = buildProxyPayload(form.value.proxy)
+ const proxyConfig = proxyPayload ? { proxy: proxyPayload } : {}
const result = await accountsStore.generateClaudeSetupTokenUrl(proxyConfig)
setupTokenAuthUrl.value = result.authUrl
@@ -3657,14 +3819,9 @@ const exchangeSetupTokenCode = async () => {
}
// 添加代理配置(如果启用)
- if (form.value.proxy?.enabled) {
- data.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 proxyPayload = buildProxyPayload(form.value.proxy)
+ if (proxyPayload) {
+ data.proxy = proxyPayload
}
const tokenInfo = await accountsStore.exchangeClaudeSetupTokenCode(data)
@@ -3696,6 +3853,8 @@ const handleOAuthSuccess = async (tokenInfo) => {
form.value.unifiedClientId = generateClientId()
}
+ const proxyPayload = buildProxyPayload(form.value.proxy)
+
const data = {
name: form.value.name,
description: form.value.description,
@@ -3703,15 +3862,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
- proxy: form.value.proxy.enabled
- ? {
- 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
+ proxy: proxyPayload
}
const currentPlatform = form.value.platform
@@ -3994,6 +4145,8 @@ const createAccount = async () => {
loading.value = true
try {
+ const proxyPayload = buildProxyPayload(form.value.proxy)
+
const data = {
name: form.value.name,
description: form.value.description,
@@ -4001,15 +4154,7 @@ const createAccount = async () => {
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
- proxy: form.value.proxy.enabled
- ? {
- 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
+ proxy: proxyPayload
}
if (form.value.platform === 'claude') {
@@ -4260,6 +4405,8 @@ const updateAccount = async () => {
loading.value = true
try {
+ const proxyPayload = buildProxyPayload(form.value.proxy)
+
const data = {
name: form.value.name,
description: form.value.description,
@@ -4267,15 +4414,7 @@ const updateAccount = async () => {
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
- proxy: form.value.proxy.enabled
- ? {
- 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
+ proxy: proxyPayload
}
// 只有非空时才更新token
@@ -4338,19 +4477,40 @@ const updateAccount = async () => {
if (props.account.platform === 'droid') {
const trimmedApiKeysInput = form.value.apiKeysInput?.trim() || ''
+ const apiKeyUpdateMode = form.value.apiKeyUpdateMode || 'append'
- if (trimmedApiKeysInput) {
- const apiKeys = parseApiKeysInput(trimmedApiKeysInput)
- if (apiKeys.length === 0) {
- errors.value.apiKeys = '请至少填写一个 API Key'
+ if (apiKeyUpdateMode === 'delete') {
+ if (!trimmedApiKeysInput) {
+ errors.value.apiKeys = '请填写需要删除的 API Key'
loading.value = false
return
}
- data.apiKeys = apiKeys
- }
- if (form.value.clearExistingApiKeys) {
- data.clearApiKeys = true
+ 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) {
+ const apiKeys = parseApiKeysInput(trimmedApiKeysInput)
+ if (apiKeys.length === 0) {
+ errors.value.apiKeys = '请至少填写一个 API Key'
+ loading.value = false
+ return
+ }
+ data.apiKeys = apiKeys
+ } else if (apiKeyUpdateMode === 'replace') {
+ data.apiKeys = []
+ }
+
+ if (apiKeyUpdateMode !== 'append' || trimmedApiKeysInput) {
+ data.apiKeyUpdateMode = apiKeyUpdateMode
+ }
}
if (isEditingDroidApiKey.value) {
@@ -4699,10 +4859,11 @@ watch(
errors.value.accessToken = ''
errors.value.refreshToken = ''
form.value.authenticationMethod = 'api_key'
+ form.value.apiKeyUpdateMode = 'append'
} else if (oldType === 'apikey') {
// 切换离开 API Key 模式时重置 API Key 输入
form.value.apiKeysInput = ''
- form.value.clearExistingApiKeys = false
+ form.value.apiKeyUpdateMode = 'append'
errors.value.apiKeys = ''
if (!isEdit.value) {
form.value.authenticationMethod = ''
@@ -4711,6 +4872,20 @@ watch(
}
)
+// 监听 API Key 更新模式切换,自动清理提示
+watch(
+ () => form.value.apiKeyUpdateMode,
+ (newMode, oldMode) => {
+ if (newMode === oldMode) {
+ return
+ }
+
+ if (errors.value.apiKeys) {
+ errors.value.apiKeys = ''
+ }
+ }
+)
+
// 监听 API Key 输入,自动清理错误提示
watch(
() => form.value.apiKeysInput,
@@ -4719,7 +4894,22 @@ watch(
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 = ''
}
}
@@ -4854,24 +5044,17 @@ watch(
if (newAccount) {
initModelMappings()
// 重新初始化代理配置
- const proxyConfig =
- newAccount.proxy && newAccount.proxy.host && newAccount.proxy.port
- ? {
- enabled: true,
- type: newAccount.proxy.type || 'socks5',
- host: newAccount.proxy.host,
- port: newAccount.proxy.port,
- username: newAccount.proxy.username || '',
- password: newAccount.proxy.password || ''
- }
- : {
- enabled: false,
- type: 'socks5',
- host: '',
- port: '',
- username: '',
- password: ''
- }
+ const proxyConfig = normalizeProxyFormState(newAccount.proxy)
+ const normalizedAuthMethod =
+ typeof newAccount.authenticationMethod === 'string'
+ ? newAccount.authenticationMethod.trim().toLowerCase()
+ : ''
+ const derivedAddType =
+ normalizedAuthMethod === 'api_key'
+ ? 'apikey'
+ : normalizedAuthMethod === 'manual'
+ ? 'manual'
+ : 'oauth'
// 获取分组ID - 可能来自 groupId 字段或 groupInfo 对象
let groupId = ''
@@ -4900,7 +5083,7 @@ watch(
form.value = {
platform: newAccount.platform,
- addType: 'oauth',
+ addType: derivedAddType,
name: newAccount.name,
description: newAccount.description || '',
accountType: newAccount.accountType || 'shared',
@@ -4914,6 +5097,9 @@ watch(
projectId: newAccount.projectId || '',
accessToken: '',
refreshToken: '',
+ authenticationMethod: newAccount.authenticationMethod || '',
+ apiKeysInput: '',
+ apiKeyUpdateMode: 'append',
proxy: proxyConfig,
// Claude Console 特定字段
apiUrl: newAccount.apiUrl || '',
diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue
index b0101404..5f672f2a 100644
--- a/web/admin-spa/src/views/AccountsView.vue
+++ b/web/admin-spa/src/views/AccountsView.vue
@@ -561,7 +561,16 @@
>Droid
- OAuth
+
+ {{ getDroidAuthType(account) }}
+
+
+
+ x{{ getDroidApiKeyCount(account) }}
+
{
}
}
+ filteredAccounts = filteredAccounts.map((account) => {
+ const proxyConfig = normalizeProxyData(account.proxyConfig || account.proxy)
+ return {
+ ...account,
+ proxyConfig: proxyConfig || null
+ }
+ })
+
accounts.value = filteredAccounts
cleanupSelectedAccounts()
@@ -2551,24 +2568,86 @@ const filterByGroup = () => {
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) => {
- if (!proxy || !proxy.host || !proxy.port) return null
+ const parsed = normalizeProxyData(proxy)
+ if (!parsed) {
+ return null
+ }
- // 缩短类型名称
- const typeShort = proxy.type === 'socks5' ? 'S5' : proxy.type.toUpperCase()
+ const typeShort = parsed.type.toLowerCase() === 'socks5' ? 'S5' : parsed.type.toUpperCase()
- // 缩短主机名(如果太长)
- let host = proxy.host
+ let host = parsed.host
if (host.length > 15) {
host = host.substring(0, 12) + '...'
}
- let display = `${typeShort}://${host}:${proxy.port}`
+ let display = `${typeShort}://${host}:${parsed.port}`
- // 如果有用户名密码,添加认证信息(部分隐藏)
- if (proxy.username) {
- display = `${typeShort}://***@${host}:${proxy.port}`
+ if (parsed.username) {
+ display = `${typeShort}://***@${host}:${parsed.port}`
}
return display
@@ -2974,6 +3053,112 @@ const getOpenAIAuthType = () => {
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 账号类型显示
const getClaudeAccountType = (account) => {
// 如果有订阅信息
diff --git a/web/admin-spa/src/views/TutorialView.vue b/web/admin-spa/src/views/TutorialView.vue
index 38d3e5dc..3a100ecc 100644
--- a/web/admin-spa/src/views/TutorialView.vue
+++ b/web/admin-spa/src/views/TutorialView.vue
@@ -2309,7 +2309,7 @@ const droidCliConfigLines = computed(() => [
'{',
' "custom_models": [',
' {',
- ' "model_display_name": "Sonnet 4.5 [Custom]",',
+ ' "model_display_name": "Sonnet 4.5 [crs]",',
' "model": "claude-sonnet-4-5-20250929",',
` "base_url": "${droidClaudeBaseUrl.value}",`,
' "api_key": "你的API密钥",',
@@ -2317,7 +2317,7 @@ const droidCliConfigLines = computed(() => [
' "max_tokens": 8192',
' },',
' {',
- ' "model_display_name": "GPT5-Codex [Custom]",',
+ ' "model_display_name": "GPT5-Codex [crs]",',
' "model": "gpt-5-codex",',
` "base_url": "${droidOpenaiBaseUrl.value}",`,
' "api_key": "你的API密钥",',