mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
feat: 新增支持Azure OpenAI账户
This commit is contained in:
@@ -21,6 +21,7 @@ const geminiRoutes = require('./routes/geminiRoutes')
|
|||||||
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
|
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
|
||||||
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
|
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
|
||||||
const openaiRoutes = require('./routes/openaiRoutes')
|
const openaiRoutes = require('./routes/openaiRoutes')
|
||||||
|
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
|
||||||
const webhookRoutes = require('./routes/webhook')
|
const webhookRoutes = require('./routes/webhook')
|
||||||
|
|
||||||
// Import middleware
|
// Import middleware
|
||||||
@@ -241,6 +242,7 @@ class Application {
|
|||||||
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
||||||
this.app.use('/openai/claude', openaiClaudeRoutes)
|
this.app.use('/openai/claude', openaiClaudeRoutes)
|
||||||
this.app.use('/openai', openaiRoutes)
|
this.app.use('/openai', openaiRoutes)
|
||||||
|
this.app.use('/azure', azureOpenaiRoutes)
|
||||||
this.app.use('/admin/webhook', webhookRoutes)
|
this.app.use('/admin/webhook', webhookRoutes)
|
||||||
|
|
||||||
// 🏠 根路径重定向到新版管理界面
|
// 🏠 根路径重定向到新版管理界面
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const claudeConsoleAccountService = require('../services/claudeConsoleAccountSer
|
|||||||
const bedrockAccountService = require('../services/bedrockAccountService')
|
const bedrockAccountService = require('../services/bedrockAccountService')
|
||||||
const geminiAccountService = require('../services/geminiAccountService')
|
const geminiAccountService = require('../services/geminiAccountService')
|
||||||
const openaiAccountService = require('../services/openaiAccountService')
|
const openaiAccountService = require('../services/openaiAccountService')
|
||||||
|
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
|
||||||
const accountGroupService = require('../services/accountGroupService')
|
const accountGroupService = require('../services/accountGroupService')
|
||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const { authenticateAdmin } = require('../middleware/auth')
|
const { authenticateAdmin } = require('../middleware/auth')
|
||||||
@@ -5227,4 +5228,291 @@ 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)
|
||||||
|
|
||||||
|
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
|
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
|
||||||
@@ -20,6 +20,7 @@ class ApiKeyService {
|
|||||||
claudeConsoleAccountId = null,
|
claudeConsoleAccountId = null,
|
||||||
geminiAccountId = null,
|
geminiAccountId = null,
|
||||||
openaiAccountId = null,
|
openaiAccountId = null,
|
||||||
|
azureOpenaiAccountId = null,
|
||||||
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
||||||
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
|
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
|
||||||
isActive = true,
|
isActive = true,
|
||||||
@@ -53,6 +54,7 @@ class ApiKeyService {
|
|||||||
claudeConsoleAccountId: claudeConsoleAccountId || '',
|
claudeConsoleAccountId: claudeConsoleAccountId || '',
|
||||||
geminiAccountId: geminiAccountId || '',
|
geminiAccountId: geminiAccountId || '',
|
||||||
openaiAccountId: openaiAccountId || '',
|
openaiAccountId: openaiAccountId || '',
|
||||||
|
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
||||||
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
||||||
permissions: permissions || 'all',
|
permissions: permissions || 'all',
|
||||||
enableModelRestriction: String(enableModelRestriction),
|
enableModelRestriction: String(enableModelRestriction),
|
||||||
@@ -86,6 +88,7 @@ class ApiKeyService {
|
|||||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||||
geminiAccountId: keyData.geminiAccountId,
|
geminiAccountId: keyData.geminiAccountId,
|
||||||
openaiAccountId: keyData.openaiAccountId,
|
openaiAccountId: keyData.openaiAccountId,
|
||||||
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||||
permissions: keyData.permissions,
|
permissions: keyData.permissions,
|
||||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||||
@@ -174,6 +177,7 @@ class ApiKeyService {
|
|||||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||||
geminiAccountId: keyData.geminiAccountId,
|
geminiAccountId: keyData.geminiAccountId,
|
||||||
openaiAccountId: keyData.openaiAccountId,
|
openaiAccountId: keyData.openaiAccountId,
|
||||||
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions || 'all',
|
||||||
tokenLimit: parseInt(keyData.tokenLimit),
|
tokenLimit: parseInt(keyData.tokenLimit),
|
||||||
@@ -308,6 +312,7 @@ class ApiKeyService {
|
|||||||
'claudeConsoleAccountId',
|
'claudeConsoleAccountId',
|
||||||
'geminiAccountId',
|
'geminiAccountId',
|
||||||
'openaiAccountId',
|
'openaiAccountId',
|
||||||
|
'azureOpenaiAccountId',
|
||||||
'bedrockAccountId', // 添加 Bedrock 账号ID
|
'bedrockAccountId', // 添加 Bedrock 账号ID
|
||||||
'permissions',
|
'permissions',
|
||||||
'expiresAt',
|
'expiresAt',
|
||||||
|
|||||||
479
src/services/azureOpenaiAccountService.js
Normal file
479
src/services/azureOpenaiAccountService.js
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
const redisClient = require('../models/redis')
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const config = require('../../config/config')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
// 加密相关常量
|
||||||
|
const ALGORITHM = 'aes-256-cbc'
|
||||||
|
const IV_LENGTH = 16
|
||||||
|
|
||||||
|
// 🚀 安全的加密密钥生成,支持动态salt
|
||||||
|
const ENCRYPTION_SALT = config.security?.azureOpenaiSalt || 'azure-openai-account-default-salt'
|
||||||
|
|
||||||
|
class EncryptionKeyManager {
|
||||||
|
constructor() {
|
||||||
|
this.keyCache = new Map()
|
||||||
|
this.keyRotationInterval = 24 * 60 * 60 * 1000 // 24小时
|
||||||
|
}
|
||||||
|
|
||||||
|
getKey(version = 'current') {
|
||||||
|
const cached = this.keyCache.get(version)
|
||||||
|
if (cached && Date.now() - cached.timestamp < this.keyRotationInterval) {
|
||||||
|
return cached.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成新密钥
|
||||||
|
const key = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||||
|
this.keyCache.set(version, {
|
||||||
|
key,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug('🔑 Azure OpenAI encryption key generated/refreshed')
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理过期密钥
|
||||||
|
cleanup() {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [version, cached] of this.keyCache.entries()) {
|
||||||
|
if (now - cached.timestamp > this.keyRotationInterval) {
|
||||||
|
this.keyCache.delete(version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptionKeyManager = new EncryptionKeyManager()
|
||||||
|
|
||||||
|
// 定期清理过期密钥
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
encryptionKeyManager.cleanup()
|
||||||
|
},
|
||||||
|
60 * 60 * 1000
|
||||||
|
) // 每小时清理一次
|
||||||
|
|
||||||
|
// 生成加密密钥 - 使用安全的密钥管理器
|
||||||
|
function generateEncryptionKey() {
|
||||||
|
return encryptionKeyManager.getKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Azure OpenAI 账户键前缀
|
||||||
|
const AZURE_OPENAI_ACCOUNT_KEY_PREFIX = 'azure_openai:account:'
|
||||||
|
const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts'
|
||||||
|
const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:'
|
||||||
|
|
||||||
|
// 加密函数
|
||||||
|
function encrypt(text) {
|
||||||
|
if (!text) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const key = generateEncryptionKey()
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH)
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||||
|
let encrypted = cipher.update(text)
|
||||||
|
encrypted = Buffer.concat([encrypted, cipher.final()])
|
||||||
|
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密函数 - 移除缓存以提高安全性
|
||||||
|
function decrypt(text) {
|
||||||
|
if (!text) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = generateEncryptionKey()
|
||||||
|
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||||||
|
const ivHex = text.substring(0, 32)
|
||||||
|
const encryptedHex = text.substring(33) // 跳过冒号
|
||||||
|
|
||||||
|
if (ivHex.length !== 32 || !encryptedHex) {
|
||||||
|
throw new Error('Invalid encrypted text format')
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = Buffer.from(ivHex, 'hex')
|
||||||
|
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||||
|
let decrypted = decipher.update(encryptedText)
|
||||||
|
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||||
|
const result = decrypted.toString()
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Azure OpenAI decryption error:', error.message)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建账户
|
||||||
|
async function createAccount(accountData) {
|
||||||
|
const accountId = uuidv4()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const account = {
|
||||||
|
id: accountId,
|
||||||
|
name: accountData.name,
|
||||||
|
description: accountData.description || '',
|
||||||
|
accountType: accountData.accountType || 'shared',
|
||||||
|
groupId: accountData.groupId || null,
|
||||||
|
priority: accountData.priority || 50,
|
||||||
|
// Azure OpenAI 特有字段
|
||||||
|
azureEndpoint: accountData.azureEndpoint || '',
|
||||||
|
apiVersion: accountData.apiVersion || '2024-02-01', // 使用稳定版本
|
||||||
|
deploymentName: accountData.deploymentName || 'gpt-4', // 使用默认部署名称
|
||||||
|
apiKey: encrypt(accountData.apiKey || ''),
|
||||||
|
// 支持的模型
|
||||||
|
supportedModels: JSON.stringify(
|
||||||
|
accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k']
|
||||||
|
),
|
||||||
|
// 状态字段
|
||||||
|
isActive: accountData.isActive !== false ? 'true' : 'false',
|
||||||
|
status: 'active',
|
||||||
|
schedulable: accountData.schedulable !== false ? 'true' : 'false',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代理配置
|
||||||
|
if (accountData.proxy) {
|
||||||
|
account.proxy =
|
||||||
|
typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
||||||
|
|
||||||
|
// 如果是共享账户,添加到共享账户集合
|
||||||
|
if (account.accountType === 'shared') {
|
||||||
|
await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Created Azure OpenAI account: ${accountId}`)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账户
|
||||||
|
async function getAccount(accountId) {
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
const accountData = await client.hgetall(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||||
|
|
||||||
|
if (!accountData || Object.keys(accountData).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密敏感数据(仅用于内部处理,不返回给前端)
|
||||||
|
if (accountData.apiKey) {
|
||||||
|
accountData.apiKey = decrypt(accountData.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析代理配置
|
||||||
|
if (accountData.proxy && typeof accountData.proxy === 'string') {
|
||||||
|
try {
|
||||||
|
accountData.proxy = JSON.parse(accountData.proxy)
|
||||||
|
} catch (e) {
|
||||||
|
accountData.proxy = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析支持的模型
|
||||||
|
if (accountData.supportedModels && typeof accountData.supportedModels === 'string') {
|
||||||
|
try {
|
||||||
|
accountData.supportedModels = JSON.parse(accountData.supportedModels)
|
||||||
|
} catch (e) {
|
||||||
|
accountData.supportedModels = ['gpt-4', 'gpt-35-turbo']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountData
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新账户
|
||||||
|
async function updateAccount(accountId, updates) {
|
||||||
|
const existingAccount = await getAccount(accountId)
|
||||||
|
if (!existingAccount) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.updatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
// 加密敏感数据
|
||||||
|
if (updates.apiKey) {
|
||||||
|
updates.apiKey = encrypt(updates.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理代理配置
|
||||||
|
if (updates.proxy) {
|
||||||
|
updates.proxy =
|
||||||
|
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理支持的模型
|
||||||
|
if (updates.supportedModels) {
|
||||||
|
updates.supportedModels =
|
||||||
|
typeof updates.supportedModels === 'string'
|
||||||
|
? updates.supportedModels
|
||||||
|
: JSON.stringify(updates.supportedModels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新账户类型时处理共享账户集合
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
||||||
|
if (updates.accountType === 'shared') {
|
||||||
|
await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||||||
|
} else {
|
||||||
|
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
||||||
|
|
||||||
|
logger.info(`Updated Azure OpenAI account: ${accountId}`)
|
||||||
|
|
||||||
|
// 合并更新后的账户数据
|
||||||
|
const updatedAccount = { ...existingAccount, ...updates }
|
||||||
|
|
||||||
|
// 返回时解析代理配置
|
||||||
|
if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') {
|
||||||
|
try {
|
||||||
|
updatedAccount.proxy = JSON.parse(updatedAccount.proxy)
|
||||||
|
} catch (e) {
|
||||||
|
updatedAccount.proxy = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除账户
|
||||||
|
async function deleteAccount(accountId) {
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`
|
||||||
|
|
||||||
|
// 从Redis中删除账户数据
|
||||||
|
await client.del(accountKey)
|
||||||
|
|
||||||
|
// 从共享账户集合中移除
|
||||||
|
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||||||
|
|
||||||
|
logger.info(`Deleted Azure OpenAI account: ${accountId}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有账户
|
||||||
|
async function getAllAccounts() {
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`)
|
||||||
|
|
||||||
|
if (!keys || keys.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = []
|
||||||
|
for (const key of keys) {
|
||||||
|
const accountData = await client.hgetall(key)
|
||||||
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
|
// 不返回敏感数据给前端
|
||||||
|
delete accountData.apiKey
|
||||||
|
|
||||||
|
// 解析代理配置
|
||||||
|
if (accountData.proxy && typeof accountData.proxy === 'string') {
|
||||||
|
try {
|
||||||
|
accountData.proxy = JSON.parse(accountData.proxy)
|
||||||
|
} catch (e) {
|
||||||
|
accountData.proxy = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析支持的模型
|
||||||
|
if (accountData.supportedModels && typeof accountData.supportedModels === 'string') {
|
||||||
|
try {
|
||||||
|
accountData.supportedModels = JSON.parse(accountData.supportedModels)
|
||||||
|
} catch (e) {
|
||||||
|
accountData.supportedModels = ['gpt-4', 'gpt-35-turbo']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.push(accountData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取共享账户
|
||||||
|
async function getSharedAccounts() {
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
const accountIds = await client.smembers(SHARED_AZURE_OPENAI_ACCOUNTS_KEY)
|
||||||
|
|
||||||
|
if (!accountIds || accountIds.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = []
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const account = await getAccount(accountId)
|
||||||
|
if (account && account.isActive === 'true') {
|
||||||
|
accounts.push(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择可用账户
|
||||||
|
async function selectAvailableAccount(sessionId = null) {
|
||||||
|
// 如果有会话ID,尝试获取之前分配的账户
|
||||||
|
if (sessionId) {
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}`
|
||||||
|
const accountId = await client.get(mappingKey)
|
||||||
|
|
||||||
|
if (accountId) {
|
||||||
|
const account = await getAccount(accountId)
|
||||||
|
if (account && account.isActive === 'true' && account.schedulable === 'true') {
|
||||||
|
logger.debug(`Reusing Azure OpenAI account ${accountId} for session ${sessionId}`)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有共享账户
|
||||||
|
const sharedAccounts = await getSharedAccounts()
|
||||||
|
|
||||||
|
// 过滤出可用的账户
|
||||||
|
const availableAccounts = sharedAccounts.filter(
|
||||||
|
(acc) => acc.isActive === 'true' && acc.schedulable === 'true'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (availableAccounts.length === 0) {
|
||||||
|
throw new Error('No available Azure OpenAI accounts')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按优先级排序并选择
|
||||||
|
availableAccounts.sort((a, b) => (b.priority || 50) - (a.priority || 50))
|
||||||
|
const selectedAccount = availableAccounts[0]
|
||||||
|
|
||||||
|
// 如果有会话ID,保存映射关系
|
||||||
|
if (sessionId && selectedAccount) {
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}`
|
||||||
|
await client.setex(mappingKey, 3600, selectedAccount.id) // 1小时过期
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Selected Azure OpenAI account: ${selectedAccount.id}`)
|
||||||
|
return selectedAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新账户使用量
|
||||||
|
async function updateAccountUsage(accountId, tokens) {
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// 使用 HINCRBY 原子操作更新使用量
|
||||||
|
await client.hincrby(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'totalTokensUsed', tokens)
|
||||||
|
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'lastUsedAt', now)
|
||||||
|
|
||||||
|
logger.debug(`Updated Azure OpenAI account ${accountId} usage: ${tokens} tokens`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 健康检查单个账户
|
||||||
|
async function healthCheckAccount(accountId) {
|
||||||
|
try {
|
||||||
|
const account = await getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return { id: accountId, status: 'error', message: 'Account not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单检查配置是否完整
|
||||||
|
if (!account.azureEndpoint || !account.apiKey || !account.deploymentName) {
|
||||||
|
return {
|
||||||
|
id: accountId,
|
||||||
|
status: 'error',
|
||||||
|
message: 'Incomplete configuration'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可以在这里添加实际的API调用测试
|
||||||
|
// 暂时返回成功状态
|
||||||
|
return {
|
||||||
|
id: accountId,
|
||||||
|
status: 'healthy',
|
||||||
|
message: 'Account is configured correctly'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Health check failed for Azure OpenAI account ${accountId}:`, error)
|
||||||
|
return {
|
||||||
|
id: accountId,
|
||||||
|
status: 'error',
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量健康检查
|
||||||
|
async function performHealthChecks() {
|
||||||
|
const accounts = await getAllAccounts()
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const result = await healthCheckAccount(account.id)
|
||||||
|
results.push(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换账户的可调度状态
|
||||||
|
async function toggleSchedulable(accountId) {
|
||||||
|
const account = await getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSchedulable = account.schedulable === 'true' ? 'false' : 'true'
|
||||||
|
await updateAccount(accountId, { schedulable: newSchedulable })
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: accountId,
|
||||||
|
schedulable: newSchedulable === 'true'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移 API Keys 以支持 Azure OpenAI
|
||||||
|
async function migrateApiKeysForAzureSupport() {
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
const apiKeyIds = await client.smembers('api_keys')
|
||||||
|
|
||||||
|
let migratedCount = 0
|
||||||
|
for (const keyId of apiKeyIds) {
|
||||||
|
const keyData = await client.hgetall(`api_key:${keyId}`)
|
||||||
|
if (keyData && !keyData.azureOpenaiAccountId) {
|
||||||
|
// 添加 Azure OpenAI 账户ID字段(初始为空)
|
||||||
|
await client.hset(`api_key:${keyId}`, 'azureOpenaiAccountId', '')
|
||||||
|
migratedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Migrated ${migratedCount} API keys for Azure OpenAI support`)
|
||||||
|
return migratedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createAccount,
|
||||||
|
getAccount,
|
||||||
|
updateAccount,
|
||||||
|
deleteAccount,
|
||||||
|
getAllAccounts,
|
||||||
|
getSharedAccounts,
|
||||||
|
selectAvailableAccount,
|
||||||
|
updateAccountUsage,
|
||||||
|
healthCheckAccount,
|
||||||
|
performHealthChecks,
|
||||||
|
toggleSchedulable,
|
||||||
|
migrateApiKeysForAzureSupport,
|
||||||
|
encrypt,
|
||||||
|
decrypt
|
||||||
|
}
|
||||||
529
src/services/azureOpenaiRelayService.js
Normal file
529
src/services/azureOpenaiRelayService.js
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
const axios = require('axios')
|
||||||
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
// 转换模型名称(去掉 azure/ 前缀)
|
||||||
|
function normalizeModelName(model) {
|
||||||
|
if (model && model.startsWith('azure/')) {
|
||||||
|
return model.replace('azure/', '')
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 Azure OpenAI 请求
|
||||||
|
async function handleAzureOpenAIRequest({
|
||||||
|
account,
|
||||||
|
requestBody,
|
||||||
|
headers: _headers = {}, // 前缀下划线表示未使用
|
||||||
|
isStream = false,
|
||||||
|
endpoint = 'chat/completions'
|
||||||
|
}) {
|
||||||
|
// 声明变量在函数顶部,确保在 catch 块中也能访问
|
||||||
|
let requestUrl = ''
|
||||||
|
let proxyAgent = null
|
||||||
|
let deploymentName = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构建 Azure OpenAI 请求 URL
|
||||||
|
const baseUrl = account.azureEndpoint
|
||||||
|
deploymentName = account.deploymentName || 'default'
|
||||||
|
// Azure Responses API requires preview versions; fall back appropriately
|
||||||
|
const apiVersion =
|
||||||
|
account.apiVersion || (endpoint === 'responses' ? '2024-10-01-preview' : '2024-02-01')
|
||||||
|
if (endpoint === 'chat/completions') {
|
||||||
|
requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
|
||||||
|
} else if (endpoint === 'responses') {
|
||||||
|
requestUrl = `${baseUrl}/openai/responses?api-version=${apiVersion}`
|
||||||
|
} else {
|
||||||
|
requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/${endpoint}?api-version=${apiVersion}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备请求头
|
||||||
|
const requestHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'api-key': account.apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除不需要的头部
|
||||||
|
delete requestHeaders['anthropic-version']
|
||||||
|
delete requestHeaders['x-api-key']
|
||||||
|
delete requestHeaders['host']
|
||||||
|
|
||||||
|
// 处理请求体
|
||||||
|
const processedBody = { ...requestBody }
|
||||||
|
|
||||||
|
// 标准化模型名称
|
||||||
|
if (processedBody.model) {
|
||||||
|
processedBody.model = normalizeModelName(processedBody.model)
|
||||||
|
} else {
|
||||||
|
processedBody.model = 'gpt-4'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用统一的代理创建工具
|
||||||
|
proxyAgent = ProxyHelper.createProxyAgent(account.proxy)
|
||||||
|
|
||||||
|
// 配置请求选项
|
||||||
|
const axiosConfig = {
|
||||||
|
method: 'POST',
|
||||||
|
url: requestUrl,
|
||||||
|
headers: requestHeaders,
|
||||||
|
data: processedBody,
|
||||||
|
timeout: 600000, // 10 minutes for Azure OpenAI
|
||||||
|
validateStatus: () => true,
|
||||||
|
// 添加连接保活选项
|
||||||
|
keepAlive: true,
|
||||||
|
maxRedirects: 5,
|
||||||
|
// 防止socket hang up
|
||||||
|
socketKeepAlive: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有代理,添加代理配置
|
||||||
|
if (proxyAgent) {
|
||||||
|
axiosConfig.httpsAgent = proxyAgent
|
||||||
|
// 为代理添加额外的keep-alive设置
|
||||||
|
if (proxyAgent.options) {
|
||||||
|
proxyAgent.options.keepAlive = true
|
||||||
|
proxyAgent.options.keepAliveMsecs = 1000
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`Using proxy for Azure OpenAI request: ${ProxyHelper.getProxyDescription(account.proxy)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式请求特殊处理
|
||||||
|
if (isStream) {
|
||||||
|
axiosConfig.responseType = 'stream'
|
||||||
|
requestHeaders.accept = 'text/event-stream'
|
||||||
|
} else {
|
||||||
|
requestHeaders.accept = 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Making Azure OpenAI request`, {
|
||||||
|
requestUrl,
|
||||||
|
method: 'POST',
|
||||||
|
endpoint,
|
||||||
|
deploymentName,
|
||||||
|
apiVersion,
|
||||||
|
hasProxy: !!proxyAgent,
|
||||||
|
proxyInfo: ProxyHelper.maskProxyInfo(account.proxy),
|
||||||
|
isStream,
|
||||||
|
requestBodySize: JSON.stringify(processedBody).length
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug('Azure OpenAI request headers', {
|
||||||
|
'content-type': requestHeaders['Content-Type'],
|
||||||
|
'user-agent': requestHeaders['user-agent'] || 'not-set',
|
||||||
|
customHeaders: Object.keys(requestHeaders).filter(
|
||||||
|
(key) => !['Content-Type', 'user-agent'].includes(key)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug('Azure OpenAI request body', {
|
||||||
|
model: processedBody.model,
|
||||||
|
messages: processedBody.messages?.length || 0,
|
||||||
|
otherParams: Object.keys(processedBody).filter((key) => !['model', 'messages'].includes(key))
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestStartTime = Date.now()
|
||||||
|
logger.debug(`🔄 Starting Azure OpenAI HTTP request at ${new Date().toISOString()}`)
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const response = await axios(axiosConfig)
|
||||||
|
|
||||||
|
const requestDuration = Date.now() - requestStartTime
|
||||||
|
logger.debug(`✅ Azure OpenAI HTTP request completed at ${new Date().toISOString()}`)
|
||||||
|
|
||||||
|
logger.debug(`Azure OpenAI response received`, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
duration: `${requestDuration}ms`,
|
||||||
|
responseHeaders: Object.keys(response.headers || {}),
|
||||||
|
hasData: !!response.data,
|
||||||
|
contentType: response.headers?.['content-type'] || 'unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
const errorDetails = {
|
||||||
|
message: error.message,
|
||||||
|
code: error.code,
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
responseData: error.response?.data,
|
||||||
|
requestUrl: requestUrl || 'unknown',
|
||||||
|
endpoint,
|
||||||
|
deploymentName: deploymentName || account?.deploymentName || 'unknown',
|
||||||
|
hasProxy: !!proxyAgent,
|
||||||
|
proxyType: account?.proxy?.type || 'none',
|
||||||
|
isTimeout: error.code === 'ECONNABORTED',
|
||||||
|
isNetworkError: !error.response,
|
||||||
|
stack: error.stack
|
||||||
|
}
|
||||||
|
|
||||||
|
// 特殊错误类型的详细日志
|
||||||
|
if (error.code === 'ENOTFOUND') {
|
||||||
|
logger.error('DNS Resolution Failed for Azure OpenAI', {
|
||||||
|
...errorDetails,
|
||||||
|
hostname: requestUrl && requestUrl !== 'unknown' ? new URL(requestUrl).hostname : 'unknown',
|
||||||
|
suggestion: 'Check if Azure endpoint URL is correct and accessible'
|
||||||
|
})
|
||||||
|
} else if (error.code === 'ECONNREFUSED') {
|
||||||
|
logger.error('Connection Refused by Azure OpenAI', {
|
||||||
|
...errorDetails,
|
||||||
|
suggestion: 'Check if proxy settings are correct or Azure service is accessible'
|
||||||
|
})
|
||||||
|
} else if (error.code === 'ECONNRESET' || error.message.includes('socket hang up')) {
|
||||||
|
logger.error('🚨 Azure OpenAI Connection Reset / Socket Hang Up', {
|
||||||
|
...errorDetails,
|
||||||
|
suggestion:
|
||||||
|
'Connection was dropped by Azure OpenAI or proxy. This might be due to long request processing time, proxy timeout, or network instability. Try reducing request complexity or check proxy settings.'
|
||||||
|
})
|
||||||
|
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||||
|
logger.error('🚨 Azure OpenAI Request Timeout', {
|
||||||
|
...errorDetails,
|
||||||
|
timeoutMs: 600000,
|
||||||
|
suggestion:
|
||||||
|
'Request exceeded 10-minute timeout. Consider reducing model complexity or check if Azure service is responding slowly.'
|
||||||
|
})
|
||||||
|
} else if (
|
||||||
|
error.code === 'CERT_AUTHORITY_INVALID' ||
|
||||||
|
error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
|
||||||
|
) {
|
||||||
|
logger.error('SSL Certificate Error for Azure OpenAI', {
|
||||||
|
...errorDetails,
|
||||||
|
suggestion: 'SSL certificate validation failed - check proxy SSL settings'
|
||||||
|
})
|
||||||
|
} else if (error.response?.status === 401) {
|
||||||
|
logger.error('Azure OpenAI Authentication Failed', {
|
||||||
|
...errorDetails,
|
||||||
|
suggestion: 'Check if Azure OpenAI API key is valid and not expired'
|
||||||
|
})
|
||||||
|
} else if (error.response?.status === 404) {
|
||||||
|
logger.error('Azure OpenAI Deployment Not Found', {
|
||||||
|
...errorDetails,
|
||||||
|
suggestion: 'Check if deployment name and Azure endpoint are correct'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.error('Azure OpenAI Request Failed', errorDetails)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全的流管理器
|
||||||
|
class StreamManager {
|
||||||
|
constructor() {
|
||||||
|
this.activeStreams = new Set()
|
||||||
|
this.cleanupCallbacks = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
registerStream(streamId, cleanup) {
|
||||||
|
this.activeStreams.add(streamId)
|
||||||
|
this.cleanupCallbacks.set(streamId, cleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(streamId) {
|
||||||
|
if (this.activeStreams.has(streamId)) {
|
||||||
|
try {
|
||||||
|
const cleanup = this.cleanupCallbacks.get(streamId)
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Stream cleanup error for ${streamId}:`, error.message)
|
||||||
|
} finally {
|
||||||
|
this.activeStreams.delete(streamId)
|
||||||
|
this.cleanupCallbacks.delete(streamId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveStreamCount() {
|
||||||
|
return this.activeStreams.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamManager = new StreamManager()
|
||||||
|
|
||||||
|
// SSE 缓冲区大小限制
|
||||||
|
const MAX_BUFFER_SIZE = 64 * 1024 // 64KB
|
||||||
|
const MAX_EVENT_SIZE = 16 * 1024 // 16KB 单个事件最大大小
|
||||||
|
|
||||||
|
// 处理流式响应
|
||||||
|
function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
|
||||||
|
const { onData, onEnd, onError } = options
|
||||||
|
const streamId = `stream_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
|
||||||
|
logger.info(`Starting Azure OpenAI stream handling`, {
|
||||||
|
streamId,
|
||||||
|
upstreamStatus: upstreamResponse.status,
|
||||||
|
upstreamHeaders: Object.keys(upstreamResponse.headers || {}),
|
||||||
|
clientRemoteAddress: clientResponse.req?.connection?.remoteAddress,
|
||||||
|
hasOnData: !!onData,
|
||||||
|
hasOnEnd: !!onEnd,
|
||||||
|
hasOnError: !!onError
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = ''
|
||||||
|
let usageData = null
|
||||||
|
let actualModel = null
|
||||||
|
let hasEnded = false
|
||||||
|
let eventCount = 0
|
||||||
|
const maxEvents = 10000 // 最大事件数量限制
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
clientResponse.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
clientResponse.setHeader('Cache-Control', 'no-cache')
|
||||||
|
clientResponse.setHeader('Connection', 'keep-alive')
|
||||||
|
clientResponse.setHeader('X-Accel-Buffering', 'no')
|
||||||
|
|
||||||
|
// 透传某些头部
|
||||||
|
const passThroughHeaders = [
|
||||||
|
'x-request-id',
|
||||||
|
'x-ratelimit-remaining-requests',
|
||||||
|
'x-ratelimit-remaining-tokens'
|
||||||
|
]
|
||||||
|
passThroughHeaders.forEach((header) => {
|
||||||
|
const value = upstreamResponse.headers[header]
|
||||||
|
if (value) {
|
||||||
|
clientResponse.setHeader(header, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 立即刷新响应头
|
||||||
|
if (typeof clientResponse.flushHeaders === 'function') {
|
||||||
|
clientResponse.flushHeaders()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 SSE 事件以捕获 usage 数据
|
||||||
|
const parseSSEForUsage = (data) => {
|
||||||
|
const lines = data.split('\n')
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const jsonStr = line.slice(6) // 移除 'data: ' 前缀
|
||||||
|
if (jsonStr.trim() === '[DONE]') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const eventData = JSON.parse(jsonStr)
|
||||||
|
|
||||||
|
// 获取模型信息
|
||||||
|
if (eventData.model) {
|
||||||
|
actualModel = eventData.model
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取使用统计(Responses API: response.completed -> response.usage)
|
||||||
|
if (eventData.type === 'response.completed' && eventData.response) {
|
||||||
|
if (eventData.response.model) {
|
||||||
|
actualModel = eventData.response.model
|
||||||
|
}
|
||||||
|
if (eventData.response.usage) {
|
||||||
|
usageData = eventData.response.usage
|
||||||
|
logger.debug('Captured Azure OpenAI nested usage (response.usage):', usageData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容 Chat Completions 风格(顶层 usage)
|
||||||
|
if (!usageData && eventData.usage) {
|
||||||
|
usageData = eventData.usage
|
||||||
|
logger.debug('Captured Azure OpenAI usage (top-level):', usageData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是完成事件
|
||||||
|
if (eventData.choices && eventData.choices[0] && eventData.choices[0].finish_reason) {
|
||||||
|
// 这是最后一个 chunk
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册流清理
|
||||||
|
const cleanup = () => {
|
||||||
|
if (!hasEnded) {
|
||||||
|
hasEnded = true
|
||||||
|
try {
|
||||||
|
upstreamResponse.data?.removeAllListeners?.()
|
||||||
|
upstreamResponse.data?.destroy?.()
|
||||||
|
|
||||||
|
if (!clientResponse.headersSent) {
|
||||||
|
clientResponse.status(502).end()
|
||||||
|
} else if (!clientResponse.destroyed) {
|
||||||
|
clientResponse.end()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Stream cleanup error:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
streamManager.registerStream(streamId, cleanup)
|
||||||
|
|
||||||
|
upstreamResponse.data.on('data', (chunk) => {
|
||||||
|
try {
|
||||||
|
if (hasEnded || clientResponse.destroyed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eventCount++
|
||||||
|
if (eventCount > maxEvents) {
|
||||||
|
logger.warn(`Stream ${streamId} exceeded max events limit`)
|
||||||
|
cleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkStr = chunk.toString()
|
||||||
|
|
||||||
|
// 转发数据给客户端
|
||||||
|
if (!clientResponse.destroyed) {
|
||||||
|
clientResponse.write(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时解析数据以捕获 usage 信息,带缓冲区大小限制
|
||||||
|
buffer += chunkStr
|
||||||
|
|
||||||
|
// 防止缓冲区过大
|
||||||
|
if (buffer.length > MAX_BUFFER_SIZE) {
|
||||||
|
logger.warn(`Stream ${streamId} buffer exceeded limit, truncating`)
|
||||||
|
buffer = buffer.slice(-MAX_BUFFER_SIZE / 2) // 保留后一半
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理完整的 SSE 事件
|
||||||
|
if (buffer.includes('\n\n')) {
|
||||||
|
const events = buffer.split('\n\n')
|
||||||
|
buffer = events.pop() || '' // 保留最后一个可能不完整的事件
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.trim() && event.length <= MAX_EVENT_SIZE) {
|
||||||
|
parseSSEForUsage(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onData) {
|
||||||
|
onData(chunk, { usageData, actualModel })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing Azure OpenAI stream chunk:', error)
|
||||||
|
if (!hasEnded) {
|
||||||
|
cleanup()
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
upstreamResponse.data.on('end', () => {
|
||||||
|
if (hasEnded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
streamManager.cleanup(streamId)
|
||||||
|
hasEnded = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 处理剩余的 buffer
|
||||||
|
if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) {
|
||||||
|
parseSSEForUsage(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onEnd) {
|
||||||
|
onEnd({ usageData, actualModel })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientResponse.destroyed) {
|
||||||
|
clientResponse.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ usageData, actualModel })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Stream end handling error:', error)
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
upstreamResponse.data.on('error', (error) => {
|
||||||
|
if (hasEnded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
streamManager.cleanup(streamId)
|
||||||
|
hasEnded = true
|
||||||
|
|
||||||
|
logger.error('Upstream stream error:', error)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (onError) {
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientResponse.headersSent) {
|
||||||
|
clientResponse.status(502).json({ error: { message: 'Upstream stream error' } })
|
||||||
|
} else if (!clientResponse.destroyed) {
|
||||||
|
clientResponse.end()
|
||||||
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.warn('Error during stream error cleanup:', cleanupError.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 客户端断开时清理
|
||||||
|
const clientCleanup = () => {
|
||||||
|
streamManager.cleanup(streamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientResponse.on('close', clientCleanup)
|
||||||
|
clientResponse.on('aborted', clientCleanup)
|
||||||
|
clientResponse.on('error', clientCleanup)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理非流式响应
|
||||||
|
function handleNonStreamResponse(upstreamResponse, clientResponse) {
|
||||||
|
try {
|
||||||
|
// 设置状态码
|
||||||
|
clientResponse.status(upstreamResponse.status)
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
clientResponse.setHeader('Content-Type', 'application/json')
|
||||||
|
|
||||||
|
// 透传某些头部
|
||||||
|
const passThroughHeaders = [
|
||||||
|
'x-request-id',
|
||||||
|
'x-ratelimit-remaining-requests',
|
||||||
|
'x-ratelimit-remaining-tokens'
|
||||||
|
]
|
||||||
|
passThroughHeaders.forEach((header) => {
|
||||||
|
const value = upstreamResponse.headers[header]
|
||||||
|
if (value) {
|
||||||
|
clientResponse.setHeader(header, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 返回响应数据
|
||||||
|
const responseData = upstreamResponse.data
|
||||||
|
clientResponse.json(responseData)
|
||||||
|
|
||||||
|
// 提取 usage 数据
|
||||||
|
const usageData = responseData.usage
|
||||||
|
const actualModel = responseData.model
|
||||||
|
|
||||||
|
return { usageData, actualModel, responseData }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error handling Azure OpenAI non-stream response:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handleAzureOpenAIRequest,
|
||||||
|
handleStreamResponse,
|
||||||
|
handleNonStreamResponse,
|
||||||
|
normalizeModelName
|
||||||
|
}
|
||||||
@@ -105,6 +105,15 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.platform"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="radio"
|
||||||
|
value="azure_openai"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Azure OpenAI</span>
|
||||||
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.platform"
|
v-model="form.platform"
|
||||||
@@ -118,7 +127,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!isEdit && form.platform !== 'claude-console' && form.platform !== 'bedrock'"
|
v-if="
|
||||||
|
!isEdit &&
|
||||||
|
form.platform !== 'claude-console' &&
|
||||||
|
form.platform !== 'bedrock' &&
|
||||||
|
form.platform !== 'azure_openai'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>添加方式</label
|
>添加方式</label
|
||||||
@@ -429,7 +443,111 @@
|
|||||||
用于快速响应的轻量级模型,留空将使用系统默认
|
用于快速响应的轻量级模型,留空将使用系统默认
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Azure OpenAI 特定字段 -->
|
||||||
|
<div v-if="form.platform === 'azure_openai' && !isEdit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>Azure Endpoint *</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.azureEndpoint"
|
||||||
|
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
:class="{ 'border-red-500': errors.azureEndpoint }"
|
||||||
|
placeholder="https://your-resource.openai.azure.com"
|
||||||
|
required
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
<p v-if="errors.azureEndpoint" class="mt-1 text-xs text-red-500">
|
||||||
|
{{ errors.azureEndpoint }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Azure OpenAI 资源的终结点 URL,格式:https://your-resource.openai.azure.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>API 版本</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.apiVersion"
|
||||||
|
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="2024-02-01"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Azure OpenAI API 版本,默认使用最新稳定版本 2024-02-01
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>部署名称 *</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.deploymentName"
|
||||||
|
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
:class="{ 'border-red-500': errors.deploymentName }"
|
||||||
|
placeholder="gpt-4"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p v-if="errors.deploymentName" class="mt-1 text-xs text-red-500">
|
||||||
|
{{ errors.deploymentName }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
在 Azure OpenAI Studio 中创建的部署名称
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>API Key *</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.apiKey"
|
||||||
|
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
:class="{ 'border-red-500': errors.apiKey }"
|
||||||
|
placeholder="请输入 Azure OpenAI API Key"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<p v-if="errors.apiKey" class="mt-1 text-xs text-red-500">
|
||||||
|
{{ errors.apiKey }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
从 Azure 门户获取的 API 密钥
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>支持的模型</label
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="model in ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k']"
|
||||||
|
:key="model"
|
||||||
|
class="flex cursor-pointer items-center"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.supportedModels"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
:value="model"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
选择此部署支持的模型类型
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.platform === 'bedrock' && !isEdit">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>限流机制</label
|
>限流机制</label
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
const bedrockAccounts = ref([])
|
const bedrockAccounts = ref([])
|
||||||
const geminiAccounts = ref([])
|
const geminiAccounts = ref([])
|
||||||
const openaiAccounts = ref([])
|
const openaiAccounts = ref([])
|
||||||
|
const azureOpenaiAccounts = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const sortBy = ref('')
|
const sortBy = ref('')
|
||||||
@@ -111,6 +112,25 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取Azure OpenAI账户列表
|
||||||
|
const fetchAzureOpenAIAccounts = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/azure-openai-accounts')
|
||||||
|
if (response.success) {
|
||||||
|
azureOpenaiAccounts.value = response.data || []
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '获取Azure OpenAI账户失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取所有账户
|
// 获取所有账户
|
||||||
const fetchAllAccounts = async () => {
|
const fetchAllAccounts = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -121,7 +141,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
fetchClaudeConsoleAccounts(),
|
fetchClaudeConsoleAccounts(),
|
||||||
fetchBedrockAccounts(),
|
fetchBedrockAccounts(),
|
||||||
fetchGeminiAccounts(),
|
fetchGeminiAccounts(),
|
||||||
fetchOpenAIAccounts()
|
fetchOpenAIAccounts(),
|
||||||
|
fetchAzureOpenAIAccounts()
|
||||||
])
|
])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message
|
error.value = err.message
|
||||||
@@ -231,6 +252,26 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建Azure OpenAI账户
|
||||||
|
const createAzureOpenAIAccount = async (data) => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/admin/azure-openai-accounts', data)
|
||||||
|
if (response.success) {
|
||||||
|
await fetchAzureOpenAIAccounts()
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '创建Azure OpenAI账户失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 更新Claude账户
|
// 更新Claude账户
|
||||||
const updateClaudeAccount = async (id, data) => {
|
const updateClaudeAccount = async (id, data) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -331,6 +372,26 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新Azure OpenAI账户
|
||||||
|
const updateAzureOpenAIAccount = async (id, data) => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/admin/azure-openai-accounts/${id}`, data)
|
||||||
|
if (response.success) {
|
||||||
|
await fetchAzureOpenAIAccounts()
|
||||||
|
return response
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '更新Azure OpenAI账户失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 切换账户状态
|
// 切换账户状态
|
||||||
const toggleAccount = async (platform, id) => {
|
const toggleAccount = async (platform, id) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -345,6 +406,10 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
endpoint = `/admin/bedrock-accounts/${id}/toggle`
|
endpoint = `/admin/bedrock-accounts/${id}/toggle`
|
||||||
} else if (platform === 'gemini') {
|
} else if (platform === 'gemini') {
|
||||||
endpoint = `/admin/gemini-accounts/${id}/toggle`
|
endpoint = `/admin/gemini-accounts/${id}/toggle`
|
||||||
|
} else if (platform === 'openai') {
|
||||||
|
endpoint = `/admin/openai-accounts/${id}/toggle`
|
||||||
|
} else if (platform === 'azure_openai') {
|
||||||
|
endpoint = `/admin/azure-openai-accounts/${id}/toggle`
|
||||||
} else {
|
} else {
|
||||||
endpoint = `/admin/openai-accounts/${id}/toggle`
|
endpoint = `/admin/openai-accounts/${id}/toggle`
|
||||||
}
|
}
|
||||||
@@ -359,6 +424,10 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
await fetchBedrockAccounts()
|
await fetchBedrockAccounts()
|
||||||
} else if (platform === 'gemini') {
|
} else if (platform === 'gemini') {
|
||||||
await fetchGeminiAccounts()
|
await fetchGeminiAccounts()
|
||||||
|
} else if (platform === 'openai') {
|
||||||
|
await fetchOpenAIAccounts()
|
||||||
|
} else if (platform === 'azure_openai') {
|
||||||
|
await fetchAzureOpenAIAccounts()
|
||||||
} else {
|
} else {
|
||||||
await fetchOpenAIAccounts()
|
await fetchOpenAIAccounts()
|
||||||
}
|
}
|
||||||
@@ -388,6 +457,10 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
endpoint = `/admin/bedrock-accounts/${id}`
|
endpoint = `/admin/bedrock-accounts/${id}`
|
||||||
} else if (platform === 'gemini') {
|
} else if (platform === 'gemini') {
|
||||||
endpoint = `/admin/gemini-accounts/${id}`
|
endpoint = `/admin/gemini-accounts/${id}`
|
||||||
|
} else if (platform === 'openai') {
|
||||||
|
endpoint = `/admin/openai-accounts/${id}`
|
||||||
|
} else if (platform === 'azure_openai') {
|
||||||
|
endpoint = `/admin/azure-openai-accounts/${id}`
|
||||||
} else {
|
} else {
|
||||||
endpoint = `/admin/openai-accounts/${id}`
|
endpoint = `/admin/openai-accounts/${id}`
|
||||||
}
|
}
|
||||||
@@ -402,6 +475,10 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
await fetchBedrockAccounts()
|
await fetchBedrockAccounts()
|
||||||
} else if (platform === 'gemini') {
|
} else if (platform === 'gemini') {
|
||||||
await fetchGeminiAccounts()
|
await fetchGeminiAccounts()
|
||||||
|
} else if (platform === 'openai') {
|
||||||
|
await fetchOpenAIAccounts()
|
||||||
|
} else if (platform === 'azure_openai') {
|
||||||
|
await fetchAzureOpenAIAccounts()
|
||||||
} else {
|
} else {
|
||||||
await fetchOpenAIAccounts()
|
await fetchOpenAIAccounts()
|
||||||
}
|
}
|
||||||
@@ -580,6 +657,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
bedrockAccounts.value = []
|
bedrockAccounts.value = []
|
||||||
geminiAccounts.value = []
|
geminiAccounts.value = []
|
||||||
openaiAccounts.value = []
|
openaiAccounts.value = []
|
||||||
|
azureOpenaiAccounts.value = []
|
||||||
loading.value = false
|
loading.value = false
|
||||||
error.value = null
|
error.value = null
|
||||||
sortBy.value = ''
|
sortBy.value = ''
|
||||||
@@ -593,6 +671,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
bedrockAccounts,
|
bedrockAccounts,
|
||||||
geminiAccounts,
|
geminiAccounts,
|
||||||
openaiAccounts,
|
openaiAccounts,
|
||||||
|
azureOpenaiAccounts,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
sortBy,
|
sortBy,
|
||||||
@@ -604,17 +683,20 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
fetchBedrockAccounts,
|
fetchBedrockAccounts,
|
||||||
fetchGeminiAccounts,
|
fetchGeminiAccounts,
|
||||||
fetchOpenAIAccounts,
|
fetchOpenAIAccounts,
|
||||||
|
fetchAzureOpenAIAccounts,
|
||||||
fetchAllAccounts,
|
fetchAllAccounts,
|
||||||
createClaudeAccount,
|
createClaudeAccount,
|
||||||
createClaudeConsoleAccount,
|
createClaudeConsoleAccount,
|
||||||
createBedrockAccount,
|
createBedrockAccount,
|
||||||
createGeminiAccount,
|
createGeminiAccount,
|
||||||
createOpenAIAccount,
|
createOpenAIAccount,
|
||||||
|
createAzureOpenAIAccount,
|
||||||
updateClaudeAccount,
|
updateClaudeAccount,
|
||||||
updateClaudeConsoleAccount,
|
updateClaudeConsoleAccount,
|
||||||
updateBedrockAccount,
|
updateBedrockAccount,
|
||||||
updateGeminiAccount,
|
updateGeminiAccount,
|
||||||
updateOpenAIAccount,
|
updateOpenAIAccount,
|
||||||
|
updateAzureOpenAIAccount,
|
||||||
toggleAccount,
|
toggleAccount,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
refreshClaudeToken,
|
refreshClaudeToken,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
||||||
'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
||||||
gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
||||||
|
openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
||||||
|
azure_openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
||||||
bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }
|
bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }
|
||||||
},
|
},
|
||||||
todayRequests: 0,
|
todayRequests: 0,
|
||||||
@@ -174,6 +176,8 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
||||||
'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
||||||
gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
||||||
|
openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
||||||
|
azure_openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
|
||||||
bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }
|
bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }
|
||||||
},
|
},
|
||||||
todayRequests: recentActivity.requestsToday || 0,
|
todayRequests: recentActivity.requestsToday || 0,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
账户管理
|
账户管理
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
|
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
|
||||||
管理您的 Claude 和 Gemini 账户及代理配置
|
管理您的 Claude、Gemini、OpenAI 和 Azure OpenAI 账户及代理配置
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -298,6 +298,19 @@
|
|||||||
<span class="mx-1 h-4 w-px bg-gray-400" />
|
<span class="mx-1 h-4 w-px bg-gray-400" />
|
||||||
<span class="text-xs font-medium text-gray-950">{{ getOpenAIAuthType() }}</span>
|
<span class="text-xs font-medium text-gray-950">{{ getOpenAIAuthType() }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="account.platform === 'azure_openai'"
|
||||||
|
class="flex items-center gap-1.5 rounded-lg border border-blue-200 bg-gradient-to-r from-blue-100 to-cyan-100 px-2.5 py-1 dark:border-blue-700 dark:from-blue-900/20 dark:to-cyan-900/20"
|
||||||
|
>
|
||||||
|
<i class="fab fa-microsoft text-xs text-blue-700 dark:text-blue-400" />
|
||||||
|
<span class="text-xs font-semibold text-blue-800 dark:text-blue-300"
|
||||||
|
>Azure OpenAI</span
|
||||||
|
>
|
||||||
|
<span class="mx-1 h-4 w-px bg-blue-300 dark:bg-blue-600" />
|
||||||
|
<span class="text-xs font-medium text-blue-700 dark:text-blue-400"
|
||||||
|
>API Key</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="account.platform === 'claude' || account.platform === 'claude-oauth'"
|
v-else-if="account.platform === 'claude' || account.platform === 'claude-oauth'"
|
||||||
class="flex items-center gap-1.5 rounded-lg border border-indigo-200 bg-gradient-to-r from-indigo-100 to-blue-100 px-2.5 py-1"
|
class="flex items-center gap-1.5 rounded-lg border border-indigo-200 bg-gradient-to-r from-indigo-100 to-blue-100 px-2.5 py-1"
|
||||||
@@ -581,6 +594,10 @@
|
|||||||
? 'bg-gradient-to-br from-purple-500 to-purple-600'
|
? 'bg-gradient-to-br from-purple-500 to-purple-600'
|
||||||
: account.platform === 'bedrock'
|
: account.platform === 'bedrock'
|
||||||
? 'bg-gradient-to-br from-orange-500 to-red-600'
|
? 'bg-gradient-to-br from-orange-500 to-red-600'
|
||||||
|
: account.platform === 'azure_openai'
|
||||||
|
? 'bg-gradient-to-br from-blue-500 to-cyan-600'
|
||||||
|
: account.platform === 'openai'
|
||||||
|
? 'bg-gradient-to-br from-gray-600 to-gray-700'
|
||||||
: 'bg-gradient-to-br from-blue-500 to-blue-600'
|
: 'bg-gradient-to-br from-blue-500 to-blue-600'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -591,6 +608,10 @@
|
|||||||
? 'fas fa-brain'
|
? 'fas fa-brain'
|
||||||
: account.platform === 'bedrock'
|
: account.platform === 'bedrock'
|
||||||
? 'fab fa-aws'
|
? 'fab fa-aws'
|
||||||
|
: account.platform === 'azure_openai'
|
||||||
|
? 'fab fa-microsoft'
|
||||||
|
: account.platform === 'openai'
|
||||||
|
? 'fas fa-openai'
|
||||||
: 'fas fa-robot'
|
: 'fas fa-robot'
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
@@ -820,6 +841,7 @@ const platformOptions = ref([
|
|||||||
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
|
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
|
||||||
{ value: 'gemini', label: 'Gemini', icon: 'fa-google' },
|
{ value: 'gemini', label: 'Gemini', icon: 'fa-google' },
|
||||||
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
|
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
|
||||||
|
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
|
||||||
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' }
|
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' }
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -912,7 +934,8 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
apiClient.get('/admin/claude-console-accounts', { params }),
|
apiClient.get('/admin/claude-console-accounts', { params }),
|
||||||
apiClient.get('/admin/bedrock-accounts', { params }),
|
apiClient.get('/admin/bedrock-accounts', { params }),
|
||||||
apiClient.get('/admin/gemini-accounts', { params }),
|
apiClient.get('/admin/gemini-accounts', { params }),
|
||||||
apiClient.get('/admin/openai-accounts', { params })
|
apiClient.get('/admin/openai-accounts', { params }),
|
||||||
|
apiClient.get('/admin/azure-openai-accounts', { params })
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 只请求指定平台,其他平台设为null占位
|
// 只请求指定平台,其他平台设为null占位
|
||||||
@@ -958,7 +981,7 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
// 加载分组成员关系(需要在分组数据加载完成后)
|
// 加载分组成员关系(需要在分组数据加载完成后)
|
||||||
await loadGroupMembers(forceReload)
|
await loadGroupMembers(forceReload)
|
||||||
|
|
||||||
const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData] =
|
const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData, azureOpenaiData] =
|
||||||
await Promise.all(requests)
|
await Promise.all(requests)
|
||||||
|
|
||||||
const allAccounts = []
|
const allAccounts = []
|
||||||
@@ -1016,6 +1039,17 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
})
|
})
|
||||||
allAccounts.push(...openaiAccounts)
|
allAccounts.push(...openaiAccounts)
|
||||||
}
|
}
|
||||||
|
if (azureOpenaiData && azureOpenaiData.success) {
|
||||||
|
const azureOpenaiAccounts = (azureOpenaiData.data || []).map((acc) => {
|
||||||
|
// 计算每个Azure OpenAI账户绑定的API Key数量
|
||||||
|
const boundApiKeysCount = apiKeys.value.filter(
|
||||||
|
(key) => key.azureOpenaiAccountId === acc.id
|
||||||
|
).length
|
||||||
|
const groupInfo = accountGroupMap.value.get(acc.id) || null
|
||||||
|
return { ...acc, platform: 'azure_openai', boundApiKeysCount, groupInfo }
|
||||||
|
})
|
||||||
|
allAccounts.push(...azureOpenaiAccounts)
|
||||||
|
}
|
||||||
|
|
||||||
accounts.value = allAccounts
|
accounts.value = allAccounts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1244,6 +1278,8 @@ const deleteAccount = async (account) => {
|
|||||||
endpoint = `/admin/bedrock-accounts/${account.id}`
|
endpoint = `/admin/bedrock-accounts/${account.id}`
|
||||||
} else if (account.platform === 'openai') {
|
} else if (account.platform === 'openai') {
|
||||||
endpoint = `/admin/openai-accounts/${account.id}`
|
endpoint = `/admin/openai-accounts/${account.id}`
|
||||||
|
} else if (account.platform === 'azure_openai') {
|
||||||
|
endpoint = `/admin/azure-openai-accounts/${account.id}`
|
||||||
} else {
|
} else {
|
||||||
endpoint = `/admin/gemini-accounts/${account.id}`
|
endpoint = `/admin/gemini-accounts/${account.id}`
|
||||||
}
|
}
|
||||||
@@ -1316,6 +1352,8 @@ const toggleSchedulable = async (account) => {
|
|||||||
endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable`
|
endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable`
|
||||||
} else if (account.platform === 'openai') {
|
} else if (account.platform === 'openai') {
|
||||||
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
|
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
|
||||||
|
} else if (account.platform === 'azure_openai') {
|
||||||
|
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable`
|
||||||
} else {
|
} else {
|
||||||
showToast('该账户类型暂不支持调度控制', 'warning')
|
showToast('该账户类型暂不支持调度控制', 'warning')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -105,6 +105,20 @@
|
|||||||
dashboardData.accountsByPlatform.openai.total
|
dashboardData.accountsByPlatform.openai.total
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Azure OpenAI账户 -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
dashboardData.accountsByPlatform.azure_openai &&
|
||||||
|
dashboardData.accountsByPlatform.azure_openai.total > 0
|
||||||
|
"
|
||||||
|
class="inline-flex items-center gap-0.5"
|
||||||
|
:title="`Azure OpenAI: ${dashboardData.accountsByPlatform.azure_openai.total} 个 (正常: ${dashboardData.accountsByPlatform.azure_openai.normal})`"
|
||||||
|
>
|
||||||
|
<i class="fab fa-microsoft text-xs text-blue-600" />
|
||||||
|
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
||||||
|
dashboardData.accountsByPlatform.azure_openai.total
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
|||||||
Reference in New Issue
Block a user