From 38c1fc4785be450083130e5f734a71736da8d4f9 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 22 Jul 2025 10:17:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=9A=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=94=AF=E6=8C=81=E5=92=8COpenAI=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Gemini 模型支持和账户管理功能 - 实现 OpenAI 格式到 Claude/Gemini 的请求转换 - 添加自动 token 刷新服务,支持提前刷新策略 - 增强 Web 管理界面,支持 Gemini 账户管理 - 优化 token 显示,添加掩码功能 - 完善日志记录和错误处理 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package-lock.json | 220 +++++++++ package.json | 3 +- src/app.js | 6 + src/routes/admin.js | 220 ++++++++- src/routes/geminiRoutes.js | 275 +++++++++++ src/routes/openaiClaudeRoutes.js | 380 +++++++++++++++ src/routes/openaiGeminiRoutes.js | 297 ++++++++++++ src/routes/web.js | 171 +++++++ src/services/apiKeyService.js | 19 +- src/services/claudeAccountService.js | 94 +++- src/services/claudeRelayService.js | 25 +- src/services/geminiAccountService.js | 673 +++++++++++++++++++++++++++ src/services/geminiRelayService.js | 379 +++++++++++++++ src/services/openaiToClaude.js | 381 +++++++++++++++ src/services/tokenRefreshService.js | 147 ++++++ src/utils/oauthHelper.js | 2 +- src/utils/tokenMask.js | 95 ++++ src/utils/tokenRefreshLogger.js | 178 +++++++ web/admin/app.js | 533 ++++++++++++++++++--- web/admin/index.html | 642 ++++++++++++++++++++----- 20 files changed, 4551 insertions(+), 189 deletions(-) create mode 100644 src/routes/geminiRoutes.js create mode 100644 src/routes/openaiClaudeRoutes.js create mode 100644 src/routes/openaiGeminiRoutes.js create mode 100644 src/services/geminiAccountService.js create mode 100644 src/services/geminiRelayService.js create mode 100644 src/services/openaiToClaude.js create mode 100644 src/services/tokenRefreshService.js create mode 100644 src/utils/tokenMask.js create mode 100644 src/utils/tokenRefreshLogger.js 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 账户及代理配置

+ + + Gemini + + + Claude + + @@ -1801,39 +1839,65 @@ >
-
- - -

设置时间窗口(分钟),在此时间内限制请求次数或Token使用量

-
- -
- - -

在时间窗口内允许的最大请求次数

-
- -
- - -

设置在时间窗口内的最大 Token 使用量(需同时设置时间窗口)

+ +
+
+
+ +
+
+

速率限制设置 (可选)

+

控制 API Key 的使用频率和资源消耗

+
+
+ +
+ + +

设置一个时间段(以分钟为单位),用于计算速率限制

+
+ +
+ + +

在时间窗口内允许的最大请求次数(需要先设置时间窗口)

+
+ +
+ + +

在时间窗口内允许消耗的最大 Token 数量(需要先设置时间窗口)

+
+ + +
+
💡 使用示例
+
+

示例1: 时间窗口=60,请求次数限制=100

+

→ 每60分钟内最多允许100次请求

+

示例2: 时间窗口=10,Token限制=50000

+

→ 每10分钟内最多消耗50,000个Token

+

示例3: 时间窗口=30,请求次数限制=50,Token限制=100000

+

→ 每30分钟内最多50次请求且总Token不超过100,000

+
+
@@ -1858,21 +1922,78 @@ >
+
+ +
+ + + +
+

控制此 API Key 可以访问哪些服务

+
+
- +
+
+ + +
+
+ + +
+

选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池

@@ -1984,40 +2105,66 @@

名称不可修改

-
- - -

设置时间窗口(分钟),在此时间内限制请求次数或Token使用量

-
- -
- - -

在时间窗口内允许的最大请求次数

-
- -
- - -

设置在时间窗口内的最大 Token 使用量(需同时设置时间窗口),0 或留空表示无限制

+ +
+
+
+ +
+
+

速率限制设置

+

控制 API Key 的使用频率和资源消耗

+
+
+ +
+ + +

设置一个时间段(以分钟为单位),用于计算速率限制

+
+ +
+ + +

在时间窗口内允许的最大请求次数(需要先设置时间窗口)

+
+ +
+ + +

在时间窗口内允许消耗的最大 Token 数量(需要先设置时间窗口),0 或留空表示无限制

+
+ + +
+
💡 使用示例
+
+

示例1: 时间窗口=60,请求次数限制=100

+

→ 每60分钟内最多允许100次请求

+

示例2: 时间窗口=10,Token限制=50000

+

→ 每10分钟内最多消耗50,000个Token

+

示例3: 时间窗口=30,请求次数限制=50,Token限制=100000

+

→ 每30分钟内最多50次请求且总Token不超过100,000

+
+
@@ -2032,21 +2179,78 @@

设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制

+
+ +
+ + + +
+

控制此 API Key 可以访问哪些服务

+
+
- +
+
+ + +
+
+ + +
+

修改绑定账号将影响此API Key的请求路由

@@ -2222,7 +2426,7 @@
- + + + +
+ + +

+ + 授权完成后,从回调页面复制授权码并粘贴到此处 +

+
+
+
+ +
+ + +
- + +