Merge upstream/main into feature/account-expiry-management

解决与 upstream/main 的代码冲突:
- 保留账户到期时间 (expiresAt) 功能
- 采用 buildProxyPayload() 函数重构代理配置
- 同步最新的 Droid 平台功能和修复

主要改动:
- AccountForm.vue: 整合到期时间字段和新的 proxy 处理方式
- 合并 upstream 的 Droid 多 API Key 支持等新特性

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
litongtongxue
2025-10-12 00:55:25 +08:00
16 changed files with 1338 additions and 228 deletions

View File

@@ -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密钥",

View File

@@ -1 +1 @@
1.1.165 1.1.169

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

View File

@@ -1,47 +0,0 @@
# Claude Relay Service 界面预览
<div align="center">
**🎨 Web管理界面截图展示**
</div>
---
## 📊 管理面板概览
### 仪表板
![仪表板](./images/dashboard-overview.png)
*实时显示API调用次数、Token使用量、成本统计等关键指标*
---
## 🔑 API密钥管理
### API密钥列表
![API密钥管理](./images/api-keys-list.png)
*查看和管理所有创建的API密钥包括使用量统计和状态信息*
---
## 👤 Claude账户管理
### 账户列表
![Claude账户列表](./images/claude-accounts-list.png)
*管理多个Claude账户查看账户状态和使用情况*
### 添加新账户
![添加Claude账户](./images/add-claude-account.png)
*通过OAuth授权添加新的Claude账户*
### 使用教程
![使用教程](./images/tutorial.png)
*windows、macos、linux、wsl不同环境的claude code安装教程*
---

View File

@@ -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) {
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) if (!account) {
account = await claudeAccountService.getAccount(memberId)
// 如果找不到尝试Claude Console账户 }
if (!account) { if (!account) {
account = await claudeConsoleAccountService.getAccount(memberId) account = await claudeConsoleAccountService.getAccount(memberId)
} }
// 如果还找不到尝试Gemini账户
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)
@@ -8676,7 +8699,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) {
@@ -8689,7 +8757,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)
@@ -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 账户 // 删除 Droid 账户
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => { router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try { try {

View File

@@ -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')

View File

@@ -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
} }

View File

@@ -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 KeyAccount: ${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()

View File

@@ -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 请求对象

View File

@@ -1816,6 +1816,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>
@@ -3014,10 +3017,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>
@@ -3031,7 +3034,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">
@@ -3039,16 +3042,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"
<input >API Key 更新模式</span
v-model="form.clearExistingApiKeys" >
class="rounded border-purple-300 text-purple-600 focus:ring-purple-500 dark:border-purple-500 dark:bg-purple-900" <span class="text-xs text-purple-600 dark:text-purple-300">
type="checkbox" {{ currentApiKeyModeLabel }}
/> </span>
<span>清空已有 API Key 后再应用上方的 Key 列表</span> </div>
</label> <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"
/>
<button
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"
@@ -3056,7 +3084,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>
@@ -3223,26 +3253,126 @@ const determinePlatformGroup = (platform) => {
return '' return ''
} }
// 初始化代理配置 const createDefaultProxyState = () => ({
const initProxyConfig = () => { enabled: false,
if (props.account?.proxy && props.account.proxy.host && props.account.proxy.port) { type: 'socks5',
return { host: '',
enabled: true, port: '',
type: props.account.proxy.type || 'socks5', username: '',
host: props.account.proxy.host, password: ''
port: props.account.proxy.port, })
username: props.account.proxy.username || '',
password: props.account.proxy.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, if (
type: 'socks5', proxyObject &&
host: '', typeof proxyObject === 'object' &&
port: '', proxyObject.proxy &&
username: '', typeof proxyObject.proxy === 'object'
password: '' ) {
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: '', 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 || '',
@@ -3387,6 +3517,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: '',
@@ -3578,17 +3749,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
@@ -3657,14 +3819,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)
@@ -3696,6 +3853,8 @@ 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,
@@ -3703,15 +3862,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
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,
expiresAt: form.value.expiresAt || undefined, expiresAt: form.value.expiresAt || 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
@@ -3994,6 +4145,8 @@ 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,
@@ -4001,15 +4154,7 @@ const createAccount = async () => {
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,
expiresAt: form.value.expiresAt || undefined, expiresAt: form.value.expiresAt || 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') {
@@ -4260,6 +4405,8 @@ 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,
@@ -4267,15 +4414,7 @@ const updateAccount = async () => {
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,
expiresAt: form.value.expiresAt || undefined, expiresAt: form.value.expiresAt || 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
@@ -4338,19 +4477,40 @@ 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 (trimmedApiKeysInput) { if (apiKeyUpdateMode === 'delete') {
const apiKeys = parseApiKeysInput(trimmedApiKeysInput) if (!trimmedApiKeysInput) {
if (apiKeys.length === 0) { errors.value.apiKeys = '请填写需要删除的 API Key'
errors.value.apiKeys = '请至少填写一个 API Key'
loading.value = false loading.value = false
return return
} }
data.apiKeys = apiKeys
}
if (form.value.clearExistingApiKeys) { const removeApiKeys = parseApiKeysInput(trimmedApiKeysInput)
data.clearApiKeys = true 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) { if (isEditingDroidApiKey.value) {
@@ -4699,10 +4859,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 = ''
@@ -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 输入,自动清理错误提示 // 监听 API Key 输入,自动清理错误提示
watch( watch(
() => form.value.apiKeysInput, () => form.value.apiKeysInput,
@@ -4719,7 +4894,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 = ''
} }
} }
@@ -4854,24 +5044,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 = ''
@@ -4900,7 +5083,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',
@@ -4914,6 +5097,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 || '',

View File

@@ -561,7 +561,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
@@ -2413,6 +2422,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()
@@ -2551,24 +2568,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
@@ -2974,6 +3053,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) => {
// 如果有订阅信息 // 如果有订阅信息

View File

@@ -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密钥",',