Merge branch 'Wei-Shaw:main' into main

This commit is contained in:
jft0m
2025-10-10 21:37:18 +08:00
committed by GitHub
34 changed files with 6599 additions and 697 deletions

View File

@@ -22,6 +22,7 @@ const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
const standardGeminiRoutes = require('./routes/standardGeminiRoutes')
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
const openaiRoutes = require('./routes/openaiRoutes')
const droidRoutes = require('./routes/droidRoutes')
const userRoutes = require('./routes/userRoutes')
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
const webhookRoutes = require('./routes/webhook')
@@ -262,6 +263,8 @@ class Application {
this.app.use('/openai/gemini', openaiGeminiRoutes)
this.app.use('/openai/claude', openaiClaudeRoutes)
this.app.use('/openai', openaiRoutes)
// Droid 路由:支持多种 Factory.ai 端点
this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发
this.app.use('/azure', azureOpenaiRoutes)
this.app.use('/admin/webhook', webhookRoutes)

View File

@@ -7,6 +7,37 @@ const redis = require('../models/redis')
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
const ClientValidator = require('../validators/clientValidator')
const TOKEN_COUNT_PATHS = new Set([
'/v1/messages/count_tokens',
'/api/v1/messages/count_tokens',
'/claude/v1/messages/count_tokens',
'/droid/claude/v1/messages/count_tokens'
])
function normalizeRequestPath(value) {
if (!value) {
return '/'
}
const lower = value.split('?')[0].toLowerCase()
const collapsed = lower.replace(/\/{2,}/g, '/')
if (collapsed.length > 1 && collapsed.endsWith('/')) {
return collapsed.slice(0, -1)
}
return collapsed || '/'
}
function isTokenCountRequest(req) {
const combined = normalizeRequestPath(`${req.baseUrl || ''}${req.path || ''}`)
if (TOKEN_COUNT_PATHS.has(combined)) {
return true
}
const original = normalizeRequestPath(req.originalUrl || '')
if (TOKEN_COUNT_PATHS.has(original)) {
return true
}
return false
}
// 🔑 API Key验证中间件优化版
const authenticateApiKey = async (req, res, next) => {
const startTime = Date.now()
@@ -49,8 +80,11 @@ const authenticateApiKey = async (req, res, next) => {
})
}
const skipKeyRestrictions = isTokenCountRequest(req)
// 🔒 检查客户端限制(使用新的验证器)
if (
!skipKeyRestrictions &&
validation.keyData.enableClientRestriction &&
validation.keyData.allowedClients?.length > 0
) {
@@ -81,7 +115,7 @@ const authenticateApiKey = async (req, res, next) => {
// 检查并发限制
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
if (concurrencyLimit > 0) {
if (!skipKeyRestrictions && concurrencyLimit > 0) {
const concurrencyConfig = config.concurrency || {}
const leaseSeconds = Math.max(concurrencyConfig.leaseSeconds || 900, 30)
const rawRenewInterval =
@@ -438,6 +472,7 @@ const authenticateApiKey = async (req, res, next) => {
geminiAccountId: validation.keyData.geminiAccountId,
openaiAccountId: validation.keyData.openaiAccountId, // 添加 OpenAI 账号ID
bedrockAccountId: validation.keyData.bedrockAccountId, // 添加 Bedrock 账号ID
droidAccountId: validation.keyData.droidAccountId,
permissions: validation.keyData.permissions,
concurrencyLimit: validation.keyData.concurrencyLimit,
rateLimitWindow: validation.keyData.rateLimitWindow,

View File

@@ -858,7 +858,9 @@ class RedisClient {
// 获取账户创建时间来计算平均值 - 支持不同类型的账号
let accountData = {}
if (accountType === 'openai') {
if (accountType === 'droid') {
accountData = await this.client.hgetall(`droid:account:${accountId}`)
} else if (accountType === 'openai') {
accountData = await this.client.hgetall(`openai:account:${accountId}`)
} else if (accountType === 'openai-responses') {
accountData = await this.client.hgetall(`openai_responses_account:${accountId}`)
@@ -874,6 +876,9 @@ class RedisClient {
if (!accountData.createdAt) {
accountData = await this.client.hgetall(`openai_account:${accountId}`)
}
if (!accountData.createdAt) {
accountData = await this.client.hgetall(`droid:account:${accountId}`)
}
}
const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date()
const now = new Date()
@@ -1066,6 +1071,35 @@ class RedisClient {
const key = `claude:account:${accountId}`
return await this.client.del(key)
}
// 🤖 Droid 账户相关操作
async setDroidAccount(accountId, accountData) {
const key = `droid:account:${accountId}`
await this.client.hset(key, accountData)
}
async getDroidAccount(accountId) {
const key = `droid:account:${accountId}`
return await this.client.hgetall(key)
}
async getAllDroidAccounts() {
const keys = await this.client.keys('droid:account:*')
const accounts = []
for (const key of keys) {
const accountData = await this.client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) {
accounts.push({ id: key.replace('droid:account:', ''), ...accountData })
}
}
return accounts
}
async deleteDroidAccount(accountId) {
const key = `droid:account:${accountId}`
return await this.client.del(key)
}
async setOpenAiAccount(accountId, accountData) {
const key = `openai:account:${accountId}`
await this.client.hset(key, accountData)

View File

@@ -5,6 +5,7 @@ const claudeConsoleAccountService = require('../services/claudeConsoleAccountSer
const bedrockAccountService = require('../services/bedrockAccountService')
const ccrAccountService = require('../services/ccrAccountService')
const geminiAccountService = require('../services/geminiAccountService')
const droidAccountService = require('../services/droidAccountService')
const openaiAccountService = require('../services/openaiAccountService')
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
@@ -13,6 +14,11 @@ const redis = require('../models/redis')
const { authenticateAdmin } = require('../middleware/auth')
const logger = require('../utils/logger')
const oauthHelper = require('../utils/oauthHelper')
const {
startDeviceAuthorization,
pollDeviceAuthorization,
WorkOSDeviceAuthError
} = require('../utils/workosOAuthHelper')
const CostCalculator = require('../utils/costCalculator')
const pricingService = require('../services/pricingService')
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
@@ -533,6 +539,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
geminiAccountId,
openaiAccountId,
bedrockAccountId,
droidAccountId,
permissions,
concurrencyLimit,
rateLimitWindow,
@@ -670,6 +677,18 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
}
}
// 验证服务权限字段
if (
permissions !== undefined &&
permissions !== null &&
permissions !== '' &&
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
}
const newKey = await apiKeyService.generateApiKey({
name,
description,
@@ -680,6 +699,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
geminiAccountId,
openaiAccountId,
bedrockAccountId,
droidAccountId,
permissions,
concurrencyLimit,
rateLimitWindow,
@@ -721,6 +741,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
geminiAccountId,
openaiAccountId,
bedrockAccountId,
droidAccountId,
permissions,
concurrencyLimit,
rateLimitWindow,
@@ -755,6 +776,17 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
}
if (
permissions !== undefined &&
permissions !== null &&
permissions !== '' &&
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
}
// 生成批量API Keys
const createdKeys = []
const errors = []
@@ -772,6 +804,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
geminiAccountId,
openaiAccountId,
bedrockAccountId,
droidAccountId,
permissions,
concurrencyLimit,
rateLimitWindow,
@@ -854,6 +887,15 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
})
}
if (
updates.permissions !== undefined &&
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions)
) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
}
logger.info(
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}`
)
@@ -939,6 +981,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
if (updates.bedrockAccountId !== undefined) {
finalUpdates.bedrockAccountId = updates.bedrockAccountId
}
if (updates.droidAccountId !== undefined) {
finalUpdates.droidAccountId = updates.droidAccountId || ''
}
// 处理标签操作
if (updates.tags !== undefined) {
@@ -1025,6 +1070,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
geminiAccountId,
openaiAccountId,
bedrockAccountId,
droidAccountId,
permissions,
enableModelRestriction,
restrictedModels,
@@ -1116,12 +1162,17 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.bedrockAccountId = bedrockAccountId || ''
}
if (droidAccountId !== undefined) {
// 空字符串表示解绑null或空字符串都设置为空字符串
updates.droidAccountId = droidAccountId || ''
}
if (permissions !== undefined) {
// 验证权限值
if (!['claude', 'gemini', 'openai', 'all'].includes(permissions)) {
return res
.status(400)
.json({ error: 'Invalid permissions value. Must be claude, gemini, openai, or all' })
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
}
updates.permissions = permissions
}
@@ -4141,7 +4192,14 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
const { accountId } = req.params
const { platform = 'claude', days = 30 } = req.query
const allowedPlatforms = ['claude', 'claude-console', 'openai', 'openai-responses', 'gemini']
const allowedPlatforms = [
'claude',
'claude-console',
'openai',
'openai-responses',
'gemini',
'droid'
]
if (!allowedPlatforms.includes(platform)) {
return res.status(400).json({
success: false,
@@ -4151,7 +4209,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
const accountTypeMap = {
openai: 'openai',
'openai-responses': 'openai-responses'
'openai-responses': 'openai-responses',
droid: 'droid'
}
const fallbackModelMap = {
@@ -4159,7 +4218,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
'claude-console': 'claude-3-5-sonnet-20241022',
openai: 'gpt-4o-mini-2024-07-18',
'openai-responses': 'gpt-4o-mini-2024-07-18',
gemini: 'gemini-1.5-flash'
gemini: 'gemini-1.5-flash',
droid: 'unknown'
}
// 获取账户信息以获取创建时间
@@ -4183,6 +4243,9 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
case 'gemini':
accountData = await geminiAccountService.getAccount(accountId)
break
case 'droid':
accountData = await droidAccountService.getAccount(accountId)
break
}
if (accountData && accountData.createdAt) {
@@ -4387,6 +4450,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
openaiAccounts,
ccrAccounts,
openaiResponsesAccounts,
droidAccounts,
todayStats,
systemAverages,
realtimeMetrics
@@ -4400,6 +4464,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
redis.getAllOpenAIAccounts(),
ccrAccountService.getAllAccounts(),
openaiResponsesAccountService.getAllAccounts(true),
droidAccountService.getAllAccounts(),
redis.getTodayStats(),
redis.getSystemAverages(),
redis.getRealtimeSystemMetrics()
@@ -4407,6 +4472,42 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
// 处理Bedrock账户数据
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
const normalizeBoolean = (value) => value === true || value === 'true'
const isRateLimitedFlag = (status) => {
if (!status) {
return false
}
if (typeof status === 'string') {
return status === 'limited'
}
if (typeof status === 'object') {
return status.isRateLimited === true
}
return false
}
const normalDroidAccounts = droidAccounts.filter(
(acc) =>
normalizeBoolean(acc.isActive) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
normalizeBoolean(acc.schedulable) &&
!isRateLimitedFlag(acc.rateLimitStatus)
).length
const abnormalDroidAccounts = droidAccounts.filter(
(acc) =>
!normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedDroidAccounts = droidAccounts.filter(
(acc) =>
!normalizeBoolean(acc.schedulable) &&
normalizeBoolean(acc.isActive) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedDroidAccounts = droidAccounts.filter((acc) =>
isRateLimitedFlag(acc.rateLimitStatus)
).length
// 计算使用统计统一使用allTokens
const totalTokensUsed = apiKeys.reduce(
@@ -4654,7 +4755,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
abnormalBedrockAccounts +
abnormalOpenAIAccounts +
abnormalOpenAIResponsesAccounts +
abnormalCcrAccounts,
abnormalCcrAccounts +
abnormalDroidAccounts,
pausedAccounts:
pausedClaudeAccounts +
pausedClaudeConsoleAccounts +
@@ -4662,7 +4764,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
pausedBedrockAccounts +
pausedOpenAIAccounts +
pausedOpenAIResponsesAccounts +
pausedCcrAccounts,
pausedCcrAccounts +
pausedDroidAccounts,
rateLimitedAccounts:
rateLimitedClaudeAccounts +
rateLimitedClaudeConsoleAccounts +
@@ -4670,7 +4773,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
rateLimitedBedrockAccounts +
rateLimitedOpenAIAccounts +
rateLimitedOpenAIResponsesAccounts +
rateLimitedCcrAccounts,
rateLimitedCcrAccounts +
rateLimitedDroidAccounts,
// 各平台详细统计
accountsByPlatform: {
claude: {
@@ -4721,6 +4825,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
abnormal: abnormalOpenAIResponsesAccounts,
paused: pausedOpenAIResponsesAccounts,
rateLimited: rateLimitedOpenAIResponsesAccounts
},
droid: {
total: droidAccounts.length,
normal: normalDroidAccounts,
abnormal: abnormalDroidAccounts,
paused: pausedDroidAccounts,
rateLimited: rateLimitedDroidAccounts
}
},
// 保留旧字段以兼容
@@ -4731,7 +4842,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
normalBedrockAccounts +
normalOpenAIAccounts +
normalOpenAIResponsesAccounts +
normalCcrAccounts,
normalCcrAccounts +
normalDroidAccounts,
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
@@ -4769,6 +4881,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
redisConnected: redis.isConnected,
claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
geminiAccountsHealthy: normalGeminiAccounts > 0,
droidAccountsHealthy: normalDroidAccounts > 0,
uptime: process.uptime()
},
systemTimezone: config.system.timezoneOffset || 8
@@ -8357,4 +8470,246 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
}
})
// 🤖 Droid 账户管理
// 生成 Droid OAuth 授权链接
router.post('/droid-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
const { proxy } = req.body || {}
const deviceAuth = await startDeviceAuthorization(proxy || null)
const sessionId = crypto.randomUUID()
const expiresAt = new Date(Date.now() + deviceAuth.expiresIn * 1000).toISOString()
await redis.setOAuthSession(sessionId, {
deviceCode: deviceAuth.deviceCode,
userCode: deviceAuth.userCode,
verificationUri: deviceAuth.verificationUri,
verificationUriComplete: deviceAuth.verificationUriComplete,
interval: deviceAuth.interval,
proxy: proxy || null,
createdAt: new Date().toISOString(),
expiresAt
})
logger.success('🤖 生成 Droid 设备码授权信息成功', { sessionId })
return res.json({
success: true,
data: {
sessionId,
userCode: deviceAuth.userCode,
verificationUri: deviceAuth.verificationUri,
verificationUriComplete: deviceAuth.verificationUriComplete,
expiresIn: deviceAuth.expiresIn,
interval: deviceAuth.interval,
instructions: [
'1. 使用下方验证码进入授权页面并确认访问权限。',
'2. 在授权页面登录 Factory / Droid 账户并点击允许。',
'3. 回到此处点击“完成授权”完成凭证获取。'
]
}
})
} catch (error) {
const message =
error instanceof WorkOSDeviceAuthError ? error.message : error.message || '未知错误'
logger.error('❌ 生成 Droid 设备码授权失败:', message)
return res.status(500).json({ error: 'Failed to start Droid device authorization', message })
}
})
// 交换 Droid 授权码
router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) => {
const { sessionId, proxy } = req.body || {}
try {
if (!sessionId) {
return res.status(400).json({ error: 'Session ID is required' })
}
const oauthSession = await redis.getOAuthSession(sessionId)
if (!oauthSession) {
return res.status(400).json({ error: 'Invalid or expired OAuth session' })
}
if (oauthSession.expiresAt && new Date() > new Date(oauthSession.expiresAt)) {
await redis.deleteOAuthSession(sessionId)
return res
.status(400)
.json({ error: 'OAuth session has expired, please generate a new authorization URL' })
}
if (!oauthSession.deviceCode) {
await redis.deleteOAuthSession(sessionId)
return res.status(400).json({ error: 'OAuth session missing device code, please retry' })
}
const proxyConfig = proxy || oauthSession.proxy || null
const tokens = await pollDeviceAuthorization(oauthSession.deviceCode, proxyConfig)
await redis.deleteOAuthSession(sessionId)
logger.success('🤖 成功获取 Droid 访问令牌', { sessionId })
return res.json({ success: true, data: { tokens } })
} catch (error) {
if (error instanceof WorkOSDeviceAuthError) {
if (error.code === 'authorization_pending' || error.code === 'slow_down') {
const oauthSession = await redis.getOAuthSession(sessionId)
const expiresAt = oauthSession?.expiresAt ? new Date(oauthSession.expiresAt) : null
const remainingSeconds =
expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime())
? Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000))
: null
return res.json({
success: false,
pending: true,
error: error.code,
message: error.message,
retryAfter: error.retryAfter || Number(oauthSession?.interval) || 5,
expiresIn: remainingSeconds
})
}
if (error.code === 'expired_token') {
await redis.deleteOAuthSession(sessionId)
return res.status(400).json({
error: 'Device code expired',
message: '授权已过期,请重新生成设备码并再次授权'
})
}
logger.error('❌ Droid 授权失败:', error.message)
return res.status(500).json({
error: 'Failed to exchange Droid authorization code',
message: error.message,
errorCode: error.code
})
}
logger.error('❌ 交换 Droid 授权码失败:', error)
return res.status(500).json({
error: 'Failed to exchange Droid authorization code',
message: error.message
})
}
})
// 获取所有 Droid 账户
router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
try {
const accounts = await droidAccountService.getAllAccounts()
const allApiKeys = await redis.getAllApiKeys()
// 添加使用统计
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
let groupInfos = []
try {
groupInfos = await accountGroupService.getAccountGroups(account.id)
} catch (groupError) {
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError)
groupInfos = []
}
const groupIds = groupInfos.map((group) => group.id)
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
const binding = key.droidAccountId
if (!binding) {
return count
}
if (binding === account.id) {
return count + 1
}
if (binding.startsWith('group:')) {
const groupId = binding.substring('group:'.length)
if (groupIds.includes(groupId)) {
return count + 1
}
}
return count
}, 0)
return {
...account,
schedulable: account.schedulable === 'true',
boundApiKeysCount,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages
}
}
} catch (error) {
logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
return {
...account,
boundApiKeysCount: 0,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0 },
total: { tokens: 0, requests: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
}
})
)
return res.json({ success: true, data: accountsWithStats })
} catch (error) {
logger.error('Failed to get Droid accounts:', error)
return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message })
}
})
// 创建 Droid 账户
router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
try {
const account = await droidAccountService.createAccount(req.body)
logger.success(`Created Droid account: ${account.name} (${account.id})`)
return res.json({ success: true, data: account })
} catch (error) {
logger.error('Failed to create Droid account:', error)
return res.status(500).json({ error: 'Failed to create Droid account', message: error.message })
}
})
// 更新 Droid 账户
router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await droidAccountService.updateAccount(id, req.body)
return res.json({ success: true, data: account })
} catch (error) {
logger.error(`Failed to update Droid account ${req.params.id}:`, error)
return res.status(500).json({ error: 'Failed to update Droid account', message: error.message })
}
})
// 删除 Droid 账户
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
await droidAccountService.deleteAccount(id)
return res.json({ success: true, message: 'Droid account deleted successfully' })
} catch (error) {
logger.error(`Failed to delete Droid account ${req.params.id}:`, error)
return res.status(500).json({ error: 'Failed to delete Droid account', message: error.message })
}
})
// 刷新 Droid 账户 token
router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const result = await droidAccountService.refreshAccessToken(id)
return res.json({ success: true, data: result })
} catch (error) {
logger.error(`Failed to refresh Droid account token ${req.params.id}:`, error)
return res.status(500).json({ error: 'Failed to refresh token', message: error.message })
}
})
module.exports = router

191
src/routes/droidRoutes.js Normal file
View File

@@ -0,0 +1,191 @@
const crypto = require('crypto')
const express = require('express')
const { authenticateApiKey } = require('../middleware/auth')
const droidRelayService = require('../services/droidRelayService')
const sessionHelper = require('../utils/sessionHelper')
const logger = require('../utils/logger')
const router = express.Router()
function hasDroidPermission(apiKeyData) {
const permissions = apiKeyData?.permissions || 'all'
return permissions === 'all' || permissions === 'droid'
}
/**
* Droid API 转发路由
*
* 支持的 Factory.ai 端点:
* - /droid/claude - Anthropic (Claude) Messages API
* - /droid/openai - OpenAI Responses API
*/
// Claude (Anthropic) 端点 - /v1/messages
router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => {
try {
const sessionHash = sessionHelper.generateSessionHash(req.body)
if (!hasDroidPermission(req.apiKey)) {
logger.security(
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
)
return res.status(403).json({
error: 'permission_denied',
message: '此 API Key 未启用 Droid 权限'
})
}
const result = await droidRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
{ endpointType: 'anthropic', sessionHash }
)
// 如果是流式响应,已经在 relayService 中处理了
if (result.streaming) {
return
}
// 非流式响应
res.status(result.statusCode).set(result.headers).send(result.body)
} catch (error) {
logger.error('Droid Claude relay error:', error)
res.status(500).json({
error: 'internal_server_error',
message: error.message
})
}
})
router.post('/claude/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
try {
const requestBody = { ...req.body }
if ('stream' in requestBody) {
delete requestBody.stream
}
const sessionHash = sessionHelper.generateSessionHash(requestBody)
if (!hasDroidPermission(req.apiKey)) {
logger.security(
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
)
return res.status(403).json({
error: 'permission_denied',
message: '此 API Key 未启用 Droid 权限'
})
}
const result = await droidRelayService.relayRequest(
requestBody,
req.apiKey,
req,
res,
req.headers,
{
endpointType: 'anthropic',
sessionHash,
customPath: '/a/v1/messages/count_tokens',
skipUsageRecord: true,
disableStreaming: true
}
)
res.status(result.statusCode).set(result.headers).send(result.body)
} catch (error) {
logger.error('Droid Claude count_tokens relay error:', error)
res.status(500).json({
error: 'internal_server_error',
message: error.message
})
}
})
// OpenAI 端点 - /v1/responses
router.post(['/openai/v1/responses', '/openai/responses'], authenticateApiKey, async (req, res) => {
try {
const sessionId =
req.headers['session_id'] ||
req.headers['x-session-id'] ||
req.body?.session_id ||
req.body?.conversation_id ||
null
const sessionHash = sessionId
? crypto.createHash('sha256').update(String(sessionId)).digest('hex')
: null
if (!hasDroidPermission(req.apiKey)) {
logger.security(
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
)
return res.status(403).json({
error: 'permission_denied',
message: '此 API Key 未启用 Droid 权限'
})
}
const result = await droidRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
{ endpointType: 'openai', sessionHash }
)
if (result.streaming) {
return
}
res.status(result.statusCode).set(result.headers).send(result.body)
} catch (error) {
logger.error('Droid OpenAI relay error:', error)
res.status(500).json({
error: 'internal_server_error',
message: error.message
})
}
})
// 模型列表端点(兼容性)
router.get('/*/v1/models', authenticateApiKey, async (req, res) => {
try {
// 返回可用的模型列表
const models = [
{
id: 'claude-opus-4-1-20250805',
object: 'model',
created: Date.now(),
owned_by: 'anthropic'
},
{
id: 'claude-sonnet-4-5-20250929',
object: 'model',
created: Date.now(),
owned_by: 'anthropic'
},
{
id: 'gpt-5-2025-08-07',
object: 'model',
created: Date.now(),
owned_by: 'openai'
}
]
res.json({
object: 'list',
data: models
})
} catch (error) {
logger.error('Droid models list error:', error)
res.status(500).json({
error: 'internal_server_error',
message: error.message
})
}
})
module.exports = router

View File

@@ -27,8 +27,8 @@ class AccountGroupService {
}
// 验证平台类型
if (!['claude', 'gemini', 'openai'].includes(platform)) {
throw new Error('平台类型必须是 claude、geminiopenai')
if (!['claude', 'gemini', 'openai', 'droid'].includes(platform)) {
throw new Error('平台类型必须是 claude、geminiopenai 或 droid')
}
const client = redis.getClientSafe()
@@ -311,7 +311,8 @@ class AccountGroupService {
keyData &&
(keyData.claudeAccountId === groupKey ||
keyData.geminiAccountId === groupKey ||
keyData.openaiAccountId === groupKey)
keyData.openaiAccountId === groupKey ||
keyData.droidAccountId === groupKey)
) {
boundApiKeys.push({
id: keyId,

View File

@@ -22,7 +22,8 @@ class ApiKeyService {
openaiAccountId = null,
azureOpenaiAccountId = null,
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
droidAccountId = null,
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all'
isActive = true,
concurrencyLimit = 0,
rateLimitWindow = null,
@@ -64,6 +65,7 @@ class ApiKeyService {
openaiAccountId: openaiAccountId || '',
azureOpenaiAccountId: azureOpenaiAccountId || '',
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
droidAccountId: droidAccountId || '',
permissions: permissions || 'all',
enableModelRestriction: String(enableModelRestriction),
restrictedModels: JSON.stringify(restrictedModels || []),
@@ -109,6 +111,7 @@ class ApiKeyService {
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions,
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels: JSON.parse(keyData.restrictedModels),
@@ -256,6 +259,7 @@ class ApiKeyService {
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
@@ -382,6 +386,7 @@ class ApiKeyService {
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId,
droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
@@ -553,6 +558,7 @@ class ApiKeyService {
'openaiAccountId',
'azureOpenaiAccountId',
'bedrockAccountId', // 添加 Bedrock 账号ID
'droidAccountId',
'permissions',
'expiresAt',
'activationDays', // 新增:激活后有效天数
@@ -1211,6 +1217,7 @@ class ApiKeyService {
userId: key.userId,
userUsername: key.userUsername,
createdBy: key.createdBy,
droidAccountId: key.droidAccountId,
// Include deletion fields for deleted keys
isDeleted: key.isDeleted,
deletedAt: key.deletedAt,
@@ -1254,7 +1261,8 @@ class ApiKeyService {
createdBy: keyData.createdBy,
permissions: keyData.permissions,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
totalCostLimit: parseFloat(keyData.totalCostLimit || 0)
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
droidAccountId: keyData.droidAccountId
}
} catch (error) {
logger.error('❌ Failed to get API key by ID:', error)
@@ -1401,6 +1409,7 @@ class ApiKeyService {
'openai-responses': 'openaiAccountId', // 特殊处理,带 responses: 前缀
azure_openai: 'azureOpenaiAccountId',
bedrock: 'bedrockAccountId',
droid: 'droidAccountId',
ccr: null // CCR 账号没有对应的 API Key 字段
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,218 @@
const droidAccountService = require('./droidAccountService')
const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis')
const logger = require('../utils/logger')
class DroidScheduler {
constructor() {
this.STICKY_PREFIX = 'droid'
}
_normalizeEndpointType(endpointType) {
if (!endpointType) {
return 'anthropic'
}
const normalized = String(endpointType).toLowerCase()
if (normalized === 'openai' || normalized === 'common') {
return 'openai'
}
return 'anthropic'
}
_isTruthy(value) {
if (value === undefined || value === null) {
return false
}
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'string') {
return value.toLowerCase() === 'true'
}
return Boolean(value)
}
_isAccountActive(account) {
if (!account) {
return false
}
const isActive = this._isTruthy(account.isActive)
if (!isActive) {
return false
}
const status = (account.status || 'active').toLowerCase()
const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked'])
return !unhealthyStatuses.has(status)
}
_isAccountSchedulable(account) {
return this._isTruthy(account?.schedulable ?? true)
}
_matchesEndpoint(account, endpointType) {
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const accountEndpoint = this._normalizeEndpointType(account?.endpointType)
if (normalizedEndpoint === accountEndpoint) {
return true
}
const sharedEndpoints = new Set(['anthropic', 'openai'])
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
}
_sortCandidates(candidates) {
return [...candidates].sort((a, b) => {
const priorityA = parseInt(a.priority, 10) || 50
const priorityB = parseInt(b.priority, 10) || 50
if (priorityA !== priorityB) {
return priorityA - priorityB
}
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
if (lastUsedA !== lastUsedB) {
return lastUsedA - lastUsedB
}
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return createdA - createdB
})
}
_composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
if (!sessionHash) {
return null
}
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const apiKeyPart = apiKeyId || 'default'
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
}
async _loadGroupAccounts(groupId) {
const memberIds = await accountGroupService.getGroupMembers(groupId)
if (!memberIds || memberIds.length === 0) {
return []
}
const accounts = await Promise.all(
memberIds.map(async (memberId) => {
try {
return await droidAccountService.getAccount(memberId)
} catch (error) {
logger.warn(`⚠️ 获取 Droid 分组成员账号失败: ${memberId}`, error)
return null
}
})
)
return accounts.filter(
(account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account)
)
}
async _ensureLastUsedUpdated(accountId) {
try {
await droidAccountService.touchLastUsedAt(accountId)
} catch (error) {
logger.warn(`⚠️ 更新 Droid 账号最后使用时间失败: ${accountId}`, error)
}
}
async _cleanupStickyMapping(stickyKey) {
if (!stickyKey) {
return
}
try {
await redis.deleteSessionAccountMapping(stickyKey)
} catch (error) {
logger.warn(`⚠️ 清理 Droid 粘性会话映射失败: ${stickyKey}`, error)
}
}
async selectAccount(apiKeyData, endpointType, sessionHash) {
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
let candidates = []
let isDedicatedBinding = false
if (apiKeyData?.droidAccountId) {
const binding = apiKeyData.droidAccountId
if (binding.startsWith('group:')) {
const groupId = binding.substring('group:'.length)
logger.info(
`🤖 API Key ${apiKeyData.name || apiKeyData.id} 绑定 Droid 分组 ${groupId},按分组调度`
)
candidates = await this._loadGroupAccounts(groupId, normalizedEndpoint)
} else {
const account = await droidAccountService.getAccount(binding)
if (account) {
candidates = [account]
isDedicatedBinding = true
}
}
}
if (!candidates || candidates.length === 0) {
candidates = await droidAccountService.getSchedulableAccounts(normalizedEndpoint)
}
const filtered = candidates.filter(
(account) =>
account &&
this._isAccountActive(account) &&
this._isAccountSchedulable(account) &&
this._matchesEndpoint(account, normalizedEndpoint)
)
if (filtered.length === 0) {
throw new Error(
`No available Droid accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}`
)
}
if (stickyKey && !isDedicatedBinding) {
const mappedAccountId = await redis.getSessionAccountMapping(stickyKey)
if (mappedAccountId) {
const mappedAccount = filtered.find((account) => account.id === mappedAccountId)
if (mappedAccount) {
await redis.extendSessionAccountMappingTTL(stickyKey)
logger.info(
`🤖 命中 Droid 粘性会话: ${sessionHash} -> ${mappedAccount.name || mappedAccount.id}`
)
await this._ensureLastUsedUpdated(mappedAccount.id)
return mappedAccount
}
await this._cleanupStickyMapping(stickyKey)
}
}
const sorted = this._sortCandidates(filtered)
const selected = sorted[0]
if (!selected) {
throw new Error(
`No schedulable Droid account available after sorting (${normalizedEndpoint})`
)
}
if (stickyKey && !isDedicatedBinding) {
await redis.setSessionAccountMapping(stickyKey, selected.id)
}
await this._ensureLastUsedUpdated(selected.id)
logger.info(
`🤖 选择 Droid 账号 ${selected.name || selected.id}endpoint: ${normalizedEndpoint}, priority: ${selected.priority || 50}`
)
return selected
}
}
module.exports = new DroidScheduler()

View File

@@ -17,8 +17,18 @@ function maskToken(token, visiblePercent = 70) {
const { length } = token
// 对于非常短的 token至少隐藏一部分
if (length <= 2) {
return '*'.repeat(length)
}
if (length <= 5) {
return token.slice(0, 1) + '*'.repeat(length - 1)
}
if (length <= 10) {
return token.slice(0, 5) + '*'.repeat(length - 5)
const visibleLength = Math.min(5, length - 2)
const front = token.slice(0, visibleLength)
return front + '*'.repeat(length - visibleLength)
}
// 计算可见字符数量

View File

@@ -0,0 +1,170 @@
const axios = require('axios')
const config = require('../../config/config')
const logger = require('./logger')
const ProxyHelper = require('./proxyHelper')
const WORKOS_CONFIG = config.droid || {}
const WORKOS_DEVICE_AUTHORIZE_URL =
WORKOS_CONFIG.deviceAuthorizeUrl || 'https://api.workos.com/user_management/authorize/device'
const WORKOS_TOKEN_URL =
WORKOS_CONFIG.tokenUrl || 'https://api.workos.com/user_management/authenticate'
const WORKOS_CLIENT_ID = WORKOS_CONFIG.clientId || 'client_01HNM792M5G5G1A2THWPXKFMXB'
const DEFAULT_POLL_INTERVAL = 5
class WorkOSDeviceAuthError extends Error {
constructor(message, code, options = {}) {
super(message)
this.name = 'WorkOSDeviceAuthError'
this.code = code || 'unknown_error'
this.retryAfter = options.retryAfter || null
}
}
/**
* 启动设备码授权流程
* @param {object|null} proxyConfig - 代理配置
* @returns {Promise<object>} WorkOS 返回的数据
*/
async function startDeviceAuthorization(proxyConfig = null) {
const form = new URLSearchParams({
client_id: WORKOS_CLIENT_ID
})
const agent = ProxyHelper.createProxyAgent(proxyConfig)
try {
logger.info('🔐 请求 WorkOS 设备码授权', {
url: WORKOS_DEVICE_AUTHORIZE_URL,
hasProxy: !!agent
})
const response = await axios.post(WORKOS_DEVICE_AUTHORIZE_URL, form.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
httpsAgent: agent,
timeout: 15000
})
const data = response.data || {}
if (!data.device_code || !data.verification_uri) {
throw new Error('WorkOS 返回数据缺少必要字段 (device_code / verification_uri)')
}
logger.success('✅ 成功获取 WorkOS 设备码授权信息', {
verificationUri: data.verification_uri,
userCode: data.user_code
})
return {
deviceCode: data.device_code,
userCode: data.user_code,
verificationUri: data.verification_uri,
verificationUriComplete: data.verification_uri_complete || data.verification_uri,
expiresIn: data.expires_in || 300,
interval: data.interval || DEFAULT_POLL_INTERVAL
}
} catch (error) {
if (error.response) {
logger.error('❌ WorkOS 设备码授权失败', {
status: error.response.status,
data: error.response.data
})
throw new WorkOSDeviceAuthError(
error.response.data?.error_description ||
error.response.data?.error ||
'WorkOS 设备码授权失败',
error.response.data?.error
)
}
logger.error('❌ 请求 WorkOS 设备码授权异常', {
message: error.message
})
throw new WorkOSDeviceAuthError(error.message)
}
}
/**
* 轮询授权结果
* @param {string} deviceCode - 设备码
* @param {object|null} proxyConfig - 代理配置
* @returns {Promise<object>} WorkOS 返回的 token 数据
*/
async function pollDeviceAuthorization(deviceCode, proxyConfig = null) {
if (!deviceCode) {
throw new WorkOSDeviceAuthError('缺少设备码,无法查询授权结果', 'missing_device_code')
}
const form = new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: deviceCode,
client_id: WORKOS_CLIENT_ID
})
const agent = ProxyHelper.createProxyAgent(proxyConfig)
try {
const response = await axios.post(WORKOS_TOKEN_URL, form.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
httpsAgent: agent,
timeout: 15000
})
const data = response.data || {}
if (!data.access_token) {
throw new WorkOSDeviceAuthError('WorkOS 返回结果缺少 access_token', 'missing_access_token')
}
logger.success('🤖 Droid 授权完成,获取到访问令牌', {
hasRefreshToken: !!data.refresh_token
})
return data
} catch (error) {
if (error.response) {
const responseData = error.response.data || {}
const errorCode = responseData.error || `http_${error.response.status}`
const errorDescription =
responseData.error_description || responseData.error || 'WorkOS 授权失败'
if (errorCode === 'authorization_pending' || errorCode === 'slow_down') {
const retryAfter =
Number(responseData.interval) ||
Number(error.response.headers?.['retry-after']) ||
DEFAULT_POLL_INTERVAL
throw new WorkOSDeviceAuthError(errorDescription, errorCode, {
retryAfter
})
}
if (errorCode === 'expired_token') {
throw new WorkOSDeviceAuthError(errorDescription, errorCode)
}
logger.error('❌ WorkOS 设备授权轮询失败', {
status: error.response.status,
data: responseData
})
throw new WorkOSDeviceAuthError(errorDescription, errorCode)
}
logger.error('❌ WorkOS 设备授权轮询异常', {
message: error.message
})
throw new WorkOSDeviceAuthError(error.message)
}
}
module.exports = {
startDeviceAuthorization,
pollDeviceAuthorization,
WorkOSDeviceAuthError
}

View File

@@ -26,6 +26,14 @@ const CLIENT_DEFINITIONS = {
displayName: 'Codex Command Line Tool',
description: 'Cursor/Codex command-line interface',
icon: '🔷'
},
DROID_CLI: {
id: 'droid_cli',
name: 'Droid CLI',
displayName: 'Factory Droid CLI',
description: 'Factory Droid platform command-line interface',
icon: '🤖'
}
}
@@ -33,7 +41,8 @@ const CLIENT_DEFINITIONS = {
const CLIENT_IDS = {
CLAUDE_CODE: 'claude_code',
GEMINI_CLI: 'gemini_cli',
CODEX_CLI: 'codex_cli'
CODEX_CLI: 'codex_cli',
DROID_CLI: 'droid_cli'
}
// 获取所有客户端定义

View File

@@ -8,6 +8,7 @@ const { CLIENT_DEFINITIONS, getAllClientDefinitions } = require('./clientDefinit
const ClaudeCodeValidator = require('./clients/claudeCodeValidator')
const GeminiCliValidator = require('./clients/geminiCliValidator')
const CodexCliValidator = require('./clients/codexCliValidator')
const DroidCliValidator = require('./clients/droidCliValidator')
/**
* 客户端验证器类
@@ -26,6 +27,8 @@ class ClientValidator {
return GeminiCliValidator
case 'codex_cli':
return CodexCliValidator
case 'droid_cli':
return DroidCliValidator
default:
logger.warn(`Unknown client ID: ${clientId}`)
return null
@@ -37,7 +40,7 @@ class ClientValidator {
* @returns {Array<string>} 客户端ID列表
*/
static getSupportedClients() {
return ['claude_code', 'gemini_cli', 'codex_cli']
return ['claude_code', 'gemini_cli', 'codex_cli', 'droid_cli']
}
/**

View File

@@ -50,7 +50,11 @@ class ClaudeCodeValidator {
return false
}
const systemEntries = Array.isArray(body.system) ? body.system : []
const systemEntries = Array.isArray(body.system) ? body.system : null
if (!systemEntries) {
return false
}
for (const entry of systemEntries) {
const rawText = typeof entry?.text === 'string' ? entry.text : ''
const { bestScore } = bestSimilarityByTemplates(rawText)

View File

@@ -0,0 +1,57 @@
const logger = require('../../utils/logger')
const { CLIENT_DEFINITIONS } = require('../clientDefinitions')
/**
* Droid CLI 验证器
* 检查请求是否来自 Factory Droid CLI
*/
class DroidCliValidator {
static getId() {
return CLIENT_DEFINITIONS.DROID_CLI.id
}
static getName() {
return CLIENT_DEFINITIONS.DROID_CLI.name
}
static getDescription() {
return CLIENT_DEFINITIONS.DROID_CLI.description
}
static validate(req) {
try {
const userAgent = req.headers['user-agent'] || ''
const factoryClientHeader = (req.headers['x-factory-client'] || '').toString().toLowerCase()
const uaMatch = /factory-cli\/(\d+\.\d+\.\d+)/i.exec(userAgent)
const hasFactoryClientHeader =
typeof factoryClientHeader === 'string' &&
(factoryClientHeader.includes('droid') || factoryClientHeader.includes('factory-cli'))
if (!uaMatch && !hasFactoryClientHeader) {
logger.debug(`Droid CLI validation failed - UA mismatch: ${userAgent}`)
return false
}
// 允许,通过基础验证
logger.debug(
`Droid CLI validation passed (UA: ${userAgent || 'N/A'}, header: ${factoryClientHeader || 'N/A'})`
)
return true
} catch (error) {
logger.error('Error in DroidCliValidator:', error)
return false
}
}
static getInfo() {
return {
id: this.getId(),
name: this.getName(),
description: this.getDescription(),
icon: CLIENT_DEFINITIONS.DROID_CLI.icon
}
}
}
module.exports = DroidCliValidator