feat: 实现OpenAI账户管理和统一调度系统

- 新增 OpenAI 账户管理服务,支持多账户轮询和负载均衡
- 实现统一的 OpenAI API 调度器,智能选择最优账户
- 优化成本计算器,支持更精确的 token 计算
- 更新模型定价数据,包含最新的 OpenAI 模型价格
- 增强 API Key 管理,支持更灵活的配额控制
- 改进管理界面,添加教程视图和账户分组管理
- 优化限流配置组件,提供更直观的用户体验

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-08-11 13:58:43 +08:00
parent f22a38d24a
commit f462684f97
22 changed files with 6163 additions and 3134 deletions

View File

@@ -478,6 +478,18 @@ claude
gemini # 或其他 Gemini CLI 命令
```
**Codex 设置环境变量:**
```bash
export OPENAI_BASE_URL="http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
export OPENAI_API_KEY="后台创建的API密钥" # 使用后台创建的API密钥格式如 cr_9022cccc8d42e94db4d6f6d27bc93a5d271be86cf86d4d167627eb31eb4492eb
```
**使用 Codex**
```bash
# 配置环境变量后,即可正常使用支持 OpenAI API 的工具
# 例如使用支持 OpenAI API 的代码补全工具等
```
### 5. 第三方工具API接入
本服务支持多种API端点格式方便接入不同的第三方工具如Cherry Studio等

View File

@@ -34,4 +34,4 @@ The file contains JSON data with model pricing information including:
- Context window sizes
- Model capabilities
Last updated: 2025-08-06
Last updated: 2025-08-10

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
const axios = require('axios')
const BASE_URL = 'http://localhost:3312'
// 你需要替换为一个有效的 API Key
const API_KEY = 'cr_your_api_key_here'
async function testWindowRemaining() {
try {
console.log('🔍 测试时间窗口剩余时间功能...\n')
// 第一步:获取 API Key ID
console.log('1. 获取 API Key ID...')
const idResponse = await axios.post(`${BASE_URL}/api-stats/api/get-key-id`, {
apiKey: API_KEY
})
if (!idResponse.data.success) {
throw new Error('Failed to get API Key ID')
}
const apiId = idResponse.data.data.id
console.log(` ✅ API Key ID: ${apiId}\n`)
// 第二步:查询统计数据
console.log('2. 查询统计数据(包含时间窗口信息)...')
const statsResponse = await axios.post(`${BASE_URL}/api-stats/api/user-stats`, {
apiId
})
if (!statsResponse.data.success) {
throw new Error('Failed to get stats data')
}
const stats = statsResponse.data.data
console.log(` ✅ 成功获取统计数据\n`)
// 第三步:检查时间窗口信息
console.log('3. 时间窗口信息:')
console.log(` - 窗口时长: ${stats.limits.rateLimitWindow} 分钟`)
console.log(` - 请求限制: ${stats.limits.rateLimitRequests || '无限制'}`)
console.log(` - Token限制: ${stats.limits.tokenLimit || '无限制'}`)
console.log(` - 当前请求数: ${stats.limits.currentWindowRequests}`)
console.log(` - 当前Token数: ${stats.limits.currentWindowTokens}`)
if (stats.limits.windowStartTime) {
const startTime = new Date(stats.limits.windowStartTime)
const endTime = new Date(stats.limits.windowEndTime)
console.log(`\n ⏰ 时间窗口状态:`)
console.log(` - 窗口开始时间: ${startTime.toLocaleString()}`)
console.log(` - 窗口结束时间: ${endTime.toLocaleString()}`)
console.log(` - 剩余时间: ${stats.limits.windowRemainingSeconds}`)
if (stats.limits.windowRemainingSeconds > 0) {
const minutes = Math.floor(stats.limits.windowRemainingSeconds / 60)
const seconds = stats.limits.windowRemainingSeconds % 60
console.log(` - 格式化剩余时间: ${minutes}${seconds}`)
console.log(` - 窗口状态: 🟢 活跃中`)
} else {
console.log(` - 窗口状态: 🔴 已过期(下次请求时重置)`)
}
} else {
console.log(`\n ⏰ 时间窗口状态: ⚪ 未启动(还没有任何请求)`)
}
console.log('\n✅ 测试完成!时间窗口剩余时间功能正常工作。')
} catch (error) {
console.error('❌ 测试失败:', error.message)
if (error.response) {
console.error(' 响应数据:', error.response.data)
}
process.exit(1)
}
}
// 运行测试
testWindowRemaining()

View File

@@ -836,7 +836,7 @@ class RedisClient {
for (const key of keys) {
const accountData = await this.client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) {
accounts.push({ id: key.replace('claude:account:', ''), ...accountData })
accounts.push({ id: key.replace('openai:account:', ''), ...accountData })
}
}
return accounts

View File

@@ -4,6 +4,7 @@ const claudeAccountService = require('../services/claudeAccountService')
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const bedrockAccountService = require('../services/bedrockAccountService')
const geminiAccountService = require('../services/geminiAccountService')
const openaiAccountService = require('../services/openaiAccountService')
const accountGroupService = require('../services/accountGroupService')
const redis = require('../models/redis')
const { authenticateAdmin } = require('../middleware/auth')
@@ -388,6 +389,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
claudeAccountId,
claudeConsoleAccountId,
geminiAccountId,
openaiAccountId,
permissions,
concurrencyLimit,
rateLimitWindow,
@@ -483,6 +485,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
claudeAccountId,
claudeConsoleAccountId,
geminiAccountId,
openaiAccountId,
permissions,
concurrencyLimit,
rateLimitWindow,
@@ -515,6 +518,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
claudeAccountId,
claudeConsoleAccountId,
geminiAccountId,
openaiAccountId,
permissions,
concurrencyLimit,
rateLimitWindow,
@@ -557,6 +561,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
claudeAccountId,
claudeConsoleAccountId,
geminiAccountId,
openaiAccountId,
permissions,
concurrencyLimit,
rateLimitWindow,
@@ -626,6 +631,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
claudeAccountId,
claudeConsoleAccountId,
geminiAccountId,
openaiAccountId,
permissions,
enableModelRestriction,
restrictedModels,
@@ -684,12 +690,17 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.geminiAccountId = geminiAccountId || ''
}
if (openaiAccountId !== undefined) {
// 空字符串表示解绑null或空字符串都设置为空字符串
updates.openaiAccountId = openaiAccountId || ''
}
if (permissions !== undefined) {
// 验证权限值
if (!['claude', 'gemini', 'all'].includes(permissions)) {
if (!['claude', 'gemini', 'openai', 'all'].includes(permissions)) {
return res
.status(400)
.json({ error: 'Invalid permissions value. Must be claude, gemini, or all' })
.json({ error: 'Invalid permissions value. Must be claude, gemini, openai, or all' })
}
updates.permissions = permissions
}
@@ -894,6 +905,11 @@ router.get('/account-groups/:groupId/members', authenticateAdmin, async (req, re
account = await geminiAccountService.getAccount(memberId)
}
// 如果还找不到尝试OpenAI账户
if (!account) {
account = await openaiAccountService.getAccount(memberId)
}
if (account) {
members.push(account)
}
@@ -2396,6 +2412,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
claudeConsoleAccounts,
geminiAccounts,
bedrockAccountsResult,
openaiAccounts,
todayStats,
systemAverages,
realtimeMetrics
@@ -2406,6 +2423,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
claudeConsoleAccountService.getAllAccounts(),
geminiAccountService.getAllAccounts(),
bedrockAccountService.getAllAccounts(),
redis.getAllOpenAIAccounts(),
redis.getTodayStats(),
redis.getSystemAverages(),
redis.getRealtimeSystemMetrics()
@@ -2543,6 +2561,39 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
// OpenAI账户统计
// 注意OpenAI账户的isActive和schedulable是字符串类型默认值为'true'
const normalOpenAIAccounts = openaiAccounts.filter(
(acc) =>
(acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== 'false' &&
acc.schedulable !== false && // 包括'true'、true和undefined
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalOpenAIAccounts = openaiAccounts.filter(
(acc) =>
acc.isActive === 'false' ||
acc.isActive === false ||
acc.status === 'blocked' ||
acc.status === 'unauthorized'
).length
const pausedOpenAIAccounts = openaiAccounts.filter(
(acc) =>
(acc.schedulable === 'false' || acc.schedulable === false) &&
(acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedOpenAIAccounts = openaiAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
const dashboard = {
overview: {
totalApiKeys: apiKeys.length,
@@ -2552,27 +2603,32 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
claudeAccounts.length +
claudeConsoleAccounts.length +
geminiAccounts.length +
bedrockAccounts.length,
bedrockAccounts.length +
openaiAccounts.length,
normalAccounts:
normalClaudeAccounts +
normalClaudeConsoleAccounts +
normalGeminiAccounts +
normalBedrockAccounts,
normalBedrockAccounts +
normalOpenAIAccounts,
abnormalAccounts:
abnormalClaudeAccounts +
abnormalClaudeConsoleAccounts +
abnormalGeminiAccounts +
abnormalBedrockAccounts,
abnormalBedrockAccounts +
abnormalOpenAIAccounts,
pausedAccounts:
pausedClaudeAccounts +
pausedClaudeConsoleAccounts +
pausedGeminiAccounts +
pausedBedrockAccounts,
pausedBedrockAccounts +
pausedOpenAIAccounts,
rateLimitedAccounts:
rateLimitedClaudeAccounts +
rateLimitedClaudeConsoleAccounts +
rateLimitedGeminiAccounts +
rateLimitedBedrockAccounts,
rateLimitedBedrockAccounts +
rateLimitedOpenAIAccounts,
// 各平台详细统计
accountsByPlatform: {
claude: {
@@ -2602,6 +2658,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
abnormal: abnormalBedrockAccounts,
paused: pausedBedrockAccounts,
rateLimited: rateLimitedBedrockAccounts
},
openai: {
total: openaiAccounts.length,
normal: normalOpenAIAccounts,
abnormal: abnormalOpenAIAccounts,
paused: pausedOpenAIAccounts,
rateLimited: rateLimitedOpenAIAccounts
}
},
// 保留旧字段以兼容
@@ -2609,7 +2672,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
normalClaudeAccounts +
normalClaudeConsoleAccounts +
normalGeminiAccounts +
normalBedrockAccounts,
normalBedrockAccounts +
normalOpenAIAccounts,
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
@@ -4513,7 +4577,7 @@ router.post('/openai-accounts/exchange-code', authenticateAdmin, async (req, res
// 获取所有 OpenAI 账户
router.get('/openai-accounts', authenticateAdmin, async (req, res) => {
try {
const accounts = await redis.getAllOpenAIAccounts()
const accounts = await openaiAccountService.getAllAccounts()
logger.info(`获取 OpenAI 账户列表: ${accounts.length} 个账户`)
@@ -4553,60 +4617,41 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
message: '账户名称不能为空'
})
}
const id = uuidv4()
// 创建账户数据
const accountData = {
id,
name,
description: description || '',
platform: 'openai',
accountType: accountType || 'shared',
groupId: groupId || null,
dedicatedApiKeys: dedicatedApiKeys || [],
priority: priority || 50,
rateLimitDuration: rateLimitDuration || 60,
enabled: true,
idToken: claudeAccountService._encryptSensitiveData(openaiOauth.idToken),
accessToken: claudeAccountService._encryptSensitiveData(openaiOauth.accessToken),
refreshToken: claudeAccountService._encryptSensitiveData(openaiOauth.refreshToken),
accountId: accountInfo?.accountId || '',
expiresAt: (Math.floor(Date.now() / 1000) + openaiOauth.expires_in) * 1000,
chatgptUserId: accountInfo?.chatgptUserId || '',
organizationId: accountInfo?.organizationId || '',
organizationRole: accountInfo?.organizationRole || '',
organizationTitle: accountInfo?.organizationTitle || '',
planType: accountInfo?.planType || '',
email: claudeAccountService._encryptSensitiveData(accountInfo?.email || ''),
emailVerified: accountInfo?.emailVerified || false,
openaiOauth: openaiOauth || {},
accountInfo: accountInfo || {},
proxy: proxy?.enabled
? {
type: proxy.type,
host: proxy.host,
port: proxy.port,
username: proxy.username || null,
password: proxy.password || null
}
: null,
isActive: true,
status: 'active',
lastRefresh: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
schedulable: true
}
// 存储代理配置(如果提供)
if (proxy?.enabled) {
accountData.proxy = {
type: proxy.type,
host: proxy.host,
port: proxy.port,
username: proxy.username || null,
password: proxy.password || null
}
// 创建账户
const createdAccount = await openaiAccountService.createAccount(accountData)
// 如果是分组类型,添加到分组
if (accountType === 'group' && groupId) {
await accountGroupService.addAccountToGroup(createdAccount.id, groupId, 'openai')
}
// 保存到 Redis
const accountId = await redis.setOpenAiAccount(id, accountData)
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${accountId})`)
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
return res.json({
success: true,
data: {
id: accountId,
...accountData
}
data: createdAccount
})
} catch (error) {
logger.error('创建 OpenAI 账户失败:', error)
@@ -4619,19 +4664,100 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
})
// 更新 OpenAI 账户
router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) =>
//TODO:
res.json({
success: true
})
)
router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const updates = req.body
// 验证accountType的有效性
if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
}
// 如果更新为分组类型验证groupId
if (updates.accountType === 'group' && !updates.groupId) {
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
}
// 获取账户当前信息以处理分组变更
const currentAccount = await openaiAccountService.getAccount(id)
if (!currentAccount) {
return res.status(404).json({ error: 'Account not found' })
}
// 处理分组的变更
if (updates.accountType !== undefined) {
// 如果之前是分组类型,需要从原分组中移除
if (currentAccount.accountType === 'group') {
const oldGroup = await accountGroupService.getAccountGroup(id)
if (oldGroup) {
await accountGroupService.removeAccountFromGroup(id, oldGroup.id)
}
}
// 如果新类型是分组,添加到新分组
if (updates.accountType === 'group' && updates.groupId) {
await accountGroupService.addAccountToGroup(id, updates.groupId, 'openai')
}
}
// 准备更新数据
const updateData = { ...updates }
// 处理敏感数据加密
if (updates.openaiOauth) {
updateData.openaiOauth = updates.openaiOauth
if (updates.openaiOauth.idToken) {
updateData.idToken = updates.openaiOauth.idToken
}
if (updates.openaiOauth.accessToken) {
updateData.accessToken = updates.openaiOauth.accessToken
}
if (updates.openaiOauth.refreshToken) {
updateData.refreshToken = updates.openaiOauth.refreshToken
}
if (updates.openaiOauth.expires_in) {
updateData.expiresAt = new Date(
Date.now() + updates.openaiOauth.expires_in * 1000
).toISOString()
}
}
// 更新账户信息
if (updates.accountInfo) {
updateData.accountId = updates.accountInfo.accountId || currentAccount.accountId
updateData.chatgptUserId = updates.accountInfo.chatgptUserId || currentAccount.chatgptUserId
updateData.organizationId =
updates.accountInfo.organizationId || currentAccount.organizationId
updateData.organizationRole =
updates.accountInfo.organizationRole || currentAccount.organizationRole
updateData.organizationTitle =
updates.accountInfo.organizationTitle || currentAccount.organizationTitle
updateData.planType = updates.accountInfo.planType || currentAccount.planType
updateData.email = updates.accountInfo.email || currentAccount.email
updateData.emailVerified =
updates.accountInfo.emailVerified !== undefined
? updates.accountInfo.emailVerified
: currentAccount.emailVerified
}
const updatedAccount = await openaiAccountService.updateAccount(id, updateData)
logger.success(`📝 Admin updated OpenAI account: ${id}`)
return res.json({ success: true, data: updatedAccount })
} catch (error) {
logger.error('❌ Failed to update OpenAI account:', error)
return res.status(500).json({ error: 'Failed to update account', message: error.message })
}
})
// 删除 OpenAI 账户
router.delete('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await redis.getOpenAiAccount(id)
const account = await openaiAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
success: false,
@@ -4639,7 +4765,15 @@ router.delete('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
})
}
await redis.deleteOpenAiAccount(id)
// 如果账户在分组中,从分组中移除
if (account.accountType === 'group') {
const group = await accountGroupService.getAccountGroup(id)
if (group) {
await accountGroupService.removeAccountFromGroup(id, group.id)
}
}
await openaiAccountService.deleteAccount(id)
logger.success(`✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id})`)
@@ -4695,4 +4829,30 @@ router.put('/openai-accounts/:id/toggle', authenticateAdmin, async (req, res) =>
}
})
// 切换 OpenAI 账户调度状态
router.put(
'/openai-accounts/:accountId/toggle-schedulable',
authenticateAdmin,
async (req, res) => {
try {
const { accountId } = req.params
const result = await openaiAccountService.toggleSchedulable(accountId)
return res.json({
success: result.success,
schedulable: result.schedulable,
message: result.schedulable ? '已启用调度' : '已禁用调度'
})
} catch (error) {
logger.error('切换 OpenAI 账户调度状态失败:', error)
return res.status(500).json({
success: false,
message: '切换调度状态失败',
error: error.message
})
}
}
)
module.exports = router

