From 11fc8569996f50940333fb86a15858b39dd413ad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=83=E7=BE=BD?=
Date: Sun, 10 Aug 2025 17:46:31 +0900
Subject: [PATCH 1/2] chore: commit all changes
---
src/models/redis.js | 24 ++
src/routes/admin.js | 397 ++++++++++++++++++
src/routes/geminiRoutes.js | 1 -
web/admin-spa/index.html | 9 +-
.../src/assets/styles/components.css | 7 +
.../src/components/accounts/AccountForm.vue | 21 +
.../src/components/accounts/OAuthFlow.vue | 141 ++++++-
web/admin-spa/src/stores/accounts.js | 116 ++++-
web/admin-spa/src/views/AccountsView.vue | 25 +-
9 files changed, 728 insertions(+), 13 deletions(-)
diff --git a/src/models/redis.js b/src/models/redis.js
index 0f5a57ec..d1d2757b 100644
--- a/src/models/redis.js
+++ b/src/models/redis.js
@@ -817,6 +817,30 @@ class RedisClient {
const key = `claude:account:${accountId}`
return await this.client.del(key)
}
+ async setOpenAiAccount(accountId, accountData) {
+ const key = `openai:account:${accountId}`
+ await this.client.hset(key, accountData)
+ }
+ async getOpenAiAccount(accountId) {
+ const key = `openai:account:${accountId}`
+ return await this.client.hgetall(key)
+ }
+ async deleteOpenAiAccount(accountId) {
+ const key = `openai:account:${accountId}`
+ return await this.client.del(key)
+ }
+
+ async getAllOpenAIAccounts() {
+ const keys = await this.client.keys('openai:account:*')
+ const accounts = []
+ for (const key of keys) {
+ const accountData = await this.client.hgetall(key)
+ if (accountData && Object.keys(accountData).length > 0) {
+ accounts.push({ id: key.replace('claude:account:', ''), ...accountData })
+ }
+ }
+ return accounts
+ }
// 🔐 会话管理(用于管理员登录等)
async setSession(sessionId, sessionData, ttl = 86400) {
diff --git a/src/routes/admin.js b/src/routes/admin.js
index 676f419a..ac11ab29 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -13,9 +13,11 @@ const CostCalculator = require('../utils/costCalculator')
const pricingService = require('../services/pricingService')
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
const axios = require('axios')
+const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const config = require('../../config/config')
+const { v4: uuidv4 } = require('uuid')
const router = express.Router()
@@ -4298,4 +4300,399 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
}
})
+// 🤖 OpenAI 账户管理
+
+// OpenAI OAuth 配置
+const OPENAI_CONFIG = {
+ BASE_URL: 'https://auth.openai.com',
+ CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann',
+ REDIRECT_URI: 'http://localhost:1455/auth/callback',
+ SCOPE: 'openid profile email offline_access'
+}
+
+// 生成 PKCE 参数
+function generateOpenAIPKCE() {
+ const codeVerifier = crypto.randomBytes(64).toString('hex')
+ const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
+
+ return {
+ codeVerifier,
+ codeChallenge
+ }
+}
+
+// 生成 OpenAI OAuth 授权 URL
+router.post('/openai-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
+ try {
+ const { proxy } = req.body
+
+ // 生成 PKCE 参数
+ const pkce = generateOpenAIPKCE()
+
+ // 生成随机 state
+ const state = crypto.randomBytes(32).toString('hex')
+
+ // 创建会话 ID
+ const sessionId = crypto.randomUUID()
+
+ // 将 PKCE 参数和代理配置存储到 Redis
+ await redis.setOAuthSession(sessionId, {
+ codeVerifier: pkce.codeVerifier,
+ codeChallenge: pkce.codeChallenge,
+ state,
+ proxy: proxy || null,
+ platform: 'openai',
+ createdAt: new Date().toISOString(),
+ expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString()
+ })
+
+ // 构建授权 URL 参数
+ const params = new URLSearchParams({
+ response_type: 'code',
+ client_id: OPENAI_CONFIG.CLIENT_ID,
+ redirect_uri: OPENAI_CONFIG.REDIRECT_URI,
+ scope: OPENAI_CONFIG.SCOPE,
+ code_challenge: pkce.codeChallenge,
+ code_challenge_method: 'S256',
+ state,
+ id_token_add_organizations: 'true',
+ codex_cli_simplified_flow: 'true'
+ })
+
+ const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}`
+
+ logger.success('🔗 Generated OpenAI OAuth authorization URL')
+
+ return res.json({
+ success: true,
+ data: {
+ authUrl,
+ sessionId,
+ instructions: [
+ '1. 复制上面的链接到浏览器中打开',
+ '2. 登录您的 OpenAI 账户',
+ '3. 同意应用权限',
+ '4. 复制浏览器地址栏中的完整 URL(包含 code 参数)',
+ '5. 在添加账户表单中粘贴完整的回调 URL'
+ ]
+ }
+ })
+ } catch (error) {
+ logger.error('生成 OpenAI OAuth URL 失败:', error)
+ return res.status(500).json({
+ success: false,
+ message: '生成授权链接失败',
+ error: error.message
+ })
+ }
+})
+
+// 交换 OpenAI 授权码
+router.post('/openai-accounts/exchange-code', authenticateAdmin, async (req, res) => {
+ try {
+ const { code, sessionId } = req.body
+
+ if (!code || !sessionId) {
+ return res.status(400).json({
+ success: false,
+ message: '缺少必要参数'
+ })
+ }
+
+ // 从 Redis 获取会话数据
+ const sessionData = await redis.getOAuthSession(sessionId)
+ if (!sessionData) {
+ return res.status(400).json({
+ success: false,
+ message: '会话已过期或无效'
+ })
+ }
+
+ // 准备 token 交换请求
+ const tokenData = {
+ grant_type: 'authorization_code',
+ code: code.trim(),
+ redirect_uri: OPENAI_CONFIG.REDIRECT_URI,
+ client_id: OPENAI_CONFIG.CLIENT_ID,
+ code_verifier: sessionData.codeVerifier
+ }
+
+ logger.info('Exchanging OpenAI authorization code:', {
+ sessionId,
+ codeLength: code.length,
+ hasCodeVerifier: !!sessionData.codeVerifier
+ })
+
+ // 配置代理(如果有)
+ const axiosConfig = {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+ }
+
+ if (sessionData.proxy) {
+ const { type, host, port, username, password } = sessionData.proxy
+ if (type === 'http' || type === 'https') {
+ axiosConfig.proxy = {
+ host,
+ port: parseInt(port),
+ auth: username && password ? { username, password } : undefined
+ }
+ }
+ }
+
+ // 交换 authorization code 获取 tokens
+ const tokenResponse = await axios.post(
+ `${OPENAI_CONFIG.BASE_URL}/oauth/token`,
+ new URLSearchParams(tokenData).toString(),
+ axiosConfig
+ )
+
+ const { id_token, access_token, refresh_token, expires_in } = tokenResponse.data
+
+ // 解析 ID token 获取用户信息
+ const idTokenParts = id_token.split('.')
+ if (idTokenParts.length !== 3) {
+ throw new Error('Invalid ID token format')
+ }
+
+ // 解码 JWT payload
+ const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64url').toString())
+
+ // 获取 OpenAI 特定的声明
+ const authClaims = payload['https://api.openai.com/auth'] || {}
+ const accountId = authClaims.chatgpt_account_id || ''
+ const chatgptUserId = authClaims.chatgpt_user_id || authClaims.user_id || ''
+ const planType = authClaims.chatgpt_plan_type || ''
+
+ // 获取组织信息
+ const organizations = authClaims.organizations || []
+ const defaultOrg = organizations.find((org) => org.is_default) || organizations[0] || {}
+ const organizationId = defaultOrg.id || ''
+ const organizationRole = defaultOrg.role || ''
+ const organizationTitle = defaultOrg.title || ''
+
+ // 清理 Redis 会话
+ await redis.deleteOAuthSession(sessionId)
+
+ logger.success('✅ OpenAI OAuth token exchange successful')
+
+ return res.json({
+ success: true,
+ data: {
+ tokens: {
+ idToken: id_token,
+ accessToken: access_token,
+ refreshToken: refresh_token,
+ expires_in
+ },
+ accountInfo: {
+ accountId,
+ chatgptUserId,
+ organizationId,
+ organizationRole,
+ organizationTitle,
+ planType,
+ email: payload.email || '',
+ name: payload.name || '',
+ emailVerified: payload.email_verified || false,
+ organizations
+ }
+ }
+ })
+ } catch (error) {
+ logger.error('OpenAI OAuth token exchange failed:', error)
+ return res.status(500).json({
+ success: false,
+ message: '交换授权码失败',
+ error: error.message
+ })
+ }
+})
+
+// 获取所有 OpenAI 账户
+router.get('/openai-accounts', authenticateAdmin, async (req, res) => {
+ try {
+ const accounts = await redis.getAllOpenAIAccounts()
+
+ logger.info(`获取 OpenAI 账户列表: ${accounts.length} 个账户`)
+
+ return res.json({
+ success: true,
+ data: accounts
+ })
+ } catch (error) {
+ logger.error('获取 OpenAI 账户列表失败:', error)
+ return res.status(500).json({
+ success: false,
+ message: '获取账户列表失败',
+ error: error.message
+ })
+ }
+})
+
+// 创建 OpenAI 账户
+router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
+ try {
+ const {
+ name,
+ description,
+ openaiOauth,
+ accountInfo,
+ proxy,
+ accountType,
+ groupId,
+ dedicatedApiKeys,
+ rateLimitDuration,
+ priority
+ } = req.body
+
+ if (!name) {
+ return res.status(400).json({
+ success: false,
+ 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,
+ isActive: true,
+ status: 'active',
+ lastRefresh: new Date().toISOString(),
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString()
+ }
+
+ // 存储代理配置(如果提供)
+ if (proxy?.enabled) {
+ accountData.proxy = {
+ type: proxy.type,
+ host: proxy.host,
+ port: proxy.port,
+ username: proxy.username || null,
+ password: proxy.password || null
+ }
+ }
+
+ // 保存到 Redis
+ const accountId = await redis.setOpenAiAccount(id, accountData)
+
+ logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${accountId})`)
+
+ return res.json({
+ success: true,
+ data: {
+ id: accountId,
+ ...accountData
+ }
+ })
+ } catch (error) {
+ logger.error('创建 OpenAI 账户失败:', error)
+ return res.status(500).json({
+ success: false,
+ message: '创建账户失败',
+ error: error.message
+ })
+ }
+})
+
+// 更新 OpenAI 账户
+router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) =>
+ //TODO:
+ res.json({
+ success: true
+ })
+)
+
+// 删除 OpenAI 账户
+router.delete('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
+ try {
+ const { id } = req.params
+
+ const account = await redis.getOpenAiAccount(id)
+ if (!account) {
+ return res.status(404).json({
+ success: false,
+ message: '账户不存在'
+ })
+ }
+
+ await redis.deleteOpenAiAccount(id)
+
+ logger.success(`✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id})`)
+
+ return res.json({
+ success: true,
+ message: '账户删除成功'
+ })
+ } catch (error) {
+ logger.error('删除 OpenAI 账户失败:', error)
+ return res.status(500).json({
+ success: false,
+ message: '删除账户失败',
+ error: error.message
+ })
+ }
+})
+
+// 切换 OpenAI 账户状态
+router.put('/openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
+ try {
+ const { id } = req.params
+
+ const account = await redis.getOpenAiAccount(id)
+ if (!account) {
+ return res.status(404).json({
+ success: false,
+ message: '账户不存在'
+ })
+ }
+
+ // 切换启用状态
+ account.enabled = !account.enabled
+ account.updatedAt = new Date().toISOString()
+
+ // TODO: 更新方法
+ // await redis.updateOpenAiAccount(id, account)
+
+ logger.success(
+ `✅ ${account.enabled ? '启用' : '禁用'} OpenAI 账户: ${account.name} (ID: ${id})`
+ )
+
+ return res.json({
+ success: true,
+ data: account
+ })
+ } catch (error) {
+ logger.error('切换 OpenAI 账户状态失败:', error)
+ return res.status(500).json({
+ success: false,
+ message: '切换账户状态失败',
+ error: error.message
+ })
+ }
+})
+
module.exports = router
diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js
index 20cdca13..87416f79 100644
--- a/src/routes/geminiRoutes.js
+++ b/src/routes/geminiRoutes.js
@@ -319,7 +319,6 @@ async function handleLoadCodeAssist(req, res) {
requestedModel
)
const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId)
- logger.info(`accessToken: ${accessToken}`)
const { metadata, cloudaicompanionProject } = req.body
diff --git a/web/admin-spa/index.html b/web/admin-spa/index.html
index 789db5a2..27732008 100644
--- a/web/admin-spa/index.html
+++ b/web/admin-spa/index.html
@@ -1,26 +1,29 @@
+
Claude Relay Service - 管理后台
-
+
-
+
-
+
+
+
\ No newline at end of file
diff --git a/web/admin-spa/src/assets/styles/components.css b/web/admin-spa/src/assets/styles/components.css
index bb6368ab..36532d32 100644
--- a/web/admin-spa/src/assets/styles/components.css
+++ b/web/admin-spa/src/assets/styles/components.css
@@ -481,3 +481,10 @@
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
+
+.fa-openai {
+ width: 16px;
+ height: 16px;
+ background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNjQwIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIHY3LjAuMCBieSBAZm9udGF3ZXNvbWUgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbSBMaWNlbnNlIC0gaHR0cHM6Ly9mb250YXdlc29tZS5jb20vbGljZW5zZS9mcmVlIENvcHlyaWdodCAyMDI1IEZvbnRpY29ucywgSW5jLi0tPjxwYXRoIGQ9Ik0yNjAuNCAyNDkuOHYtNDguNmMwLTQuMSAxLjUtNy4yIDUuMS05LjJsOTcuOC01Ni4zYzEzLjMtNy43IDI5LjItMTEuMyA0NS42LTExLjMgNjEuNCAwIDEwMC40IDQ3LjYgMTAwLjQgOTguMyAwIDMuNiAwIDcuNy0uNSAxMS44bC0xMDEuNS01OS40Yy02LjEtMy42LTEyLjMtMy42LTE4LjQgMGwtMTI4LjUgNzQuN3ptMjI4LjMgMTg5LjRWMzIzYzAtNy4yLTMuMS0xMi4zLTkuMi0xNS45TDM1MSAyMzIuNGw0Mi0yNC4xYzMuNi0yIDYuNy0yIDEwLjIgMGw5Ny44IDU2LjRjMjguMiAxNi40IDQ3LjEgNTEuMiA0Ny4xIDg1IDAgMzguOS0yMyA3NC44LTU5LjQgODkuNnpNMjMwLjIgMzM2LjhsLTQyLTI0LjZjLTMuNi0yLTUuMS01LjEtNS4xLTkuMlYxOTAuNGMwLTU0LjggNDItOTYuMyA5OC44LTk2LjMgMjEuNSAwIDQxLjUgNy4yIDU4LjQgMjBsLTEwMC45IDU4LjRjLTYuMSAzLjYtOS4yIDguNy05LjIgMTUuOXYxNDguNXptOTAuNCA1Mi4ybC02MC4yLTMzLjh2LTcxLjdsNjAuMi0zMy44IDYwLjIgMzMuOHY3MS43TDMyMC42IDM4OXptMzguNyAxNTUuN2MtMjEuNSAwLTQxLjUtNy4yLTU4LjQtMjBsMTAwLjktNTguNGM2LjEtMy42IDkuMi04LjcgOS4yLTE1LjlWMzAxLjlsNDIuNSAyNC42YzMuNiAyIDUuMSA1LjEgNS4xIDkuMnYxMTIuNmMwIDU0LjgtNDIuNSA5Ni4zLTk5LjMgOTYuM3pNMjM3LjggNDMwLjVsLTk3LjctNTYuM0MxMTEuOSAzNTcuOCA5MyAzMjMgOTMgMjg5LjJjMC0zOS40IDIzLjYtNzQuOCA1OS45LTg5LjZ2MTE2LjdjMCA3LjIgMy4xIDEyLjMgOS4yIDE1LjlsMTI4IDc0LjItNDIgMjQuMWMtMy42IDItNi43IDItMTAuMiAwem0tNS42IDg0Yy01Ny45IDAtMTAwLjQtNDMuNS0xMDAuNC05Ny4zIDAtNC4xLjUtOC4yIDEtMTIuM2wxMDAuOSA1OC40YzYuMSAzLjYgMTIuMyAzLjYgMTguNCAwbDEyOC41LTc0LjJ2NDguNmMwIDQuMS0xLjUgNy4yLTUuMSA5LjJsLTk3LjggNTYuM2MtMTMuMyA3LjctMjkuMiAxMS4zLTQ1LjYgMTEuM3ptMTI3IDYwLjljNjIgMCAxMTMuNy00NCAxMjUuNC0xMDIuNCA1Ny4zLTE0LjkgOTQuMi02OC42IDk0LjItMTIzLjQgMC0zNS44LTE1LjQtNzAuNy00My05NS43IDIuNi0xMC44IDQuMS0yMS41IDQuMS0zMi4zIDAtNzMuMi01OS40LTEyOC0xMjgtMTI4LTEzLjggMC0yNy4xIDItNDAuNCA2LjctMjMtMjIuNS01NC44LTM2LjktODkuNi0zNi45LTYyIDAtMTEzLjcgNDQtMTI1LjQgMTAyLjQtNTcuMyAxNC44LTk0LjIgNjguNi05NC4yIDEyMy40IDAgMzUuOCAxNS40IDcwLjcgNDMgOTUuNy0yLjYgMTAuOC00LjEgMjEuNS00LjEgMzIuMyAwIDczLjIgNTkuNCAxMjggMTI4IDEyOCAxMy44IDAgMjcuMS0yIDQwLjQtNi43IDIzIDIyLjUgNTQuOCAzNi45IDg5LjYgMzYuOXoiLz48L3N2Zz4=)
+ no-repeat center/100%;
+}
diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue
index 68906886..52146ceb 100644
--- a/web/admin-spa/src/components/accounts/AccountForm.vue
+++ b/web/admin-spa/src/components/accounts/AccountForm.vue
@@ -77,6 +77,10 @@
Gemini
+
+
+ 请输入有效的 OpenAI Access Token。如果您有 Refresh
+ Token,建议也一并填写以支持自动刷新。
+
@@ -587,6 +595,10 @@
>
文件中的凭证。
+
+ 请从已登录 OpenAI 账户的机器上获取认证凭证, 或通过 OAuth 授权流程获取 Access
+ Token。
+
💡 如果未填写 Refresh Token,Token 过期后需要手动更新。
@@ -1580,11 +1592,16 @@ const handleOAuthSuccess = async (tokenInfo) => {
if (form.value.projectId) {
data.projectId = form.value.projectId
}
+ } else if (form.value.platform === 'openai') {
+ data.openaiOauth = tokenInfo.tokens || tokenInfo
+ data.accountInfo = tokenInfo.accountInfo
}
let result
if (form.value.platform === 'claude') {
result = await accountsStore.createClaudeAccount(data)
+ } else if (form.value.platform === 'openai') {
+ result = await accountsStore.createOpenAIAccount(data)
} else {
result = await accountsStore.createGeminiAccount(data)
}
@@ -1734,6 +1751,8 @@ const createAccount = async () => {
result = await accountsStore.createClaudeConsoleAccount(data)
} else if (form.value.platform === 'bedrock') {
result = await accountsStore.createBedrockAccount(data)
+ } else if (form.value.platform === 'openai') {
+ result = await accountsStore.createOpenAIAccount(data)
} else {
result = await accountsStore.createGeminiAccount(data)
}
@@ -1882,6 +1901,8 @@ const updateAccount = async () => {
await accountsStore.updateClaudeConsoleAccount(props.account.id, data)
} else if (props.account.platform === 'bedrock') {
await accountsStore.updateBedrockAccount(props.account.id, data)
+ } else if (props.account.platform === 'openai') {
+ await accountsStore.updateOpenAIAccount(props.account.id, data)
} else {
await accountsStore.updateGeminiAccount(props.account.id, data)
}
diff --git a/web/admin-spa/src/components/accounts/OAuthFlow.vue b/web/admin-spa/src/components/accounts/OAuthFlow.vue
index f82c7e37..fa798518 100644
--- a/web/admin-spa/src/components/accounts/OAuthFlow.vue
+++ b/web/admin-spa/src/components/accounts/OAuthFlow.vue
@@ -251,6 +251,131 @@
+
+
+
+
+
+
+
+
+
OpenAI 账户授权
+
请按照以下步骤完成 OpenAI 账户的授权:
+
+
+
+
+
+
+ 1
+
+
+
点击下方按钮生成授权链接
+
+
+
+
+
+
+
+
+
+
+ 2
+
+
+
在浏览器中打开链接并完成授权
+
+ 请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。
+
+
+
+
+ 注意:如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
+
+
+
+
+
+
+
+
+
+
+ 3
+
+
+
输入 Authorization Code
+
+ 授权完成后,页面会显示一个
+ Authorization Code,请将其复制并粘贴到下方输入框:
+
+
+
+
+
+
+
+
+ 请粘贴从OpenAI页面复制的Authorization Code
+
+
+
+
+
+
+
+
+
+
+
+
{
apiClient.get('/admin/claude-accounts', { params }),
apiClient.get('/admin/claude-console-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 })
)
} else {
// 只请求指定平台,其他平台设为null占位
@@ -945,7 +956,8 @@ const loadAccounts = async (forceReload = false) => {
// 加载分组成员关系(需要在分组数据加载完成后)
await loadGroupMembers(forceReload)
- const [claudeData, claudeConsoleData, bedrockData, geminiData] = await Promise.all(requests)
+ const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData] =
+ await Promise.all(requests)
const allAccounts = []
@@ -991,6 +1003,13 @@ const loadAccounts = async (forceReload = false) => {
})
allAccounts.push(...geminiAccounts)
}
+ if (openaiData.success) {
+ const openaiAccounts = (openaiData.data || []).map((acc) => {
+ const groupInfo = accountGroupMap.value.get(acc.id) || null
+ return { ...acc, platform: 'openai', boundApiKeysCount: 0, groupInfo }
+ })
+ allAccounts.push(...openaiAccounts)
+ }
accounts.value = allAccounts
} catch (error) {
@@ -1214,6 +1233,8 @@ const deleteAccount = async (account) => {
endpoint = `/admin/claude-console-accounts/${account.id}`
} else if (account.platform === 'bedrock') {
endpoint = `/admin/bedrock-accounts/${account.id}`
+ } else if (account.platform === 'openai') {
+ endpoint = `/admin/openai-accounts/${account.id}`
} else {
endpoint = `/admin/gemini-accounts/${account.id}`
}
From 5d9c8216ac66d0fe8d330dbaba4c3329f8e78d90 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=83=E7=BE=BD?=
Date: Sun, 10 Aug 2025 19:09:56 +0900
Subject: [PATCH 2/2] chore: commit all changes
---
.eslintrc.cjs | 3 +-
src/app.js | 5 +-
src/routes/openaiRoutes.js | 119 +++++++++++++++++++++++++++++++++++++
3 files changed, 122 insertions(+), 5 deletions(-)
create mode 100644 src/routes/openaiRoutes.js
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index f8c79f9c..30281309 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -14,6 +14,7 @@ module.exports = {
rules: {
// 基础规则
'no-console': 'off', // Node.js 项目允许 console
+ 'consistent-return': 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'prettier/prettier': 'error',
@@ -33,7 +34,6 @@ module.exports = {
// 代码质量
eqeqeq: ['error', 'always'],
curly: ['error', 'all'],
- 'consistent-return': 'error',
'no-throw-literal': 'error',
'prefer-promise-reject-errors': 'error',
@@ -43,7 +43,6 @@ module.exports = {
'template-curly-spacing': ['error', 'never'],
// Node.js 特定规则
- 'no-process-exit': 'error',
'no-path-concat': 'error',
'handle-callback-err': 'error',
diff --git a/src/app.js b/src/app.js
index f33b8a63..9af1a91e 100644
--- a/src/app.js
+++ b/src/app.js
@@ -19,6 +19,7 @@ const apiStatsRoutes = require('./routes/apiStats')
const geminiRoutes = require('./routes/geminiRoutes')
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
+const openaiRoutes = require('./routes/openaiRoutes')
// Import middleware
const {
@@ -234,6 +235,7 @@ class Application {
this.app.use('/gemini', geminiRoutes)
this.app.use('/openai/gemini', openaiGeminiRoutes)
this.app.use('/openai/claude', openaiClaudeRoutes)
+ this.app.use('/openai', openaiRoutes)
// 🏠 根路径重定向到新版管理界面
this.app.get('/', (req, res) => {
@@ -257,9 +259,6 @@ class Application {
let version = process.env.APP_VERSION || process.env.VERSION
if (!version) {
try {
- // 尝试从VERSION文件读取
- const fs = require('fs')
- const path = require('path')
const versionFile = path.join(__dirname, '..', 'VERSION')
if (fs.existsSync(versionFile)) {
version = fs.readFileSync(versionFile, 'utf8').trim()
diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js
new file mode 100644
index 00000000..3751121f
--- /dev/null
+++ b/src/routes/openaiRoutes.js
@@ -0,0 +1,119 @@
+const express = require('express')
+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')
+
+// 选择一个可用的 OpenAI 账户,并返回解密后的 accessToken
+async function getOpenAIAuthToken() {
+ try {
+ const accounts = await redis.getAllOpenAIAccounts()
+ if (!accounts || accounts.length === 0) {
+ throw new Error('No OpenAI accounts found in Redis')
+ }
+
+ // 简单选择策略:选择第一个启用并活跃的账户
+ 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 accessToken = claudeAccountService._decryptSensitiveData(candidate.accessToken)
+ if (!accessToken) {
+ throw new Error('Failed to decrypt OpenAI accessToken')
+ }
+ return { accessToken, accountId: candidate.accountId || 'unknown' }
+ } catch (error) {
+ logger.error('Failed to get OpenAI auth token from Redis:', error)
+ throw error
+ }
+}
+
+router.post('/responses', authenticateApiKey, async (req, res) => {
+ let upstream = null
+ try {
+ const { accessToken, accountId } = await getOpenAIAuthToken()
+ // 基于白名单构造上游所需的请求头,确保键为小写且值受控
+ const incoming = req.headers || {}
+
+ const allowedKeys = ['version', 'openai-beta', 'session_id']
+
+ const headers = {}
+ for (const key of allowedKeys) {
+ if (incoming[key] !== undefined) {
+ headers[key] = incoming[key]
+ }
+ }
+
+ // 覆盖或新增必要头部
+ headers['authorization'] = `Bearer ${accessToken}`
+ headers['chatgpt-account-id'] = accountId
+ headers['host'] = 'chatgpt.com'
+ headers['accept'] = 'text/event-stream'
+ 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
+ })
+ 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')
+
+ // 透传关键诊断头,避免传递不安全或与传输相关的头
+ const passThroughHeaderKeys = ['openai-version', 'x-request-id', 'openai-processing-ms']
+ for (const key of passThroughHeaderKeys) {
+ const val = upstream.headers?.[key]
+ if (val !== undefined) {
+ res.setHeader(key, val)
+ }
+ }
+
+ // 立即刷新响应头,开始 SSE
+ if (typeof res.flushHeaders === 'function') {
+ res.flushHeaders()
+ }
+
+ upstream.data.on('error', (err) => {
+ logger.error('Upstream stream error:', err)
+ if (!res.headersSent) {
+ res.status(502).json({ error: { message: 'Upstream stream error' } })
+ } else {
+ res.end()
+ }
+ })
+
+ upstream.data.pipe(res)
+
+ // 客户端断开时清理上游流
+ const cleanup = () => {
+ try {
+ upstream.data?.unpipe?.(res)
+ upstream.data?.destroy?.()
+ } catch (_) {
+ //
+ }
+ }
+ req.on('close', cleanup)
+ req.on('aborted', cleanup)
+ } catch (error) {
+ logger.error('Proxy to ChatGPT codex/responses failed:', error)
+ const status = error.response?.status || 500
+ const message = error.response?.data || error.message || 'Internal server error'
+ if (!res.headersSent) {
+ res.status(status).json({ error: { message } })
+ }
+ }
+})
+
+module.exports = router