diff --git a/package-lock.json b/package-lock.json
index 9e9894a7..63c3e441 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
+ "google-auth-library": "^10.1.0",
"helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2",
"inquirer": "^9.2.15",
@@ -1814,6 +1815,15 @@
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
+ "node_modules/bignumber.js": {
+ "version": "9.3.1",
+ "resolved": "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz",
+ "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1967,6 +1977,12 @@
"ieee754": "^1.1.13"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -2479,6 +2495,15 @@
"node": ">= 8"
}
},
+ "node_modules/data-uri-to-buffer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
@@ -2639,6 +2664,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
@@ -3065,6 +3099,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/external-editor": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/external-editor/-/external-editor-3.1.0.tgz",
@@ -3148,6 +3188,29 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
+ "node_modules/fetch-blob": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz",
+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ },
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ }
+ },
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -3282,6 +3345,18 @@
"node": ">= 6"
}
},
+ "node_modules/formdata-polyfill": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+ "license": "MIT",
+ "dependencies": {
+ "fetch-blob": "^3.1.2"
+ },
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/formidable": {
"version": "2.1.5",
"resolved": "https://registry.npmmirror.com/formidable/-/formidable-2.1.5.tgz",
@@ -3347,6 +3422,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gaxios": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmmirror.com/gaxios/-/gaxios-7.1.1.tgz",
+ "integrity": "sha512-Odju3uBUJyVCkW64nLD4wKLhbh93bh6vIg/ZIXkWiLPBrdgtc65+tls/qml+un3pr6JqYVFDZbbmLDQT68rTOQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "extend": "^3.0.2",
+ "https-proxy-agent": "^7.0.1",
+ "node-fetch": "^3.3.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/gcp-metadata": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-7.0.1.tgz",
+ "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "gaxios": "^7.0.0",
+ "google-logging-utils": "^1.0.0",
+ "json-bigint": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3478,6 +3581,33 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/google-auth-library": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-10.1.0.tgz",
+ "integrity": "sha512-GspVjZj1RbyRWpQ9FbAXMKjFGzZwDKnUHi66JJ+tcjcu5/xYAP1pdlWotCuIkMwjfVsxxDvsGZXGLzRt72D0sQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "ecdsa-sig-formatter": "^1.0.11",
+ "gaxios": "^7.0.0",
+ "gcp-metadata": "^7.0.0",
+ "google-logging-utils": "^1.0.0",
+ "gtoken": "^8.0.0",
+ "jws": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/google-logging-utils": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/google-logging-utils/-/google-logging-utils-1.1.1.tgz",
+ "integrity": "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
@@ -3504,6 +3634,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/gtoken": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmmirror.com/gtoken/-/gtoken-8.0.0.tgz",
+ "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
+ "license": "MIT",
+ "dependencies": {
+ "gaxios": "^7.0.0",
+ "jws": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
@@ -4718,6 +4861,15 @@
"node": ">=6"
}
},
+ "node_modules/json-bigint": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz",
+ "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bignumber.js": "^9.0.0"
+ }
+ },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -4759,6 +4911,27 @@
"node": ">=6"
}
},
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.0.tgz",
+ "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.0",
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
@@ -5134,6 +5307,44 @@
"node": ">= 0.6"
}
},
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+ "license": "MIT",
+ "dependencies": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
+ }
+ },
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz",
@@ -6933,6 +7144,15 @@
"defaults": "^1.0.3"
}
},
+ "node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
diff --git a/package.json b/package.json
index 38c88325..8e15b483 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "claude-relay-service",
"version": "1.0.0",
- "description": "Claude Code API relay service with multi-account management and API key authentication",
+ "description": "Claude Code API relay service with multi-account management, OpenAI compatibility, and API key authentication",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
@@ -36,6 +36,7 @@
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
+ "google-auth-library": "^10.1.0",
"helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2",
"inquirer": "^9.2.15",
diff --git a/src/app.js b/src/app.js
index 754194f0..d64f8223 100644
--- a/src/app.js
+++ b/src/app.js
@@ -16,6 +16,9 @@ const pricingService = require('./services/pricingService');
const apiRoutes = require('./routes/api');
const adminRoutes = require('./routes/admin');
const webRoutes = require('./routes/web');
+const geminiRoutes = require('./routes/geminiRoutes');
+const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes');
+const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes');
// Import middleware
const {
@@ -97,6 +100,9 @@ class Application {
this.app.use('/api', apiRoutes);
this.app.use('/admin', adminRoutes);
this.app.use('/web', webRoutes);
+ this.app.use('/gemini', geminiRoutes);
+ this.app.use('/openai/gemini', openaiGeminiRoutes);
+ this.app.use('/openai/claude', openaiClaudeRoutes);
// 🏠 根路径重定向到管理界面
this.app.get('/', (req, res) => {
diff --git a/src/routes/admin.js b/src/routes/admin.js
index cf726bf2..f795ad48 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -1,6 +1,7 @@
const express = require('express');
const apiKeyService = require('../services/apiKeyService');
const claudeAccountService = require('../services/claudeAccountService');
+const geminiAccountService = require('../services/geminiAccountService');
const redis = require('../models/redis');
const { authenticateAdmin } = require('../middleware/auth');
const logger = require('../utils/logger');
@@ -32,6 +33,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
tokenLimit,
expiresAt,
claudeAccountId,
+ geminiAccountId,
+ permissions,
concurrencyLimit,
rateLimitWindow,
rateLimitRequests,
@@ -84,6 +87,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
tokenLimit,
expiresAt,
claudeAccountId,
+ geminiAccountId,
+ permissions,
concurrencyLimit,
rateLimitWindow,
rateLimitRequests,
@@ -103,7 +108,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params;
- const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, enableModelRestriction, restrictedModels } = req.body;
+ const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels } = req.body;
// 只允许更新指定字段
const updates = {};
@@ -141,6 +146,19 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.claudeAccountId = claudeAccountId || '';
}
+ if (geminiAccountId !== undefined) {
+ // 空字符串表示解绑,null或空字符串都设置为空字符串
+ updates.geminiAccountId = geminiAccountId || '';
+ }
+
+ if (permissions !== undefined) {
+ // 验证权限值
+ if (!['claude', 'gemini', 'all'].includes(permissions)) {
+ return res.status(400).json({ error: 'Invalid permissions value. Must be claude, gemini, or all' });
+ }
+ updates.permissions = permissions;
+ }
+
// 处理模型限制字段
if (enableModelRestriction !== undefined) {
if (typeof enableModelRestriction !== 'boolean') {
@@ -381,15 +399,189 @@ router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req
}
});
+// 🤖 Gemini 账户管理
+
+// 生成 Gemini OAuth 授权 URL
+router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
+ try {
+ const { state } = req.body;
+
+ // 构建 redirect_uri,使用当前服务的地址
+ const protocol = req.protocol;
+ const host = req.get('host');
+ const redirectUri = `${protocol}://${host}/web/auth_gemini`;
+
+ logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`);
+
+ const { authUrl, state: authState } = await geminiAccountService.generateAuthUrl(state, redirectUri);
+
+ // 创建 OAuth 会话
+ const sessionId = authState;
+ await redis.setOAuthSession(sessionId, {
+ state: authState,
+ type: 'gemini',
+ redirectUri, // 保存 redirect_uri 用于 token 交换
+ createdAt: new Date().toISOString()
+ });
+
+ logger.info(`Generated Gemini OAuth URL with session: ${sessionId}`);
+ res.json({
+ success: true,
+ data: {
+ authUrl,
+ sessionId
+ }
+ });
+ } catch (error) {
+ logger.error('❌ Failed to generate Gemini auth URL:', error);
+ res.status(500).json({ error: 'Failed to generate auth URL', message: error.message });
+ }
+});
+
+// 轮询 Gemini OAuth 授权状态
+router.post('/gemini-accounts/poll-auth-status', authenticateAdmin, async (req, res) => {
+ try {
+ const { sessionId } = req.body;
+
+ if (!sessionId) {
+ return res.status(400).json({ error: 'Session ID is required' });
+ }
+
+ const result = await geminiAccountService.pollAuthorizationStatus(sessionId);
+
+ if (result.success) {
+ logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`);
+ res.json({ success: true, data: { tokens: result.tokens } });
+ } else {
+ res.json({ success: false, error: result.error });
+ }
+ } catch (error) {
+ logger.error('❌ Failed to poll Gemini auth status:', error);
+ res.status(500).json({ error: 'Failed to poll auth status', message: error.message });
+ }
+});
+
+// 交换 Gemini 授权码
+router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res) => {
+ try {
+ const { code, sessionId } = req.body;
+
+ if (!code) {
+ return res.status(400).json({ error: 'Authorization code is required' });
+ }
+
+ // 如果提供了 sessionId,从会话中获取 redirect_uri
+ let redirectUri = null;
+ if (sessionId) {
+ const oauthSession = await redis.getOAuthSession(sessionId);
+ if (oauthSession && oauthSession.redirectUri) {
+ redirectUri = oauthSession.redirectUri;
+ logger.info(`Using redirect_uri from session: ${redirectUri}`);
+ }
+ }
+
+ const tokens = await geminiAccountService.exchangeCodeForTokens(code, redirectUri);
+
+ // 清理 OAuth 会话
+ if (sessionId) {
+ await redis.deleteOAuthSession(sessionId);
+ }
+
+ logger.success('✅ Successfully exchanged Gemini authorization code');
+ res.json({ success: true, data: { tokens } });
+ } catch (error) {
+ logger.error('❌ Failed to exchange Gemini authorization code:', error);
+ res.status(500).json({ error: 'Failed to exchange code', message: error.message });
+ }
+});
+
+// 获取所有 Gemini 账户
+router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
+ try {
+ const accounts = await geminiAccountService.getAllAccounts();
+ res.json({ success: true, data: accounts });
+ } catch (error) {
+ logger.error('❌ Failed to get Gemini accounts:', error);
+ res.status(500).json({ error: 'Failed to get accounts', message: error.message });
+ }
+});
+
+// 创建新的 Gemini 账户
+router.post('/gemini-accounts', authenticateAdmin, async (req, res) => {
+ try {
+ const accountData = req.body;
+
+ // 输入验证
+ if (!accountData.name) {
+ return res.status(400).json({ error: 'Account name is required' });
+ }
+
+ const newAccount = await geminiAccountService.createAccount(accountData);
+
+ logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`);
+ res.json({ success: true, data: newAccount });
+ } catch (error) {
+ logger.error('❌ Failed to create Gemini account:', error);
+ res.status(500).json({ error: 'Failed to create account', message: error.message });
+ }
+});
+
+// 更新 Gemini 账户
+router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => {
+ try {
+ const { accountId } = req.params;
+ const updates = req.body;
+
+ const updatedAccount = await geminiAccountService.updateAccount(accountId, updates);
+
+ logger.success(`📝 Admin updated Gemini account: ${accountId}`);
+ res.json({ success: true, data: updatedAccount });
+ } catch (error) {
+ logger.error('❌ Failed to update Gemini account:', error);
+ res.status(500).json({ error: 'Failed to update account', message: error.message });
+ }
+});
+
+// 删除 Gemini 账户
+router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => {
+ try {
+ const { accountId } = req.params;
+
+ await geminiAccountService.deleteAccount(accountId);
+
+ logger.success(`🗑️ Admin deleted Gemini account: ${accountId}`);
+ res.json({ success: true, message: 'Gemini account deleted successfully' });
+ } catch (error) {
+ logger.error('❌ Failed to delete Gemini account:', error);
+ res.status(500).json({ error: 'Failed to delete account', message: error.message });
+ }
+});
+
+// 刷新 Gemini 账户 token
+router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => {
+ try {
+ const { accountId } = req.params;
+
+ const result = await geminiAccountService.refreshAccountToken(accountId);
+
+ logger.success(`🔄 Admin refreshed token for Gemini account: ${accountId}`);
+ res.json({ success: true, data: result });
+ } catch (error) {
+ logger.error('❌ Failed to refresh Gemini account token:', error);
+ res.status(500).json({ error: 'Failed to refresh token', message: error.message });
+ }
+});
+
// 📊 系统统计
// 获取系统概览
router.get('/dashboard', authenticateAdmin, async (req, res) => {
try {
- const [, apiKeys, accounts, todayStats, systemAverages] = await Promise.all([
+ const [, apiKeys, claudeAccounts, geminiAccounts, todayStats, systemAverages] = await Promise.all([
redis.getSystemStats(),
apiKeyService.getAllApiKeys(),
claudeAccountService.getAllAccounts(),
+ geminiAccountService.getAllAccounts(),
redis.getTodayStats(),
redis.getSystemAverages()
]);
@@ -404,16 +596,21 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
const totalAllTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.allTokens || 0), 0);
const activeApiKeys = apiKeys.filter(key => key.isActive).length;
- const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active').length;
- const rateLimitedAccounts = accounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length;
+ const activeClaudeAccounts = claudeAccounts.filter(acc => acc.isActive && acc.status === 'active').length;
+ const rateLimitedClaudeAccounts = claudeAccounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length;
+ const activeGeminiAccounts = geminiAccounts.filter(acc => acc.isActive && acc.status === 'active').length;
+ const rateLimitedGeminiAccounts = geminiAccounts.filter(acc => acc.rateLimitStatus === 'limited').length;
const dashboard = {
overview: {
totalApiKeys: apiKeys.length,
activeApiKeys,
- totalClaudeAccounts: accounts.length,
- activeClaudeAccounts: activeAccounts,
- rateLimitedClaudeAccounts: rateLimitedAccounts,
+ totalClaudeAccounts: claudeAccounts.length,
+ activeClaudeAccounts: activeClaudeAccounts,
+ rateLimitedClaudeAccounts: rateLimitedClaudeAccounts,
+ totalGeminiAccounts: geminiAccounts.length,
+ activeGeminiAccounts: activeGeminiAccounts,
+ rateLimitedGeminiAccounts: rateLimitedGeminiAccounts,
totalTokensUsed,
totalRequestsUsed,
totalInputTokensUsed,
@@ -437,7 +634,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
},
systemHealth: {
redisConnected: redis.isConnected,
- claudeAccountsHealthy: activeAccounts > 0,
+ claudeAccountsHealthy: activeClaudeAccounts > 0,
+ geminiAccountsHealthy: activeGeminiAccounts > 0,
uptime: process.uptime()
}
};
@@ -1072,7 +1270,8 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
hourData.apiKeys[apiKeyId] = {
name: apiKeyMap.get(apiKeyId).name,
- tokens: totalTokens
+ tokens: totalTokens,
+ requests: parseInt(data.requests) || 0
};
}
}
@@ -1116,7 +1315,8 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
dayData.apiKeys[apiKeyId] = {
name: apiKeyMap.get(apiKeyId).name,
- tokens: totalTokens
+ tokens: totalTokens,
+ requests: parseInt(data.requests) || 0
};
}
}
diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js
new file mode 100644
index 00000000..91618d16
--- /dev/null
+++ b/src/routes/geminiRoutes.js
@@ -0,0 +1,275 @@
+const express = require('express');
+const router = express.Router();
+const logger = require('../utils/logger');
+const { authenticateApiKey } = require('../middleware/auth');
+const geminiAccountService = require('../services/geminiAccountService');
+const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService');
+const crypto = require('crypto');
+
+// 生成会话哈希
+function generateSessionHash(req) {
+ const sessionData = [
+ req.headers['user-agent'],
+ req.ip,
+ req.headers['x-api-key']?.substring(0, 10)
+ ].filter(Boolean).join(':');
+
+ return crypto.createHash('sha256').update(sessionData).digest('hex');
+}
+
+// 检查 API Key 权限
+function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
+ const permissions = apiKeyData.permissions || 'all';
+ return permissions === 'all' || permissions === requiredPermission;
+}
+
+// Gemini 消息处理端点
+router.post('/messages', authenticateApiKey, async (req, res) => {
+ const startTime = Date.now();
+ let abortController = null;
+
+ try {
+ const apiKeyData = req.apiKeyData;
+
+ // 检查权限
+ if (!checkPermissions(apiKeyData, 'gemini')) {
+ return res.status(403).json({
+ error: {
+ message: 'This API key does not have permission to access Gemini',
+ type: 'permission_denied'
+ }
+ });
+ }
+
+ // 提取请求参数
+ const {
+ messages,
+ model = 'gemini-2.0-flash-exp',
+ temperature = 0.7,
+ max_tokens = 4096,
+ stream = false
+ } = req.body;
+
+ // 验证必需参数
+ if (!messages || !Array.isArray(messages) || messages.length === 0) {
+ return res.status(400).json({
+ error: {
+ message: 'Messages array is required',
+ type: 'invalid_request_error'
+ }
+ });
+ }
+
+ // 生成会话哈希用于粘性会话
+ const sessionHash = generateSessionHash(req);
+
+ // 选择可用的 Gemini 账户
+ const account = await geminiAccountService.selectAvailableAccount(
+ apiKeyData.id,
+ sessionHash
+ );
+
+ if (!account) {
+ return res.status(503).json({
+ error: {
+ message: 'No available Gemini accounts',
+ type: 'service_unavailable'
+ }
+ });
+ }
+
+ logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`);
+
+ // 标记账户被使用
+ await geminiAccountService.markAccountUsed(account.id);
+
+ // 创建中止控制器
+ abortController = new AbortController();
+
+ // 处理客户端断开连接
+ req.on('close', () => {
+ if (abortController && !abortController.signal.aborted) {
+ logger.info('Client disconnected, aborting Gemini request');
+ abortController.abort();
+ }
+ });
+
+ // 发送请求到 Gemini
+ const geminiResponse = await sendGeminiRequest({
+ messages,
+ model,
+ temperature,
+ maxTokens: max_tokens,
+ stream,
+ accessToken: account.accessToken,
+ proxy: account.proxy,
+ apiKeyId: apiKeyData.id,
+ signal: abortController.signal,
+ projectId: account.projectId
+ });
+
+ if (stream) {
+ // 设置流式响应头
+ res.setHeader('Content-Type', 'text/event-stream');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('Connection', 'keep-alive');
+ res.setHeader('X-Accel-Buffering', 'no');
+
+ // 流式传输响应
+ for await (const chunk of geminiResponse) {
+ if (abortController.signal.aborted) {
+ break;
+ }
+ res.write(chunk);
+ }
+
+ res.end();
+ } else {
+ // 非流式响应
+ res.json(geminiResponse);
+ }
+
+ const duration = Date.now() - startTime;
+ logger.info(`Gemini request completed in ${duration}ms`);
+
+ } catch (error) {
+ logger.error('Gemini request error:', error);
+
+ // 处理速率限制
+ if (error.status === 429) {
+ if (req.apiKeyData && req.account) {
+ await geminiAccountService.setAccountRateLimited(req.account.id, true);
+ }
+ }
+
+ // 返回错误响应
+ const status = error.status || 500;
+ const errorResponse = {
+ error: error.error || {
+ message: error.message || 'Internal server error',
+ type: 'api_error'
+ }
+ };
+
+ res.status(status).json(errorResponse);
+ } finally {
+ // 清理资源
+ if (abortController) {
+ abortController = null;
+ }
+ }
+});
+
+// 获取可用模型列表
+router.get('/models', authenticateApiKey, async (req, res) => {
+ try {
+ const apiKeyData = req.apiKeyData;
+
+ // 检查权限
+ if (!checkPermissions(apiKeyData, 'gemini')) {
+ return res.status(403).json({
+ error: {
+ message: 'This API key does not have permission to access Gemini',
+ type: 'permission_denied'
+ }
+ });
+ }
+
+ // 选择账户获取模型列表
+ const account = await geminiAccountService.selectAvailableAccount(apiKeyData.id);
+
+ if (!account) {
+ // 返回默认模型列表
+ return res.json({
+ object: 'list',
+ data: [
+ {
+ id: 'gemini-2.0-flash-exp',
+ object: 'model',
+ created: Date.now() / 1000,
+ owned_by: 'google'
+ }
+ ]
+ });
+ }
+
+ // 获取模型列表
+ const models = await getAvailableModels(account.accessToken, account.proxy);
+
+ res.json({
+ object: 'list',
+ data: models
+ });
+
+ } catch (error) {
+ logger.error('Failed to get Gemini models:', error);
+ res.status(500).json({
+ error: {
+ message: 'Failed to retrieve models',
+ type: 'api_error'
+ }
+ });
+ }
+});
+
+// 使用情况统计(与 Claude 共用)
+router.get('/usage', authenticateApiKey, async (req, res) => {
+ try {
+ const usage = req.apiKeyData.usage;
+
+ 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 信息(与 Claude 共用)
+router.get('/key-info', authenticateApiKey, async (req, res) => {
+ try {
+ const keyData = req.apiKeyData;
+
+ res.json({
+ id: keyData.id,
+ name: keyData.name,
+ 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
+ },
+ concurrency_limit: keyData.concurrencyLimit,
+ model_restrictions: {
+ enabled: keyData.enableModelRestriction,
+ models: keyData.restrictedModels
+ }
+ });
+ } 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;
\ No newline at end of file
diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js
new file mode 100644
index 00000000..90ca95cd
--- /dev/null
+++ b/src/routes/openaiClaudeRoutes.js
@@ -0,0 +1,380 @@
+/**
+ * OpenAI 兼容的 Claude API 路由
+ * 提供 OpenAI 格式的 API 接口,内部转发到 Claude
+ */
+
+const express = require('express');
+const router = express.Router();
+const fs = require('fs');
+const path = require('path');
+const logger = require('../utils/logger');
+const { authenticateApiKey } = require('../middleware/auth');
+const claudeRelayService = require('../services/claudeRelayService');
+const openaiToClaude = require('../services/openaiToClaude');
+const apiKeyService = require('../services/apiKeyService');
+
+// 加载模型定价数据
+let modelPricingData = {};
+try {
+ const pricingPath = path.join(__dirname, '../../data/model_pricing.json');
+ const pricingContent = fs.readFileSync(pricingPath, 'utf8');
+ modelPricingData = JSON.parse(pricingContent);
+ logger.info('✅ Model pricing data loaded successfully');
+} catch (error) {
+ logger.error('❌ Failed to load model pricing data:', error);
+}
+
+// 🔧 辅助函数:检查 API Key 权限
+function checkPermissions(apiKeyData, requiredPermission = 'claude') {
+ const permissions = apiKeyData.permissions || 'all';
+ return permissions === 'all' || permissions === requiredPermission;
+}
+
+// 🚀 OpenAI 兼容的聊天完成端点
+router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
+ const startTime = Date.now();
+ let abortController = null;
+
+ try {
+ const apiKeyData = req.apiKeyData;
+
+ // 检查权限
+ if (!checkPermissions(apiKeyData, 'claude')) {
+ return res.status(403).json({
+ error: {
+ message: 'This API key does not have permission to access Claude',
+ type: 'permission_denied',
+ code: 'permission_denied'
+ }
+ });
+ }
+
+ // 记录原始请求
+ logger.debug('📥 Received OpenAI format request:', {
+ model: req.body.model,
+ messageCount: req.body.messages?.length,
+ stream: req.body.stream,
+ maxTokens: req.body.max_tokens
+ });
+
+ // 转换 OpenAI 请求为 Claude 格式
+ const claudeRequest = openaiToClaude.convertRequest(req.body);
+
+ // 检查模型限制
+ if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
+ if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) {
+ return res.status(403).json({
+ error: {
+ message: `Model ${req.body.model} is not allowed for this API key`,
+ type: 'invalid_request_error',
+ code: 'model_not_allowed'
+ }
+ });
+ }
+ }
+
+ // 处理流式请求
+ if (claudeRequest.stream) {
+ logger.info(`🌊 Processing OpenAI stream request for model: ${req.body.model}`);
+
+ // 设置 SSE 响应头
+ res.setHeader('Content-Type', 'text/event-stream');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('Connection', 'keep-alive');
+ res.setHeader('X-Accel-Buffering', 'no');
+
+
+ // 创建中止控制器
+ abortController = new AbortController();
+
+ // 处理客户端断开
+ req.on('close', () => {
+ if (abortController && !abortController.signal.aborted) {
+ logger.info('🔌 Client disconnected, aborting Claude request');
+ abortController.abort();
+ }
+ });
+
+ // 使用转换后的响应流
+ await claudeRelayService.relayStreamRequestWithUsageCapture(
+ claudeRequest,
+ apiKeyData,
+ res,
+ req.headers,
+ (usage) => {
+ usageData = usage;
+ // 记录使用统计
+ if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
+ const inputTokens = usage.input_tokens || 0;
+ const outputTokens = usage.output_tokens || 0;
+ const cacheCreateTokens = usage.cache_creation_input_tokens || 0;
+ const cacheReadTokens = usage.cache_read_input_tokens || 0;
+ const model = usage.model || claudeRequest.model;
+
+ apiKeyService.recordUsage(
+ apiKeyData.id,
+ inputTokens,
+ outputTokens,
+ cacheCreateTokens,
+ cacheReadTokens,
+ model
+ ).catch(error => {
+ logger.error('❌ Failed to record usage:', error);
+ });
+ }
+ },
+ // 流转换器
+ (chunk) => {
+ return openaiToClaude.convertStreamChunk(chunk, req.body.model);
+ }
+ );
+
+ } else {
+ // 非流式请求
+ logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`);
+
+ // 发送请求到 Claude
+ const claudeResponse = await claudeRelayService.relayRequest(
+ claudeRequest,
+ apiKeyData,
+ req,
+ res,
+ req.headers
+ );
+
+ // 解析 Claude 响应
+ let claudeData;
+ try {
+ claudeData = JSON.parse(claudeResponse.body);
+ } catch (error) {
+ logger.error('❌ Failed to parse Claude response:', error);
+ return res.status(502).json({
+ error: {
+ message: 'Invalid response from Claude API',
+ type: 'api_error',
+ code: 'invalid_response'
+ }
+ });
+ }
+
+ // 处理错误响应
+ if (claudeResponse.statusCode >= 400) {
+ return res.status(claudeResponse.statusCode).json({
+ error: {
+ message: claudeData.error?.message || 'Claude API error',
+ type: claudeData.error?.type || 'api_error',
+ code: claudeData.error?.code || 'unknown_error'
+ }
+ });
+ }
+
+ // 转换为 OpenAI 格式
+ const openaiResponse = openaiToClaude.convertResponse(claudeData, req.body.model);
+
+ // 记录使用统计
+ if (claudeData.usage) {
+ const usage = claudeData.usage;
+ apiKeyService.recordUsage(
+ apiKeyData.id,
+ usage.input_tokens || 0,
+ usage.output_tokens || 0,
+ usage.cache_creation_input_tokens || 0,
+ usage.cache_read_input_tokens || 0,
+ claudeRequest.model
+ ).catch(error => {
+ logger.error('❌ Failed to record usage:', error);
+ });
+ }
+
+ // 返回 OpenAI 格式响应
+ res.json(openaiResponse);
+ }
+
+ const duration = Date.now() - startTime;
+ logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`);
+
+ } catch (error) {
+ logger.error('❌ OpenAI-Claude request error:', error);
+
+ const status = error.status || 500;
+ res.status(status).json({
+ error: {
+ message: error.message || 'Internal server error',
+ type: 'server_error',
+ code: 'internal_error'
+ }
+ });
+ } finally {
+ // 清理资源
+ if (abortController) {
+ abortController = null;
+ }
+ }
+});
+
+// 📋 OpenAI 兼容的模型列表端点
+router.get('/v1/models', authenticateApiKey, async (req, res) => {
+ try {
+ const apiKeyData = req.apiKeyData;
+
+ // 检查权限
+ if (!checkPermissions(apiKeyData, 'claude')) {
+ return res.status(403).json({
+ error: {
+ message: 'This API key does not have permission to access Claude',
+ type: 'permission_denied',
+ code: 'permission_denied'
+ }
+ });
+ }
+
+ // Claude 模型列表 - 只返回 opus-4 和 sonnet-4
+ let models = [
+ {
+ id: 'claude-opus-4-20250514',
+ object: 'model',
+ created: 1736726400, // 2025-01-13
+ owned_by: 'anthropic'
+ },
+ {
+ id: 'claude-sonnet-4-20250514',
+ object: 'model',
+ created: 1736726400, // 2025-01-13
+ owned_by: 'anthropic'
+ }
+ ];
+
+ // 如果启用了模型限制,过滤模型列表
+ if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
+ models = models.filter(model => apiKeyData.restrictedModels.includes(model.id));
+ }
+
+ res.json({
+ object: 'list',
+ data: models
+ });
+
+ } catch (error) {
+ logger.error('❌ Failed to get OpenAI-Claude models:', error);
+ res.status(500).json({
+ error: {
+ message: 'Failed to retrieve models',
+ type: 'server_error',
+ code: 'internal_error'
+ }
+ });
+ }
+});
+
+// 📄 OpenAI 兼容的模型详情端点
+router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
+ try {
+ const apiKeyData = req.apiKeyData;
+ const modelId = req.params.model;
+
+ // 检查权限
+ if (!checkPermissions(apiKeyData, 'claude')) {
+ return res.status(403).json({
+ error: {
+ message: 'This API key does not have permission to access Claude',
+ type: 'permission_denied',
+ code: 'permission_denied'
+ }
+ });
+ }
+
+ // 检查模型限制
+ if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
+ if (!apiKeyData.restrictedModels.includes(modelId)) {
+ return res.status(404).json({
+ error: {
+ message: `Model '${modelId}' not found`,
+ type: 'invalid_request_error',
+ code: 'model_not_found'
+ }
+ });
+ }
+ }
+
+ // 从 model_pricing.json 获取模型信息
+ const modelData = modelPricingData[modelId];
+
+ // 构建标准 OpenAI 格式的模型响应
+ let modelInfo;
+
+ if (modelData) {
+ // 如果在 pricing 文件中找到了模型
+ modelInfo = {
+ id: modelId,
+ object: 'model',
+ created: 1736726400, // 2025-01-13
+ owned_by: 'anthropic',
+ permission: [],
+ root: modelId,
+ parent: null
+ };
+ } else {
+ // 如果没找到,返回默认信息(但仍保持正确格式)
+ modelInfo = {
+ id: modelId,
+ object: 'model',
+ created: Math.floor(Date.now() / 1000),
+ owned_by: 'anthropic',
+ permission: [],
+ root: modelId,
+ parent: null
+ };
+ }
+
+ res.json(modelInfo);
+
+ } catch (error) {
+ logger.error('❌ Failed to get model details:', error);
+ res.status(500).json({
+ error: {
+ message: 'Failed to retrieve model details',
+ type: 'server_error',
+ code: 'internal_error'
+ }
+ });
+ }
+});
+
+// 🔧 OpenAI 兼容的 completions 端点(传统格式,转换为 chat 格式)
+router.post('/v1/completions', authenticateApiKey, async (req, res) => {
+ try {
+ // 将传统 completions 格式转换为 chat 格式
+ const chatRequest = {
+ model: req.body.model,
+ messages: [
+ {
+ role: 'user',
+ content: req.body.prompt
+ }
+ ],
+ max_tokens: req.body.max_tokens,
+ temperature: req.body.temperature,
+ top_p: req.body.top_p,
+ stream: req.body.stream,
+ stop: req.body.stop
+ };
+
+ // 使用 chat completions 处理
+ req.body = chatRequest;
+
+ // 调用 chat completions 端点
+ return router.handle(req, res);
+
+ } catch (error) {
+ logger.error('❌ OpenAI completions error:', error);
+ res.status(500).json({
+ error: {
+ message: 'Failed to process completion request',
+ type: 'server_error',
+ code: 'internal_error'
+ }
+ });
+ }
+});
+
+module.exports = router;
\ No newline at end of file
diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js
new file mode 100644
index 00000000..be061a80
--- /dev/null
+++ b/src/routes/openaiGeminiRoutes.js
@@ -0,0 +1,297 @@
+const express = require('express');
+const router = express.Router();
+const logger = require('../utils/logger');
+const { authenticateApiKey } = require('../middleware/auth');
+const geminiAccountService = require('../services/geminiAccountService');
+const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService');
+const crypto = require('crypto');
+
+// 生成会话哈希
+function generateSessionHash(req) {
+ const sessionData = [
+ req.headers['user-agent'],
+ req.ip,
+ req.headers['authorization']?.substring(0, 20)
+ ].filter(Boolean).join(':');
+
+ return crypto.createHash('sha256').update(sessionData).digest('hex');
+}
+
+// 检查 API Key 权限
+function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
+ const permissions = apiKeyData.permissions || 'all';
+ return permissions === 'all' || permissions === requiredPermission;
+}
+
+// OpenAI 兼容的聊天完成端点
+router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
+ const startTime = Date.now();
+ let abortController = null;
+
+ try {
+ const apiKeyData = req.apiKeyData;
+
+ // 检查权限
+ if (!checkPermissions(apiKeyData, 'gemini')) {
+ return res.status(403).json({
+ error: {
+ message: 'This API key does not have permission to access Gemini',
+ type: 'permission_denied',
+ code: 'permission_denied'
+ }
+ });
+ }
+
+ // 提取请求参数
+ const {
+ messages,
+ model = 'gemini-2.0-flash-exp',
+ temperature = 0.7,
+ max_tokens = 4096,
+ stream = false,
+ n = 1,
+ stop = null,
+ presence_penalty = 0,
+ frequency_penalty = 0,
+ logit_bias = null,
+ user = null
+ } = req.body;
+
+ // 验证必需参数
+ if (!messages || !Array.isArray(messages) || messages.length === 0) {
+ return res.status(400).json({
+ error: {
+ message: 'Messages array is required',
+ type: 'invalid_request_error',
+ code: 'invalid_request'
+ }
+ });
+ }
+
+ // 检查模型限制
+ if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
+ if (!apiKeyData.restrictedModels.includes(model)) {
+ return res.status(403).json({
+ error: {
+ message: `Model ${model} is not allowed for this API key`,
+ type: 'invalid_request_error',
+ code: 'model_not_allowed'
+ }
+ });
+ }
+ }
+
+ // 生成会话哈希用于粘性会话
+ const sessionHash = generateSessionHash(req);
+
+ // 选择可用的 Gemini 账户
+ const account = await geminiAccountService.selectAvailableAccount(
+ apiKeyData.id,
+ sessionHash
+ );
+
+ if (!account) {
+ return res.status(503).json({
+ error: {
+ message: 'No available Gemini accounts',
+ type: 'service_unavailable',
+ code: 'service_unavailable'
+ }
+ });
+ }
+
+ logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`);
+
+ // 标记账户被使用
+ await geminiAccountService.markAccountUsed(account.id);
+
+ // 创建中止控制器
+ abortController = new AbortController();
+
+ // 处理客户端断开连接
+ req.on('close', () => {
+ if (abortController && !abortController.signal.aborted) {
+ logger.info('Client disconnected, aborting Gemini request');
+ abortController.abort();
+ }
+ });
+
+ // 发送请求到 Gemini(已经返回 OpenAI 格式)
+ const geminiResponse = await sendGeminiRequest({
+ messages,
+ model,
+ temperature,
+ maxTokens: max_tokens,
+ stream,
+ accessToken: account.accessToken,
+ proxy: account.proxy,
+ apiKeyId: apiKeyData.id,
+ signal: abortController.signal,
+ projectId: account.projectId
+ });
+
+ if (stream) {
+ // 设置流式响应头
+ res.setHeader('Content-Type', 'text/event-stream');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('Connection', 'keep-alive');
+ res.setHeader('X-Accel-Buffering', 'no');
+
+ // 流式传输响应
+ for await (const chunk of geminiResponse) {
+ if (abortController.signal.aborted) {
+ break;
+ }
+ res.write(chunk);
+ }
+
+ res.end();
+ } else {
+ // 非流式响应
+ res.json(geminiResponse);
+ }
+
+ const duration = Date.now() - startTime;
+ logger.info(`OpenAI-Gemini request completed in ${duration}ms`);
+
+ } catch (error) {
+ logger.error('OpenAI-Gemini request error:', error);
+
+ // 处理速率限制
+ if (error.status === 429) {
+ if (req.apiKeyData && req.account) {
+ await geminiAccountService.setAccountRateLimited(req.account.id, true);
+ }
+ }
+
+ // 返回 OpenAI 格式的错误响应
+ const status = error.status || 500;
+ const errorResponse = {
+ error: error.error || {
+ message: error.message || 'Internal server error',
+ type: 'server_error',
+ code: 'internal_error'
+ }
+ };
+
+ res.status(status).json(errorResponse);
+ } finally {
+ // 清理资源
+ if (abortController) {
+ abortController = null;
+ }
+ }
+});
+
+// OpenAI 兼容的模型列表端点
+router.get('/v1/models', authenticateApiKey, async (req, res) => {
+ try {
+ const apiKeyData = req.apiKeyData;
+
+ // 检查权限
+ if (!checkPermissions(apiKeyData, 'gemini')) {
+ return res.status(403).json({
+ error: {
+ message: 'This API key does not have permission to access Gemini',
+ type: 'permission_denied',
+ code: 'permission_denied'
+ }
+ });
+ }
+
+ // 选择账户获取模型列表
+ const account = await geminiAccountService.selectAvailableAccount(apiKeyData.id);
+
+ let models = [];
+
+ if (account) {
+ // 获取实际的模型列表
+ models = await getAvailableModels(account.accessToken, account.proxy);
+ } else {
+ // 返回默认模型列表
+ models = [
+ {
+ id: 'gemini-2.0-flash-exp',
+ object: 'model',
+ created: Math.floor(Date.now() / 1000),
+ owned_by: 'google'
+ }
+ ];
+ }
+
+ // 如果启用了模型限制,过滤模型列表
+ if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
+ models = models.filter(model => apiKeyData.restrictedModels.includes(model.id));
+ }
+
+ res.json({
+ object: 'list',
+ data: models
+ });
+
+ } catch (error) {
+ logger.error('Failed to get OpenAI-Gemini models:', error);
+ res.status(500).json({
+ error: {
+ message: 'Failed to retrieve models',
+ type: 'server_error',
+ code: 'internal_error'
+ }
+ });
+ }
+});
+
+// OpenAI 兼容的模型详情端点
+router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
+ try {
+ const apiKeyData = req.apiKeyData;
+ const modelId = req.params.model;
+
+ // 检查权限
+ if (!checkPermissions(apiKeyData, 'gemini')) {
+ return res.status(403).json({
+ error: {
+ message: 'This API key does not have permission to access Gemini',
+ type: 'permission_denied',
+ code: 'permission_denied'
+ }
+ });
+ }
+
+ // 检查模型限制
+ if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
+ if (!apiKeyData.restrictedModels.includes(modelId)) {
+ return res.status(404).json({
+ error: {
+ message: `Model '${modelId}' not found`,
+ type: 'invalid_request_error',
+ code: 'model_not_found'
+ }
+ });
+ }
+ }
+
+ // 返回模型信息
+ res.json({
+ id: modelId,
+ object: 'model',
+ created: Math.floor(Date.now() / 1000),
+ owned_by: 'google',
+ permission: [],
+ root: modelId,
+ parent: null
+ });
+
+ } catch (error) {
+ logger.error('Failed to get model details:', error);
+ res.status(500).json({
+ error: {
+ message: 'Failed to retrieve model details',
+ type: 'server_error',
+ code: 'internal_error'
+ }
+ });
+ }
+});
+
+module.exports = router;
\ No newline at end of file
diff --git a/src/routes/web.js b/src/routes/web.js
index 4e6eba68..a1a01509 100644
--- a/src/routes/web.js
+++ b/src/routes/web.js
@@ -370,4 +370,175 @@ router.get('/style.css', (req, res) => {
serveWhitelistedFile(req, res, 'style.css');
});
+// 🔑 Gemini OAuth 回调页面
+router.get('/auth_gemini', (req, res) => {
+ try {
+ const code = req.query.code || '';
+ const state = req.query.state || '';
+ const error = req.query.error || '';
+ const errorDescription = req.query.error_description || '';
+
+ // 简单的 HTML 页面,用于显示授权码
+ const html = `
+
+
+
+
+
+ Gemini 授权回调
+
+
+
+
+ ${error ? `
+
授权失败
+
+
错误: ${error}
+ ${errorDescription ? `
描述: ${errorDescription}
` : ''}
+
+
+ ` : `
+
授权成功
+
请复制下面的授权码:
+
+ ${code}
+
+
+
+
+
接下来的步骤:
+
1. 点击上方按钮复制授权码
+
2. 返回到管理界面的创建账户页面
+
3. 将授权码粘贴到"授权码"输入框中
+
4. 点击"使用授权码创建账户"按钮完成创建
+
+ `}
+
+
+
+
+
+ `;
+
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.send(html);
+
+ logger.info(`📄 Served Gemini OAuth callback page: ${error ? 'error' : 'success'}`);
+ } catch (error) {
+ logger.error('❌ Error serving Gemini OAuth callback:', error);
+ res.status(500).send('Internal server error');
+ }
+});
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js
index ca38d193..c9527767 100644
--- a/src/services/apiKeyService.js
+++ b/src/services/apiKeyService.js
@@ -17,6 +17,8 @@ class ApiKeyService {
tokenLimit = config.limits.defaultTokenLimit,
expiresAt = null,
claudeAccountId = null,
+ geminiAccountId = null,
+ permissions = 'all', // 'claude', 'gemini', 'all'
isActive = true,
concurrencyLimit = 0,
rateLimitWindow = null,
@@ -41,6 +43,8 @@ class ApiKeyService {
rateLimitRequests: String(rateLimitRequests ?? 0),
isActive: String(isActive),
claudeAccountId: claudeAccountId || '',
+ geminiAccountId: geminiAccountId || '',
+ permissions: permissions || 'all',
enableModelRestriction: String(enableModelRestriction),
restrictedModels: JSON.stringify(restrictedModels || []),
createdAt: new Date().toISOString(),
@@ -65,6 +69,8 @@ class ApiKeyService {
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
isActive: keyData.isActive === 'true',
claudeAccountId: keyData.claudeAccountId,
+ geminiAccountId: keyData.geminiAccountId,
+ permissions: keyData.permissions,
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels: JSON.parse(keyData.restrictedModels),
createdAt: keyData.createdAt,
@@ -122,6 +128,8 @@ class ApiKeyService {
id: keyData.id,
name: keyData.name,
claudeAccountId: keyData.claudeAccountId,
+ geminiAccountId: keyData.geminiAccountId,
+ permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
@@ -152,6 +160,7 @@ class ApiKeyService {
key.currentConcurrency = await redis.getConcurrency(key.id);
key.isActive = key.isActive === 'true';
key.enableModelRestriction = key.enableModelRestriction === 'true';
+ key.permissions = key.permissions || 'all'; // 兼容旧数据
try {
key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [];
} catch (e) {
@@ -176,7 +185,7 @@ class ApiKeyService {
}
// 允许更新的字段
- const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'expiresAt', 'enableModelRestriction', 'restrictedModels'];
+ const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels'];
const updatedData = { ...keyData };
for (const [field, value] of Object.entries(updates)) {
@@ -292,4 +301,10 @@ class ApiKeyService {
}
}
-module.exports = new ApiKeyService();
\ No newline at end of file
+// 导出实例和单独的方法
+const apiKeyService = new ApiKeyService();
+
+// 为了方便其他服务调用,导出 recordUsage 方法
+apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService);
+
+module.exports = apiKeyService;
\ No newline at end of file
diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js
index 1af25b2a..3428b80f 100644
--- a/src/services/claudeAccountService.js
+++ b/src/services/claudeAccountService.js
@@ -6,6 +6,15 @@ const axios = require('axios');
const redis = require('../models/redis');
const logger = require('../utils/logger');
const config = require('../../config/config');
+const { maskToken } = require('../utils/tokenMask');
+const {
+ logRefreshStart,
+ logRefreshSuccess,
+ logRefreshError,
+ logTokenUsage,
+ logRefreshSkipped
+} = require('../utils/tokenRefreshLogger');
+const tokenRefreshService = require('./tokenRefreshService');
class ClaudeAccountService {
constructor() {
@@ -101,6 +110,8 @@ class ClaudeAccountService {
// 🔄 刷新Claude账户token
async refreshAccountToken(accountId) {
+ let lockAcquired = false;
+
try {
const accountData = await redis.getClaudeAccount(accountId);
@@ -114,6 +125,35 @@ class ClaudeAccountService {
throw new Error('No refresh token available - manual token update required');
}
+ // 尝试获取分布式锁
+ lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'claude');
+
+ if (!lockAcquired) {
+ // 如果无法获取锁,说明另一个进程正在刷新
+ logger.info(`🔒 Token refresh already in progress for account: ${accountData.name} (${accountId})`);
+ logRefreshSkipped(accountId, accountData.name, 'claude', 'already_locked');
+
+ // 等待一段时间后返回,期望其他进程已完成刷新
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // 重新获取账户数据(可能已被其他进程刷新)
+ const updatedData = await redis.getClaudeAccount(accountId);
+ if (updatedData && updatedData.accessToken) {
+ const accessToken = this._decryptSensitiveData(updatedData.accessToken);
+ return {
+ success: true,
+ accessToken: accessToken,
+ expiresAt: updatedData.expiresAt
+ };
+ }
+
+ throw new Error('Token refresh in progress by another process');
+ }
+
+ // 记录开始刷新
+ logRefreshStart(accountId, accountData.name, 'claude', 'manual_refresh');
+ logger.info(`🔄 Starting token refresh for account: ${accountData.name} (${accountId})`);
+
// 创建代理agent
const agent = this._createProxyAgent(accountData.proxy);
@@ -125,7 +165,7 @@ class ClaudeAccountService {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/plain, */*',
- 'User-Agent': 'claude-cli/1.0.53 (external, cli)',
+ 'User-Agent': 'claude-cli/1.0.56 (external, cli)',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': 'https://claude.ai/',
'Origin': 'https://claude.ai'
@@ -147,7 +187,15 @@ class ClaudeAccountService {
await redis.setClaudeAccount(accountId, accountData);
- logger.success(`🔄 Refreshed token for account: ${accountData.name} (${accountId})`);
+ // 记录刷新成功
+ logRefreshSuccess(accountId, accountData.name, 'claude', {
+ accessToken: access_token,
+ refreshToken: refresh_token,
+ expiresAt: accountData.expiresAt,
+ scopes: accountData.scopes
+ });
+
+ logger.success(`🔄 Refreshed token for account: ${accountData.name} (${accountId}) - Access Token: ${maskToken(access_token)}`);
return {
success: true,
@@ -158,17 +206,23 @@ class ClaudeAccountService {
throw new Error(`Token refresh failed with status: ${response.status}`);
}
} catch (error) {
- logger.error(`❌ Failed to refresh token for account ${accountId}:`, error);
-
- // 更新错误状态
+ // 记录刷新失败
const accountData = await redis.getClaudeAccount(accountId);
if (accountData) {
+ logRefreshError(accountId, accountData.name, 'claude', error);
accountData.status = 'error';
accountData.errorMessage = error.message;
await redis.setClaudeAccount(accountId, accountData);
}
+ logger.error(`❌ Failed to refresh token for account ${accountId}:`, error);
+
throw error;
+ } finally {
+ // 释放锁
+ if (lockAcquired) {
+ await tokenRefreshService.releaseRefreshLock(accountId, 'claude');
+ }
}
}
@@ -188,8 +242,12 @@ class ClaudeAccountService {
// 检查token是否过期
const expiresAt = parseInt(accountData.expiresAt);
const now = Date.now();
+ const isExpired = !expiresAt || now >= (expiresAt - 60000); // 60秒提前刷新
- if (!expiresAt || now >= (expiresAt - 60000)) { // 60秒提前刷新
+ // 记录token使用情况
+ logTokenUsage(accountId, accountData.name, 'claude', accountData.expiresAt, isExpired);
+
+ if (isExpired) {
logger.info(`🔄 Token expired/expiring for account ${accountId}, attempting refresh...`);
try {
const refreshResult = await this.refreshAccountToken(accountId);
@@ -275,6 +333,9 @@ class ClaudeAccountService {
const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth', 'accountType'];
const updatedData = { ...accountData };
+ // 检查是否新增了 refresh token
+ const oldRefreshToken = this._decryptSensitiveData(accountData.refreshToken);
+
for (const [field, value] of Object.entries(updates)) {
if (allowedUpdates.includes(field)) {
if (['email', 'password', 'refreshToken'].includes(field)) {
@@ -298,6 +359,27 @@ class ClaudeAccountService {
}
}
}
+
+ // 如果新增了 refresh token(之前没有,现在有了),更新过期时间为10分钟
+ if (updates.refreshToken && !oldRefreshToken && updates.refreshToken.trim()) {
+ const newExpiresAt = Date.now() + (10 * 60 * 1000); // 10分钟
+ updatedData.expiresAt = newExpiresAt.toString();
+ logger.info(`🔄 New refresh token added for account ${accountId}, setting expiry to 10 minutes`);
+ }
+
+ // 如果通过 claudeAiOauth 更新,也要检查是否新增了 refresh token
+ if (updates.claudeAiOauth && updates.claudeAiOauth.refreshToken && !oldRefreshToken) {
+ // 如果 expiresAt 设置的时间过长(超过1小时),调整为10分钟
+ const providedExpiry = parseInt(updates.claudeAiOauth.expiresAt);
+ const now = Date.now();
+ const oneHour = 60 * 60 * 1000;
+
+ if (providedExpiry - now > oneHour) {
+ const newExpiresAt = now + (10 * 60 * 1000); // 10分钟
+ updatedData.expiresAt = newExpiresAt.toString();
+ logger.info(`🔄 Adjusted expiry time to 10 minutes for account ${accountId} with refresh token`);
+ }
+ }
updatedData.updatedAt = new Date().toISOString();
diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js
index 8d62a96b..75cf7d79 100644
--- a/src/services/claudeRelayService.js
+++ b/src/services/claudeRelayService.js
@@ -445,7 +445,7 @@ class ClaudeRelayService {
}
// 🌊 处理流式响应(带usage数据捕获)
- async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback) {
+ async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback, streamTransformer = null) {
try {
// 调试日志:查看API Key数据(流式请求)
logger.info('🔍 [Stream] API Key data received:', {
@@ -495,7 +495,7 @@ class ClaudeRelayService {
const proxyAgent = await this._getProxyAgent(accountId);
// 发送流式请求并捕获usage数据
- return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash);
+ return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash, streamTransformer);
} catch (error) {
logger.error('❌ Claude stream relay with usage capture failed:', error);
throw error;
@@ -503,7 +503,7 @@ class ClaudeRelayService {
}
// 🌊 发送流式请求到Claude API(带usage数据捕获)
- async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash) {
+ async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash, streamTransformer = null) {
return new Promise((resolve, reject) => {
const url = new URL(this.claudeApiUrl);
@@ -559,7 +559,15 @@ class ClaudeRelayService {
// 转发已处理的完整行到客户端
if (lines.length > 0) {
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '');
- responseStream.write(linesToForward);
+ // 如果有流转换器,应用转换
+ if (streamTransformer) {
+ const transformed = streamTransformer(linesToForward);
+ if (transformed) {
+ responseStream.write(transformed);
+ }
+ } else {
+ responseStream.write(linesToForward);
+ }
}
for (const line of lines) {
@@ -612,7 +620,14 @@ class ClaudeRelayService {
res.on('end', async () => {
// 处理缓冲区中剩余的数据
if (buffer.trim()) {
- responseStream.write(buffer);
+ if (streamTransformer) {
+ const transformed = streamTransformer(buffer);
+ if (transformed) {
+ responseStream.write(transformed);
+ }
+ } else {
+ responseStream.write(buffer);
+ }
}
responseStream.end();
diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js
new file mode 100644
index 00000000..c60193a1
--- /dev/null
+++ b/src/services/geminiAccountService.js
@@ -0,0 +1,673 @@
+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 { OAuth2Client } = require('google-auth-library');
+const { maskToken } = require('../utils/tokenMask');
+const {
+ logRefreshStart,
+ logRefreshSuccess,
+ logRefreshError,
+ logTokenUsage,
+ logRefreshSkipped
+} = require('../utils/tokenRefreshLogger');
+const tokenRefreshService = require('./tokenRefreshService');
+
+// Gemini CLI OAuth 配置
+const OAUTH_CLIENT_ID = config.gemini.oauthClientId;
+const OAUTH_CLIENT_SECRET = config.gemini.oauthClientSecret;
+const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform'];
+
+// 加密相关常量
+const ALGORITHM = 'aes-256-cbc';
+const ENCRYPTION_KEY = Buffer.from(config.security.encryptionKey, 'hex');
+const IV_LENGTH = 16;
+
+// Gemini 账户键前缀
+const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:';
+const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts';
+const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:';
+
+// 加密函数
+function encrypt(text) {
+ if (!text) return '';
+ const iv = crypto.randomBytes(IV_LENGTH);
+ const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_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 textParts = text.split(':');
+ const iv = Buffer.from(textParts.shift(), 'hex');
+ const encryptedText = Buffer.from(textParts.join(':'), 'hex');
+ const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
+ let decrypted = decipher.update(encryptedText);
+ decrypted = Buffer.concat([decrypted, decipher.final()]);
+ return decrypted.toString();
+ } catch (error) {
+ logger.error('Decryption error:', error);
+ return '';
+ }
+}
+
+// 创建 OAuth2 客户端
+function createOAuth2Client(redirectUri = null) {
+ // 如果没有提供 redirectUri,使用默认值
+ const uri = redirectUri || 'http://localhost:8085';
+ return new OAuth2Client(
+ OAUTH_CLIENT_ID,
+ OAUTH_CLIENT_SECRET,
+ uri
+ );
+}
+
+// 生成授权 URL
+async function generateAuthUrl(state = null, redirectUri = null) {
+ const oAuth2Client = createOAuth2Client(redirectUri);
+ const authUrl = oAuth2Client.generateAuthUrl({
+ access_type: 'offline',
+ scope: OAUTH_SCOPES,
+ prompt: 'select_account',
+ state: state || uuidv4()
+ });
+
+ return {
+ authUrl,
+ state: state || authUrl.split('state=')[1].split('&')[0]
+ };
+}
+
+// 轮询检查 OAuth 授权状态
+async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2000) {
+ let attempts = 0;
+ const client = redisClient.getClientSafe();
+
+ while (attempts < maxAttempts) {
+ try {
+ const sessionData = await client.get(`oauth_session:${sessionId}`);
+ if (!sessionData) {
+ throw new Error('OAuth session not found');
+ }
+
+ const session = JSON.parse(sessionData);
+ if (session.code) {
+ // 授权码已获取,交换 tokens
+ const tokens = await exchangeCodeForTokens(session.code);
+
+ // 清理 session
+ await client.del(`oauth_session:${sessionId}`);
+
+ return {
+ success: true,
+ tokens
+ };
+ }
+
+ if (session.error) {
+ // 授权失败
+ await client.del(`oauth_session:${sessionId}`);
+ return {
+ success: false,
+ error: session.error
+ };
+ }
+
+ // 等待下一次轮询
+ await new Promise(resolve => setTimeout(resolve, interval));
+ attempts++;
+ } catch (error) {
+ logger.error('Error polling authorization status:', error);
+ throw error;
+ }
+ }
+
+ // 超时
+ await client.del(`oauth_session:${sessionId}`);
+ return {
+ success: false,
+ error: 'Authorization timeout'
+ };
+}
+
+// 交换授权码获取 tokens
+async function exchangeCodeForTokens(code, redirectUri = null) {
+ const oAuth2Client = createOAuth2Client(redirectUri);
+
+ try {
+ const { tokens } = await oAuth2Client.getToken(code);
+
+ // 转换为兼容格式
+ return {
+ access_token: tokens.access_token,
+ refresh_token: tokens.refresh_token,
+ scope: tokens.scope || OAUTH_SCOPES.join(' '),
+ token_type: tokens.token_type || 'Bearer',
+ expiry_date: tokens.expiry_date || Date.now() + (tokens.expires_in * 1000)
+ };
+ } catch (error) {
+ logger.error('Error exchanging code for tokens:', error);
+ throw new Error('Failed to exchange authorization code');
+ }
+}
+
+// 刷新访问令牌
+async function refreshAccessToken(refreshToken) {
+ const oAuth2Client = createOAuth2Client();
+
+ try {
+ oAuth2Client.setCredentials({
+ refresh_token: refreshToken
+ });
+
+ const { credentials } = await oAuth2Client.refreshAccessToken();
+
+ return {
+ access_token: credentials.access_token,
+ refresh_token: credentials.refresh_token || refreshToken,
+ scope: credentials.scope || OAUTH_SCOPES.join(' '),
+ token_type: credentials.token_type || 'Bearer',
+ expiry_date: credentials.expiry_date
+ };
+ } catch (error) {
+ logger.error('Error refreshing access token:', error);
+ throw new Error('Failed to refresh access token');
+ }
+}
+
+// 创建 Gemini 账户
+async function createAccount(accountData) {
+ const id = uuidv4();
+ const now = new Date().toISOString();
+
+ // 处理凭证数据
+ let geminiOauth = null;
+ let accessToken = '';
+ let refreshToken = '';
+ let expiresAt = '';
+
+ if (accountData.geminiOauth || accountData.accessToken) {
+ // 如果提供了完整的 OAuth 数据
+ if (accountData.geminiOauth) {
+ geminiOauth = typeof accountData.geminiOauth === 'string'
+ ? accountData.geminiOauth
+ : JSON.stringify(accountData.geminiOauth);
+
+ const oauthData = typeof accountData.geminiOauth === 'string'
+ ? JSON.parse(accountData.geminiOauth)
+ : accountData.geminiOauth;
+
+ accessToken = oauthData.access_token || '';
+ refreshToken = oauthData.refresh_token || '';
+ expiresAt = oauthData.expiry_date
+ ? new Date(oauthData.expiry_date).toISOString()
+ : '';
+ } else {
+ // 如果只提供了 access token
+ accessToken = accountData.accessToken;
+ refreshToken = accountData.refreshToken || '';
+
+ // 构造完整的 OAuth 数据
+ geminiOauth = JSON.stringify({
+ access_token: accessToken,
+ refresh_token: refreshToken,
+ scope: accountData.scope || OAUTH_SCOPES.join(' '),
+ token_type: accountData.tokenType || 'Bearer',
+ expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
+ });
+
+ expiresAt = new Date(accountData.expiryDate || Date.now() + 3600000).toISOString();
+ }
+ }
+
+ const account = {
+ id,
+ platform: 'gemini', // 标识为 Gemini 账户
+ name: accountData.name || 'Gemini Account',
+ description: accountData.description || '',
+ accountType: accountData.accountType || 'shared',
+ isActive: 'true',
+ status: 'active',
+
+ // OAuth 相关字段(加密存储)
+ geminiOauth: geminiOauth ? encrypt(geminiOauth) : '',
+ accessToken: accessToken ? encrypt(accessToken) : '',
+ refreshToken: refreshToken ? encrypt(refreshToken) : '',
+ expiresAt,
+ scopes: accountData.scopes || OAUTH_SCOPES.join(' '),
+
+ // 代理设置
+ proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '',
+
+ // 项目编号(Google Cloud/Workspace 账号需要)
+ projectId: accountData.projectId || '',
+
+ // 时间戳
+ createdAt: now,
+ updatedAt: now,
+ lastUsedAt: '',
+ lastRefreshAt: ''
+ };
+
+ // 保存到 Redis
+ const client = redisClient.getClientSafe();
+ await client.hset(
+ `${GEMINI_ACCOUNT_KEY_PREFIX}${id}`,
+ account
+ );
+
+ // 如果是共享账户,添加到共享账户集合
+ if (account.accountType === 'shared') {
+ await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, id);
+ }
+
+ logger.info(`Created Gemini account: ${id}`);
+ return account;
+}
+
+// 获取账户
+async function getAccount(accountId) {
+ const client = redisClient.getClientSafe();
+ const accountData = await client.hgetall(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`);
+
+ if (!accountData || Object.keys(accountData).length === 0) {
+ return null;
+ }
+
+ // 解密敏感字段
+ if (accountData.geminiOauth) {
+ accountData.geminiOauth = decrypt(accountData.geminiOauth);
+ }
+ if (accountData.accessToken) {
+ accountData.accessToken = decrypt(accountData.accessToken);
+ }
+ if (accountData.refreshToken) {
+ accountData.refreshToken = decrypt(accountData.refreshToken);
+ }
+
+ return accountData;
+}
+
+// 更新账户
+async function updateAccount(accountId, updates) {
+ const existingAccount = await getAccount(accountId);
+ if (!existingAccount) {
+ throw new Error('Account not found');
+ }
+
+ const now = new Date().toISOString();
+ updates.updatedAt = now;
+
+ // 检查是否新增了 refresh token
+ const oldRefreshToken = existingAccount.refreshToken ? decrypt(existingAccount.refreshToken) : '';
+ let needUpdateExpiry = false;
+
+ // 加密敏感字段
+ if (updates.geminiOauth) {
+ updates.geminiOauth = encrypt(
+ typeof updates.geminiOauth === 'string'
+ ? updates.geminiOauth
+ : JSON.stringify(updates.geminiOauth)
+ );
+ }
+ if (updates.accessToken) {
+ updates.accessToken = encrypt(updates.accessToken);
+ }
+ if (updates.refreshToken) {
+ updates.refreshToken = encrypt(updates.refreshToken);
+ // 如果之前没有 refresh token,现在有了,标记需要更新过期时间
+ if (!oldRefreshToken && updates.refreshToken) {
+ needUpdateExpiry = true;
+ }
+ }
+
+ // 更新账户类型时处理共享账户集合
+ const client = redisClient.getClientSafe();
+ if (updates.accountType && updates.accountType !== existingAccount.accountType) {
+ if (updates.accountType === 'shared') {
+ await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, accountId);
+ } else {
+ await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId);
+ }
+ }
+
+ // 如果新增了 refresh token,更新过期时间为10分钟
+ if (needUpdateExpiry) {
+ const newExpiry = new Date(Date.now() + (10 * 60 * 1000)).toISOString();
+ updates.expiresAt = newExpiry;
+ logger.info(`🔄 New refresh token added for Gemini account ${accountId}, setting expiry to 10 minutes`);
+ }
+
+ // 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token
+ if (updates.geminiOauth && !oldRefreshToken) {
+ const oauthData = typeof updates.geminiOauth === 'string'
+ ? JSON.parse(decrypt(updates.geminiOauth))
+ : updates.geminiOauth;
+
+ if (oauthData.refresh_token) {
+ // 如果 expiry_date 设置的时间过长(超过1小时),调整为10分钟
+ const providedExpiry = oauthData.expiry_date || 0;
+ const now = Date.now();
+ const oneHour = 60 * 60 * 1000;
+
+ if (providedExpiry - now > oneHour) {
+ const newExpiry = new Date(now + (10 * 60 * 1000)).toISOString();
+ updates.expiresAt = newExpiry;
+ logger.info(`🔄 Adjusted expiry time to 10 minutes for Gemini account ${accountId} with refresh token`);
+ }
+ }
+ }
+
+ await client.hset(
+ `${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`,
+ updates
+ );
+
+ logger.info(`Updated Gemini account: ${accountId}`);
+ return { ...existingAccount, ...updates };
+}
+
+// 删除账户
+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(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`);
+
+ // 从共享账户集合中移除
+ if (account.accountType === 'shared') {
+ await client.srem(SHARED_GEMINI_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 Gemini account: ${accountId}`);
+ return true;
+}
+
+// 获取所有账户
+async function getAllAccounts() {
+ const client = redisClient.getClientSafe();
+ const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`);
+ const accounts = [];
+
+ for (const key of keys) {
+ const accountData = await client.hgetall(key);
+ if (accountData && Object.keys(accountData).length > 0) {
+ // 不解密敏感字段,只返回基本信息
+ accounts.push({
+ ...accountData,
+ geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '',
+ accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
+ refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : ''
+ });
+ }
+ }
+
+ 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}`);
+
+ // 检查是否绑定了 Gemini 账户
+ if (apiKeyData.geminiAccountId) {
+ const account = await getAccount(apiKeyData.geminiAccountId);
+ if (account && account.isActive === 'true') {
+ // 检查 token 是否过期
+ const isExpired = isTokenExpired(account);
+
+ // 记录token使用情况
+ logTokenUsage(account.id, account.name, 'gemini', 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_GEMINI_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 Gemini accounts');
+ }
+
+ // 选择最少使用的账户
+ availableAccounts.sort((a, b) => {
+ const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0;
+ const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0;
+ return aLastUsed - bLastUsed;
+ });
+
+ const selectedAccount = availableAccounts[0];
+
+ // 检查并刷新 token
+ const isExpired = isTokenExpired(selectedAccount);
+
+ // 记录token使用情况
+ logTokenUsage(selectedAccount.id, selectedAccount.name, 'gemini', selectedAccount.expiresAt, isExpired);
+
+ if (isExpired) {
+ await refreshAccountToken(selectedAccount.id);
+ return await getAccount(selectedAccount.id);
+ }
+
+ // 创建粘性会话映射
+ if (sessionHash) {
+ await client.setex(
+ `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`,
+ 3600,
+ selectedAccount.id
+ );
+ }
+
+ return selectedAccount;
+}
+
+// 检查 token 是否过期
+function isTokenExpired(account) {
+ if (!account.expiresAt) return true;
+
+ const expiryTime = new Date(account.expiresAt).getTime();
+ const now = Date.now();
+ const buffer = 10 * 1000; // 10秒缓冲
+
+ return now >= (expiryTime - buffer);
+}
+
+// 检查账户是否被限流
+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;
+}
+
+// 刷新账户 token
+async function refreshAccountToken(accountId) {
+ let lockAcquired = false;
+ let account = null;
+
+ try {
+ account = await getAccount(accountId);
+ if (!account) {
+ throw new Error('Account not found');
+ }
+
+ if (!account.refreshToken) {
+ throw new Error('No refresh token available');
+ }
+
+ // 尝试获取分布式锁
+ lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'gemini');
+
+ if (!lockAcquired) {
+ // 如果无法获取锁,说明另一个进程正在刷新
+ logger.info(`🔒 Token refresh already in progress for Gemini account: ${account.name} (${accountId})`);
+ logRefreshSkipped(accountId, account.name, 'gemini', 'already_locked');
+
+ // 等待一段时间后返回,期望其他进程已完成刷新
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // 重新获取账户数据(可能已被其他进程刷新)
+ const updatedAccount = await getAccount(accountId);
+ if (updatedAccount && updatedAccount.accessToken) {
+ const accessToken = decrypt(updatedAccount.accessToken);
+ return {
+ access_token: accessToken,
+ refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
+ expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0,
+ scope: updatedAccount.scope || OAUTH_SCOPES.join(' '),
+ token_type: 'Bearer'
+ };
+ }
+
+ throw new Error('Token refresh in progress by another process');
+ }
+
+ // 记录开始刷新
+ logRefreshStart(accountId, account.name, 'gemini', 'manual_refresh');
+ logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`);
+
+ const newTokens = await refreshAccessToken(decrypt(account.refreshToken));
+
+ // 更新账户信息
+ const updates = {
+ accessToken: newTokens.access_token,
+ refreshToken: newTokens.refresh_token || account.refreshToken,
+ expiresAt: new Date(newTokens.expiry_date).toISOString(),
+ lastRefreshAt: new Date().toISOString(),
+ geminiOauth: JSON.stringify(newTokens)
+ };
+
+ await updateAccount(accountId, updates);
+
+ // 记录刷新成功
+ logRefreshSuccess(accountId, account.name, 'gemini', {
+ accessToken: newTokens.access_token,
+ refreshToken: newTokens.refresh_token,
+ expiresAt: newTokens.expiry_date,
+ scopes: newTokens.scope
+ });
+
+ logger.info(`Refreshed token for Gemini account: ${accountId} - Access Token: ${maskToken(newTokens.access_token)}`);
+
+ return newTokens;
+ } catch (error) {
+ // 记录刷新失败
+ logRefreshError(accountId, account ? account.name : 'Unknown', 'gemini', error);
+
+ logger.error(`Failed to refresh token for account ${accountId}:`, error);
+
+ // 标记账户为错误状态
+ await updateAccount(accountId, {
+ status: 'error',
+ errorMessage: error.message
+ });
+
+ throw error;
+ } finally {
+ // 释放锁
+ if (lockAcquired) {
+ await tokenRefreshService.releaseRefreshLock(accountId, 'gemini');
+ }
+ }
+}
+
+// 标记账户被使用
+async function markAccountUsed(accountId) {
+ await updateAccount(accountId, {
+ lastUsedAt: new Date().toISOString()
+ });
+}
+
+// 设置账户限流状态
+async function setAccountRateLimited(accountId, isLimited = true) {
+ const updates = isLimited ? {
+ rateLimitStatus: 'limited',
+ rateLimitedAt: new Date().toISOString()
+ } : {
+ rateLimitStatus: '',
+ rateLimitedAt: ''
+ };
+
+ await updateAccount(accountId, updates);
+}
+
+module.exports = {
+ generateAuthUrl,
+ pollAuthorizationStatus,
+ exchangeCodeForTokens,
+ refreshAccessToken,
+ createAccount,
+ getAccount,
+ updateAccount,
+ deleteAccount,
+ getAllAccounts,
+ selectAvailableAccount,
+ refreshAccountToken,
+ markAccountUsed,
+ setAccountRateLimited,
+ isTokenExpired,
+ OAUTH_CLIENT_ID,
+ OAUTH_SCOPES
+};
\ No newline at end of file
diff --git a/src/services/geminiRelayService.js b/src/services/geminiRelayService.js
new file mode 100644
index 00000000..4040e890
--- /dev/null
+++ b/src/services/geminiRelayService.js
@@ -0,0 +1,379 @@
+const axios = require('axios');
+const { HttpsProxyAgent } = require('https-proxy-agent');
+const { SocksProxyAgent } = require('socks-proxy-agent');
+const logger = require('../utils/logger');
+const config = require('../../config/config');
+const { recordUsageMetrics } = require('./apiKeyService');
+
+// Gemini API 配置
+const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1';
+const DEFAULT_MODEL = 'models/gemini-2.0-flash-exp';
+
+// 创建代理 agent
+function createProxyAgent(proxyConfig) {
+ if (!proxyConfig) return null;
+
+ try {
+ const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig;
+
+ if (!proxy.type || !proxy.host || !proxy.port) {
+ return null;
+ }
+
+ const proxyUrl = proxy.username && proxy.password
+ ? `${proxy.type}://${proxy.username}:${proxy.password}@${proxy.host}:${proxy.port}`
+ : `${proxy.type}://${proxy.host}:${proxy.port}`;
+
+ if (proxy.type === 'socks5') {
+ return new SocksProxyAgent(proxyUrl);
+ } else if (proxy.type === 'http' || proxy.type === 'https') {
+ return new HttpsProxyAgent(proxyUrl);
+ }
+ } catch (error) {
+ logger.error('Error creating proxy agent:', error);
+ }
+
+ return null;
+}
+
+// 转换 OpenAI 消息格式到 Gemini 格式
+function convertMessagesToGemini(messages) {
+ const contents = [];
+ let systemInstruction = '';
+
+ for (const message of messages) {
+ if (message.role === 'system') {
+ systemInstruction += (systemInstruction ? '\n\n' : '') + message.content;
+ } else if (message.role === 'user') {
+ contents.push({
+ role: 'user',
+ parts: [{ text: message.content }]
+ });
+ } else if (message.role === 'assistant') {
+ contents.push({
+ role: 'model',
+ parts: [{ text: message.content }]
+ });
+ }
+ }
+
+ return { contents, systemInstruction };
+}
+
+// 转换 Gemini 响应到 OpenAI 格式
+function convertGeminiResponse(geminiResponse, model, stream = false) {
+ if (stream) {
+ // 流式响应
+ const candidate = geminiResponse.candidates?.[0];
+ if (!candidate) return null;
+
+ const content = candidate.content?.parts?.[0]?.text || '';
+ const finishReason = candidate.finishReason?.toLowerCase();
+
+ return {
+ id: `chatcmpl-${Date.now()}`,
+ object: 'chat.completion.chunk',
+ created: Math.floor(Date.now() / 1000),
+ model: model,
+ choices: [{
+ index: 0,
+ delta: {
+ content: content
+ },
+ finish_reason: finishReason === 'stop' ? 'stop' : null
+ }]
+ };
+ } else {
+ // 非流式响应
+ const candidate = geminiResponse.candidates?.[0];
+ if (!candidate) {
+ throw new Error('No response from Gemini');
+ }
+
+ const content = candidate.content?.parts?.[0]?.text || '';
+ const finishReason = candidate.finishReason?.toLowerCase() || 'stop';
+
+ // 计算 token 使用量
+ const usage = geminiResponse.usageMetadata || {
+ promptTokenCount: 0,
+ candidatesTokenCount: 0,
+ totalTokenCount: 0
+ };
+
+ return {
+ id: `chatcmpl-${Date.now()}`,
+ object: 'chat.completion',
+ created: Math.floor(Date.now() / 1000),
+ model: model,
+ choices: [{
+ index: 0,
+ message: {
+ role: 'assistant',
+ content: content
+ },
+ finish_reason: finishReason
+ }],
+ usage: {
+ prompt_tokens: usage.promptTokenCount,
+ completion_tokens: usage.candidatesTokenCount,
+ total_tokens: usage.totalTokenCount
+ }
+ };
+ }
+}
+
+// 处理流式响应
+async function* handleStreamResponse(response, model, apiKeyId) {
+ let buffer = '';
+ let totalUsage = {
+ promptTokenCount: 0,
+ candidatesTokenCount: 0,
+ totalTokenCount: 0
+ };
+
+ try {
+ for await (const chunk of response.data) {
+ buffer += chunk.toString();
+
+ // 处理可能的多个 JSON 对象
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || ''; // 保留最后一个不完整的行
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+
+ try {
+ const data = JSON.parse(line);
+
+ // 更新使用量统计
+ if (data.usageMetadata) {
+ totalUsage = data.usageMetadata;
+ }
+
+ // 转换并发送响应
+ const openaiResponse = convertGeminiResponse(data, model, true);
+ if (openaiResponse) {
+ yield `data: ${JSON.stringify(openaiResponse)}\n\n`;
+ }
+
+ // 检查是否结束
+ if (data.candidates?.[0]?.finishReason === 'STOP') {
+ // 记录使用量
+ if (apiKeyId && totalUsage.totalTokenCount > 0) {
+ await recordUsageMetrics(apiKeyId, {
+ inputTokens: totalUsage.promptTokenCount,
+ outputTokens: totalUsage.candidatesTokenCount,
+ model: model
+ });
+ }
+
+ yield 'data: [DONE]\n\n';
+ return;
+ }
+ } catch (e) {
+ logger.debug('Error parsing JSON line:', e.message);
+ }
+ }
+ }
+
+ // 处理剩余的 buffer
+ if (buffer.trim()) {
+ try {
+ const data = JSON.parse(buffer);
+ const openaiResponse = convertGeminiResponse(data, model, true);
+ if (openaiResponse) {
+ yield `data: ${JSON.stringify(openaiResponse)}\n\n`;
+ }
+ } catch (e) {
+ logger.debug('Error parsing final buffer:', e.message);
+ }
+ }
+
+ yield 'data: [DONE]\n\n';
+ } catch (error) {
+ logger.error('Stream processing error:', error);
+ yield `data: ${JSON.stringify({
+ error: {
+ message: error.message,
+ type: 'stream_error'
+ }
+ })}\n\n`;
+ }
+}
+
+// 发送请求到 Gemini
+async function sendGeminiRequest({
+ messages,
+ model = DEFAULT_MODEL,
+ temperature = 0.7,
+ maxTokens = 4096,
+ stream = false,
+ accessToken,
+ proxy,
+ apiKeyId,
+ projectId,
+ location = 'us-central1'
+}) {
+ // 确保模型名称格式正确
+ if (!model.startsWith('models/')) {
+ model = `models/${model}`;
+ }
+
+ // 转换消息格式
+ const { contents, systemInstruction } = convertMessagesToGemini(messages);
+
+ // 构建请求体
+ const requestBody = {
+ contents,
+ generationConfig: {
+ temperature,
+ maxOutputTokens: maxTokens,
+ candidateCount: 1
+ }
+ };
+
+ if (systemInstruction) {
+ requestBody.systemInstruction = { parts: [{ text: systemInstruction }] };
+ }
+
+ // 配置请求选项
+ let apiUrl;
+ if (projectId) {
+ // 使用项目特定的 URL 格式(Google Cloud/Workspace 账号)
+ apiUrl = `${GEMINI_API_BASE}/projects/${projectId}/locations/${location}/${model}:${stream ? 'streamGenerateContent' : 'generateContent'}?alt=sse`;
+ logger.debug(`Using project-specific URL with projectId: ${projectId}, location: ${location}`);
+ } else {
+ // 使用标准 URL 格式(个人 Google 账号)
+ apiUrl = `${GEMINI_API_BASE}/${model}:${stream ? 'streamGenerateContent' : 'generateContent'}?alt=sse`;
+ logger.debug('Using standard URL without projectId');
+ }
+
+ const axiosConfig = {
+ method: 'POST',
+ url: apiUrl,
+ headers: {
+ 'Authorization': `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json'
+ },
+ data: requestBody,
+ timeout: config.requestTimeout || 120000
+ };
+
+ // 添加代理配置
+ const proxyAgent = createProxyAgent(proxy);
+ if (proxyAgent) {
+ axiosConfig.httpsAgent = proxyAgent;
+ logger.debug('Using proxy for Gemini request');
+ }
+
+ if (stream) {
+ axiosConfig.responseType = 'stream';
+ }
+
+ try {
+ logger.debug('Sending request to Gemini API');
+ const response = await axios(axiosConfig);
+
+ if (stream) {
+ return handleStreamResponse(response, model, apiKeyId);
+ } else {
+ // 非流式响应
+ const openaiResponse = convertGeminiResponse(response.data, model, false);
+
+ // 记录使用量
+ if (apiKeyId && openaiResponse.usage) {
+ await recordUsageMetrics(apiKeyId, {
+ inputTokens: openaiResponse.usage.prompt_tokens,
+ outputTokens: openaiResponse.usage.completion_tokens,
+ model: model
+ });
+ }
+
+ return openaiResponse;
+ }
+ } catch (error) {
+ logger.error('Gemini API request failed:', error.response?.data || error.message);
+
+ // 转换错误格式
+ if (error.response) {
+ const geminiError = error.response.data?.error;
+ throw {
+ status: error.response.status,
+ error: {
+ message: geminiError?.message || 'Gemini API request failed',
+ type: geminiError?.code || 'api_error',
+ code: geminiError?.code
+ }
+ };
+ }
+
+ throw {
+ status: 500,
+ error: {
+ message: error.message,
+ type: 'network_error'
+ }
+ };
+ }
+}
+
+// 获取可用模型列表
+async function getAvailableModels(accessToken, proxy, projectId, location = 'us-central1') {
+ let apiUrl;
+ if (projectId) {
+ // 使用项目特定的 URL 格式
+ apiUrl = `${GEMINI_API_BASE}/projects/${projectId}/locations/${location}/models`;
+ logger.debug(`Fetching models with projectId: ${projectId}, location: ${location}`);
+ } else {
+ // 使用标准 URL 格式
+ apiUrl = `${GEMINI_API_BASE}/models`;
+ logger.debug('Fetching models without projectId');
+ }
+
+ const axiosConfig = {
+ method: 'GET',
+ url: apiUrl,
+ headers: {
+ 'Authorization': `Bearer ${accessToken}`
+ },
+ timeout: 30000
+ };
+
+ const proxyAgent = createProxyAgent(proxy);
+ if (proxyAgent) {
+ axiosConfig.httpsAgent = proxyAgent;
+ }
+
+ try {
+ const response = await axios(axiosConfig);
+ const models = response.data.models || [];
+
+ // 转换为 OpenAI 格式
+ return models
+ .filter(model => model.supportedGenerationMethods?.includes('generateContent'))
+ .map(model => ({
+ id: model.name.replace('models/', ''),
+ object: 'model',
+ created: Date.now() / 1000,
+ owned_by: 'google'
+ }));
+ } catch (error) {
+ logger.error('Failed to get Gemini models:', error);
+ // 返回默认模型列表
+ return [
+ {
+ id: 'gemini-2.0-flash-exp',
+ object: 'model',
+ created: Date.now() / 1000,
+ owned_by: 'google'
+ }
+ ];
+ }
+}
+
+module.exports = {
+ sendGeminiRequest,
+ getAvailableModels,
+ convertMessagesToGemini,
+ convertGeminiResponse
+};
\ No newline at end of file
diff --git a/src/services/openaiToClaude.js b/src/services/openaiToClaude.js
new file mode 100644
index 00000000..ccb133ce
--- /dev/null
+++ b/src/services/openaiToClaude.js
@@ -0,0 +1,381 @@
+/**
+ * OpenAI 到 Claude 格式转换服务
+ * 处理 OpenAI API 格式与 Claude API 格式之间的转换
+ */
+
+const logger = require('../utils/logger');
+
+class OpenAIToClaudeConverter {
+ constructor() {
+ // 停止原因映射
+ this.stopReasonMapping = {
+ 'end_turn': 'stop',
+ 'max_tokens': 'length',
+ 'stop_sequence': 'stop',
+ 'tool_use': 'tool_calls'
+ };
+ }
+
+ /**
+ * 将 OpenAI 请求格式转换为 Claude 格式
+ * @param {Object} openaiRequest - OpenAI 格式的请求
+ * @returns {Object} Claude 格式的请求
+ */
+ convertRequest(openaiRequest) {
+ const claudeRequest = {
+ model: openaiRequest.model, // 直接使用提供的模型名,不进行映射
+ messages: this._convertMessages(openaiRequest.messages),
+ max_tokens: openaiRequest.max_tokens || 4096,
+ temperature: openaiRequest.temperature,
+ top_p: openaiRequest.top_p,
+ stream: openaiRequest.stream || false
+ };
+
+ // 处理系统消息
+ const systemMessage = this._extractSystemMessage(openaiRequest.messages);
+ if (systemMessage) {
+ claudeRequest.system = systemMessage;
+ }
+
+ // 处理停止序列
+ if (openaiRequest.stop) {
+ claudeRequest.stop_sequences = Array.isArray(openaiRequest.stop)
+ ? openaiRequest.stop
+ : [openaiRequest.stop];
+ }
+
+ // 处理工具调用
+ if (openaiRequest.tools) {
+ claudeRequest.tools = this._convertTools(openaiRequest.tools);
+ if (openaiRequest.tool_choice) {
+ claudeRequest.tool_choice = this._convertToolChoice(openaiRequest.tool_choice);
+ }
+ }
+
+ // OpenAI 特有的参数已在转换过程中被忽略
+ // 包括: n, presence_penalty, frequency_penalty, logit_bias, user
+
+ logger.debug('📝 Converted OpenAI request to Claude format:', {
+ model: claudeRequest.model,
+ messageCount: claudeRequest.messages.length,
+ hasSystem: !!claudeRequest.system,
+ stream: claudeRequest.stream
+ });
+
+ return claudeRequest;
+ }
+
+ /**
+ * 将 Claude 响应格式转换为 OpenAI 格式
+ * @param {Object} claudeResponse - Claude 格式的响应
+ * @param {String} requestModel - 原始请求的模型名
+ * @returns {Object} OpenAI 格式的响应
+ */
+ convertResponse(claudeResponse, requestModel) {
+ const timestamp = Math.floor(Date.now() / 1000);
+
+ const openaiResponse = {
+ id: `chatcmpl-${this._generateId()}`,
+ object: 'chat.completion',
+ created: timestamp,
+ model: requestModel || 'gpt-4',
+ choices: [{
+ index: 0,
+ message: this._convertClaudeMessage(claudeResponse),
+ finish_reason: this._mapStopReason(claudeResponse.stop_reason)
+ }],
+ usage: this._convertUsage(claudeResponse.usage)
+ };
+
+ logger.debug('📝 Converted Claude response to OpenAI format:', {
+ responseId: openaiResponse.id,
+ finishReason: openaiResponse.choices[0].finish_reason,
+ usage: openaiResponse.usage
+ });
+
+ return openaiResponse;
+ }
+
+ /**
+ * 转换流式响应的单个数据块
+ * @param {String} chunk - Claude SSE 数据块
+ * @param {String} requestModel - 原始请求的模型名
+ * @returns {String} OpenAI 格式的 SSE 数据块
+ */
+ convertStreamChunk(chunk, requestModel) {
+ if (!chunk || chunk.trim() === '') return '';
+
+ // 解析 SSE 数据
+ const lines = chunk.split('\n');
+ let convertedChunks = [];
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const data = line.substring(6);
+ if (data === '[DONE]') {
+ convertedChunks.push('data: [DONE]\n\n');
+ continue;
+ }
+
+ try {
+ const claudeEvent = JSON.parse(data);
+ const openaiChunk = this._convertStreamEvent(claudeEvent, requestModel);
+ if (openaiChunk) {
+ convertedChunks.push(`data: ${JSON.stringify(openaiChunk)}\n\n`);
+ }
+ } catch (e) {
+ // 如果不是 JSON,原样传递
+ convertedChunks.push(line + '\n');
+ }
+ } else if (line.startsWith('event:') || line === '') {
+ // 保留事件类型行和空行
+ convertedChunks.push(line + '\n');
+ }
+ }
+
+ return convertedChunks.join('');
+ }
+
+
+ /**
+ * 提取系统消息
+ */
+ _extractSystemMessage(messages) {
+ const systemMessages = messages.filter(msg => msg.role === 'system');
+ if (systemMessages.length === 0) return null;
+
+ // 合并所有系统消息
+ return systemMessages.map(msg => msg.content).join('\n\n');
+ }
+
+ /**
+ * 转换消息格式
+ */
+ _convertMessages(messages) {
+ const claudeMessages = [];
+
+ for (const msg of messages) {
+ // 跳过系统消息(已经在 system 字段处理)
+ if (msg.role === 'system') continue;
+
+ // 转换角色名称
+ const role = msg.role === 'user' ? 'user' : 'assistant';
+
+ // 转换消息内容
+ let content;
+ if (typeof msg.content === 'string') {
+ content = msg.content;
+ } else if (Array.isArray(msg.content)) {
+ // 处理多模态内容
+ content = this._convertMultimodalContent(msg.content);
+ } else {
+ content = JSON.stringify(msg.content);
+ }
+
+ const claudeMsg = {
+ role: role,
+ content: content
+ };
+
+ // 处理工具调用
+ if (msg.tool_calls) {
+ claudeMsg.content = this._convertToolCalls(msg.tool_calls);
+ }
+
+ // 处理工具响应
+ if (msg.role === 'tool') {
+ claudeMsg.role = 'user';
+ claudeMsg.content = [{
+ type: 'tool_result',
+ tool_use_id: msg.tool_call_id,
+ content: msg.content
+ }];
+ }
+
+ claudeMessages.push(claudeMsg);
+ }
+
+ return claudeMessages;
+ }
+
+ /**
+ * 转换多模态内容
+ */
+ _convertMultimodalContent(content) {
+ return content.map(item => {
+ if (item.type === 'text') {
+ return {
+ type: 'text',
+ text: item.text
+ };
+ } else if (item.type === 'image_url') {
+ return {
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: 'image/jpeg', // 默认类型
+ data: item.image_url.url.split(',')[1] // 假设是 base64
+ }
+ };
+ }
+ return item;
+ });
+ }
+
+ /**
+ * 转换工具定义
+ */
+ _convertTools(tools) {
+ return tools.map(tool => {
+ if (tool.type === 'function') {
+ return {
+ name: tool.function.name,
+ description: tool.function.description,
+ input_schema: tool.function.parameters
+ };
+ }
+ return tool;
+ });
+ }
+
+ /**
+ * 转换工具选择
+ */
+ _convertToolChoice(toolChoice) {
+ if (toolChoice === 'none') return { type: 'none' };
+ if (toolChoice === 'auto') return { type: 'auto' };
+ if (toolChoice === 'required') return { type: 'any' };
+ if (toolChoice.type === 'function') {
+ return {
+ type: 'tool',
+ name: toolChoice.function.name
+ };
+ }
+ return { type: 'auto' };
+ }
+
+ /**
+ * 转换工具调用
+ */
+ _convertToolCalls(toolCalls) {
+ return toolCalls.map(tc => ({
+ type: 'tool_use',
+ id: tc.id,
+ name: tc.function.name,
+ input: JSON.parse(tc.function.arguments)
+ }));
+ }
+
+ /**
+ * 转换 Claude 消息为 OpenAI 格式
+ */
+ _convertClaudeMessage(claudeResponse) {
+ const message = {
+ role: 'assistant',
+ content: null
+ };
+
+ // 处理内容
+ if (claudeResponse.content) {
+ if (typeof claudeResponse.content === 'string') {
+ message.content = claudeResponse.content;
+ } else if (Array.isArray(claudeResponse.content)) {
+ // 提取文本内容和工具调用
+ const textParts = [];
+ const toolCalls = [];
+
+ for (const item of claudeResponse.content) {
+ if (item.type === 'text') {
+ textParts.push(item.text);
+ } else if (item.type === 'tool_use') {
+ toolCalls.push({
+ id: item.id,
+ type: 'function',
+ function: {
+ name: item.name,
+ arguments: JSON.stringify(item.input)
+ }
+ });
+ }
+ }
+
+ message.content = textParts.join('') || null;
+ if (toolCalls.length > 0) {
+ message.tool_calls = toolCalls;
+ }
+ }
+ }
+
+ return message;
+ }
+
+ /**
+ * 转换停止原因
+ */
+ _mapStopReason(claudeReason) {
+ return this.stopReasonMapping[claudeReason] || 'stop';
+ }
+
+ /**
+ * 转换使用统计
+ */
+ _convertUsage(claudeUsage) {
+ if (!claudeUsage) return undefined;
+
+ return {
+ prompt_tokens: claudeUsage.input_tokens || 0,
+ completion_tokens: claudeUsage.output_tokens || 0,
+ total_tokens: (claudeUsage.input_tokens || 0) + (claudeUsage.output_tokens || 0)
+ };
+ }
+
+ /**
+ * 转换流式事件
+ */
+ _convertStreamEvent(event, requestModel) {
+ const timestamp = Math.floor(Date.now() / 1000);
+ const baseChunk = {
+ id: `chatcmpl-${this._generateId()}`,
+ object: 'chat.completion.chunk',
+ created: timestamp,
+ model: requestModel || 'gpt-4',
+ choices: [{
+ index: 0,
+ delta: {},
+ finish_reason: null
+ }]
+ };
+
+ // 根据事件类型处理
+ if (event.type === 'content_block_start' && event.content_block) {
+ if (event.content_block.type === 'text') {
+ baseChunk.choices[0].delta.content = event.content_block.text || '';
+ }
+ } else if (event.type === 'content_block_delta' && event.delta) {
+ if (event.delta.type === 'text_delta') {
+ baseChunk.choices[0].delta.content = event.delta.text || '';
+ }
+ } else if (event.type === 'message_delta' && event.delta) {
+ if (event.delta.stop_reason) {
+ baseChunk.choices[0].finish_reason = this._mapStopReason(event.delta.stop_reason);
+ }
+ if (event.usage) {
+ baseChunk.usage = this._convertUsage(event.usage);
+ }
+ } else if (event.type === 'message_stop') {
+ baseChunk.choices[0].finish_reason = 'stop';
+ }
+
+ return baseChunk;
+ }
+
+ /**
+ * 生成随机 ID
+ */
+ _generateId() {
+ return Math.random().toString(36).substring(2, 15) +
+ Math.random().toString(36).substring(2, 15);
+ }
+}
+
+module.exports = new OpenAIToClaudeConverter();
\ No newline at end of file
diff --git a/src/services/tokenRefreshService.js b/src/services/tokenRefreshService.js
new file mode 100644
index 00000000..d51151ee
--- /dev/null
+++ b/src/services/tokenRefreshService.js
@@ -0,0 +1,147 @@
+const redis = require('../models/redis');
+const logger = require('../utils/logger');
+const { v4: uuidv4 } = require('uuid');
+const {
+ logRefreshSkipped
+} = require('../utils/tokenRefreshLogger');
+
+/**
+ * Token 刷新锁服务
+ * 提供分布式锁机制,避免并发刷新问题
+ */
+class TokenRefreshService {
+ constructor() {
+ this.lockTTL = 60; // 锁的TTL: 60秒(token刷新通常在30秒内完成)
+ this.lockValue = new Map(); // 存储每个锁的唯一值
+ }
+
+
+ /**
+ * 获取分布式锁
+ * 使用唯一标识符作为值,避免误释放其他进程的锁
+ */
+ async acquireLock(lockKey) {
+ try {
+ const client = redis.getClientSafe();
+ const lockId = uuidv4();
+ const result = await client.set(lockKey, lockId, 'NX', 'EX', this.lockTTL);
+
+ if (result === 'OK') {
+ this.lockValue.set(lockKey, lockId);
+ logger.debug(`🔒 Acquired lock ${lockKey} with ID ${lockId}, TTL: ${this.lockTTL}s`);
+ return true;
+ }
+ return false;
+ } catch (error) {
+ logger.error(`Failed to acquire lock ${lockKey}:`, error);
+ return false;
+ }
+ }
+
+ /**
+ * 释放分布式锁
+ * 使用 Lua 脚本确保只释放自己持有的锁
+ */
+ async releaseLock(lockKey) {
+ try {
+ const client = redis.getClientSafe();
+ const lockId = this.lockValue.get(lockKey);
+
+ if (!lockId) {
+ logger.warn(`⚠️ No lock ID found for ${lockKey}, skipping release`);
+ return;
+ }
+
+ // Lua 脚本:只有当值匹配时才删除
+ const luaScript = `
+ if redis.call("get", KEYS[1]) == ARGV[1] then
+ return redis.call("del", KEYS[1])
+ else
+ return 0
+ end
+ `;
+
+ const result = await client.eval(luaScript, 1, lockKey, lockId);
+
+ if (result === 1) {
+ this.lockValue.delete(lockKey);
+ logger.debug(`🔓 Released lock ${lockKey} with ID ${lockId}`);
+ } else {
+ logger.warn(`⚠️ Lock ${lockKey} was not released - value mismatch or already expired`);
+ }
+ } catch (error) {
+ logger.error(`Failed to release lock ${lockKey}:`, error);
+ }
+ }
+
+ /**
+ * 获取刷新锁
+ * @param {string} accountId - 账户ID
+ * @param {string} platform - 平台类型 (claude/gemini)
+ * @returns {Promise} 是否成功获取锁
+ */
+ async acquireRefreshLock(accountId, platform = 'claude') {
+ const lockKey = `token_refresh_lock:${platform}:${accountId}`;
+ return await this.acquireLock(lockKey);
+ }
+
+ /**
+ * 释放刷新锁
+ * @param {string} accountId - 账户ID
+ * @param {string} platform - 平台类型 (claude/gemini)
+ */
+ async releaseRefreshLock(accountId, platform = 'claude') {
+ const lockKey = `token_refresh_lock:${platform}:${accountId}`;
+ await this.releaseLock(lockKey);
+ }
+
+ /**
+ * 检查刷新锁状态
+ * @param {string} accountId - 账户ID
+ * @param {string} platform - 平台类型 (claude/gemini)
+ * @returns {Promise} 锁是否存在
+ */
+ async isRefreshLocked(accountId, platform = 'claude') {
+ const lockKey = `token_refresh_lock:${platform}:${accountId}`;
+ try {
+ const client = redis.getClientSafe();
+ const exists = await client.exists(lockKey);
+ return exists === 1;
+ } catch (error) {
+ logger.error(`Failed to check lock status ${lockKey}:`, error);
+ return false;
+ }
+ }
+
+ /**
+ * 获取锁的剩余TTL
+ * @param {string} accountId - 账户ID
+ * @param {string} platform - 平台类型 (claude/gemini)
+ * @returns {Promise} 剩余秒数,-1表示锁不存在
+ */
+ async getLockTTL(accountId, platform = 'claude') {
+ const lockKey = `token_refresh_lock:${platform}:${accountId}`;
+ try {
+ const client = redis.getClientSafe();
+ const ttl = await client.ttl(lockKey);
+ return ttl;
+ } catch (error) {
+ logger.error(`Failed to get lock TTL ${lockKey}:`, error);
+ return -1;
+ }
+ }
+
+ /**
+ * 清理本地锁记录
+ * 在进程退出时调用,避免内存泄漏
+ */
+ cleanup() {
+ this.lockValue.clear();
+ logger.info('🧹 Cleaned up local lock records');
+ }
+}
+
+// 创建单例实例
+const tokenRefreshService = new TokenRefreshService();
+
+module.exports = tokenRefreshService;
\ No newline at end of file
diff --git a/src/utils/oauthHelper.js b/src/utils/oauthHelper.js
index 784d8abf..5389dd86 100644
--- a/src/utils/oauthHelper.js
+++ b/src/utils/oauthHelper.js
@@ -148,7 +148,7 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, {
headers: {
'Content-Type': 'application/json',
- 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
+ 'User-Agent': 'claude-cli/1.0.56 (external, cli)',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': 'https://claude.ai/',
diff --git a/src/utils/tokenMask.js b/src/utils/tokenMask.js
new file mode 100644
index 00000000..81acc9d6
--- /dev/null
+++ b/src/utils/tokenMask.js
@@ -0,0 +1,95 @@
+/**
+ * Token 脱敏工具
+ * 用于在日志中安全显示 token,只显示70%的内容,其余用*代替
+ */
+
+/**
+ * 对 token 进行脱敏处理
+ * @param {string} token - 需要脱敏的 token
+ * @param {number} visiblePercent - 可见部分的百分比,默认 70
+ * @returns {string} 脱敏后的 token
+ */
+function maskToken(token, visiblePercent = 70) {
+ if (!token || typeof token !== 'string') {
+ return '[EMPTY]';
+ }
+
+ const length = token.length;
+
+ // 对于非常短的 token,至少隐藏一部分
+ if (length <= 10) {
+ return token.slice(0, 5) + '*'.repeat(length - 5);
+ }
+
+ // 计算可见字符数量
+ const visibleLength = Math.floor(length * (visiblePercent / 100));
+
+ // 在前部和尾部分配可见字符
+ const frontLength = Math.ceil(visibleLength * 0.6);
+ const backLength = visibleLength - frontLength;
+
+ // 构建脱敏后的 token
+ const front = token.slice(0, frontLength);
+ const back = token.slice(-backLength);
+ const middle = '*'.repeat(length - visibleLength);
+
+ return `${front}${middle}${back}`;
+}
+
+/**
+ * 对包含 token 的对象进行脱敏处理
+ * @param {Object} obj - 包含 token 的对象
+ * @param {Array} tokenFields - 需要脱敏的字段名列表
+ * @returns {Object} 脱敏后的对象副本
+ */
+function maskTokensInObject(obj, tokenFields = ['accessToken', 'refreshToken', 'access_token', 'refresh_token']) {
+ if (!obj || typeof obj !== 'object') {
+ return obj;
+ }
+
+ const masked = { ...obj };
+
+ tokenFields.forEach(field => {
+ if (masked[field]) {
+ masked[field] = maskToken(masked[field]);
+ }
+ });
+
+ return masked;
+}
+
+/**
+ * 格式化 token 刷新日志
+ * @param {string} accountId - 账户 ID
+ * @param {string} accountName - 账户名称
+ * @param {Object} tokens - 包含 access_token 和 refresh_token 的对象
+ * @param {string} status - 刷新状态 (success/failed)
+ * @param {string} message - 额外的消息
+ * @returns {Object} 格式化的日志对象
+ */
+function formatTokenRefreshLog(accountId, accountName, tokens, status, message = '') {
+ const log = {
+ timestamp: new Date().toISOString(),
+ event: 'token_refresh',
+ accountId,
+ accountName,
+ status,
+ message
+ };
+
+ if (tokens) {
+ log.tokens = {
+ accessToken: tokens.accessToken ? maskToken(tokens.accessToken) : '[NOT_PROVIDED]',
+ refreshToken: tokens.refreshToken ? maskToken(tokens.refreshToken) : '[NOT_PROVIDED]',
+ expiresAt: tokens.expiresAt || '[NOT_PROVIDED]'
+ };
+ }
+
+ return log;
+}
+
+module.exports = {
+ maskToken,
+ maskTokensInObject,
+ formatTokenRefreshLog
+};
\ No newline at end of file
diff --git a/src/utils/tokenRefreshLogger.js b/src/utils/tokenRefreshLogger.js
new file mode 100644
index 00000000..17f0a7ed
--- /dev/null
+++ b/src/utils/tokenRefreshLogger.js
@@ -0,0 +1,178 @@
+const winston = require('winston');
+const path = require('path');
+const fs = require('fs');
+const { maskToken, formatTokenRefreshLog } = require('./tokenMask');
+
+// 确保日志目录存在
+const logDir = path.join(process.cwd(), 'logs');
+if (!fs.existsSync(logDir)) {
+ fs.mkdirSync(logDir, { recursive: true });
+}
+
+// 创建专用的 token 刷新日志记录器
+const tokenRefreshLogger = winston.createLogger({
+ level: 'info',
+ format: winston.format.combine(
+ winston.format.timestamp({
+ format: 'YYYY-MM-DD HH:mm:ss.SSS'
+ }),
+ winston.format.json(),
+ winston.format.printf(info => {
+ return JSON.stringify(info, null, 2);
+ })
+ ),
+ transports: [
+ // 文件传输 - 每日轮转
+ new winston.transports.File({
+ filename: path.join(logDir, 'token-refresh.log'),
+ maxsize: 10 * 1024 * 1024, // 10MB
+ maxFiles: 30, // 保留30天
+ tailable: true
+ }),
+ // 错误单独记录
+ new winston.transports.File({
+ filename: path.join(logDir, 'token-refresh-error.log'),
+ level: 'error',
+ maxsize: 10 * 1024 * 1024,
+ maxFiles: 30
+ })
+ ],
+ // 错误处理
+ exitOnError: false
+});
+
+// 在开发环境添加控制台输出
+if (process.env.NODE_ENV !== 'production') {
+ tokenRefreshLogger.add(new winston.transports.Console({
+ format: winston.format.combine(
+ winston.format.colorize(),
+ winston.format.simple()
+ )
+ }));
+}
+
+/**
+ * 记录 token 刷新开始
+ */
+function logRefreshStart(accountId, accountName, platform = 'claude', reason = '') {
+ tokenRefreshLogger.info({
+ event: 'token_refresh_start',
+ accountId,
+ accountName,
+ platform,
+ reason,
+ timestamp: new Date().toISOString()
+ });
+}
+
+/**
+ * 记录 token 刷新成功
+ */
+function logRefreshSuccess(accountId, accountName, platform = 'claude', tokenData = {}) {
+ const maskedTokenData = {
+ accessToken: tokenData.accessToken ? maskToken(tokenData.accessToken) : '[NOT_PROVIDED]',
+ refreshToken: tokenData.refreshToken ? maskToken(tokenData.refreshToken) : '[NOT_PROVIDED]',
+ expiresAt: tokenData.expiresAt || tokenData.expiry_date || '[NOT_PROVIDED]',
+ scopes: tokenData.scopes || tokenData.scope || '[NOT_PROVIDED]'
+ };
+
+ tokenRefreshLogger.info({
+ event: 'token_refresh_success',
+ accountId,
+ accountName,
+ platform,
+ tokenData: maskedTokenData,
+ timestamp: new Date().toISOString()
+ });
+}
+
+/**
+ * 记录 token 刷新失败
+ */
+function logRefreshError(accountId, accountName, platform = 'claude', error, attemptNumber = 1) {
+ const errorInfo = {
+ message: error.message || error.toString(),
+ code: error.code || 'UNKNOWN',
+ statusCode: error.response?.status || 'N/A',
+ responseData: error.response?.data || 'N/A'
+ };
+
+ tokenRefreshLogger.error({
+ event: 'token_refresh_error',
+ accountId,
+ accountName,
+ platform,
+ error: errorInfo,
+ attemptNumber,
+ timestamp: new Date().toISOString()
+ });
+}
+
+/**
+ * 记录 token 刷新跳过(由于并发锁)
+ */
+function logRefreshSkipped(accountId, accountName, platform = 'claude', reason = 'locked') {
+ tokenRefreshLogger.info({
+ event: 'token_refresh_skipped',
+ accountId,
+ accountName,
+ platform,
+ reason,
+ timestamp: new Date().toISOString()
+ });
+}
+
+/**
+ * 记录 token 使用情况
+ */
+function logTokenUsage(accountId, accountName, platform = 'claude', expiresAt, isExpired) {
+ tokenRefreshLogger.debug({
+ event: 'token_usage_check',
+ accountId,
+ accountName,
+ platform,
+ expiresAt,
+ isExpired,
+ remainingMinutes: expiresAt ? Math.floor((new Date(expiresAt) - Date.now()) / 60000) : 'N/A',
+ timestamp: new Date().toISOString()
+ });
+}
+
+/**
+ * 记录批量刷新任务
+ */
+function logBatchRefreshStart(totalAccounts, platform = 'all') {
+ tokenRefreshLogger.info({
+ event: 'batch_refresh_start',
+ totalAccounts,
+ platform,
+ timestamp: new Date().toISOString()
+ });
+}
+
+/**
+ * 记录批量刷新结果
+ */
+function logBatchRefreshComplete(results) {
+ tokenRefreshLogger.info({
+ event: 'batch_refresh_complete',
+ results: {
+ total: results.total || 0,
+ success: results.success || 0,
+ failed: results.failed || 0,
+ skipped: results.skipped || 0
+ },
+ timestamp: new Date().toISOString()
+ });
+}
+
+module.exports = {
+ logger: tokenRefreshLogger,
+ logRefreshStart,
+ logRefreshSuccess,
+ logRefreshError,
+ logRefreshSkipped,
+ logTokenUsage,
+ logBatchRefreshStart,
+ logBatchRefreshComplete
+};
\ No newline at end of file
diff --git a/web/admin/app.js b/web/admin/app.js
index 014703a2..7e2f8ccc 100644
--- a/web/admin/app.js
+++ b/web/admin/app.js
@@ -23,7 +23,7 @@ const app = createApp({
tabs: [
{ key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' },
{ key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' },
- { key: 'accounts', name: 'Claude账户', icon: 'fas fa-user-circle' },
+ { key: 'accounts', name: '账户管理', icon: 'fas fa-user-circle' },
{ key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' }
],
@@ -86,6 +86,7 @@ const app = createApp({
topApiKeys: [],
totalApiKeys: 0
},
+ apiKeysTrendMetric: 'requests', // 'requests' 或 'tokens' - 默认显示请求次数
// 统一的日期筛选
dateFilter: {
@@ -120,6 +121,8 @@ const app = createApp({
rateLimitWindow: '',
rateLimitRequests: '',
claudeAccountId: '',
+ geminiAccountId: '',
+ permissions: 'all', // 'claude', 'gemini', 'all'
enableModelRestriction: false,
restrictedModels: [],
modelInput: ''
@@ -163,6 +166,8 @@ const app = createApp({
rateLimitWindow: '',
rateLimitRequests: '',
claudeAccountId: '',
+ geminiAccountId: '',
+ permissions: 'all',
enableModelRestriction: false,
restrictedModels: [],
modelInput: ''
@@ -174,6 +179,7 @@ const app = createApp({
showCreateAccountModal: false,
createAccountLoading: false,
accountForm: {
+ platform: 'claude', // 'claude' 或 'gemini'
name: '',
description: '',
addType: 'oauth', // 'oauth' 或 'manual'
@@ -184,7 +190,8 @@ const app = createApp({
proxyHost: '',
proxyPort: '',
proxyUsername: '',
- proxyPassword: ''
+ proxyPassword: '',
+ projectId: '' // Gemini 项目编号
},
// 编辑账户相关
@@ -192,6 +199,7 @@ const app = createApp({
editAccountLoading: false,
editAccountForm: {
id: '',
+ platform: 'claude',
name: '',
description: '',
accountType: 'shared',
@@ -202,7 +210,8 @@ const app = createApp({
proxyHost: '',
proxyPort: '',
proxyUsername: '',
- proxyPassword: ''
+ proxyPassword: '',
+ projectId: '' // Gemini 项目编号
},
// OAuth 相关
@@ -214,6 +223,15 @@ const app = createApp({
callbackUrl: ''
},
+ // Gemini OAuth 相关
+ geminiOauthPolling: false,
+ geminiOauthInterval: null,
+ geminiOauthData: {
+ sessionId: '',
+ authUrl: '',
+ code: ''
+ },
+
// 用户菜单和账户修改相关
userMenuOpen: false,
currentUser: {
@@ -228,6 +246,16 @@ const app = createApp({
confirmPassword: ''
},
+ // 确认弹窗相关
+ showConfirmModal: false,
+ confirmModal: {
+ title: '',
+ message: '',
+ confirmText: '继续',
+ cancelText: '取消',
+ onConfirm: null,
+ onCancel: null
+ }
}
},
@@ -312,6 +340,41 @@ const app = createApp({
},
methods: {
+ // 显示确认弹窗
+ showConfirm(title, message, confirmText = '继续', cancelText = '取消') {
+ return new Promise((resolve) => {
+ this.confirmModal = {
+ title,
+ message,
+ confirmText,
+ cancelText,
+ onConfirm: () => {
+ this.showConfirmModal = false;
+ resolve(true);
+ },
+ onCancel: () => {
+ this.showConfirmModal = false;
+ resolve(false);
+ }
+ };
+ this.showConfirmModal = true;
+ });
+ },
+
+ // 处理确认弹窗确定按钮
+ handleConfirmOk() {
+ if (this.confirmModal.onConfirm) {
+ this.confirmModal.onConfirm();
+ }
+ },
+
+ // 处理确认弹窗取消按钮
+ handleConfirmCancel() {
+ if (this.confirmModal.onCancel) {
+ this.confirmModal.onCancel();
+ }
+ },
+
// 获取绑定账号名称
getBoundAccountName(accountId) {
const account = this.accounts.find(acc => acc.id === accountId);
@@ -320,7 +383,9 @@ const app = createApp({
// 获取绑定到特定账号的API Key数量
getBoundApiKeysCount(accountId) {
- return this.apiKeys.filter(key => key.claudeAccountId === accountId).length;
+ return this.apiKeys.filter(key =>
+ key.claudeAccountId === accountId || key.geminiAccountId === accountId
+ ).length;
},
// 添加限制模型
@@ -422,6 +487,7 @@ const app = createApp({
openEditAccountModal(account) {
this.editAccountForm = {
id: account.id,
+ platform: account.platform || 'claude',
name: account.name,
description: account.description || '',
accountType: account.accountType || 'shared',
@@ -432,7 +498,8 @@ const app = createApp({
proxyHost: account.proxy ? account.proxy.host : '',
proxyPort: account.proxy ? account.proxy.port : '',
proxyUsername: account.proxy ? account.proxy.username : '',
- proxyPassword: account.proxy ? account.proxy.password : ''
+ proxyPassword: account.proxy ? account.proxy.password : '',
+ projectId: account.projectId || '' // 添加项目编号
};
this.showEditAccountModal = true;
},
@@ -442,6 +509,7 @@ const app = createApp({
this.showEditAccountModal = false;
this.editAccountForm = {
id: '',
+ platform: 'claude',
name: '',
description: '',
accountType: 'shared',
@@ -452,12 +520,29 @@ const app = createApp({
proxyHost: '',
proxyPort: '',
proxyUsername: '',
- proxyPassword: ''
+ proxyPassword: '',
+ projectId: '' // 重置项目编号
};
},
// 更新账户
async updateAccount() {
+ // 对于Gemini账户,检查项目编号
+ if (this.editAccountForm.platform === 'gemini') {
+ if (!this.editAccountForm.projectId || this.editAccountForm.projectId.trim() === '') {
+ // 使用自定义确认弹窗
+ const confirmed = await this.showConfirm(
+ '项目编号未填写',
+ '您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目编号。\n如果您使用的是普通个人账号,可以继续不填写。',
+ '继续保存',
+ '返回填写'
+ );
+ if (!confirmed) {
+ return;
+ }
+ }
+ }
+
this.editAccountLoading = true;
try {
// 验证账户类型切换
@@ -478,19 +563,42 @@ const app = createApp({
let updateData = {
name: this.editAccountForm.name,
description: this.editAccountForm.description,
- accountType: this.editAccountForm.accountType
+ accountType: this.editAccountForm.accountType,
+ projectId: this.editAccountForm.projectId || '' // 添加项目编号
};
// 只在有值时才更新 token
if (this.editAccountForm.accessToken.trim()) {
- // 构建新的 OAuth 数据
- const newOauthData = {
- accessToken: this.editAccountForm.accessToken,
- refreshToken: this.editAccountForm.refreshToken || '',
- expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000), // 默认设置1年后过期
- scopes: ['user:inference']
- };
- updateData.claudeAiOauth = newOauthData;
+ if (this.editAccountForm.platform === 'gemini') {
+ // Gemini OAuth 数据格式
+ // 如果有 Refresh Token,设置10分钟过期;否则设置1年
+ const expiresInMs = this.editAccountForm.refreshToken
+ ? (10 * 60 * 1000) // 10分钟
+ : (365 * 24 * 60 * 60 * 1000); // 1年
+
+ const newOauthData = {
+ access_token: this.editAccountForm.accessToken,
+ refresh_token: this.editAccountForm.refreshToken || '',
+ scope: 'https://www.googleapis.com/auth/cloud-platform',
+ token_type: 'Bearer',
+ expiry_date: Date.now() + expiresInMs
+ };
+ updateData.geminiOauth = newOauthData;
+ } else {
+ // Claude OAuth 数据格式
+ // 如果有 Refresh Token,设置10分钟过期;否则设置1年
+ const expiresInMs = this.editAccountForm.refreshToken
+ ? (10 * 60 * 1000) // 10分钟
+ : (365 * 24 * 60 * 60 * 1000); // 1年
+
+ const newOauthData = {
+ accessToken: this.editAccountForm.accessToken,
+ refreshToken: this.editAccountForm.refreshToken || '',
+ expiresAt: Date.now() + expiresInMs,
+ scopes: ['user:inference']
+ };
+ updateData.claudeAiOauth = newOauthData;
+ }
}
// 更新代理配置
@@ -506,7 +614,12 @@ const app = createApp({
updateData.proxy = null;
}
- const response = await fetch(`/admin/claude-accounts/${this.editAccountForm.id}`, {
+ // 根据平台选择端点
+ const endpoint = this.editAccountForm.platform === 'gemini'
+ ? `/admin/gemini-accounts/${this.editAccountForm.id}`
+ : `/admin/claude-accounts/${this.editAccountForm.id}`;
+
+ const response = await fetch(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -549,6 +662,7 @@ const app = createApp({
// 重置账户表单
resetAccountForm() {
this.accountForm = {
+ platform: 'claude',
name: '',
description: '',
addType: 'oauth',
@@ -559,7 +673,8 @@ const app = createApp({
proxyHost: '',
proxyPort: '',
proxyUsername: '',
- proxyPassword: ''
+ proxyPassword: '',
+ projectId: '' // 重置项目编号
};
this.oauthStep = 1;
this.oauthData = {
@@ -567,10 +682,37 @@ const app = createApp({
authUrl: '',
callbackUrl: ''
};
+ this.geminiOauthData = {
+ sessionId: '',
+ authUrl: '',
+ code: ''
+ };
+ // 停止 Gemini OAuth 轮询
+ if (this.geminiOauthInterval) {
+ clearInterval(this.geminiOauthInterval);
+ this.geminiOauthInterval = null;
+ }
+ this.geminiOauthPolling = false;
},
// OAuth步骤前进
- nextOAuthStep() {
+ async nextOAuthStep() {
+ // 对于Gemini账户,检查项目编号
+ if (this.accountForm.platform === 'gemini' && this.oauthStep === 1 && this.accountForm.addType === 'oauth') {
+ if (!this.accountForm.projectId || this.accountForm.projectId.trim() === '') {
+ // 使用自定义确认弹窗
+ const confirmed = await this.showConfirm(
+ '项目编号未填写',
+ '您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目编号。\n如果您使用的是普通个人账号,可以继续不填写。',
+ '继续',
+ '返回填写'
+ );
+ if (!confirmed) {
+ return;
+ }
+ }
+ }
+
if (this.oauthStep < 3) {
this.oauthStep++;
}
@@ -592,7 +734,11 @@ const app = createApp({
};
}
- const response = await fetch('/admin/claude-accounts/generate-auth-url', {
+ const endpoint = this.accountForm.platform === 'gemini'
+ ? '/admin/gemini-accounts/generate-auth-url'
+ : '/admin/claude-accounts/generate-auth-url';
+
+ const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -606,8 +752,14 @@ const app = createApp({
const data = await response.json();
if (data.success) {
- this.oauthData.authUrl = data.data.authUrl;
- this.oauthData.sessionId = data.data.sessionId;
+ if (this.accountForm.platform === 'gemini') {
+ this.geminiOauthData.authUrl = data.data.authUrl;
+ this.geminiOauthData.sessionId = data.data.sessionId;
+ // 不再自动开始轮询,改为手动输入授权码
+ } else {
+ this.oauthData.authUrl = data.data.authUrl;
+ this.oauthData.sessionId = data.data.sessionId;
+ }
this.showToast('授权链接生成成功!', 'success', '生成成功');
} else {
this.showToast(data.message || '生成失败', 'error', '生成失败');
@@ -633,6 +785,12 @@ const app = createApp({
// 创建OAuth账户
async createOAuthAccount() {
+ // 如果是 Gemini,不应该调用这个方法
+ if (this.accountForm.platform === 'gemini') {
+ console.error('createOAuthAccount should not be called for Gemini');
+ return;
+ }
+
this.createAccountLoading = true;
try {
// 首先交换authorization code获取token
@@ -735,28 +893,65 @@ const app = createApp({
};
}
- // 构建手动 OAuth 数据
- const manualOauthData = {
- accessToken: this.accountForm.accessToken,
- refreshToken: this.accountForm.refreshToken || '',
- expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000), // 默认设置1年后过期
- scopes: ['user:inference'] // 默认权限
- };
+ // 根据平台构建 OAuth 数据
+ let endpoint, bodyData;
- // 创建账户
- const createResponse = await fetch('/admin/claude-accounts', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': 'Bearer ' + this.authToken
- },
- body: JSON.stringify({
+ if (this.accountForm.platform === 'gemini') {
+ // Gemini 账户
+ // 如果有 Refresh Token,设置10分钟过期;否则设置1年
+ const expiresInMs = this.accountForm.refreshToken
+ ? (10 * 60 * 1000) // 10分钟
+ : (365 * 24 * 60 * 60 * 1000); // 1年
+
+ const geminiOauthData = {
+ access_token: this.accountForm.accessToken,
+ refresh_token: this.accountForm.refreshToken || '',
+ scope: 'https://www.googleapis.com/auth/cloud-platform',
+ token_type: 'Bearer',
+ expiry_date: Date.now() + expiresInMs
+ };
+
+ endpoint = '/admin/gemini-accounts';
+ bodyData = {
+ name: this.accountForm.name,
+ description: this.accountForm.description,
+ geminiOauth: geminiOauthData,
+ proxy: proxy,
+ accountType: this.accountForm.accountType,
+ projectId: this.accountForm.projectId || '' // 添加项目编号
+ };
+ } else {
+ // Claude 账户
+ // 如果有 Refresh Token,设置10分钟过期;否则设置1年
+ const expiresInMs = this.accountForm.refreshToken
+ ? (10 * 60 * 1000) // 10分钟
+ : (365 * 24 * 60 * 60 * 1000); // 1年
+
+ const manualOauthData = {
+ accessToken: this.accountForm.accessToken,
+ refreshToken: this.accountForm.refreshToken || '',
+ expiresAt: Date.now() + expiresInMs,
+ scopes: ['user:inference'] // 默认权限
+ };
+
+ endpoint = '/admin/claude-accounts';
+ bodyData = {
name: this.accountForm.name,
description: this.accountForm.description,
claudeAiOauth: manualOauthData,
proxy: proxy,
accountType: this.accountForm.accountType
- })
+ };
+ }
+
+ // 创建账户
+ const createResponse = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + this.authToken
+ },
+ body: JSON.stringify(bodyData)
});
const createData = await createResponse.json();
@@ -790,6 +985,131 @@ const app = createApp({
}
},
+ // Gemini OAuth 轮询
+ async startGeminiOAuthPolling() {
+ if (this.geminiOauthPolling) return;
+
+ this.geminiOauthPolling = true;
+ let attempts = 0;
+ const maxAttempts = 30; // 最多轮询 30 次(60秒)
+
+ this.geminiOauthInterval = setInterval(async () => {
+ attempts++;
+
+ try {
+ const response = await fetch('/admin/gemini-accounts/poll-auth-status', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + this.authToken
+ },
+ body: JSON.stringify({
+ sessionId: this.geminiOauthData.sessionId
+ })
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ // 授权成功
+ this.stopGeminiOAuthPolling();
+ this.geminiOauthData.code = 'authorized';
+
+ // 自动创建账户
+ await this.createGeminiOAuthAccount(data.data.tokens);
+ } else if (data.error === 'Authorization timeout' || attempts >= maxAttempts) {
+ // 超时
+ this.stopGeminiOAuthPolling();
+ this.showToast('授权超时,请重试', 'error', '授权超时');
+ }
+ } catch (error) {
+ console.error('Polling error:', error);
+ if (attempts >= maxAttempts) {
+ this.stopGeminiOAuthPolling();
+ this.showToast('轮询失败,请检查网络连接', 'error', '网络错误');
+ }
+ }
+ }, 2000); // 每2秒轮询一次
+ },
+
+ stopGeminiOAuthPolling() {
+ if (this.geminiOauthInterval) {
+ clearInterval(this.geminiOauthInterval);
+ this.geminiOauthInterval = null;
+ }
+ this.geminiOauthPolling = false;
+ },
+
+ // 创建 Gemini OAuth 账户
+ async createGeminiOAuthAccount() {
+ this.createAccountLoading = true;
+ try {
+ // 首先交换授权码获取 tokens
+ const tokenResponse = await fetch('/admin/gemini-accounts/exchange-code', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + this.authToken
+ },
+ body: JSON.stringify({
+ code: this.geminiOauthData.code,
+ sessionId: this.geminiOauthData.sessionId
+ })
+ });
+
+ const tokenData = await tokenResponse.json();
+
+ if (!tokenData.success) {
+ this.showToast(tokenData.message || '授权码交换失败', 'error', '交换失败');
+ return;
+ }
+
+ // 构建代理配置
+ let proxy = null;
+ if (this.accountForm.proxyType) {
+ proxy = {
+ type: this.accountForm.proxyType,
+ host: this.accountForm.proxyHost,
+ port: parseInt(this.accountForm.proxyPort),
+ username: this.accountForm.proxyUsername || null,
+ password: this.accountForm.proxyPassword || null
+ };
+ }
+
+ // 创建账户
+ const response = await fetch('/admin/gemini-accounts', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + this.authToken
+ },
+ body: JSON.stringify({
+ name: this.accountForm.name,
+ description: this.accountForm.description,
+ geminiOauth: tokenData.data.tokens,
+ proxy: proxy,
+ accountType: this.accountForm.accountType,
+ projectId: this.accountForm.projectId || '' // 添加项目编号
+ })
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.showToast('Gemini OAuth账户创建成功!', 'success', '账户创建成功');
+ this.closeCreateAccountModal();
+ await this.loadAccounts();
+ } else {
+ this.showToast(data.message || 'Account creation failed', 'error', 'Creation Failed');
+ }
+ } catch (error) {
+ console.error('Error creating Gemini OAuth account:', error);
+ this.showToast('创建失败,请检查网络连接', 'error', '网络错误', 8000);
+ } finally {
+ this.createAccountLoading = false;
+ }
+ },
+
// 根据当前标签页加载数据
loadCurrentTabData() {
@@ -1160,18 +1480,50 @@ const app = createApp({
async loadAccounts() {
this.accountsLoading = true;
try {
- const response = await fetch('/admin/claude-accounts', {
- headers: { 'Authorization': 'Bearer ' + this.authToken }
- });
- const data = await response.json();
+ // 并行加载 Claude 和 Gemini 账户
+ const [claudeResponse, geminiResponse] = await Promise.all([
+ fetch('/admin/claude-accounts', {
+ headers: { 'Authorization': 'Bearer ' + this.authToken }
+ }),
+ fetch('/admin/gemini-accounts', {
+ headers: { 'Authorization': 'Bearer ' + this.authToken }
+ })
+ ]);
- if (data.success) {
- this.accounts = data.data || [];
- // 为每个账号计算绑定的API Key数量
- this.accounts.forEach(account => {
- account.boundApiKeysCount = this.apiKeys.filter(key => key.claudeAccountId === account.id).length;
- });
+ const [claudeData, geminiData] = await Promise.all([
+ claudeResponse.json(),
+ geminiResponse.json()
+ ]);
+
+ // 合并账户数据
+ const allAccounts = [];
+
+ if (claudeData.success) {
+ const claudeAccounts = (claudeData.data || []).map(acc => ({
+ ...acc,
+ platform: 'claude'
+ }));
+ allAccounts.push(...claudeAccounts);
}
+
+ if (geminiData.success) {
+ const geminiAccounts = (geminiData.data || []).map(acc => ({
+ ...acc,
+ platform: 'gemini'
+ }));
+ allAccounts.push(...geminiAccounts);
+ }
+
+ this.accounts = allAccounts;
+
+ // 为每个账号计算绑定的API Key数量
+ this.accounts.forEach(account => {
+ if (account.platform === 'claude') {
+ account.boundApiKeysCount = this.apiKeys.filter(key => key.claudeAccountId === account.id).length;
+ } else {
+ account.boundApiKeysCount = this.apiKeys.filter(key => key.geminiAccountId === account.id).length;
+ }
+ });
} catch (error) {
console.error('Failed to load accounts:', error);
} finally {
@@ -1253,7 +1605,13 @@ const app = createApp({
},
async deleteApiKey(keyId) {
- if (!confirm('确定要删除这个 API Key 吗?')) return;
+ const confirmed = await this.showConfirm(
+ '删除 API Key',
+ '确定要删除这个 API Key 吗?\n\n此操作不可撤销,删除后将无法恢复。',
+ '确认删除',
+ '取消'
+ );
+ if (!confirmed) return;
try {
const response = await fetch('/admin/api-keys/' + keyId, {
@@ -1350,6 +1708,13 @@ const app = createApp({
await this.loadApiKeys();
}
+ // 查找账户以确定平台类型
+ const account = this.accounts.find(acc => acc.id === accountId);
+ if (!account) {
+ this.showToast('账户不存在', 'error', '删除失败');
+ return;
+ }
+
// 检查是否有API Key绑定到此账号
const boundKeysCount = this.getBoundApiKeysCount(accountId);
if (boundKeysCount > 0) {
@@ -1357,10 +1722,22 @@ const app = createApp({
return;
}
- if (!confirm('确定要删除这个 Claude 账户吗?')) return;
+ const platformName = account.platform === 'gemini' ? 'Gemini' : 'Claude';
+ const confirmed = await this.showConfirm(
+ `删除 ${platformName} 账户`,
+ `确定要删除这个 ${platformName} 账户吗?\n\n账户名称:${account.name}\n此操作不可撤销,删除后将无法恢复。`,
+ '确认删除',
+ '取消'
+ );
+ if (!confirmed) return;
+
+ // 根据平台选择端点
+ const endpoint = account.platform === 'gemini'
+ ? `/admin/gemini-accounts/${accountId}`
+ : `/admin/claude-accounts/${accountId}`;
try {
- const response = await fetch('/admin/claude-accounts/' + accountId, {
+ const response = await fetch(endpoint, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + this.authToken }
});
@@ -1379,6 +1756,40 @@ const app = createApp({
}
},
+ // 刷新账户 Token
+ async refreshAccountToken(accountId) {
+ const account = this.accounts.find(acc => acc.id === accountId);
+ if (!account) {
+ this.showToast('账户不存在', 'error', '刷新失败');
+ return;
+ }
+
+ // 根据平台选择端点
+ const endpoint = account.platform === 'gemini'
+ ? `/admin/gemini-accounts/${accountId}/refresh`
+ : `/admin/claude-accounts/${accountId}/refresh`;
+
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + this.authToken }
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ const platformName = account.platform === 'gemini' ? 'Gemini' : 'Claude';
+ this.showToast(`${platformName} Token 刷新成功`, 'success', '刷新成功');
+ await this.loadAccounts();
+ } else {
+ this.showToast(data.message || '刷新失败', 'error', '刷新失败');
+ }
+ } catch (error) {
+ console.error('Error refreshing token:', error);
+ this.showToast('刷新失败,请检查网络连接', 'error', '网络错误');
+ }
+ },
+
// API Key 展示相关方法
toggleApiKeyVisibility() {
this.newApiKey.showFullKey = !this.newApiKey.showFullKey;
@@ -1416,9 +1827,15 @@ const app = createApp({
}
},
- closeNewApiKeyModal() {
+ async closeNewApiKeyModal() {
// 显示确认提示
- if (confirm('关闭后将无法再次查看完整的API Key,请确保已经妥善保存。确定要关闭吗?')) {
+ const confirmed = await this.showConfirm(
+ '关闭 API Key',
+ '关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?',
+ '我已保存',
+ '取消'
+ );
+ if (confirmed) {
this.showNewApiKeyModal = false;
this.newApiKey = { key: '', name: '', description: '', showFullKey: false };
}
@@ -1991,7 +2408,10 @@ const app = createApp({
// 只显示前10个使用量最多的API Key
this.apiKeysTrendData.topApiKeys.forEach((apiKeyId, index) => {
const data = this.apiKeysTrendData.data.map(item => {
- return item.apiKeys[apiKeyId] ? item.apiKeys[apiKeyId].tokens : 0;
+ if (!item.apiKeys[apiKeyId]) return 0;
+ return this.apiKeysTrendMetric === 'tokens'
+ ? item.apiKeys[apiKeyId].tokens
+ : item.apiKeys[apiKeyId].requests || 0;
});
// 获取API Key名称
@@ -2049,7 +2469,7 @@ const app = createApp({
position: 'left',
title: {
display: true,
- text: 'Token 数量'
+ text: this.apiKeysTrendMetric === 'tokens' ? 'Token 数量' : '请求次数'
},
ticks: {
callback: function(value) {
@@ -2087,10 +2507,11 @@ const app = createApp({
}
return tooltipItems[0].label;
},
- label: function(context) {
+ label: (context) => {
const label = context.dataset.label || '';
const value = context.parsed.y;
- return label + ': ' + value.toLocaleString() + ' tokens';
+ const unit = this.apiKeysTrendMetric === 'tokens' ? ' tokens' : ' 次';
+ return label + ': ' + value.toLocaleString() + unit;
}
}
}
diff --git a/web/admin/index.html b/web/admin/index.html
index 6b1c7d43..1163ec62 100644
--- a/web/admin/index.html
+++ b/web/admin/index.html
@@ -158,7 +158,7 @@
-
Claude账户
+
服务账户
{{ dashboardData.totalAccounts }}
活跃: {{ dashboardData.activeAccounts || 0 }}
@@ -406,10 +406,37 @@
-
+
-
API Keys Token 消耗趋势
+
+
API Keys 使用趋势
+
+
+
+
+
+
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key,显示使用量前 10 个
@@ -770,8 +797,8 @@
-
Claude 账户管理
-
管理您的 Claude 账户和代理配置
+
账户管理
+
管理您的 Claude 和 Gemini 账户及代理配置