View File

@@ -279,6 +279,9 @@ router.post('/api/user-stats', async (req, res) => {
let currentWindowRequests = 0
let currentWindowTokens = 0
let currentDailyCost = 0
let windowStartTime = null
let windowEndTime = null
let windowRemainingSeconds = null
try {
// 获取当前时间窗口的请求次数和Token使用量
@@ -286,9 +289,32 @@ router.post('/api/user-stats', async (req, res) => {
const client = redis.getClientSafe()
const requestCountKey = `rate_limit:requests:${keyId}`
const tokenCountKey = `rate_limit:tokens:${keyId}`
const windowStartKey = `rate_limit:window_start:${keyId}`
currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0')
currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0')
// 获取窗口开始时间和计算剩余时间
const windowStart = await client.get(windowStartKey)
if (windowStart) {
const now = Date.now()
windowStartTime = parseInt(windowStart)
const windowDuration = fullKeyData.rateLimitWindow * 60 * 1000 // 转换为毫秒
windowEndTime = windowStartTime + windowDuration
// 如果窗口还有效
if (now < windowEndTime) {
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
} else {
// 窗口已过期,下次请求会重置
windowStartTime = null
windowEndTime = null
windowRemainingSeconds = 0
// 重置计数为0因为窗口已过期
currentWindowRequests = 0
currentWindowTokens = 0
}
}
}
// 获取当日费用
@@ -334,7 +360,11 @@ router.post('/api/user-stats', async (req, res) => {
// 当前使用量
currentWindowRequests,
currentWindowTokens,
currentDailyCost
currentDailyCost,
// 时间窗口信息
windowStartTime,
windowEndTime,
windowRemainingSeconds
},
// 绑定的账户信息只显示ID不显示敏感信息

View File

@@ -3,33 +3,51 @@ const axios = require('axios')
const router = express.Router()
const logger = require('../utils/logger')
const { authenticateApiKey } = require('../middleware/auth')
const redis = require('../models/redis')
const claudeAccountService = require('../services/claudeAccountService')
const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
const openaiAccountService = require('../services/openaiAccountService')
const apiKeyService = require('../services/apiKeyService')
const crypto = require('crypto')
// 选择一个可用的 OpenAI 账户,并返回解密后的 accessToken
async function getOpenAIAuthToken() {
// 使用统一调度器选择 OpenAI 账户
async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = null) {
try {
const accounts = await redis.getAllOpenAIAccounts()
if (!accounts || accounts.length === 0) {
throw new Error('No OpenAI accounts found in Redis')
// 生成会话哈希如果有会话ID
const sessionHash = sessionId
? crypto.createHash('sha256').update(sessionId).digest('hex')
: null
// 使用统一调度器选择账户
const result = await unifiedOpenAIScheduler.selectAccountForApiKey(
apiKeyData,
sessionHash,
requestedModel
)
if (!result || !result.accountId) {
throw new Error('No available OpenAI account found')
}
// 简单选择策略:选择第一个启用并活跃的账户
const candidate =
accounts.find((a) => String(a.enabled) === 'true' && String(a.isActive) === 'true') ||
accounts[0]
if (!candidate || !candidate.accessToken) {
throw new Error('No valid OpenAI account with accessToken')
// 获取账户详情
const account = await openaiAccountService.getAccount(result.accountId)
if (!account || !account.accessToken) {
throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`)
}
const accessToken = claudeAccountService._decryptSensitiveData(candidate.accessToken)
// 解密 accessToken
const accessToken = claudeAccountService._decryptSensitiveData(account.accessToken)
if (!accessToken) {
throw new Error('Failed to decrypt OpenAI accessToken')
}
return { accessToken, accountId: candidate.accountId || 'unknown' }
logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`)
return {
accessToken,
accountId: result.accountId,
accountName: account.name
}
} catch (error) {
logger.error('Failed to get OpenAI auth token from Redis:', error)
logger.error('Failed to get OpenAI auth token:', error)
throw error
}
}
@@ -37,7 +55,27 @@ async function getOpenAIAuthToken() {
router.post('/responses', authenticateApiKey, async (req, res) => {
let upstream = null
try {
const { accessToken, accountId } = await getOpenAIAuthToken()
// 从中间件获取 API Key 数据
const apiKeyData = req.apiKeyData || {}
// 从请求头或请求体中提取会话 ID
const sessionId =
req.headers['session_id'] ||
req.headers['x-session-id'] ||
req.body?.session_id ||
req.body?.conversation_id ||
null
// 从请求体中提取模型和流式标志
const requestedModel = req.body?.model || null
const isStream = req.body?.stream !== false // 默认为流式(兼容现有行为)
// 使用调度器选择账户
const { accessToken, accountId } = await getOpenAIAuthToken(
apiKeyData,
sessionId,
requestedModel
)
// 基于白名单构造上游所需的请求头,确保键为小写且值受控
const incoming = req.headers || {}
@@ -54,21 +92,39 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
headers['authorization'] = `Bearer ${accessToken}`
headers['chatgpt-account-id'] = accountId
headers['host'] = 'chatgpt.com'
headers['accept'] = 'text/event-stream'
headers['accept'] = isStream ? 'text/event-stream' : 'application/json'
headers['content-type'] = 'application/json'
req.body['store'] = false
// 使用流式转发,保持与上游一致
upstream = await axios.post('https://chatgpt.com/backend-api/codex/responses', req.body, {
headers,
responseType: 'stream',
timeout: 60000,
validateStatus: () => true
})
// 根据 stream 参数决定请求类型
if (isStream) {
// 流式请求
upstream = await axios.post('https://chatgpt.com/backend-api/codex/responses', req.body, {
headers,
responseType: 'stream',
timeout: 60000,
validateStatus: () => true
})
} else {
// 非流式请求
upstream = await axios.post('https://chatgpt.com/backend-api/codex/responses', req.body, {
headers,
timeout: 60000,
validateStatus: () => true
})
}
res.status(upstream.status)
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
if (isStream) {
// 流式响应头
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
} else {
// 非流式响应头
res.setHeader('Content-Type', 'application/json')
}
// 透传关键诊断头,避免传递不安全或与传输相关的头
const passThroughHeaderKeys = ['openai-version', 'x-request-id', 'openai-processing-ms']
@@ -79,11 +135,170 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
}
}
// 立即刷新响应头,开始 SSE
if (typeof res.flushHeaders === 'function') {
res.flushHeaders()
if (isStream) {
// 立即刷新响应头,开始 SSE
if (typeof res.flushHeaders === 'function') {
res.flushHeaders()
}
}
// 处理响应并捕获 usage 数据和真实的 model
let buffer = ''
let usageData = null
let actualModel = null
let usageReported = false
if (!isStream) {
// 非流式响应处理
try {
logger.info(`📄 Processing OpenAI non-stream response for model: ${requestedModel}`)
// 直接获取完整响应
const responseData = upstream.data
// 从响应中获取实际的 model 和 usage
actualModel = responseData.model || requestedModel || 'gpt-4'
usageData = responseData.usage
logger.debug(`📊 Non-stream response - Model: ${actualModel}, Usage:`, usageData)
// 记录使用统计
if (usageData) {
const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0
const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0
const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0
const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0
await apiKeyService.recordUsage(
apiKeyData.id,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
actualModel,
accountId
)
logger.info(
`📊 Recorded OpenAI non-stream usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${actualModel}`
)
}
// 返回响应
res.json(responseData)
return
} catch (error) {
logger.error('Failed to process non-stream response:', error)
if (!res.headersSent) {
res.status(500).json({ error: { message: 'Failed to process response' } })
}
return
}
}
// 解析 SSE 事件以捕获 usage 数据和 model
const parseSSEForUsage = (data) => {
const lines = data.split('\n')
for (const line of lines) {
if (line.startsWith('event: response.completed')) {
// 下一行应该是数据
continue
}
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6) // 移除 'data: ' 前缀
const eventData = JSON.parse(jsonStr)
// 检查是否是 response.completed 事件
if (eventData.type === 'response.completed' && eventData.response) {
// 从响应中获取真实的 model
if (eventData.response.model) {
actualModel = eventData.response.model
logger.debug(`📊 Captured actual model: ${actualModel}`)
}
// 获取 usage 数据
if (eventData.response.usage) {
usageData = eventData.response.usage
logger.debug('📊 Captured OpenAI usage data:', usageData)
}
}
} catch (e) {
// 忽略解析错误
}
}
}
}
upstream.data.on('data', (chunk) => {
try {
const chunkStr = chunk.toString()
// 转发数据给客户端
if (!res.headersSent) {
res.write(chunk)
}
// 同时解析数据以捕获 usage 信息
buffer += chunkStr
// 处理完整的 SSE 事件
if (buffer.includes('\n\n')) {
const events = buffer.split('\n\n')
buffer = events.pop() || '' // 保留最后一个可能不完整的事件
for (const event of events) {
if (event.trim()) {
parseSSEForUsage(event)
}
}
}
} catch (error) {
logger.error('Error processing OpenAI stream chunk:', error)
}
})
upstream.data.on('end', async () => {
// 处理剩余的 buffer
if (buffer.trim()) {
parseSSEForUsage(buffer)
}
// 记录使用统计
if (!usageReported && usageData) {
try {
const inputTokens = usageData.input_tokens || 0
const outputTokens = usageData.output_tokens || 0
const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0
const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0
// 使用响应中的真实 model如果没有则使用请求中的 model最后回退到默认值
const modelToRecord = actualModel || requestedModel || 'gpt-4'
await apiKeyService.recordUsage(
apiKeyData.id,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
modelToRecord,
accountId
)
logger.info(
`📊 Recorded OpenAI usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${modelToRecord} (actual: ${actualModel}, requested: ${requestedModel})`
)
usageReported = true
} catch (error) {
logger.error('Failed to record OpenAI usage:', error)
}
}
res.end()
})
upstream.data.on('error', (err) => {
logger.error('Upstream stream error:', err)
if (!res.headersSent) {
@@ -93,8 +308,6 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
}
})
upstream.data.pipe(res)
// 客户端断开时清理上游流
const cleanup = () => {
try {
@@ -116,4 +329,65 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
}
})
// 使用情况统计端点
router.get('/usage', authenticateApiKey, async (req, res) => {
try {
const { usage } = req.apiKey
res.json({
object: 'usage',
total_tokens: usage.total.tokens,
total_requests: usage.total.requests,
daily_tokens: usage.daily.tokens,
daily_requests: usage.daily.requests,
monthly_tokens: usage.monthly.tokens,
monthly_requests: usage.monthly.requests
})
} catch (error) {
logger.error('Failed to get usage stats:', error)
res.status(500).json({
error: {
message: 'Failed to retrieve usage statistics',
type: 'api_error'
}
})
}
})
// API Key 信息端点
router.get('/key-info', authenticateApiKey, async (req, res) => {
try {
const keyData = req.apiKey
res.json({
id: keyData.id,
name: keyData.name,
description: keyData.description,
permissions: keyData.permissions || 'all',
token_limit: keyData.tokenLimit,
tokens_used: keyData.usage.total.tokens,
tokens_remaining:
keyData.tokenLimit > 0
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
: null,
rate_limit: {
window: keyData.rateLimitWindow,
requests: keyData.rateLimitRequests
},
usage: {
total: keyData.usage.total,
daily: keyData.usage.daily,
monthly: keyData.usage.monthly
}
})
} catch (error) {
logger.error('Failed to get key info:', error)
res.status(500).json({
error: {
message: 'Failed to retrieve API key information',
type: 'api_error'
}
})
}
})
module.exports = router

View File

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

View File

@@ -19,7 +19,8 @@ class ApiKeyService {
claudeAccountId = null,
claudeConsoleAccountId = null,
geminiAccountId = null,
permissions = 'all', // 'claude', 'gemini', 'all'
openaiAccountId = null,
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
isActive = true,
concurrencyLimit = 0,
rateLimitWindow = null,
@@ -50,6 +51,7 @@ class ApiKeyService {
claudeAccountId: claudeAccountId || '',
claudeConsoleAccountId: claudeConsoleAccountId || '',
geminiAccountId: geminiAccountId || '',
openaiAccountId: openaiAccountId || '',
permissions: permissions || 'all',
enableModelRestriction: String(enableModelRestriction),
restrictedModels: JSON.stringify(restrictedModels || []),
@@ -81,6 +83,7 @@ class ApiKeyService {
claudeAccountId: keyData.claudeAccountId,
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
permissions: keyData.permissions,
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels: JSON.parse(keyData.restrictedModels),
@@ -167,6 +170,7 @@ class ApiKeyService {
claudeAccountId: keyData.claudeAccountId,
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
@@ -299,6 +303,7 @@ class ApiKeyService {
'claudeAccountId',
'claudeConsoleAccountId',
'geminiAccountId',
'openaiAccountId',
'permissions',
'expiresAt',
'enableModelRestriction',

View File

@@ -0,0 +1,583 @@
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 { maskToken } = require('../utils/tokenMask')
const {
logRefreshStart,
logRefreshSuccess,
logRefreshError,
logTokenUsage,
logRefreshSkipped
} = require('../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService')
// 加密相关常量
const ALGORITHM = 'aes-256-cbc'
const ENCRYPTION_SALT = 'openai-account-salt'
const IV_LENGTH = 16
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
function generateEncryptionKey() {
return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
}
// OpenAI 账户键前缀
const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:'
const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts'
const ACCOUNT_SESSION_MAPPING_PREFIX = '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) // 跳过冒号
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()])
return decrypted.toString()
} catch (error) {
logger.error('Decryption error:', error)
return ''
}
}
// 刷新访问令牌
async function refreshAccessToken(refreshToken) {
try {
// OpenAI OAuth token 刷新实现
// TODO: 实现具体的 OpenAI OAuth token 刷新逻辑
logger.warn('OpenAI token refresh not yet implemented')
return null
} catch (error) {
logger.error('Error refreshing OpenAI access token:', error)
throw error
}
}
// 检查 token 是否过期
function isTokenExpired(account) {
if (!account.expiresAt) {
return false
}
return new Date(account.expiresAt) <= new Date()
}
// 刷新账户的 access token
async function refreshAccountToken(accountId) {
const account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const accountName = account.name || accountId
logRefreshStart(accountId, accountName, 'openai')
// 检查是否有 refresh token
const refreshToken = account.refreshToken ? decrypt(account.refreshToken) : null
if (!refreshToken) {
logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available')
throw new Error('No refresh token available')
}
try {
const newTokens = await refreshAccessToken(refreshToken)
if (!newTokens) {
throw new Error('Failed to refresh token')
}
// 更新账户信息
await updateAccount(accountId, {
accessToken: encrypt(newTokens.access_token),
expiresAt: new Date(newTokens.expiry_date).toISOString()
})
logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date)
return newTokens
} catch (error) {
logRefreshError(accountId, accountName, 'openai', error.message)
throw error
}
}
// 创建账户
async function createAccount(accountData) {
const accountId = uuidv4()
const now = new Date().toISOString()
// 处理OAuth数据
let oauthData = {}
if (accountData.openaiOauth) {
oauthData =
typeof accountData.openaiOauth === 'string'
? JSON.parse(accountData.openaiOauth)
: accountData.openaiOauth
}
// 处理账户信息
const accountInfo = accountData.accountInfo || {}
const account = {
id: accountId,
name: accountData.name,
description: accountData.description || '',
accountType: accountData.accountType || 'shared',
groupId: accountData.groupId || null,
priority: accountData.priority || 50,
rateLimitDuration: accountData.rateLimitDuration || 60,
// OAuth相关字段加密存储
idToken: encrypt(oauthData.idToken || ''),
accessToken: encrypt(oauthData.accessToken || ''),
refreshToken: encrypt(oauthData.refreshToken || ''),
openaiOauth: encrypt(JSON.stringify(oauthData)),
// 账户信息字段
accountId: accountInfo.accountId || '',
chatgptUserId: accountInfo.chatgptUserId || '',
organizationId: accountInfo.organizationId || '',
organizationRole: accountInfo.organizationRole || '',
organizationTitle: accountInfo.organizationTitle || '',
planType: accountInfo.planType || '',
email: encrypt(accountInfo.email || ''),
emailVerified: accountInfo.emailVerified || false,
// 过期时间
expiresAt: oauthData.expires_in
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // 默认1年
// 状态字段
isActive: accountData.isActive !== false ? 'true' : 'false',
status: 'active',
schedulable: accountData.schedulable !== false ? 'true' : 'false',
lastRefresh: now,
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(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
// 如果是共享账户,添加到共享账户集合
if (account.accountType === 'shared') {
await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
}
logger.info(`Created OpenAI account: ${accountId}`)
return account
}
// 获取账户
async function getAccount(accountId) {
const client = redisClient.getClientSafe()
const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
if (!accountData || Object.keys(accountData).length === 0) {
return null
}
// 解密敏感数据(仅用于内部处理,不返回给前端)
if (accountData.idToken) {
accountData.idToken = decrypt(accountData.idToken)
}
if (accountData.accessToken) {
accountData.accessToken = decrypt(accountData.accessToken)
}
if (accountData.refreshToken) {
accountData.refreshToken = decrypt(accountData.refreshToken)
}
if (accountData.email) {
accountData.email = decrypt(accountData.email)
}
if (accountData.openaiOauth) {
try {
accountData.openaiOauth = JSON.parse(decrypt(accountData.openaiOauth))
} catch (e) {
accountData.openaiOauth = null
}
}
// 解析代理配置
if (accountData.proxy && typeof accountData.proxy === 'string') {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) {
accountData.proxy = null
}
}
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.openaiOauth) {
const oauthData =
typeof updates.openaiOauth === 'string'
? updates.openaiOauth
: JSON.stringify(updates.openaiOauth)
updates.openaiOauth = encrypt(oauthData)
}
if (updates.idToken) {
updates.idToken = encrypt(updates.idToken)
}
if (updates.accessToken) {
updates.accessToken = encrypt(updates.accessToken)
}
if (updates.refreshToken) {
updates.refreshToken = encrypt(updates.refreshToken)
}
if (updates.email) {
updates.email = encrypt(updates.email)
}
// 处理代理配置
if (updates.proxy) {
updates.proxy =
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy)
}
// 更新账户类型时处理共享账户集合
const client = redisClient.getClientSafe()
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
if (updates.accountType === 'shared') {
await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
} else {
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
}
}
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
logger.info(`Updated 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 account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
// 从 Redis 删除
const client = redisClient.getClientSafe()
await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
// 从共享账户集合中移除
if (account.accountType === 'shared') {
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
}
// 清理会话映射
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
for (const key of sessionMappings) {
const mappedAccountId = await client.get(key)
if (mappedAccountId === accountId) {
await client.del(key)
}
}
logger.info(`Deleted OpenAI account: ${accountId}`)
return true
}
// 获取所有账户
async function getAllAccounts() {
const client = redisClient.getClientSafe()
const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`)
const accounts = []
for (const key of keys) {
const accountData = await client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) {
// 解密敏感数据(但不返回给前端)
if (accountData.email) {
accountData.email = decrypt(accountData.email)
}
// 屏蔽敏感信息token等不应该返回给前端
delete accountData.idToken
delete accountData.accessToken
delete accountData.refreshToken
delete accountData.openaiOauth
// 获取限流状态信息
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
// 解析代理配置
if (accountData.proxy) {
try {
accountData.proxy = JSON.parse(accountData.proxy)
// 屏蔽代理密码
if (accountData.proxy && accountData.proxy.password) {
accountData.proxy.password = '******'
}
} catch (e) {
// 如果解析失败设置为null
accountData.proxy = null
}
}
// 不解密敏感字段,只返回基本信息
accounts.push({
...accountData,
openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '',
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
// 添加限流状态信息(统一格式)
rateLimitStatus: rateLimitInfo
? {
isRateLimited: rateLimitInfo.isRateLimited,
rateLimitedAt: rateLimitInfo.rateLimitedAt,
minutesRemaining: rateLimitInfo.minutesRemaining
}
: {
isRateLimited: false,
rateLimitedAt: null,
minutesRemaining: 0
}
})
}
}
return accounts
}
// 选择可用账户(支持专属和共享账户)
async function selectAvailableAccount(apiKeyId, sessionHash = null) {
// 首先检查是否有粘性会话
const client = redisClient.getClientSafe()
if (sessionHash) {
const mappedAccountId = await client.get(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`)
if (mappedAccountId) {
const account = await getAccount(mappedAccountId)
if (account && account.isActive === 'true' && !isTokenExpired(account)) {
logger.debug(`Using sticky session account: ${mappedAccountId}`)
return account
}
}
}
// 获取 API Key 信息
const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`)
// 检查是否绑定了 OpenAI 账户
if (apiKeyData.openaiAccountId) {
const account = await getAccount(apiKeyData.openaiAccountId)
if (account && account.isActive === 'true') {
// 检查 token 是否过期
const isExpired = isTokenExpired(account)
// 记录token使用情况
logTokenUsage(account.id, account.name, 'openai', account.expiresAt, isExpired)
if (isExpired) {
await refreshAccountToken(account.id)
return await getAccount(account.id)
}
// 创建粘性会话映射
if (sessionHash) {
await client.setex(
`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`,
3600, // 1小时过期
account.id
)
}
return account
}
}
// 从共享账户池选择
const sharedAccountIds = await client.smembers(SHARED_OPENAI_ACCOUNTS_KEY)
const availableAccounts = []
for (const accountId of sharedAccountIds) {
const account = await getAccount(accountId)
if (account && account.isActive === 'true' && !isRateLimited(account)) {
availableAccounts.push(account)
}
}
if (availableAccounts.length === 0) {
throw new Error('No available OpenAI accounts')
}
// 选择使用最少的账户
const selectedAccount = availableAccounts.reduce((prev, curr) => {
const prevUsage = parseInt(prev.totalUsage || 0)
const currUsage = parseInt(curr.totalUsage || 0)
return prevUsage <= currUsage ? prev : curr
})
// 检查 token 是否过期
if (isTokenExpired(selectedAccount)) {
await refreshAccountToken(selectedAccount.id)
return await getAccount(selectedAccount.id)
}
// 创建粘性会话映射
if (sessionHash) {
await client.setex(
`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`,
3600, // 1小时过期
selectedAccount.id
)
}
return selectedAccount
}
// 检查账户是否被限流
function isRateLimited(account) {
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const now = Date.now()
const limitDuration = 60 * 60 * 1000 // 1小时
return now < limitedAt + limitDuration
}
return false
}
// 设置账户限流状态
async function setAccountRateLimited(accountId, isLimited) {
const updates = {
rateLimitStatus: isLimited ? 'limited' : 'normal',
rateLimitedAt: isLimited ? new Date().toISOString() : null
}
await updateAccount(accountId, updates)
logger.info(`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}`)
}
// 切换账户调度状态
async function toggleSchedulable(accountId) {
const account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
// 切换调度状态
const newSchedulable = account.schedulable === 'false' ? 'true' : 'false'
await updateAccount(accountId, {
schedulable: newSchedulable
})
logger.info(`Toggled schedulable status for OpenAI account ${accountId}: ${newSchedulable}`)
return {
success: true,
schedulable: newSchedulable === 'true'
}
}
// 获取账户限流信息
async function getAccountRateLimitInfo(accountId) {
const account = await getAccount(accountId)
if (!account) {
return null
}
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const now = Date.now()
const limitDuration = 60 * 60 * 1000 // 1小时
const remainingTime = Math.max(0, limitedAt + limitDuration - now)
return {
isRateLimited: remainingTime > 0,
rateLimitedAt: account.rateLimitedAt,
minutesRemaining: Math.ceil(remainingTime / (60 * 1000))
}
}
return {
isRateLimited: false,
rateLimitedAt: null,
minutesRemaining: 0
}
}
// 更新账户使用统计
async function updateAccountUsage(accountId, tokens) {
const account = await getAccount(accountId)
if (!account) {
return
}
const totalUsage = parseInt(account.totalUsage || 0) + tokens
const lastUsedAt = new Date().toISOString()
await updateAccount(accountId, {
totalUsage: totalUsage.toString(),
lastUsedAt
})
}
module.exports = {
createAccount,
getAccount,
updateAccount,
deleteAccount,
getAllAccounts,
selectAvailableAccount,
refreshAccountToken,
isTokenExpired,
setAccountRateLimited,
toggleSchedulable,
getAccountRateLimitInfo,
updateAccountUsage,
encrypt,
decrypt
}

View File

@@ -0,0 +1,492 @@
const openaiAccountService = require('./openaiAccountService')
const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis')
const logger = require('../utils/logger')
class UnifiedOpenAIScheduler {
constructor() {
this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:'
}
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
_isSchedulable(schedulable) {
// 如果是 undefined 或 null默认为可调度
if (schedulable === undefined || schedulable === null) {
return true
}
// 明确设置为 false布尔值或 'false'(字符串)时不可调度
return schedulable !== false && schedulable !== 'false'
}
// 🎯 统一调度OpenAI账号
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
try {
// 如果API Key绑定了专属账户或分组优先使用
if (apiKeyData.openaiAccountId) {
// 检查是否是分组
if (apiKeyData.openaiAccountId.startsWith('group:')) {
const groupId = apiKeyData.openaiAccountId.replace('group:', '')
logger.info(
`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`
)
return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData)
}
// 普通专属账户
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
logger.info(
`🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId}) for API key ${apiKeyData.name}`
)
return {
accountId: apiKeyData.openaiAccountId,
accountType: 'openai'
}
} else {
logger.warn(
`⚠️ Bound OpenAI account ${apiKeyData.openaiAccountId} is not available, falling back to pool`
)
}
}
// 如果有会话哈希,检查是否有已映射的账户
if (sessionHash) {
const mappedAccount = await this._getSessionMapping(sessionHash)
if (mappedAccount) {
// 验证映射的账户是否仍然可用
const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId,
mappedAccount.accountType
)
if (isAvailable) {
logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
return mappedAccount
} else {
logger.warn(
`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`
)
await this._deleteSessionMapping(sessionHash)
}
}
}
// 获取所有可用账户
const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel)
if (availableAccounts.length === 0) {
// 提供更详细的错误信息
if (requestedModel) {
throw new Error(
`No available OpenAI accounts support the requested model: ${requestedModel}`
)
} else {
throw new Error('No available OpenAI accounts')
}
}
// 按优先级和最后使用时间排序
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 选择第一个账户
const selectedAccount = sortedAccounts[0]
// 如果有会话哈希,建立新的映射
if (sessionHash) {
await this._setSessionMapping(
sessionHash,
selectedAccount.accountId,
selectedAccount.accountType
)
logger.info(
`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`
)
}
logger.info(
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`
)
return {
accountId: selectedAccount.accountId,
accountType: selectedAccount.accountType
}
} catch (error) {
logger.error('❌ Failed to select account for API key:', error)
throw error
}
}
// 📋 获取所有可用账户
async _getAllAvailableAccounts(apiKeyData, requestedModel = null) {
const availableAccounts = []
// 如果API Key绑定了专属账户优先返回
if (apiKeyData.openaiAccountId) {
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
if (!isRateLimited) {
// 检查模型支持
if (
requestedModel &&
boundAccount.supportedModels &&
boundAccount.supportedModels.length > 0
) {
const modelSupported = boundAccount.supportedModels.includes(requestedModel)
if (!modelSupported) {
logger.warn(
`⚠️ Bound OpenAI account ${boundAccount.name} does not support model ${requestedModel}`
)
return availableAccounts
}
}
logger.info(
`🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId})`
)
return [
{
...boundAccount,
accountId: boundAccount.id,
accountType: 'openai',
priority: parseInt(boundAccount.priority) || 50,
lastUsedAt: boundAccount.lastUsedAt || '0'
}
]
}
} else {
logger.warn(`⚠️ Bound OpenAI account ${apiKeyData.openaiAccountId} is not available`)
}
}
// 获取所有OpenAI账户共享池
const openaiAccounts = await openaiAccountService.getAllAccounts()
for (const account of openaiAccounts) {
if (
account.isActive === 'true' &&
account.status !== 'error' &&
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
this._isSchedulable(account.schedulable)
) {
// 检查是否可调度
// 检查token是否过期
const isExpired = openaiAccountService.isTokenExpired(account)
if (isExpired && !account.refreshToken) {
logger.warn(
`⚠️ OpenAI account ${account.name} token expired and no refresh token available`
)
continue
}
// 检查模型支持
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
const modelSupported = account.supportedModels.includes(requestedModel)
if (!modelSupported) {
logger.debug(
`⏭️ Skipping OpenAI account ${account.name} - doesn't support model ${requestedModel}`
)
continue
}
}
// 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(account.id)
if (isRateLimited) {
logger.debug(`⏭️ Skipping OpenAI account ${account.name} - rate limited`)
continue
}
availableAccounts.push({
...account,
accountId: account.id,
accountType: 'openai',
priority: parseInt(account.priority) || 50,
lastUsedAt: account.lastUsedAt || '0'
})
}
}
return availableAccounts
}
// 🔢 按优先级和最后使用时间排序账户
_sortAccountsByPriority(accounts) {
return accounts.sort((a, b) => {
// 首先按优先级排序(数字越小优先级越高)
if (a.priority !== b.priority) {
return a.priority - b.priority
}
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
return aLastUsed - bLastUsed
})
}
// 🔍 检查账户是否可用
async _isAccountAvailable(accountId, accountType) {
try {
if (accountType === 'openai') {
const account = await openaiAccountService.getAccount(accountId)
if (!account || account.isActive !== 'true' || account.status === 'error') {
return false
}
// 检查是否可调度
if (!this._isSchedulable(account.schedulable)) {
logger.info(`🚫 OpenAI account ${accountId} is not schedulable`)
return false
}
return !(await this.isAccountRateLimited(accountId))
}
return false
} catch (error) {
logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error)
return false
}
}
// 🔗 获取会话映射
async _getSessionMapping(sessionHash) {
const client = redis.getClientSafe()
const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
if (mappingData) {
try {
return JSON.parse(mappingData)
} catch (error) {
logger.warn('⚠️ Failed to parse session mapping:', error)
return null
}
}
return null
}
// 💾 设置会话映射
async _setSessionMapping(sessionHash, accountId, accountType) {
const client = redis.getClientSafe()
const mappingData = JSON.stringify({ accountId, accountType })
// 设置1小时过期
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, mappingData)
}
// 🗑️ 删除会话映射
async _deleteSessionMapping(sessionHash) {
const client = redis.getClientSafe()
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
}
// 🚫 标记账户为限流状态
async markAccountRateLimited(accountId, accountType, sessionHash = null) {
try {
if (accountType === 'openai') {
await openaiAccountService.setAccountRateLimited(accountId, true)
}
// 删除会话映射
if (sessionHash) {
await this._deleteSessionMapping(sessionHash)
}
return { success: true }
} catch (error) {
logger.error(
`❌ Failed to mark account as rate limited: ${accountId} (${accountType})`,
error
)
throw error
}
}
// ✅ 移除账户的限流状态
async removeAccountRateLimit(accountId, accountType) {
try {
if (accountType === 'openai') {
await openaiAccountService.setAccountRateLimited(accountId, false)
}
return { success: true }
} catch (error) {
logger.error(
`❌ Failed to remove rate limit for account: ${accountId} (${accountType})`,
error
)
throw error
}
}
// 🔍 检查账户是否处于限流状态
async isAccountRateLimited(accountId) {
try {
const account = await openaiAccountService.getAccount(accountId)
if (!account) {
return false
}
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const now = Date.now()
const limitDuration = 60 * 60 * 1000 // 1小时
return now < limitedAt + limitDuration
}
return false
} catch (error) {
logger.error(`❌ Failed to check rate limit status: ${accountId}`, error)
return false
}
}
// 👥 从分组中选择账户
async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) {
try {
// 获取分组信息
const group = await accountGroupService.getGroup(groupId)
if (!group) {
throw new Error(`Group ${groupId} not found`)
}
if (group.platform !== 'openai') {
throw new Error(`Group ${group.name} is not an OpenAI group`)
}
logger.info(`👥 Selecting account from OpenAI group: ${group.name}`)
// 如果有会话哈希,检查是否有已映射的账户
if (sessionHash) {
const mappedAccount = await this._getSessionMapping(sessionHash)
if (mappedAccount) {
// 验证映射的账户是否仍然可用并且在分组中
const isInGroup = await this._isAccountInGroup(mappedAccount.accountId, groupId)
if (isInGroup) {
const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId,
mappedAccount.accountType
)
if (isAvailable) {
logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
)
return mappedAccount
}
}
// 如果账户不可用或不在分组中,删除映射
await this._deleteSessionMapping(sessionHash)
}
}
// 获取分组成员
const memberIds = await accountGroupService.getGroupMembers(groupId)
if (memberIds.length === 0) {
throw new Error(`Group ${group.name} has no members`)
}
// 获取可用的分组成员账户
const availableAccounts = []
for (const memberId of memberIds) {
const account = await openaiAccountService.getAccount(memberId)
if (
account &&
account.isActive === 'true' &&
account.status !== 'error' &&
this._isSchedulable(account.schedulable)
) {
// 检查token是否过期
const isExpired = openaiAccountService.isTokenExpired(account)
if (isExpired && !account.refreshToken) {
logger.warn(
`⚠️ Group member OpenAI account ${account.name} token expired and no refresh token available`
)
continue
}
// 检查模型支持
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
const modelSupported = account.supportedModels.includes(requestedModel)
if (!modelSupported) {
logger.debug(
`⏭️ Skipping group member OpenAI account ${account.name} - doesn't support model ${requestedModel}`
)
continue
}
}
// 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(account.id)
if (isRateLimited) {
logger.debug(`⏭️ Skipping group member OpenAI account ${account.name} - rate limited`)
continue
}
availableAccounts.push({
...account,
accountId: account.id,
accountType: 'openai',
priority: parseInt(account.priority) || 50,
lastUsedAt: account.lastUsedAt || '0'
})
}
}
if (availableAccounts.length === 0) {
throw new Error(`No available accounts in group ${group.name}`)
}
// 按优先级和最后使用时间排序
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 选择第一个账户
const selectedAccount = sortedAccounts[0]
// 如果有会话哈希,建立新的映射
if (sessionHash) {
await this._setSessionMapping(
sessionHash,
selectedAccount.accountId,
selectedAccount.accountType
)
logger.info(
`🎯 Created new sticky session mapping from group: ${selectedAccount.name} (${selectedAccount.accountId})`
)
}
logger.info(
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId}) with priority ${selectedAccount.priority}`
)
return {
accountId: selectedAccount.accountId,
accountType: selectedAccount.accountType
}
} catch (error) {
logger.error(`❌ Failed to select account from group ${groupId}:`, error)
throw error
}
}
// 🔍 检查账户是否在分组中
async _isAccountInGroup(accountId, groupId) {
const members = await accountGroupService.getGroupMembers(groupId)
return members.includes(accountId)
}
// 📊 更新账户最后使用时间
async updateAccountLastUsed(accountId, accountType) {
try {
if (accountType === 'openai') {
await openaiAccountService.updateAccount(accountId, {
lastUsedAt: new Date().toISOString()
})
}
} catch (error) {
logger.warn(`⚠️ Failed to update last used time for account ${accountId}:`, error)
}
}
}
module.exports = new UnifiedOpenAIScheduler()

View File

@@ -81,11 +81,29 @@ class CostCalculator {
if (pricingData) {
// 转换动态价格格式为内部格式
const inputPrice = (pricingData.input_cost_per_token || 0) * 1000000 // 转换为per 1M tokens
const outputPrice = (pricingData.output_cost_per_token || 0) * 1000000
const cacheReadPrice = (pricingData.cache_read_input_token_cost || 0) * 1000000
// OpenAI 模型的特殊处理:
// - 如果没有 cache_creation_input_token_cost缓存创建按普通 input 价格计费
// - Claude 模型有专门的 cache_creation_input_token_cost
let cacheWritePrice = (pricingData.cache_creation_input_token_cost || 0) * 1000000
// 检测是否为 OpenAI 模型(通过模型名或 litellm_provider
const isOpenAIModel =
model.includes('gpt') || model.includes('o1') || pricingData.litellm_provider === 'openai'
if (isOpenAIModel && !pricingData.cache_creation_input_token_cost && cacheCreateTokens > 0) {
// OpenAI 模型:缓存创建按普通 input 价格计费
cacheWritePrice = inputPrice
}
pricing = {
input: (pricingData.input_cost_per_token || 0) * 1000000, // 转换为per 1M tokens
output: (pricingData.output_cost_per_token || 0) * 1000000,
cacheWrite: (pricingData.cache_creation_input_token_cost || 0) * 1000000,
cacheRead: (pricingData.cache_read_input_token_cost || 0) * 1000000
input: inputPrice,
output: outputPrice,
cacheWrite: cacheWritePrice,
cacheRead: cacheReadPrice
}
usingDynamicPricing = true
} else {
@@ -126,6 +144,13 @@ class CostCalculator {
cacheWrite: this.formatCost(cacheWriteCost),
cacheRead: this.formatCost(cacheReadCost),
total: this.formatCost(totalCost)
},
// 添加调试信息
debug: {
isOpenAIModel: model.includes('gpt') || model.includes('o1'),
hasCacheCreatePrice: !!pricingData?.cache_creation_input_token_cost,
cacheCreateTokens,
cacheWritePriceUsed: pricing.cacheWrite
}
}
}

View File

@@ -606,6 +606,25 @@
</div>
</div>
<!-- OpenAI 平台需要 ID Token -->
<div v-if="form.platform === 'openai'">
<label class="mb-3 block text-sm font-semibold text-gray-700">ID Token *</label>
<textarea
v-model="form.idToken"
class="form-input w-full resize-none font-mono text-xs"
:class="{ 'border-red-500': errors.idToken }"
placeholder="请输入 ID Token (JWT 格式)..."
required
rows="4"
/>
<p v-if="errors.idToken" class="mt-1 text-xs text-red-500">
{{ errors.idToken }}
</p>
<p class="mt-2 text-xs text-gray-500">
ID Token 是 OpenAI OAuth 认证返回的 JWT token包含用户信息和组织信息
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700">Access Token *</label>
<textarea
@@ -1332,6 +1351,7 @@ const form = ref({
accountType: props.account?.accountType || 'shared',
groupId: '',
projectId: props.account?.projectId || '',
idToken: '',
accessToken: '',
refreshToken: '',
proxy: initProxyConfig(),
@@ -1391,6 +1411,7 @@ const initModelMappings = () => {
// 表单验证错误
const errors = ref({
name: '',
idToken: '',
accessToken: '',
apiUrl: '',
apiKey: '',
@@ -1653,12 +1674,20 @@ const createAccount = async () => {
errors.value.region = '请选择 AWS 区域'
hasError = true
}
} else if (
form.value.addType === 'manual' &&
(!form.value.accessToken || form.value.accessToken.trim() === '')
) {
errors.value.accessToken = '请填写 Access Token'
hasError = true
} else if (form.value.addType === 'manual') {
// 手动模式验证
if (!form.value.accessToken || form.value.accessToken.trim() === '') {
errors.value.accessToken = '请填写 Access Token'
hasError = true
}
// OpenAI 平台需要验证 ID Token
if (
form.value.platform === 'openai' &&
(!form.value.idToken || form.value.idToken.trim() === '')
) {
errors.value.idToken = '请填写 ID Token'
hasError = true
}
}
// 分组类型验证
@@ -1722,6 +1751,57 @@ const createAccount = async () => {
if (form.value.projectId) {
data.projectId = form.value.projectId
}
} else if (form.value.platform === 'openai') {
// OpenAI手动模式需要构建openaiOauth对象
const expiresInMs = form.value.refreshToken
? 10 * 60 * 1000 // 10分钟
: 365 * 24 * 60 * 60 * 1000 // 1年
data.openaiOauth = {
idToken: form.value.idToken, // 使用用户输入的 ID Token
accessToken: form.value.accessToken,
refreshToken: form.value.refreshToken || '',
expires_in: Math.floor(expiresInMs / 1000) // 转换为秒
}
// 手动模式下,尝试从 ID Token 解析用户信息
let accountInfo = {
accountId: '',
chatgptUserId: '',
organizationId: '',
organizationRole: '',
organizationTitle: '',
planType: '',
email: '',
emailVerified: false
}
// 尝试解析 ID Token (JWT)
if (form.value.idToken) {
try {
const idTokenParts = form.value.idToken.split('.')
if (idTokenParts.length === 3) {
const payload = JSON.parse(atob(idTokenParts[1]))
const authClaims = payload['https://api.openai.com/auth'] || {}
accountInfo = {
accountId: authClaims.accountId || '',
chatgptUserId: authClaims.chatgptUserId || '',
organizationId: authClaims.organizationId || '',
organizationRole: authClaims.organizationRole || '',
organizationTitle: authClaims.organizationTitle || '',
planType: authClaims.planType || '',
email: payload.email || '',
emailVerified: payload.email_verified || false
}
}
} catch (e) {
console.warn('Failed to parse ID Token:', e)
}
}
data.accountInfo = accountInfo
data.priority = form.value.priority || 50
} else if (form.value.platform === 'claude-console') {
// Claude Console 账户特定数据
data.apiUrl = form.value.apiUrl
@@ -1846,6 +1926,18 @@ const updateAccount = async () => {
token_type: 'Bearer',
expiry_date: Date.now() + expiresInMs
}
} else if (props.account.platform === 'openai') {
// OpenAI需要构建openaiOauth对象
const expiresInMs = form.value.refreshToken
? 10 * 60 * 1000 // 10分钟
: 365 * 24 * 60 * 60 * 1000 // 1年
data.openaiOauth = {
idToken: form.value.idToken || '', // 更新时使用用户输入的 ID Token
accessToken: form.value.accessToken || '',
refreshToken: form.value.refreshToken || '',
expires_in: Math.floor(expiresInMs / 1000) // 转换为秒
}
}
}
@@ -1858,6 +1950,11 @@ const updateAccount = async () => {
data.priority = form.value.priority || 50
}
// OpenAI 账号优先级更新
if (props.account.platform === 'openai') {
data.priority = form.value.priority || 50
}
// Claude Console 特定更新
if (props.account.platform === 'claude-console') {
data.apiUrl = form.value.apiUrl
@@ -2140,13 +2237,19 @@ watch(
password: ''
}
// 获取分组ID - 可能来自 groupId 字段或 groupInfo 对象
let groupId = ''
if (newAccount.accountType === 'group') {
groupId = newAccount.groupId || (newAccount.groupInfo && newAccount.groupInfo.id) || ''
}
form.value = {
platform: newAccount.platform,
addType: 'oauth',
name: newAccount.name,
description: newAccount.description || '',
accountType: newAccount.accountType || 'shared',
groupId: '',
groupId: groupId,
projectId: newAccount.projectId || '',
accessToken: '',
refreshToken: '',

View File

@@ -54,6 +54,10 @@
<input v-model="createForm.platform" class="mr-2" type="radio" value="gemini" />
<span class="text-sm text-gray-700">Gemini</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="createForm.platform" class="mr-2" type="radio" value="openai" />
<span class="text-sm text-gray-700">OpenAI</span>
</label>
</div>
</div>
@@ -114,10 +118,18 @@
'rounded-full px-2 py-1 text-xs font-medium',
group.platform === 'claude'
? 'bg-purple-100 text-purple-700'
: 'bg-blue-100 text-blue-700'
: group.platform === 'gemini'
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700'
]"
>
{{ group.platform === 'claude' ? 'Claude' : 'Gemini' }}
{{
group.platform === 'claude'
? 'Claude'
: group.platform === 'gemini'
? 'Gemini'
: 'OpenAI'
}}
</span>
</div>
</div>
@@ -184,7 +196,13 @@
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700">平台类型</label>
<div class="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
{{ editForm.platform === 'claude' ? 'Claude' : 'Gemini' }}
{{
editForm.platform === 'claude'
? 'Claude'
: editForm.platform === 'gemini'
? 'Gemini'
: 'OpenAI'
}}
<span class="ml-2 text-xs text-gray-500">(不可修改)</span>
</div>
</div>

View File

@@ -371,6 +371,10 @@
<input v-model="form.permissions" class="mr-2" type="radio" value="gemini" />
<span class="text-sm text-gray-700"> Gemini</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="openai" />
<span class="text-sm text-gray-700"> OpenAI</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500">控制此 API Key 可以访问哪些服务</p>
</div>
@@ -402,7 +406,7 @@
v-model="form.claudeAccountId"
:accounts="localAccounts.claude"
default-option-text="使用共享账号池"
:disabled="form.permissions === 'gemini'"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
:groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号"
platform="claude"
@@ -414,12 +418,24 @@
v-model="form.geminiAccountId"
:accounts="localAccounts.gemini"
default-option-text="使用共享账号池"
:disabled="form.permissions === 'claude'"
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
:groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号"
platform="gemini"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-600">OpenAI 专属账号</label>
<AccountSelector
v-model="form.openaiAccountId"
:accounts="localAccounts.openai"
default-option-text="使用共享账号池"
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
:groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号"
platform="openai"
/>
</div>
</div>
<p class="mt-2 text-xs text-gray-500">
选择专属账号后此API Key将只使用该账号不选择则使用共享账号池
@@ -598,7 +614,14 @@ const clientsStore = useClientsStore()
const apiKeysStore = useApiKeysStore()
const loading = ref(false)
const accountsLoading = ref(false)
const localAccounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
const localAccounts = ref({
claude: [],
gemini: [],
openai: [],
claudeGroups: [],
geminiGroups: [],
openaiGroups: []
})
// 表单验证状态
const errors = ref({
@@ -634,6 +657,7 @@ const form = reactive({
permissions: 'all',
claudeAccountId: '',
geminiAccountId: '',
openaiAccountId: '',
enableModelRestriction: false,
restrictedModels: [],
modelInput: '',
@@ -651,8 +675,10 @@ onMounted(async () => {
localAccounts.value = {
claude: props.accounts.claude || [],
gemini: props.accounts.gemini || [],
openai: props.accounts.openai || [],
claudeGroups: props.accounts.claudeGroups || [],
geminiGroups: props.accounts.geminiGroups || []
geminiGroups: props.accounts.geminiGroups || [],
openaiGroups: props.accounts.openaiGroups || []
}
}
})
@@ -661,10 +687,11 @@ onMounted(async () => {
const refreshAccounts = async () => {
accountsLoading.value = true
try {
const [claudeData, claudeConsoleData, geminiData, groupsData] = await Promise.all([
const [claudeData, claudeConsoleData, geminiData, openaiData, groupsData] = await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/openai-accounts'),
apiClient.get('/admin/account-groups')
])
@@ -700,11 +727,19 @@ const refreshAccounts = async () => {
}))
}
if (openaiData.success) {
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
...account,
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
}))
}
// 处理分组数据
if (groupsData.success) {
const allGroups = groupsData.data || []
localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
}
showToast('账号列表已刷新', 'success')
@@ -899,6 +934,11 @@ const createApiKey = async () => {
baseData.geminiAccountId = form.geminiAccountId
}
// OpenAI账户绑定
if (form.openaiAccountId) {
baseData.openaiAccountId = form.openaiAccountId
}
if (form.createType === 'single') {
// 单个创建
const data = {

View File

@@ -274,6 +274,10 @@
<input v-model="form.permissions" class="mr-2" type="radio" value="gemini" />
<span class="text-sm text-gray-700"> Gemini</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="openai" />
<span class="text-sm text-gray-700"> OpenAI</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500">控制此 API Key 可以访问哪些服务</p>
</div>
@@ -305,7 +309,7 @@
v-model="form.claudeAccountId"
:accounts="localAccounts.claude"
default-option-text="使用共享账号池"
:disabled="form.permissions === 'gemini'"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
:groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号"
platform="claude"
@@ -317,12 +321,24 @@
v-model="form.geminiAccountId"
:accounts="localAccounts.gemini"
default-option-text="使用共享账号池"
:disabled="form.permissions === 'claude'"
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
:groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号"
platform="gemini"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-600">OpenAI 专属账号</label>
<AccountSelector
v-model="form.openaiAccountId"
:accounts="localAccounts.openai"
default-option-text="使用共享账号池"
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
:groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号"
platform="openai"
/>
</div>
</div>
<p class="mt-2 text-xs text-gray-500">修改绑定账号将影响此API Key的请求路由</p>
</div>
@@ -502,7 +518,14 @@ const clientsStore = useClientsStore()
const apiKeysStore = useApiKeysStore()
const loading = ref(false)
const accountsLoading = ref(false)
const localAccounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
const localAccounts = ref({
claude: [],
gemini: [],
openai: [],
claudeGroups: [],
geminiGroups: [],
openaiGroups: []
})
// 支持的客户端列表
const supportedClients = ref([])
@@ -527,6 +550,7 @@ const form = reactive({
permissions: 'all',
claudeAccountId: '',
geminiAccountId: '',
openaiAccountId: '',
enableModelRestriction: false,
restrictedModels: [],
modelInput: '',
@@ -642,6 +666,13 @@ const updateApiKey = async () => {
data.geminiAccountId = null
}
// OpenAI账户绑定
if (form.openaiAccountId) {
data.openaiAccountId = form.openaiAccountId
} else {
data.openaiAccountId = null
}
// 模型限制 - 始终提交这些字段
data.enableModelRestriction = form.enableModelRestriction
data.restrictedModels = form.restrictedModels
@@ -672,10 +703,11 @@ const updateApiKey = async () => {
const refreshAccounts = async () => {
accountsLoading.value = true
try {
const [claudeData, claudeConsoleData, geminiData, groupsData] = await Promise.all([
const [claudeData, claudeConsoleData, geminiData, openaiData, groupsData] = await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/openai-accounts'),
apiClient.get('/admin/account-groups')
])
@@ -711,11 +743,19 @@ const refreshAccounts = async () => {
}))
}
if (openaiData.success) {
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
...account,
isDedicated: account.accountType === 'dedicated'
}))
}
// 处理分组数据
if (groupsData.success) {
const allGroups = groupsData.data || []
localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
}
showToast('账号列表已刷新', 'success')
@@ -737,8 +777,10 @@ onMounted(async () => {
localAccounts.value = {
claude: props.accounts.claude || [],
gemini: props.accounts.gemini || [],
openai: props.accounts.openai || [],
claudeGroups: props.accounts.claudeGroups || [],
geminiGroups: props.accounts.geminiGroups || []
geminiGroups: props.accounts.geminiGroups || [],
openaiGroups: props.accounts.openaiGroups || []
}
}
@@ -756,6 +798,7 @@ onMounted(async () => {
form.claudeAccountId = props.apiKey.claudeAccountId || ''
}
form.geminiAccountId = props.apiKey.geminiAccountId || ''
form.openaiAccountId = props.apiKey.openaiAccountId || ''
form.restrictedModels = props.apiKey.restrictedModels || []
form.allowedClients = props.apiKey.allowedClients || []
form.tags = props.apiKey.tags || []

View File

@@ -44,47 +44,19 @@
(statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)
"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium text-gray-600 md:text-base">
时间窗口限制 ({{ statsData.limits.rateLimitWindow }}分钟)
</span>
</div>
<!-- 请求次数限制 -->
<div v-if="statsData.limits.rateLimitRequests > 0" class="mb-3 space-y-1.5">
<div class="flex items-center justify-between text-xs md:text-sm">
<span class="text-gray-500">请求次数</span>
<span class="text-gray-700">
{{ formatNumber(statsData.limits.currentWindowRequests) }} /
{{ formatNumber(statsData.limits.rateLimitRequests) }}
</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200">
<div
class="h-1.5 rounded-full transition-all duration-300"
:class="getWindowRequestProgressColor()"
:style="{ width: getWindowRequestProgress() + '%' }"
/>
</div>
</div>
<!-- Token使用量限制 -->
<div v-if="statsData.limits.tokenLimit > 0" class="space-y-1.5">
<div class="flex items-center justify-between text-xs md:text-sm">
<span class="text-gray-500">Token 使用量</span>
<span class="text-gray-700">
{{ formatNumber(statsData.limits.currentWindowTokens) }} /
{{ formatNumber(statsData.limits.tokenLimit) }}
</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200">
<div
class="h-1.5 rounded-full transition-all duration-300"
:class="getWindowTokenProgressColor()"
:style="{ width: getWindowTokenProgress() + '%' }"
/>
</div>
</div>
<WindowCountdown
:current-requests="statsData.limits.currentWindowRequests"
:current-tokens="statsData.limits.currentWindowTokens"
label="时间窗口限制"
:rate-limit-window="statsData.limits.rateLimitWindow"
:request-limit="statsData.limits.rateLimitRequests"
:show-progress="true"
:show-tooltip="true"
:token-limit="statsData.limits.tokenLimit"
:window-end-time="statsData.limits.windowEndTime"
:window-remaining-seconds="statsData.limits.windowRemainingSeconds"
:window-start-time="statsData.limits.windowStartTime"
/>
<div class="mt-2 text-xs text-gray-500">
<i class="fas fa-info-circle mr-1" />
@@ -226,28 +198,11 @@
<script setup>
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
const apiStatsStore = useApiStatsStore()
const { statsData } = storeToRefs(apiStatsStore)
// 格式化数字
const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
// 大数字使用简化格式
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
} else {
return num.toLocaleString()
}
}
// 获取每日费用进度
const getDailyCostProgress = () => {
if (!statsData.value.limits.dailyCostLimit || statsData.value.limits.dailyCostLimit === 0)
@@ -264,39 +219,6 @@ const getDailyCostProgressColor = () => {
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 获取窗口请求进度
const getWindowRequestProgress = () => {
if (!statsData.value.limits.rateLimitRequests || statsData.value.limits.rateLimitRequests === 0)
return 0
const percentage =
(statsData.value.limits.currentWindowRequests / statsData.value.limits.rateLimitRequests) * 100
return Math.min(percentage, 100)
}
// 获取窗口请求进度条颜色
const getWindowRequestProgressColor = () => {
const progress = getWindowRequestProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-blue-500'
}
// 获取窗口Token进度
const getWindowTokenProgress = () => {
if (!statsData.value.limits.tokenLimit || statsData.value.limits.tokenLimit === 0) return 0
const percentage =
(statsData.value.limits.currentWindowTokens / statsData.value.limits.tokenLimit) * 100
return Math.min(percentage, 100)
}
// 获取窗口Token进度条颜色
const getWindowTokenProgressColor = () => {
const progress = getWindowTokenProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-purple-500'
}
</script>
<style scoped>

View File

@@ -822,7 +822,7 @@ const platformOptions = ref([
{ value: 'claude', label: 'Claude', icon: 'fa-brain' },
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
{ value: 'gemini', label: 'Gemini', icon: 'fa-robot' },
{ value: 'openai', label: 'OpenAi', icon: 'fa-robot' },
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' }
])
@@ -834,8 +834,13 @@ const groupOptions = computed(() => {
accountGroups.value.forEach((group) => {
options.push({
value: group.id,
label: `${group.name} (${group.platform === 'claude' ? 'Claude' : 'Gemini'})`,
icon: group.platform === 'claude' ? 'fa-brain' : 'fa-robot'
label: `${group.name} (${group.platform === 'claude' ? 'Claude' : group.platform === 'gemini' ? 'Gemini' : 'OpenAI'})`,
icon:
group.platform === 'claude'
? 'fa-brain'
: group.platform === 'gemini'
? 'fa-robot'
: 'fa-openai'
})
})
return options
@@ -1326,6 +1331,8 @@ const toggleSchedulable = async (account) => {
endpoint = `/admin/bedrock-accounts/${account.id}/toggle-schedulable`
} else if (account.platform === 'gemini') {
endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable`
} else if (account.platform === 'openai') {
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
} else {
showToast('该账户类型暂不支持调度控制', 'warning')
return

View File

@@ -1052,7 +1052,14 @@ const expandedApiKeys = ref({})
const apiKeyModelStats = ref({})
const apiKeyDateFilters = ref({})
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
const accounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
const accounts = ref({
claude: [],
gemini: [],
openai: [],
claudeGroups: [],
geminiGroups: [],
openaiGroups: []
})
const editingExpiryKey = ref(null)
const expiryEditModalRef = ref(null)
const showUsageDetailModal = ref(false)
@@ -1185,10 +1192,11 @@ const paginatedApiKeys = computed(() => {
// 加载账户列表
const loadAccounts = async () => {
try {
const [claudeData, claudeConsoleData, geminiData, groupsData] = await Promise.all([
const [claudeData, claudeConsoleData, geminiData, openaiData, groupsData] = await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/openai-accounts'),
apiClient.get('/admin/account-groups')
])
@@ -1209,11 +1217,16 @@ const loadAccounts = async () => {
accounts.value.gemini = geminiData.data || []
}
if (openaiData.success) {
accounts.value.openai = openaiData.data || []
}
if (groupsData.success) {
// 处理分组数据
const allGroups = groupsData.data || []
accounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
accounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
accounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
}
} catch (error) {
console.error('加载账户列表失败:', error)

View File

@@ -85,6 +85,20 @@
dashboardData.accountsByPlatform.bedrock.total
}}</span>
</div>
<!-- OpenAI账户 -->
<div
v-if="
dashboardData.accountsByPlatform.openai &&
dashboardData.accountsByPlatform.openai.total > 0
"
class="inline-flex items-center gap-0.5"
:title="`OpenAI: ${dashboardData.accountsByPlatform.openai.total} 个 (正常: ${dashboardData.accountsByPlatform.openai.normal})`"
>
<i class="fas fa-openai text-xs text-gray-100" />
<span class="text-xs font-medium text-gray-700">{{
dashboardData.accountsByPlatform.openai.total
}}</span>
</div>
</div>
</div>
<p class="mt-1 text-xs text-gray-500">

View File

@@ -382,6 +382,76 @@
</div>
</div>
</div>
<!-- Codex 环境变量设置 -->
<div class="mt-8">
<h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg"
>
<i class="fas fa-code mr-2 text-indigo-600" />
配置 Codex 环境变量
</h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base">
如果你使用支持 OpenAI API 的工具 Codex需要设置以下环境变量
</p>
<div class="space-y-4">
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">
PowerShell 设置方法
</h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
<div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">
$env:OPENAI_BASE_URL = "{{ openaiBaseUrl }}"
</div>
<div class="whitespace-nowrap text-gray-300">
$env:OPENAI_API_KEY = "你的API密钥"
</div>
</div>
<p class="mt-2 text-xs text-yellow-700">
💡 使用与 Claude Code 相同的 API 密钥即可格式如 cr_xxxxxxxxxx
</p>
</div>
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">
PowerShell 永久设置用户级
</h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
<div
class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="mb-2"># 设置用户级环境变量永久生效</div>
<div class="whitespace-nowrap text-gray-300">
[System.Environment]::SetEnvironmentVariable("OPENAI_BASE_URL", "{{
openaiBaseUrl
}}", [System.EnvironmentVariableTarget]::User)
</div>
<div class="whitespace-nowrap text-gray-300">
[System.Environment]::SetEnvironmentVariable("OPENAI_API_KEY", "你的API密钥",
[System.EnvironmentVariableTarget]::User)
</div>
</div>
<p class="mt-2 text-xs text-blue-700">
💡 设置后需要重新打开 PowerShell 窗口才能生效
</p>
</div>
<div class="rounded-lg border border-indigo-200 bg-indigo-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-indigo-800">验证 Codex 环境变量</h6>
<p class="mb-3 text-sm text-indigo-700"> PowerShell 中验证</p>
<div
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">echo $env:OPENAI_BASE_URL</div>
<div class="whitespace-nowrap text-gray-300">echo $env:OPENAI_API_KEY</div>
</div>
</div>
</div>
</div>
</div>
<!-- 第四步开始使用 -->
@@ -790,6 +860,79 @@
</div>
</div>
</div>
<!-- Codex 环境变量设置 -->
<div class="mt-8">
<h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg"
>
<i class="fas fa-code mr-2 text-indigo-600" />
配置 Codex 环境变量
</h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base">
如果你使用支持 OpenAI API 的工具 Codex需要设置以下环境变量
</p>
<div class="space-y-4">
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">Terminal 设置方法</h6>
<p class="mb-3 text-sm text-gray-600"> Terminal 中运行以下命令</p>
<div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">
export OPENAI_BASE_URL="{{ openaiBaseUrl }}"
</div>
<div class="whitespace-nowrap text-gray-300">
export OPENAI_API_KEY="你的API密钥"
</div>
</div>
<p class="mt-2 text-xs text-yellow-700">
💡 使用与 Claude Code 相同的 API 密钥即可格式如 cr_xxxxxxxxxx
</p>
</div>
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">永久设置方法</h6>
<p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p>
<div
class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="mb-2"># 对于 zsh (默认)</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_BASE_URL="{{ openaiBaseUrl }}"' >> ~/.zshrc
</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_API_KEY="你的API密钥"' >> ~/.zshrc
</div>
<div class="whitespace-nowrap text-gray-300">source ~/.zshrc</div>
</div>
<div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="mb-2"># 对于 bash</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_BASE_URL="{{ openaiBaseUrl }}"' >> ~/.bash_profile
</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_API_KEY="你的API密钥"' >> ~/.bash_profile
</div>
<div class="whitespace-nowrap text-gray-300">source ~/.bash_profile</div>
</div>
</div>
<div class="rounded-lg border border-indigo-200 bg-indigo-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-indigo-800">验证 Codex 环境变量</h6>
<p class="mb-3 text-sm text-indigo-700"> Terminal 中验证</p>
<div
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">echo $OPENAI_BASE_URL</div>
<div class="whitespace-nowrap text-gray-300">echo $OPENAI_API_KEY</div>
</div>
</div>
</div>
</div>
</div>
<!-- 第四步开始使用 -->
@@ -1191,6 +1334,79 @@
</div>
</div>
</div>
<!-- Codex 环境变量设置 -->
<div class="mt-8">
<h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg"
>
<i class="fas fa-code mr-2 text-indigo-600" />
配置 Codex 环境变量
</h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base">
如果你使用支持 OpenAI API 的工具 Codex需要设置以下环境变量
</p>
<div class="space-y-4">
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">终端设置方法</h6>
<p class="mb-3 text-sm text-gray-600">在终端中运行以下命令</p>
<div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">
export OPENAI_BASE_URL="{{ openaiBaseUrl }}"
</div>
<div class="whitespace-nowrap text-gray-300">
export OPENAI_API_KEY="你的API密钥"
</div>
</div>
<p class="mt-2 text-xs text-yellow-700">
💡 使用与 Claude Code 相同的 API 密钥即可格式如 cr_xxxxxxxxxx
</p>
</div>
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">永久设置方法</h6>
<p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p>
<div
class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="mb-2"># 对于 bash (默认)</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_BASE_URL="{{ openaiBaseUrl }}"' >> ~/.bashrc
</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_API_KEY="你的API密钥"' >> ~/.bashrc
</div>
<div class="whitespace-nowrap text-gray-300">source ~/.bashrc</div>
</div>
<div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="mb-2"># 对于 zsh</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_BASE_URL="{{ openaiBaseUrl }}"' >> ~/.zshrc
</div>
<div class="whitespace-nowrap text-gray-300">
echo 'export OPENAI_API_KEY="你的API密钥"' >> ~/.zshrc
</div>
<div class="whitespace-nowrap text-gray-300">source ~/.zshrc</div>
</div>
</div>
<div class="rounded-lg border border-indigo-200 bg-indigo-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-indigo-800">验证 Codex 环境变量</h6>
<p class="mb-3 text-sm text-indigo-700">在终端中验证</p>
<div
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
>
<div class="whitespace-nowrap text-gray-300">echo $OPENAI_BASE_URL</div>
<div class="whitespace-nowrap text-gray-300">echo $OPENAI_API_KEY</div>
</div>
</div>
</div>
</div>
</div>
<!-- 第四步开始使用 -->
@@ -1395,6 +1611,11 @@ const currentBaseUrl = computed(() => {
const geminiBaseUrl = computed(() => {
return getBaseUrlPrefix() + '/gemini'
})
// OpenAI/Codex 基础URL
const openaiBaseUrl = computed(() => {
return getBaseUrlPrefix() + '/openai'
})
</script>
<style scoped>