mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Merge branch 'main' into um-5
This commit is contained in:
@@ -5,6 +5,7 @@ const claudeConsoleAccountService = require('../services/claudeConsoleAccountSer
|
||||
const bedrockAccountService = require('../services/bedrockAccountService')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
|
||||
const accountGroupService = require('../services/accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const { authenticateAdmin } = require('../middleware/auth')
|
||||
@@ -13,13 +14,13 @@ const oauthHelper = require('../utils/oauthHelper')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const pricingService = require('../services/pricingService')
|
||||
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const axios = require('axios')
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const config = require('../../config/config')
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent')
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -621,6 +622,170 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 批量编辑API Keys
|
||||
router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyIds, updates } = req.body
|
||||
|
||||
if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid input',
|
||||
message: 'keyIds must be a non-empty array'
|
||||
})
|
||||
}
|
||||
|
||||
if (!updates || typeof updates !== 'object') {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid input',
|
||||
message: 'updates must be an object'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}`
|
||||
)
|
||||
logger.info(`🔍 Debug: keyIds received: ${JSON.stringify(keyIds)}`)
|
||||
|
||||
const results = {
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
errors: []
|
||||
}
|
||||
|
||||
// 处理每个API Key
|
||||
for (const keyId of keyIds) {
|
||||
try {
|
||||
// 获取当前API Key信息
|
||||
const currentKey = await redis.getApiKey(keyId)
|
||||
if (!currentKey || Object.keys(currentKey).length === 0) {
|
||||
results.failedCount++
|
||||
results.errors.push(`API key ${keyId} not found`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建最终更新数据
|
||||
const finalUpdates = {}
|
||||
|
||||
// 处理普通字段
|
||||
if (updates.name) {
|
||||
finalUpdates.name = updates.name
|
||||
}
|
||||
if (updates.tokenLimit !== undefined) {
|
||||
finalUpdates.tokenLimit = updates.tokenLimit
|
||||
}
|
||||
if (updates.concurrencyLimit !== undefined) {
|
||||
finalUpdates.concurrencyLimit = updates.concurrencyLimit
|
||||
}
|
||||
if (updates.rateLimitWindow !== undefined) {
|
||||
finalUpdates.rateLimitWindow = updates.rateLimitWindow
|
||||
}
|
||||
if (updates.rateLimitRequests !== undefined) {
|
||||
finalUpdates.rateLimitRequests = updates.rateLimitRequests
|
||||
}
|
||||
if (updates.dailyCostLimit !== undefined) {
|
||||
finalUpdates.dailyCostLimit = updates.dailyCostLimit
|
||||
}
|
||||
if (updates.permissions !== undefined) {
|
||||
finalUpdates.permissions = updates.permissions
|
||||
}
|
||||
if (updates.isActive !== undefined) {
|
||||
finalUpdates.isActive = updates.isActive
|
||||
}
|
||||
if (updates.monthlyLimit !== undefined) {
|
||||
finalUpdates.monthlyLimit = updates.monthlyLimit
|
||||
}
|
||||
if (updates.priority !== undefined) {
|
||||
finalUpdates.priority = updates.priority
|
||||
}
|
||||
if (updates.enabled !== undefined) {
|
||||
finalUpdates.enabled = updates.enabled
|
||||
}
|
||||
|
||||
// 处理账户绑定
|
||||
if (updates.claudeAccountId !== undefined) {
|
||||
finalUpdates.claudeAccountId = updates.claudeAccountId
|
||||
}
|
||||
if (updates.claudeConsoleAccountId !== undefined) {
|
||||
finalUpdates.claudeConsoleAccountId = updates.claudeConsoleAccountId
|
||||
}
|
||||
if (updates.geminiAccountId !== undefined) {
|
||||
finalUpdates.geminiAccountId = updates.geminiAccountId
|
||||
}
|
||||
if (updates.openaiAccountId !== undefined) {
|
||||
finalUpdates.openaiAccountId = updates.openaiAccountId
|
||||
}
|
||||
if (updates.bedrockAccountId !== undefined) {
|
||||
finalUpdates.bedrockAccountId = updates.bedrockAccountId
|
||||
}
|
||||
|
||||
// 处理标签操作
|
||||
if (updates.tags !== undefined) {
|
||||
if (updates.tagOperation) {
|
||||
const currentTags = currentKey.tags ? JSON.parse(currentKey.tags) : []
|
||||
const operationTags = updates.tags
|
||||
|
||||
switch (updates.tagOperation) {
|
||||
case 'replace': {
|
||||
finalUpdates.tags = operationTags
|
||||
break
|
||||
}
|
||||
case 'add': {
|
||||
const newTags = [...currentTags]
|
||||
operationTags.forEach((tag) => {
|
||||
if (!newTags.includes(tag)) {
|
||||
newTags.push(tag)
|
||||
}
|
||||
})
|
||||
finalUpdates.tags = newTags
|
||||
break
|
||||
}
|
||||
case 'remove': {
|
||||
finalUpdates.tags = currentTags.filter((tag) => !operationTags.includes(tag))
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果没有指定操作类型,默认为替换
|
||||
finalUpdates.tags = updates.tags
|
||||
}
|
||||
}
|
||||
|
||||
// 执行更新
|
||||
await apiKeyService.updateApiKey(keyId, finalUpdates)
|
||||
results.successCount++
|
||||
logger.success(`✅ Batch edit: API key ${keyId} updated successfully`)
|
||||
} catch (error) {
|
||||
results.failedCount++
|
||||
results.errors.push(`Failed to update key ${keyId}: ${error.message}`)
|
||||
logger.error(`❌ Batch edit failed for key ${keyId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录批量编辑结果
|
||||
if (results.successCount > 0) {
|
||||
logger.success(
|
||||
`🎉 Batch edit completed: ${results.successCount} successful, ${results.failedCount} failed`
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Batch edit completed with no successful updates: ${results.failedCount} failed`
|
||||
)
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `批量编辑完成`,
|
||||
data: results
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to batch edit API keys:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Batch edit failed',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 更新API Key
|
||||
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
@@ -799,7 +964,105 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 删除API Key
|
||||
// 批量删除API Keys(必须在 :keyId 路由之前定义)
|
||||
router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyIds } = req.body
|
||||
|
||||
// 调试信息
|
||||
logger.info(`🐛 Batch delete request body: ${JSON.stringify(req.body)}`)
|
||||
logger.info(`🐛 keyIds type: ${typeof keyIds}, value: ${JSON.stringify(keyIds)}`)
|
||||
|
||||
// 参数验证
|
||||
if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) {
|
||||
logger.warn(
|
||||
`🚨 Invalid keyIds: ${JSON.stringify({ keyIds, type: typeof keyIds, isArray: Array.isArray(keyIds) })}`
|
||||
)
|
||||
return res.status(400).json({
|
||||
error: 'Invalid request',
|
||||
message: 'keyIds 必须是一个非空数组'
|
||||
})
|
||||
}
|
||||
|
||||
if (keyIds.length > 100) {
|
||||
return res.status(400).json({
|
||||
error: 'Too many keys',
|
||||
message: '每次最多只能删除100个API Keys'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证keyIds格式
|
||||
const invalidKeys = keyIds.filter((id) => !id || typeof id !== 'string')
|
||||
if (invalidKeys.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid key IDs',
|
||||
message: '包含无效的API Key ID'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🗑️ Admin attempting batch delete of ${keyIds.length} API keys: ${JSON.stringify(keyIds)}`
|
||||
)
|
||||
|
||||
const results = {
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
errors: []
|
||||
}
|
||||
|
||||
// 逐个删除,记录成功和失败情况
|
||||
for (const keyId of keyIds) {
|
||||
try {
|
||||
// 检查API Key是否存在
|
||||
const apiKey = await redis.getApiKey(keyId)
|
||||
if (!apiKey || Object.keys(apiKey).length === 0) {
|
||||
results.failedCount++
|
||||
results.errors.push({ keyId, error: 'API Key 不存在' })
|
||||
continue
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
await apiKeyService.deleteApiKey(keyId)
|
||||
results.successCount++
|
||||
|
||||
logger.success(`✅ Batch delete: API key ${keyId} deleted successfully`)
|
||||
} catch (error) {
|
||||
results.failedCount++
|
||||
results.errors.push({
|
||||
keyId,
|
||||
error: error.message || '删除失败'
|
||||
})
|
||||
|
||||
logger.error(`❌ Batch delete failed for key ${keyId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录批量删除结果
|
||||
if (results.successCount > 0) {
|
||||
logger.success(
|
||||
`🎉 Batch delete completed: ${results.successCount} successful, ${results.failedCount} failed`
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Batch delete completed with no successful deletions: ${results.failedCount} failed`
|
||||
)
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `批量删除完成`,
|
||||
data: results
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to batch delete API keys:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Batch delete failed',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除单个API Key(必须在批量删除路由之后定义)
|
||||
router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params
|
||||
@@ -1268,6 +1531,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
claudeAiOauth,
|
||||
proxy,
|
||||
accountType,
|
||||
platform = 'claude',
|
||||
priority,
|
||||
groupId
|
||||
} = req.body
|
||||
@@ -1305,6 +1569,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
claudeAiOauth,
|
||||
proxy,
|
||||
accountType: accountType || 'shared', // 默认为共享类型
|
||||
platform,
|
||||
priority: priority || 50 // 默认优先级为50
|
||||
})
|
||||
|
||||
@@ -1498,6 +1763,19 @@ router.put(
|
||||
const newSchedulable = !account.schedulable
|
||||
await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable })
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!newSchedulable) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.name || account.claudeAiOauth?.email || 'Claude Account',
|
||||
platform: 'claude-oauth',
|
||||
status: 'disabled',
|
||||
errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
|
||||
)
|
||||
@@ -1768,6 +2046,19 @@ router.put(
|
||||
const newSchedulable = !account.schedulable
|
||||
await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable })
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!newSchedulable) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.name || 'Claude Console Account',
|
||||
platform: 'claude-console',
|
||||
status: 'disabled',
|
||||
errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
|
||||
)
|
||||
@@ -2042,6 +2333,19 @@ router.put(
|
||||
.json({ error: 'Failed to toggle schedulable status', message: updateResult.error })
|
||||
}
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!newSchedulable) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: accountResult.data.id,
|
||||
accountName: accountResult.data.name || 'Bedrock Account',
|
||||
platform: 'bedrock',
|
||||
status: 'disabled',
|
||||
errorCode: 'BEDROCK_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
|
||||
)
|
||||
@@ -2079,7 +2383,7 @@ router.post('/bedrock-accounts/:accountId/test', authenticateAdmin, async (req,
|
||||
// 生成 Gemini OAuth 授权 URL
|
||||
router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { state } = req.body
|
||||
const { state, proxy } = req.body // 接收代理配置
|
||||
|
||||
// 使用新的 codeassist.google.com 回调地址
|
||||
const redirectUri = 'https://codeassist.google.com/authcode'
|
||||
@@ -2093,13 +2397,14 @@ router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req,
|
||||
redirectUri: finalRedirectUri
|
||||
} = await geminiAccountService.generateAuthUrl(state, redirectUri)
|
||||
|
||||
// 创建 OAuth 会话,包含 codeVerifier
|
||||
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
|
||||
const sessionId = authState
|
||||
await redis.setOAuthSession(sessionId, {
|
||||
state: authState,
|
||||
type: 'gemini',
|
||||
redirectUri: finalRedirectUri,
|
||||
codeVerifier, // 保存 PKCE code verifier
|
||||
proxy: proxy || null, // 保存代理配置
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
@@ -2143,7 +2448,7 @@ router.post('/gemini-accounts/poll-auth-status', authenticateAdmin, async (req,
|
||||
// 交换 Gemini 授权码
|
||||
router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { code, sessionId } = req.body
|
||||
const { code, sessionId, proxy: requestProxy } = req.body
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Authorization code is required' })
|
||||
@@ -2151,21 +2456,40 @@ router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res
|
||||
|
||||
let redirectUri = 'https://codeassist.google.com/authcode'
|
||||
let codeVerifier = null
|
||||
let proxyConfig = null
|
||||
|
||||
// 如果提供了 sessionId,从 OAuth 会话中获取信息
|
||||
if (sessionId) {
|
||||
const sessionData = await redis.getOAuthSession(sessionId)
|
||||
if (sessionData) {
|
||||
const { redirectUri: sessionRedirectUri, codeVerifier: sessionCodeVerifier } = sessionData
|
||||
const {
|
||||
redirectUri: sessionRedirectUri,
|
||||
codeVerifier: sessionCodeVerifier,
|
||||
proxy
|
||||
} = sessionData
|
||||
redirectUri = sessionRedirectUri || redirectUri
|
||||
codeVerifier = sessionCodeVerifier
|
||||
proxyConfig = proxy // 获取代理配置
|
||||
logger.info(
|
||||
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}`
|
||||
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = await geminiAccountService.exchangeCodeForTokens(code, redirectUri, codeVerifier)
|
||||
// 如果请求体中直接提供了代理配置,优先使用它
|
||||
if (requestProxy) {
|
||||
proxyConfig = requestProxy
|
||||
logger.info(
|
||||
`Using proxy from request body: ${proxyConfig ? JSON.stringify(proxyConfig) : 'none'}`
|
||||
)
|
||||
}
|
||||
|
||||
const tokens = await geminiAccountService.exchangeCodeForTokens(
|
||||
code,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 清理 OAuth 会话
|
||||
if (sessionId) {
|
||||
@@ -2393,6 +2717,19 @@ router.put(
|
||||
const updatedAccount = await geminiAccountService.getAccount(accountId)
|
||||
const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!actualSchedulable) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.accountName || 'Gemini Account',
|
||||
platform: 'gemini',
|
||||
status: 'disabled',
|
||||
errorCode: 'GEMINI_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${actualSchedulable ? 'schedulable' : 'not schedulable'}`
|
||||
)
|
||||
@@ -4575,19 +4912,10 @@ router.post('/openai-accounts/exchange-code', authenticateAdmin, async (req, res
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionData.proxy) {
|
||||
const { type, host, port, username, password } = sessionData.proxy
|
||||
if (type === 'socks5') {
|
||||
// SOCKS5 代理
|
||||
const auth = username && password ? `${username}:${password}@` : ''
|
||||
const socksUrl = `socks5://${auth}${host}:${port}`
|
||||
axiosConfig.httpsAgent = new SocksProxyAgent(socksUrl)
|
||||
} else if (type === 'http' || type === 'https') {
|
||||
// HTTP/HTTPS 代理
|
||||
const auth = username && password ? `${username}:${password}@` : ''
|
||||
const proxyUrl = `${type}://${auth}${host}:${port}`
|
||||
axiosConfig.httpsAgent = new HttpsProxyAgent(proxyUrl)
|
||||
}
|
||||
// 配置代理(如果有)
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy)
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
}
|
||||
|
||||
// 交换 authorization code 获取 tokens
|
||||
@@ -4963,6 +5291,23 @@ router.put(
|
||||
|
||||
const result = await openaiAccountService.toggleSchedulable(accountId)
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!result.schedulable) {
|
||||
// 获取账号信息
|
||||
const account = await redis.getOpenAiAccount(accountId)
|
||||
if (account) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.name || 'OpenAI Account',
|
||||
platform: 'openai',
|
||||
status: 'disabled',
|
||||
errorCode: 'OPENAI_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
schedulable: result.schedulable,
|
||||
@@ -4979,4 +5324,308 @@ router.put(
|
||||
}
|
||||
)
|
||||
|
||||
// 🌐 Azure OpenAI 账户管理
|
||||
|
||||
// 获取所有 Azure OpenAI 账户
|
||||
router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accounts = await azureOpenaiAccountService.getAllAccounts()
|
||||
res.json({
|
||||
success: true,
|
||||
data: accounts
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch Azure OpenAI accounts:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch accounts',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 创建 Azure OpenAI 账户
|
||||
router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
accountType,
|
||||
azureEndpoint,
|
||||
apiVersion,
|
||||
deploymentName,
|
||||
apiKey,
|
||||
supportedModels,
|
||||
proxy,
|
||||
groupId,
|
||||
priority,
|
||||
isActive,
|
||||
schedulable
|
||||
} = req.body
|
||||
|
||||
// 验证必填字段
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Account name is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!azureEndpoint) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Azure endpoint is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'API key is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!deploymentName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Deployment name is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 Azure endpoint 格式
|
||||
if (!azureEndpoint.match(/^https:\/\/[\w-]+\.openai\.azure\.com$/)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
'Invalid Azure OpenAI endpoint format. Expected: https://your-resource.openai.azure.com'
|
||||
})
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
try {
|
||||
const testUrl = `${azureEndpoint}/openai/deployments/${deploymentName}?api-version=${apiVersion || '2024-02-01'}`
|
||||
await axios.get(testUrl, {
|
||||
headers: {
|
||||
'api-key': apiKey
|
||||
},
|
||||
timeout: 5000
|
||||
})
|
||||
} catch (testError) {
|
||||
if (testError.response?.status === 404) {
|
||||
logger.warn('Azure OpenAI deployment not found, but continuing with account creation')
|
||||
} else if (testError.response?.status === 401) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid API key or unauthorized access'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const account = await azureOpenaiAccountService.createAccount({
|
||||
name,
|
||||
description,
|
||||
accountType: accountType || 'shared',
|
||||
azureEndpoint,
|
||||
apiVersion: apiVersion || '2024-02-01',
|
||||
deploymentName,
|
||||
apiKey,
|
||||
supportedModels,
|
||||
proxy,
|
||||
groupId,
|
||||
priority: priority || 50,
|
||||
isActive: isActive !== false,
|
||||
schedulable: schedulable !== false
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: account,
|
||||
message: 'Azure OpenAI account created successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to create Azure OpenAI account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create account',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 更新 Azure OpenAI 账户
|
||||
router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const updates = req.body
|
||||
|
||||
const account = await azureOpenaiAccountService.updateAccount(id, updates)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: account,
|
||||
message: 'Azure OpenAI account updated successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update Azure OpenAI account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update account',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除 Azure OpenAI 账户
|
||||
router.delete('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
await azureOpenaiAccountService.deleteAccount(id)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Azure OpenAI account deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete Azure OpenAI account:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to delete account',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 Azure OpenAI 账户状态
|
||||
router.put('/azure-openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const account = await azureOpenaiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Account not found'
|
||||
})
|
||||
}
|
||||
|
||||
const newStatus = account.isActive === 'true' ? 'false' : 'true'
|
||||
await azureOpenaiAccountService.updateAccount(id, { isActive: newStatus })
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Account ${newStatus === 'true' ? 'activated' : 'deactivated'} successfully`,
|
||||
isActive: newStatus === 'true'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle Azure OpenAI account status:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to toggle account status',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 Azure OpenAI 账户调度状态
|
||||
router.put(
|
||||
'/azure-openai-accounts/:accountId/toggle-schedulable',
|
||||
authenticateAdmin,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const result = await azureOpenaiAccountService.toggleSchedulable(accountId)
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!result.schedulable) {
|
||||
// 获取账号信息
|
||||
const account = await azureOpenaiAccountService.getAccount(accountId)
|
||||
if (account) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.name || 'Azure OpenAI Account',
|
||||
platform: 'azure-openai',
|
||||
status: 'disabled',
|
||||
errorCode: 'AZURE_OPENAI_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
schedulable: result.schedulable,
|
||||
message: result.schedulable ? '已启用调度' : '已禁用调度'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('切换 Azure OpenAI 账户调度状态失败:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '切换调度状态失败',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 健康检查单个 Azure OpenAI 账户
|
||||
router.post('/azure-openai-accounts/:id/health-check', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const healthResult = await azureOpenaiAccountService.healthCheckAccount(id)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: healthResult
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to perform health check:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to perform health check',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 批量健康检查所有 Azure OpenAI 账户
|
||||
router.post('/azure-openai-accounts/health-check-all', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const healthResults = await azureOpenaiAccountService.performHealthChecks()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: healthResults
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to perform batch health check:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to perform batch health check',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 迁移 API Keys 以支持 Azure OpenAI
|
||||
router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const migratedCount = await azureOpenaiAccountService.migrateApiKeysForAzureSupport()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Successfully migrated ${migratedCount} API keys for Azure OpenAI support`
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to migrate API keys:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to migrate API keys',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -671,4 +671,103 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
|
||||
}
|
||||
})
|
||||
|
||||
// 🔢 Token计数端点 - count_tokens beta API
|
||||
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
// 检查权限
|
||||
if (
|
||||
req.apiKey.permissions &&
|
||||
req.apiKey.permissions !== 'all' &&
|
||||
req.apiKey.permissions !== 'claude'
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: 'This API key does not have permission to access Claude'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
|
||||
|
||||
// 生成会话哈希用于sticky会话
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 选择可用的Claude账户
|
||||
const requestedModel = req.body.model
|
||||
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
requestedModel
|
||||
)
|
||||
|
||||
let response
|
||||
if (accountType === 'claude-official') {
|
||||
// 使用官方Claude账号转发count_tokens请求
|
||||
response = await claudeRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
res,
|
||||
req.headers,
|
||||
{
|
||||
skipUsageRecord: true, // 跳过usage记录,这只是计数请求
|
||||
customPath: '/v1/messages/count_tokens' // 指定count_tokens路径
|
||||
}
|
||||
)
|
||||
} else if (accountType === 'claude-console') {
|
||||
// 使用Console Claude账号转发count_tokens请求
|
||||
response = await claudeConsoleRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
res,
|
||||
req.headers,
|
||||
accountId,
|
||||
{
|
||||
skipUsageRecord: true, // 跳过usage记录,这只是计数请求
|
||||
customPath: '/v1/messages/count_tokens' // 指定count_tokens路径
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Bedrock不支持count_tokens
|
||||
return res.status(501).json({
|
||||
error: {
|
||||
type: 'not_supported',
|
||||
message: 'Token counting is not supported for Bedrock accounts'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 直接返回响应,不记录token使用量
|
||||
res.status(response.statusCode)
|
||||
|
||||
// 设置响应头
|
||||
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length']
|
||||
Object.keys(response.headers).forEach((key) => {
|
||||
if (!skipHeaders.includes(key.toLowerCase())) {
|
||||
res.setHeader(key, response.headers[key])
|
||||
}
|
||||
})
|
||||
|
||||
// 尝试解析并返回JSON响应
|
||||
try {
|
||||
const jsonData = JSON.parse(response.body)
|
||||
res.json(jsonData)
|
||||
} catch (parseError) {
|
||||
res.send(response.body)
|
||||
}
|
||||
|
||||
logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ Token count error:', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
type: 'server_error',
|
||||
message: 'Failed to count tokens'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
318
src/routes/azureOpenaiRoutes.js
Normal file
318
src/routes/azureOpenaiRoutes.js
Normal file
@@ -0,0 +1,318 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
|
||||
const azureOpenaiRelayService = require('../services/azureOpenaiRelayService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const crypto = require('crypto')
|
||||
|
||||
// 支持的模型列表 - 基于真实的 Azure OpenAI 模型
|
||||
const ALLOWED_MODELS = {
|
||||
CHAT_MODELS: [
|
||||
'gpt-4',
|
||||
'gpt-4-turbo',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-35-turbo',
|
||||
'gpt-35-turbo-16k'
|
||||
],
|
||||
EMBEDDING_MODELS: ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large']
|
||||
}
|
||||
|
||||
const ALL_ALLOWED_MODELS = [...ALLOWED_MODELS.CHAT_MODELS, ...ALLOWED_MODELS.EMBEDDING_MODELS]
|
||||
|
||||
// Azure OpenAI 稳定 API 版本
|
||||
// const AZURE_API_VERSION = '2024-02-01' // 当前未使用,保留以备后用
|
||||
|
||||
// 原子使用统计报告器
|
||||
class AtomicUsageReporter {
|
||||
constructor() {
|
||||
this.reportedUsage = new Set()
|
||||
this.pendingReports = new Map()
|
||||
}
|
||||
|
||||
async reportOnce(requestId, usageData, apiKeyId, modelToRecord, accountId) {
|
||||
if (this.reportedUsage.has(requestId)) {
|
||||
logger.debug(`Usage already reported for request: ${requestId}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 防止并发重复报告
|
||||
if (this.pendingReports.has(requestId)) {
|
||||
return this.pendingReports.get(requestId)
|
||||
}
|
||||
|
||||
const reportPromise = this._performReport(
|
||||
requestId,
|
||||
usageData,
|
||||
apiKeyId,
|
||||
modelToRecord,
|
||||
accountId
|
||||
)
|
||||
this.pendingReports.set(requestId, reportPromise)
|
||||
|
||||
try {
|
||||
const result = await reportPromise
|
||||
this.reportedUsage.add(requestId)
|
||||
return result
|
||||
} finally {
|
||||
this.pendingReports.delete(requestId)
|
||||
// 清理过期的已报告记录
|
||||
setTimeout(() => this.reportedUsage.delete(requestId), 60 * 1000) // 1分钟后清理
|
||||
}
|
||||
}
|
||||
|
||||
async _performReport(requestId, usageData, apiKeyId, modelToRecord, accountId) {
|
||||
try {
|
||||
const inputTokens = usageData.prompt_tokens || usageData.input_tokens || 0
|
||||
const outputTokens = usageData.completion_tokens || usageData.output_tokens || 0
|
||||
const cacheCreateTokens =
|
||||
usageData.prompt_tokens_details?.cache_creation_tokens ||
|
||||
usageData.input_tokens_details?.cache_creation_tokens ||
|
||||
0
|
||||
const cacheReadTokens =
|
||||
usageData.prompt_tokens_details?.cached_tokens ||
|
||||
usageData.input_tokens_details?.cached_tokens ||
|
||||
0
|
||||
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyId,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
modelToRecord,
|
||||
accountId
|
||||
)
|
||||
|
||||
// 同步更新 Azure 账户的 lastUsedAt 和累计使用量
|
||||
try {
|
||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
if (accountId) {
|
||||
await azureOpenaiAccountService.updateAccountUsage(accountId, totalTokens)
|
||||
}
|
||||
} catch (acctErr) {
|
||||
logger.warn(`Failed to update Azure account usage for ${accountId}: ${acctErr.message}`)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`📊 Azure OpenAI Usage recorded for ${requestId}: ` +
|
||||
`model=${modelToRecord}, ` +
|
||||
`input=${inputTokens}, output=${outputTokens}, ` +
|
||||
`cache_create=${cacheCreateTokens}, cache_read=${cacheReadTokens}`
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to report Azure OpenAI usage:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const usageReporter = new AtomicUsageReporter()
|
||||
|
||||
// 健康检查
|
||||
router.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'healthy',
|
||||
service: 'azure-openai-relay',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
|
||||
// 获取可用模型列表(兼容 OpenAI API)
|
||||
router.get('/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const models = ALL_ALLOWED_MODELS.map((model) => ({
|
||||
id: `azure/${model}`,
|
||||
object: 'model',
|
||||
created: Date.now(),
|
||||
owned_by: 'azure-openai'
|
||||
}))
|
||||
|
||||
res.json({
|
||||
object: 'list',
|
||||
data: models
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Azure OpenAI models:', error)
|
||||
res.status(500).json({ error: { message: 'Failed to fetch models' } })
|
||||
}
|
||||
})
|
||||
|
||||
// 处理聊天完成请求
|
||||
router.post('/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
const requestId = `azure_req_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`
|
||||
const sessionId = req.sessionId || req.headers['x-session-id'] || null
|
||||
|
||||
logger.info(`🚀 Azure OpenAI Chat Request ${requestId}`, {
|
||||
apiKeyId: req.apiKey?.id,
|
||||
sessionId,
|
||||
model: req.body.model,
|
||||
stream: req.body.stream || false,
|
||||
messages: req.body.messages?.length || 0
|
||||
})
|
||||
|
||||
try {
|
||||
// 获取绑定的 Azure OpenAI 账户
|
||||
let account = null
|
||||
if (req.apiKey?.azureOpenaiAccountId) {
|
||||
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
|
||||
if (!account) {
|
||||
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有绑定账户或账户不可用,选择一个可用账户
|
||||
if (!account || account.isActive !== 'true') {
|
||||
account = await azureOpenaiAccountService.selectAvailableAccount(sessionId)
|
||||
}
|
||||
|
||||
// 发送请求到 Azure OpenAI
|
||||
const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({
|
||||
account,
|
||||
requestBody: req.body,
|
||||
headers: req.headers,
|
||||
isStream: req.body.stream || false,
|
||||
endpoint: 'chat/completions'
|
||||
})
|
||||
|
||||
// 处理流式响应
|
||||
if (req.body.stream) {
|
||||
await azureOpenaiRelayService.handleStreamResponse(response, res, {
|
||||
onEnd: async ({ usageData, actualModel }) => {
|
||||
if (usageData) {
|
||||
const modelToRecord = actualModel || req.body.model || 'unknown'
|
||||
await usageReporter.reportOnce(
|
||||
requestId,
|
||||
usageData,
|
||||
req.apiKey.id,
|
||||
modelToRecord,
|
||||
account.id
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error(`Stream error for request ${requestId}:`, error)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 处理非流式响应
|
||||
const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse(
|
||||
response,
|
||||
res
|
||||
)
|
||||
|
||||
if (usageData) {
|
||||
const modelToRecord = actualModel || req.body.model || 'unknown'
|
||||
await usageReporter.reportOnce(
|
||||
requestId,
|
||||
usageData,
|
||||
req.apiKey.id,
|
||||
modelToRecord,
|
||||
account.id
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Azure OpenAI request failed ${requestId}:`, error)
|
||||
|
||||
if (!res.headersSent) {
|
||||
const statusCode = error.response?.status || 500
|
||||
const errorMessage =
|
||||
error.response?.data?.error?.message || error.message || 'Internal server error'
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: 'azure_openai_error',
|
||||
code: error.code || 'unknown'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 处理嵌入请求
|
||||
router.post('/embeddings', authenticateApiKey, async (req, res) => {
|
||||
const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`
|
||||
const sessionId = req.sessionId || req.headers['x-session-id'] || null
|
||||
|
||||
logger.info(`🚀 Azure OpenAI Embeddings Request ${requestId}`, {
|
||||
apiKeyId: req.apiKey?.id,
|
||||
sessionId,
|
||||
model: req.body.model,
|
||||
input: Array.isArray(req.body.input) ? req.body.input.length : 1
|
||||
})
|
||||
|
||||
try {
|
||||
// 获取绑定的 Azure OpenAI 账户
|
||||
let account = null
|
||||
if (req.apiKey?.azureOpenaiAccountId) {
|
||||
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
|
||||
if (!account) {
|
||||
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有绑定账户或账户不可用,选择一个可用账户
|
||||
if (!account || account.isActive !== 'true') {
|
||||
account = await azureOpenaiAccountService.selectAvailableAccount(sessionId)
|
||||
}
|
||||
|
||||
// 发送请求到 Azure OpenAI
|
||||
const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({
|
||||
account,
|
||||
requestBody: req.body,
|
||||
headers: req.headers,
|
||||
isStream: false,
|
||||
endpoint: 'embeddings'
|
||||
})
|
||||
|
||||
// 处理响应
|
||||
const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse(
|
||||
response,
|
||||
res
|
||||
)
|
||||
|
||||
if (usageData) {
|
||||
const modelToRecord = actualModel || req.body.model || 'unknown'
|
||||
await usageReporter.reportOnce(requestId, usageData, req.apiKey.id, modelToRecord, account.id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Azure OpenAI embeddings request failed ${requestId}:`, error)
|
||||
|
||||
if (!res.headersSent) {
|
||||
const statusCode = error.response?.status || 500
|
||||
const errorMessage =
|
||||
error.response?.data?.error?.message || error.message || 'Internal server error'
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: 'azure_openai_error',
|
||||
code: error.code || 'unknown'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 获取使用统计
|
||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const { start_date, end_date } = req.query
|
||||
const usage = await apiKeyService.getUsageStats(req.apiKey.id, start_date, end_date)
|
||||
|
||||
res.json({
|
||||
object: 'usage',
|
||||
data: usage
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Azure OpenAI usage:', error)
|
||||
res.status(500).json({ error: { message: 'Failed to fetch usage data' } })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -541,12 +541,24 @@ async function handleGenerateContent(req, res) {
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project
|
||||
req.apiKey?.id // 使用 API Key ID 作为 session ID
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 记录使用统计
|
||||
@@ -573,7 +585,16 @@ async function handleGenerateContent(req, res) {
|
||||
res.json(response)
|
||||
} catch (error) {
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.error(`Error in generateContent endpoint (${version})`, { error: error.message })
|
||||
// 打印详细的错误信息
|
||||
logger.error(`Error in generateContent endpoint (${version})`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
requestUrl: error.config?.url,
|
||||
requestMethod: error.config?.method,
|
||||
stack: error.stack
|
||||
})
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
@@ -654,13 +675,25 @@ async function handleStreamGenerateContent(req, res) {
|
||||
})
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal // 传递中止信号
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 设置 SSE 响应头
|
||||
@@ -756,7 +789,16 @@ async function handleStreamGenerateContent(req, res) {
|
||||
})
|
||||
} catch (error) {
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.error(`Error in streamGenerateContent endpoint (${version})`, { error: error.message })
|
||||
// 打印详细的错误信息
|
||||
logger.error(`Error in streamGenerateContent endpoint (${version})`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
requestUrl: error.config?.url,
|
||||
requestMethod: error.config?.method,
|
||||
stack: error.stack
|
||||
})
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
|
||||
@@ -8,30 +8,11 @@ const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const crypto = require('crypto')
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent')
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
|
||||
// 创建代理 Agent
|
||||
// 创建代理 Agent(使用统一的代理工具)
|
||||
function createProxyAgent(proxy) {
|
||||
if (!proxy) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (proxy.type === 'socks5') {
|
||||
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
|
||||
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`
|
||||
return new SocksProxyAgent(socksUrl)
|
||||
} else if (proxy.type === 'http' || proxy.type === 'https') {
|
||||
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
|
||||
const proxyUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`
|
||||
return new HttpsProxyAgent(proxyUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to create proxy agent:', error)
|
||||
}
|
||||
|
||||
return null
|
||||
return ProxyHelper.createProxyAgent(proxy)
|
||||
}
|
||||
|
||||
// 使用统一调度器选择 OpenAI 账户
|
||||
@@ -80,7 +61,8 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
|
||||
accessToken,
|
||||
accountId: result.accountId,
|
||||
accountName: account.name,
|
||||
proxy
|
||||
proxy,
|
||||
account
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get OpenAI auth token:', error)
|
||||
@@ -146,11 +128,13 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
|
||||
// 使用调度器选择账户
|
||||
const { accessToken, accountId, proxy } = await getOpenAIAuthToken(
|
||||
apiKeyData,
|
||||
sessionId,
|
||||
requestedModel
|
||||
)
|
||||
const {
|
||||
accessToken,
|
||||
accountId,
|
||||
accountName: _accountName,
|
||||
proxy,
|
||||
account
|
||||
} = await getOpenAIAuthToken(apiKeyData, sessionId, requestedModel)
|
||||
// 基于白名单构造上游所需的请求头,确保键为小写且值受控
|
||||
const incoming = req.headers || {}
|
||||
|
||||
@@ -165,7 +149,7 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
|
||||
// 覆盖或新增必要头部
|
||||
headers['authorization'] = `Bearer ${accessToken}`
|
||||
headers['chatgpt-account-id'] = accountId
|
||||
headers['chatgpt-account-id'] = account.accountId || account.chatgptUserId || accountId
|
||||
headers['host'] = 'chatgpt.com'
|
||||
headers['accept'] = isStream ? 'text/event-stream' : 'application/json'
|
||||
headers['content-type'] = 'application/json'
|
||||
@@ -184,7 +168,9 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
// 如果有代理,添加代理配置
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
logger.info('Using proxy for OpenAI request')
|
||||
logger.info(`🌐 Using proxy for OpenAI request: ${ProxyHelper.getProxyDescription(proxy)}`)
|
||||
} else {
|
||||
logger.debug('🌐 No proxy configured for OpenAI request')
|
||||
}
|
||||
|
||||
// 根据 stream 参数决定请求类型
|
||||
|
||||
@@ -1,18 +1,125 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookService = require('../services/webhookService')
|
||||
const webhookConfigService = require('../services/webhookConfigService')
|
||||
const { authenticateAdmin } = require('../middleware/auth')
|
||||
|
||||
// 获取webhook配置
|
||||
router.get('/config', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await webhookConfigService.getConfig()
|
||||
res.json({
|
||||
success: true,
|
||||
config
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取webhook配置失败:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: '获取webhook配置失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 保存webhook配置
|
||||
router.post('/config', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await webhookConfigService.saveConfig(req.body)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Webhook配置已保存',
|
||||
config
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('保存webhook配置失败:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message || '保存webhook配置失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 添加webhook平台
|
||||
router.post('/platforms', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const platform = await webhookConfigService.addPlatform(req.body)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Webhook平台已添加',
|
||||
platform
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('添加webhook平台失败:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message || '添加webhook平台失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 更新webhook平台
|
||||
router.put('/platforms/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const platform = await webhookConfigService.updatePlatform(req.params.id, req.body)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Webhook平台已更新',
|
||||
platform
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('更新webhook平台失败:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message || '更新webhook平台失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除webhook平台
|
||||
router.delete('/platforms/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
await webhookConfigService.deletePlatform(req.params.id)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Webhook平台已删除'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('删除webhook平台失败:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message || '删除webhook平台失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 切换webhook平台启用状态
|
||||
router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const platform = await webhookConfigService.togglePlatform(req.params.id)
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Webhook平台已${platform.enabled ? '启用' : '禁用'}`,
|
||||
platform
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('切换webhook平台状态失败:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message || '切换webhook平台状态失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 测试Webhook连通性
|
||||
router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body
|
||||
const { url, type = 'custom', secret, enableSign } = req.body
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing webhook URL',
|
||||
message: 'Please provide a webhook URL to test'
|
||||
message: '请提供webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,99 +129,144 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid URL format',
|
||||
message: 'Please provide a valid webhook URL'
|
||||
message: '请提供有效的webhook URL'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 Testing webhook URL: ${url}`)
|
||||
logger.info(`🧪 测试webhook: ${type} - ${url}`)
|
||||
|
||||
const result = await webhookNotifier.testWebhook(url)
|
||||
// 创建临时平台配置
|
||||
const platform = {
|
||||
type,
|
||||
url,
|
||||
secret,
|
||||
enableSign,
|
||||
enabled: true,
|
||||
timeout: 10000
|
||||
}
|
||||
|
||||
const result = await webhookService.testWebhook(platform)
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`✅ Webhook test successful for: ${url}`)
|
||||
logger.info(`✅ Webhook测试成功: ${url}`)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Webhook test successful',
|
||||
message: 'Webhook测试成功',
|
||||
url
|
||||
})
|
||||
} else {
|
||||
logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`)
|
||||
logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`)
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Webhook test failed',
|
||||
message: 'Webhook测试失败',
|
||||
url,
|
||||
error: result.error
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Webhook test error:', error)
|
||||
logger.error('❌ Webhook测试错误:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to test webhook'
|
||||
message: '测试webhook失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发账号异常通知(用于测试)
|
||||
// 手动触发测试通知
|
||||
router.post('/test-notification', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
type = 'test',
|
||||
accountId = 'test-account-id',
|
||||
accountName = 'Test Account',
|
||||
accountName = '测试账号',
|
||||
platform = 'claude-oauth',
|
||||
status = 'error',
|
||||
errorCode = 'TEST_ERROR',
|
||||
reason = 'Manual test notification'
|
||||
status = 'test',
|
||||
errorCode = 'TEST_NOTIFICATION',
|
||||
reason = '手动测试通知',
|
||||
message = '这是一条测试通知消息,用于验证 Webhook 通知功能是否正常工作'
|
||||
} = req.body
|
||||
|
||||
logger.info(`🧪 Sending test notification for account: ${accountName}`)
|
||||
logger.info(`🧪 发送测试通知: ${type}`)
|
||||
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
// 先检查webhook配置
|
||||
const config = await webhookConfigService.getConfig()
|
||||
logger.debug(
|
||||
`Webhook配置: enabled=${config.enabled}, platforms=${config.platforms?.length || 0}`
|
||||
)
|
||||
if (!config.enabled) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Webhook通知未启用,请先在设置中启用通知功能'
|
||||
})
|
||||
}
|
||||
|
||||
const enabledPlatforms = await webhookConfigService.getEnabledPlatforms()
|
||||
logger.info(`找到 ${enabledPlatforms.length} 个启用的通知平台`)
|
||||
|
||||
if (enabledPlatforms.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '没有启用的通知平台,请先添加并启用至少一个通知平台'
|
||||
})
|
||||
}
|
||||
|
||||
const testData = {
|
||||
accountId,
|
||||
accountName,
|
||||
platform,
|
||||
status,
|
||||
errorCode,
|
||||
reason
|
||||
})
|
||||
reason,
|
||||
message,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
logger.info(`✅ Test notification sent successfully`)
|
||||
const result = await webhookService.sendNotification(type, testData)
|
||||
|
||||
// 如果没有返回结果,说明可能是配置问题
|
||||
if (!result) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Webhook服务未返回结果,请检查配置和日志',
|
||||
enabledPlatforms: enabledPlatforms.length
|
||||
})
|
||||
}
|
||||
|
||||
// 如果没有成功和失败的记录
|
||||
if (result.succeeded === 0 && result.failed === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '没有发送任何通知,请检查通知类型配置',
|
||||
result,
|
||||
enabledPlatforms: enabledPlatforms.length
|
||||
})
|
||||
}
|
||||
|
||||
if (result.failed > 0) {
|
||||
logger.warn(`⚠️ 测试通知部分失败: ${result.succeeded}成功, ${result.failed}失败`)
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `测试通知部分成功: ${result.succeeded}个平台成功, ${result.failed}个平台失败`,
|
||||
data: testData,
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`✅ 测试通知发送成功到 ${result.succeeded} 个平台`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Test notification sent successfully',
|
||||
data: {
|
||||
accountId,
|
||||
accountName,
|
||||
platform,
|
||||
status,
|
||||
errorCode,
|
||||
reason
|
||||
}
|
||||
message: `测试通知已成功发送到 ${result.succeeded} 个平台`,
|
||||
data: testData,
|
||||
result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to send test notification:', error)
|
||||
logger.error('❌ 发送测试通知失败:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to send test notification'
|
||||
message: `发送测试通知失败: ${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取Webhook配置信息
|
||||
router.get('/config', authenticateAdmin, (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
enabled: config.webhook?.enabled !== false,
|
||||
urls: config.webhook?.urls || [],
|
||||
timeout: config.webhook?.timeout || 10000,
|
||||
retries: config.webhook?.retries || 3,
|
||||
urlCount: (config.webhook?.urls || []).length
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
Reference in New Issue
Block a user