feat: 新增Droid cli支持

This commit is contained in:
shaw
2025-10-09 23:05:09 +08:00
parent 4de2ea3d17
commit 2fc84a6aca
13 changed files with 2734 additions and 36 deletions

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')
@@ -8357,4 +8363,215 @@ 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 accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
return {
...account,
schedulable: account.schedulable === 'true',
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,
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

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

@@ -0,0 +1,135 @@
const express = require('express')
const { authenticateApiKey } = require('../middleware/auth')
const droidRelayService = require('../services/droidRelayService')
const logger = require('../utils/logger')
const router = express.Router()
/**
* Droid API 转发路由
*
* 支持多种 Factory.ai 端点:
* - /droid/claude - Anthropic (Claude) Messages API
* - /droid/openai - OpenAI Responses API
* - /droid/chat - OpenAI Chat Completions API (通用)
*/
// Claude (Anthropic) 端点 - /v1/messages
router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => {
try {
const result = await droidRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
{ endpointType: 'anthropic' }
)
// 如果是流式响应,已经在 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
})
}
})
// OpenAI 端点 - /v1/responses
router.post('/openai/v1/responses', authenticateApiKey, async (req, res) => {
try {
const result = await droidRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
{ endpointType: 'openai' }
)
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
})
}
})
// 通用 OpenAI Chat Completions 端点
router.post('/chat/v1/chat/completions', authenticateApiKey, async (req, res) => {
try {
const result = await droidRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
{ endpointType: 'common' }
)
if (result.streaming) {
return
}
res.status(result.statusCode).set(result.headers).send(result.body)
} catch (error) {
logger.error('Droid Chat 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