diff --git a/.github/secret_scanning.yml b/.github/secret_scanning.yml new file mode 100644 index 00000000..9350743b --- /dev/null +++ b/.github/secret_scanning.yml @@ -0,0 +1,6 @@ +# GitHub Secret Scanning Configuration +# This file excludes specific paths from secret scanning + +paths-ignore: + - 'src/services/geminiAccountService.js' + - 'data/demo/Gemini-CLI-2-API/gemini-core.js' \ No newline at end of file diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index aeb0a145..122e56b5 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -127,18 +127,32 @@ jobs: prerelease: false generate_release_notes: true + - name: Update VERSION file + if: steps.check_changes.outputs.has_changes == 'true' + run: | + # 更新 VERSION 文件 + echo "${{ steps.next_version.outputs.new_version }}" > VERSION + + # 检查是否有更改 + if git diff --quiet VERSION; then + echo "VERSION file already up to date" + else + git add VERSION + echo "Updated VERSION file to ${{ steps.next_version.outputs.new_version }}" + fi + - name: Update CHANGELOG.md if: steps.check_changes.outputs.has_changes == 'true' run: | # 生成完整的 CHANGELOG git cliff --config .github/cliff.toml --output CHANGELOG.md - # 提交 CHANGELOG 更新 - if git diff --quiet CHANGELOG.md; then - echo "No changes to CHANGELOG.md" + # 提交 CHANGELOG 和 VERSION 更新 + if git diff --quiet CHANGELOG.md VERSION; then + echo "No changes to CHANGELOG.md or VERSION" else - git add CHANGELOG.md - git commit -m "chore: update CHANGELOG.md for ${{ steps.next_version.outputs.new_tag }} [skip ci]" + git add CHANGELOG.md VERSION + git commit -m "chore: update CHANGELOG.md and VERSION for ${{ steps.next_version.outputs.new_tag }} [skip ci]" git push origin main fi diff --git a/.gitignore b/.gitignore index 4cf0fb8d..ae992edc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ pnpm-debug.log* data/ !data/.gitkeep +# Redis data directory +redis_data/ + # Logs directory logs/ *.log diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..ab679818 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.1.6 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index cf4754d4..8bd4bbf6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,9 +36,8 @@ services: ports: - "${REDIS_PORT:-6379}:6379" volumes: - - redis_data:/data - - ./config/redis.conf:/usr/local/etc/redis/redis.conf:ro - command: redis-server /usr/local/etc/redis/redis.conf + - ./redis_data:/data + command: redis-server --save 60 1 --appendonly yes --appendfsync everysec networks: - claude-relay-network healthcheck: @@ -104,8 +103,6 @@ services: - monitoring volumes: - redis_data: - driver: local prometheus_data: driver: local grafana_data: 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..26ed5141 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 { @@ -95,8 +98,12 @@ class Application { // 🛣️ 路由 this.app.use('/api', apiRoutes); + this.app.use('/claude', apiRoutes); // /claude 路由别名,与 /api 功能相同 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) => { @@ -115,10 +122,35 @@ class Application { ]); const memory = process.memoryUsage(); + + // 获取版本号:优先使用环境变量,其次VERSION文件,再次package.json,最后使用默认值 + let version = process.env.APP_VERSION || process.env.VERSION; + if (!version) { + try { + // 尝试从VERSION文件读取 + const fs = require('fs'); + const path = require('path'); + const versionFile = path.join(__dirname, '..', 'VERSION'); + if (fs.existsSync(versionFile)) { + version = fs.readFileSync(versionFile, 'utf8').trim(); + } + } catch (error) { + // 忽略错误,继续尝试其他方式 + } + } + if (!version) { + try { + const packageJson = require('../package.json'); + version = packageJson.version; + } catch (error) { + version = '1.0.0'; + } + } + const health = { status: 'healthy', service: 'claude-relay-service', - version: '1.0.0', + version: version, timestamp: new Date().toISOString(), uptime: process.uptime(), memory: { diff --git a/src/middleware/auth.js b/src/middleware/auth.js index ee39577e..6bd9929b 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -198,11 +198,14 @@ const authenticateApiKey = async (req, res, next) => { name: validation.keyData.name, tokenLimit: validation.keyData.tokenLimit, claudeAccountId: validation.keyData.claudeAccountId, + geminiAccountId: validation.keyData.geminiAccountId, + permissions: validation.keyData.permissions, concurrencyLimit: validation.keyData.concurrencyLimit, rateLimitWindow: validation.keyData.rateLimitWindow, rateLimitRequests: validation.keyData.rateLimitRequests, enableModelRestriction: validation.keyData.enableModelRestriction, - restrictedModels: validation.keyData.restrictedModels + restrictedModels: validation.keyData.restrictedModels, + usage: validation.keyData.usage }; req.usage = validation.keyData.usage; @@ -460,9 +463,9 @@ const securityMiddleware = (req, res, next) => { if (req.path.startsWith('/web') || req.path === '/') { res.setHeader('Content-Security-Policy', [ 'default-src \'self\'', - 'script-src \'self\' \'unsafe-inline\' \'unsafe-eval\' https://unpkg.com https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net', - 'style-src \'self\' \'unsafe-inline\' https://cdn.tailwindcss.com https://cdnjs.cloudflare.com', - 'font-src \'self\' https://cdnjs.cloudflare.com', + 'script-src \'self\' \'unsafe-inline\' \'unsafe-eval\' https://unpkg.com https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://cdn.bootcdn.net', + 'style-src \'self\' \'unsafe-inline\' https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.bootcdn.net', + 'font-src \'self\' https://cdnjs.cloudflare.com https://cdn.bootcdn.net', 'img-src \'self\' data:', 'connect-src \'self\'', 'frame-ancestors \'none\'', diff --git a/src/routes/admin.js b/src/routes/admin.js index cf726bf2..a8a2fbb9 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1,12 +1,17 @@ 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'); const oauthHelper = require('../utils/oauthHelper'); const CostCalculator = require('../utils/costCalculator'); const pricingService = require('../services/pricingService'); +const claudeCodeHeadersService = require('../services/claudeCodeHeadersService'); +const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); const router = express.Router(); @@ -32,6 +37,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { tokenLimit, expiresAt, claudeAccountId, + geminiAccountId, + permissions, concurrencyLimit, rateLimitWindow, rateLimitRequests, @@ -84,6 +91,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { tokenLimit, expiresAt, claudeAccountId, + geminiAccountId, + permissions, concurrencyLimit, rateLimitWindow, rateLimitRequests, @@ -103,7 +112,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 +150,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 +403,181 @@ 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; + + // 使用固定的 localhost:45462 作为回调地址 + const redirectUri = 'http://localhost:45462'; + + 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: 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' }); + } + + // 使用固定的 localhost:45462 作为 redirect_uri + const redirectUri = 'http://localhost:45462'; + logger.info(`Using fixed redirect_uri: ${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 +592,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 +630,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 +1266,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 +1311,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 }; } } @@ -1366,4 +1562,236 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { } }); +// 📋 获取所有账号的 Claude Code headers 信息 +router.get('/claude-code-headers', authenticateAdmin, async (req, res) => { + try { + const allHeaders = await claudeCodeHeadersService.getAllAccountHeaders(); + + // 获取所有 Claude 账号信息 + const accounts = await claudeAccountService.getAllAccounts(); + const accountMap = {}; + accounts.forEach(account => { + accountMap[account.id] = account.name; + }); + + // 格式化输出 + const formattedData = Object.entries(allHeaders).map(([accountId, data]) => ({ + accountId, + accountName: accountMap[accountId] || 'Unknown', + version: data.version, + userAgent: data.headers['user-agent'], + updatedAt: data.updatedAt, + headers: data.headers + })); + + res.json({ + success: true, + data: formattedData + }); + } catch (error) { + logger.error('❌ Failed to get Claude Code headers:', error); + res.status(500).json({ error: 'Failed to get Claude Code headers', message: error.message }); + } +}); + +// 🗑️ 清除指定账号的 Claude Code headers +router.delete('/claude-code-headers/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params; + await claudeCodeHeadersService.clearAccountHeaders(accountId); + + res.json({ + success: true, + message: `Claude Code headers cleared for account ${accountId}` + }); + } catch (error) { + logger.error('❌ Failed to clear Claude Code headers:', error); + res.status(500).json({ error: 'Failed to clear Claude Code headers', message: error.message }); + } +}); + +// 🔄 版本检查 +router.get('/check-updates', authenticateAdmin, async (req, res) => { + // 读取当前版本 + const versionPath = path.join(__dirname, '../../VERSION'); + let currentVersion = '1.0.0'; + try { + currentVersion = fs.readFileSync(versionPath, 'utf8').trim(); + } catch (err) { + logger.warn('⚠️ Could not read VERSION file:', err.message); + } + + try { + + // 从缓存获取 + const cacheKey = 'version_check_cache'; + const cached = await redis.getClient().get(cacheKey); + + if (cached && !req.query.force) { + const cachedData = JSON.parse(cached); + const cacheAge = Date.now() - cachedData.timestamp; + + // 缓存有效期1小时 + if (cacheAge < 3600000) { + // 实时计算 hasUpdate,不使用缓存的值 + const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0; + + return res.json({ + success: true, + data: { + current: currentVersion, + latest: cachedData.latest, + hasUpdate: hasUpdate, // 实时计算,不用缓存 + releaseInfo: cachedData.releaseInfo, + cached: true + } + }); + } + } + + // 请求 GitHub API + const githubRepo = 'wei-shaw/claude-relay-service'; + const response = await axios.get( + `https://api.github.com/repos/${githubRepo}/releases/latest`, + { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'Claude-Relay-Service' + }, + timeout: 10000 + } + ); + + const release = response.data; + const latestVersion = release.tag_name.replace(/^v/, ''); + + // 比较版本 + const hasUpdate = compareVersions(currentVersion, latestVersion) < 0; + + const releaseInfo = { + name: release.name, + body: release.body, + publishedAt: release.published_at, + htmlUrl: release.html_url + }; + + // 缓存结果(不缓存 hasUpdate,因为它应该实时计算) + await redis.getClient().set(cacheKey, JSON.stringify({ + latest: latestVersion, + releaseInfo, + timestamp: Date.now() + }), 'EX', 3600); // 1小时过期 + + res.json({ + success: true, + data: { + current: currentVersion, + latest: latestVersion, + hasUpdate, + releaseInfo, + cached: false + } + }); + + } catch (error) { + // 改进错误日志记录 + const errorDetails = { + message: error.message || 'Unknown error', + code: error.code, + response: error.response ? { + status: error.response.status, + statusText: error.response.statusText, + data: error.response.data + } : null, + request: error.request ? 'Request was made but no response received' : null + }; + + logger.error('❌ Failed to check for updates:', errorDetails.message); + + // 处理 404 错误 - 仓库或版本不存在 + if (error.response && error.response.status === 404) { + return res.json({ + success: true, + data: { + current: currentVersion, + latest: currentVersion, + hasUpdate: false, + releaseInfo: { + name: 'No releases found', + body: 'The GitHub repository has no releases yet.', + publishedAt: new Date().toISOString(), + htmlUrl: '#' + }, + warning: 'GitHub repository has no releases' + } + }); + } + + // 如果是网络错误,尝试返回缓存的数据 + if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') { + const cacheKey = 'version_check_cache'; + const cached = await redis.getClient().get(cacheKey); + + if (cached) { + const cachedData = JSON.parse(cached); + // 实时计算 hasUpdate + const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0; + + return res.json({ + success: true, + data: { + current: currentVersion, + latest: cachedData.latest, + hasUpdate: hasUpdate, // 实时计算 + releaseInfo: cachedData.releaseInfo, + cached: true, + warning: 'Using cached data due to network error' + } + }); + } + } + + // 其他错误返回当前版本信息 + res.json({ + success: true, + data: { + current: currentVersion, + latest: currentVersion, + hasUpdate: false, + releaseInfo: { + name: 'Update check failed', + body: `Unable to check for updates: ${error.message || 'Unknown error'}`, + publishedAt: new Date().toISOString(), + htmlUrl: '#' + }, + error: true, + warning: error.message || 'Failed to check for updates' + } + }); + } +}); + +// 版本比较函数 +function compareVersions(current, latest) { + const parseVersion = (v) => { + const parts = v.split('.').map(Number); + return { + major: parts[0] || 0, + minor: parts[1] || 0, + patch: parts[2] || 0 + }; + }; + + const currentV = parseVersion(current); + const latestV = parseVersion(latest); + + if (currentV.major !== latestV.major) { + return currentV.major - latestV.major; + } + if (currentV.minor !== latestV.minor) { + return currentV.minor - latestV.minor; + } + return currentV.patch - latestV.patch; +} + module.exports = router; \ No newline at end of file diff --git a/src/routes/api.js b/src/routes/api.js index ed70ed2a..272766b5 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -7,8 +7,8 @@ const redis = require('../models/redis'); const router = express.Router(); -// 🚀 Claude API messages 端点 -router.post('/v1/messages', authenticateApiKey, async (req, res) => { +// 🔧 共享的消息处理函数 +async function handleMessagesRequest(req, res) { try { const startTime = Date.now(); @@ -199,7 +199,13 @@ router.post('/v1/messages', authenticateApiKey, async (req, res) => { } } } -}); +} + +// 🚀 Claude API messages 端点 - /api/v1/messages +router.post('/v1/messages', authenticateApiKey, handleMessagesRequest); + +// 🚀 Claude API messages 端点 - /claude/v1/messages (别名) +router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest); // 🏥 健康检查端点 router.get('/health', async (req, res) => { @@ -223,7 +229,7 @@ router.get('/health', async (req, res) => { } }); -// 📊 API Key状态检查端点 +// 📊 API Key状态检查端点 - /api/v1/key-info router.get('/v1/key-info', authenticateApiKey, async (req, res) => { try { const usage = await apiKeyService.getUsageStats(req.apiKey.id); @@ -246,7 +252,7 @@ router.get('/v1/key-info', authenticateApiKey, async (req, res) => { } }); -// 📈 使用统计端点 +// 📈 使用统计端点 - /api/v1/usage router.get('/v1/usage', authenticateApiKey, async (req, res) => { try { const usage = await apiKeyService.getUsageStats(req.apiKey.id); diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js new file mode 100644 index 00000000..45e707e1 --- /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.apiKey; + + // 检查权限 + 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.apiKey && 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.apiKey; + + // 检查权限 + 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.apiKey.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.apiKey; + + 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..b72c1257 --- /dev/null +++ b/src/routes/openaiClaudeRoutes.js @@ -0,0 +1,420 @@ +/** + * 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'); +const claudeAccountService = require('../services/claudeAccountService'); +const claudeCodeHeadersService = require('../services/claudeCodeHeadersService'); +const sessionHelper = require('../utils/sessionHelper'); + +// 加载模型定价数据 +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.get('/v1/models', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey; + + // 检查权限 + 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.apiKey; + 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' + } + }); + } +}); + +// 🔧 处理聊天完成请求的核心函数 +async function handleChatCompletion(req, res, apiKeyData) { + const startTime = Date.now(); + let abortController = null; + + try { + // 检查权限 + 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' + } + }); + } + } + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(claudeRequest); + + // 选择可用的Claude账户 + const accountId = await claudeAccountService.selectAccountForApiKey(apiKeyData, sessionHash); + + // 获取该账号存储的 Claude Code headers + const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId); + + logger.debug(`📋 Using Claude Code headers for account ${accountId}:`, { + userAgent: claudeCodeHeaders['user-agent'] + }); + + // 处理流式请求 + 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(); + } + }); + + // 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers) + await claudeRelayService.relayStreamRequestWithUsageCapture( + claudeRequest, + apiKeyData, + res, + claudeCodeHeaders, + (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); + }); + } + }, + // 流转换器 + (() => { + // 为每个请求创建独立的会话ID + const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`; + return (chunk) => { + return openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId); + }; + })(), + { betaHeader: 'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' } + ); + + } else { + // 非流式请求 + logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`); + + // 发送请求到 Claude (使用 OAuth-only beta header,添加 Claude Code 必需的 headers) + const claudeResponse = await claudeRelayService.relayRequest( + claudeRequest, + apiKeyData, + req, + res, + claudeCodeHeaders, + { betaHeader: 'oauth-2025-04-20' } + ); + + // 解析 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.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { + await handleChatCompletion(req, res, req.apiKey); +}); + +// 🔧 OpenAI 兼容的 completions 端点(传统格式,转换为 chat 格式) +router.post('/v1/completions', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey; + + // 验证必需参数 + if (!req.body.prompt) { + return res.status(400).json({ + error: { + message: 'Prompt is required', + type: 'invalid_request_error', + code: 'invalid_request' + } + }); + } + + // 将传统 completions 格式转换为 chat 格式 + const originalBody = req.body; + req.body = { + model: originalBody.model, + messages: [ + { + role: 'user', + content: originalBody.prompt + } + ], + max_tokens: originalBody.max_tokens, + temperature: originalBody.temperature, + top_p: originalBody.top_p, + stream: originalBody.stream, + stop: originalBody.stop, + n: originalBody.n || 1, + presence_penalty: originalBody.presence_penalty, + frequency_penalty: originalBody.frequency_penalty, + logit_bias: originalBody.logit_bias, + user: originalBody.user + }; + + // 使用共享的处理函数 + await handleChatCompletion(req, res, apiKeyData); + + } 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..7a48975c --- /dev/null +++ b/src/routes/openaiGeminiRoutes.js @@ -0,0 +1,291 @@ +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.apiKey; + + // 检查权限 + 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 + } = 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.apiKey && 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.apiKey; + + // 检查权限 + 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.apiKey; + 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..eac589b2 100644 --- a/src/routes/web.js +++ b/src/routes/web.js @@ -370,4 +370,6 @@ router.get('/style.css', (req, res) => { serveWhitelistedFile(req, res, 'style.css'); }); +// 🔑 Gemini OAuth 回调页面 + 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/claudeCodeHeadersService.js b/src/services/claudeCodeHeadersService.js new file mode 100644 index 00000000..5b370723 --- /dev/null +++ b/src/services/claudeCodeHeadersService.js @@ -0,0 +1,212 @@ +/** + * Claude Code Headers 管理服务 + * 负责存储和管理不同账号使用的 Claude Code headers + */ + +const redis = require('../models/redis'); +const logger = require('../utils/logger'); + +class ClaudeCodeHeadersService { + constructor() { + this.defaultHeaders = { + 'x-stainless-retry-count': '0', + 'x-stainless-timeout': '60', + 'x-stainless-lang': 'js', + 'x-stainless-package-version': '0.55.1', + 'x-stainless-os': 'Windows', + 'x-stainless-arch': 'x64', + 'x-stainless-runtime': 'node', + 'x-stainless-runtime-version': 'v20.19.2', + 'anthropic-dangerous-direct-browser-access': 'true', + 'x-app': 'cli', + 'user-agent': 'claude-cli/1.0.57 (external, cli)', + 'accept-language': '*', + 'sec-fetch-mode': 'cors' + }; + + // 需要捕获的 Claude Code 特定 headers + this.claudeCodeHeaderKeys = [ + 'x-stainless-retry-count', + 'x-stainless-timeout', + 'x-stainless-lang', + 'x-stainless-package-version', + 'x-stainless-os', + 'x-stainless-arch', + 'x-stainless-runtime', + 'x-stainless-runtime-version', + 'anthropic-dangerous-direct-browser-access', + 'x-app', + 'user-agent', + 'accept-language', + 'sec-fetch-mode', + 'accept-encoding' + ]; + } + + /** + * 从 user-agent 中提取版本号 + */ + extractVersionFromUserAgent(userAgent) { + if (!userAgent) return null; + const match = userAgent.match(/claude-cli\/(\d+\.\d+\.\d+)/); + return match ? match[1] : null; + } + + /** + * 比较版本号 + * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal + */ + compareVersions(v1, v2) { + if (!v1 || !v2) return 0; + + const parts1 = v1.split('.').map(Number); + const parts2 = v2.split('.').map(Number); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + + if (p1 > p2) return 1; + if (p1 < p2) return -1; + } + + return 0; + } + + /** + * 从客户端 headers 中提取 Claude Code 相关的 headers + */ + extractClaudeCodeHeaders(clientHeaders) { + const headers = {}; + + // 转换所有 header keys 为小写进行比较 + const lowerCaseHeaders = {}; + Object.keys(clientHeaders || {}).forEach(key => { + lowerCaseHeaders[key.toLowerCase()] = clientHeaders[key]; + }); + + // 提取需要的 headers + this.claudeCodeHeaderKeys.forEach(key => { + const lowerKey = key.toLowerCase(); + if (lowerCaseHeaders[lowerKey]) { + headers[key] = lowerCaseHeaders[lowerKey]; + } + }); + + return headers; + } + + /** + * 存储账号的 Claude Code headers + */ + async storeAccountHeaders(accountId, clientHeaders) { + try { + const extractedHeaders = this.extractClaudeCodeHeaders(clientHeaders); + + // 检查是否有 user-agent + const userAgent = extractedHeaders['user-agent']; + if (!userAgent || !userAgent.includes('claude-cli')) { + // 不是 Claude Code 的请求,不存储 + return; + } + + const version = this.extractVersionFromUserAgent(userAgent); + if (!version) { + logger.warn(`⚠️ Failed to extract version from user-agent: ${userAgent}`); + return; + } + + // 获取当前存储的 headers + const key = `claude_code_headers:${accountId}`; + const currentData = await redis.getClient().get(key); + + if (currentData) { + const current = JSON.parse(currentData); + const currentVersion = this.extractVersionFromUserAgent(current.headers['user-agent']); + + // 只有新版本更高时才更新 + if (this.compareVersions(version, currentVersion) <= 0) { + return; + } + } + + // 存储新的 headers + const data = { + headers: extractedHeaders, + version: version, + updatedAt: new Date().toISOString() + }; + + await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)); // 7天过期 + + logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`); + + } catch (error) { + logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, error); + } + } + + /** + * 获取账号的 Claude Code headers + */ + async getAccountHeaders(accountId) { + try { + const key = `claude_code_headers:${accountId}`; + const data = await redis.getClient().get(key); + + if (data) { + const parsed = JSON.parse(data); + logger.debug(`📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}`); + return parsed.headers; + } + + // 返回默认 headers + logger.debug(`📋 Using default Claude Code headers for account ${accountId}`); + return this.defaultHeaders; + + } catch (error) { + logger.error(`❌ Failed to get Claude Code headers for account ${accountId}:`, error); + return this.defaultHeaders; + } + } + + /** + * 清除账号的 Claude Code headers + */ + async clearAccountHeaders(accountId) { + try { + const key = `claude_code_headers:${accountId}`; + await redis.getClient().del(key); + logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`); + } catch (error) { + logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, error); + } + } + + /** + * 获取所有账号的 headers 信息 + */ + async getAllAccountHeaders() { + try { + const pattern = 'claude_code_headers:*'; + const keys = await redis.getClient().keys(pattern); + + const results = {}; + for (const key of keys) { + const accountId = key.replace('claude_code_headers:', ''); + const data = await redis.getClient().get(key); + if (data) { + results[accountId] = JSON.parse(data); + } + } + + return results; + + } catch (error) { + logger.error('❌ Failed to get all account headers:', error); + return {}; + } + } +} + +module.exports = new ClaudeCodeHeadersService(); \ No newline at end of file diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 8d62a96b..a70e2dea 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -8,6 +8,7 @@ const claudeAccountService = require('./claudeAccountService'); const sessionHelper = require('../utils/sessionHelper'); const logger = require('../utils/logger'); const config = require('../../config/config'); +const claudeCodeHeadersService = require('./claudeCodeHeadersService'); class ClaudeRelayService { constructor() { @@ -15,10 +16,41 @@ class ClaudeRelayService { this.apiVersion = config.claude.apiVersion; this.betaHeader = config.claude.betaHeader; this.systemPrompt = config.claude.systemPrompt; + this.claudeCodeSystemPrompt = 'You are Claude Code, Anthropic\'s official CLI for Claude.'; + } + + // 🔍 判断是否是真实的 Claude Code 请求 + isRealClaudeCodeRequest(requestBody, clientHeaders) { + // 检查 user-agent 是否匹配 Claude Code 格式 + const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] || ''; + const isClaudeCodeUserAgent = /claude-cli\/\d+\.\d+\.\d+/.test(userAgent); + + // 检查系统提示词是否包含 Claude Code 标识 + const hasClaudeCodeSystemPrompt = this._hasClaudeCodeSystemPrompt(requestBody); + + // 只有当 user-agent 匹配且系统提示词正确时,才认为是真实的 Claude Code 请求 + return isClaudeCodeUserAgent && hasClaudeCodeSystemPrompt; + } + + // 🔍 检查请求中是否包含 Claude Code 系统提示词 + _hasClaudeCodeSystemPrompt(requestBody) { + if (!requestBody || !requestBody.system) return false; + + let systemText = ''; + if (typeof requestBody.system === 'string') { + systemText = requestBody.system; + } else if (Array.isArray(requestBody.system)) { + systemText = requestBody.system + .filter(item => item && item.type === 'text' && item.text) + .map(item => item.text) + .join(' '); + } + + return systemText.includes(this.claudeCodeSystemPrompt); } // 🚀 转发请求到Claude API - async relayRequest(requestBody, apiKeyData, clientRequest, clientResponse, clientHeaders) { + async relayRequest(requestBody, apiKeyData, clientRequest, clientResponse, clientHeaders, options = {}) { let upstreamRequest = null; try { @@ -61,8 +93,8 @@ class ClaudeRelayService { // 获取有效的访问token const accessToken = await claudeAccountService.getValidAccessToken(accountId); - // 处理请求体 - const processedBody = this._processRequestBody(requestBody); + // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) + const processedBody = this._processRequestBody(requestBody, clientHeaders); // 获取代理配置 const proxyAgent = await this._getProxyAgent(accountId); @@ -89,7 +121,9 @@ class ClaudeRelayService { accessToken, proxyAgent, clientHeaders, - (req) => { upstreamRequest = req; } + accountId, + (req) => { upstreamRequest = req; }, + options ); // 移除监听器(请求成功完成) @@ -127,6 +161,11 @@ class ClaudeRelayService { if (isRateLimited) { await claudeAccountService.removeAccountRateLimit(accountId); } + + // 只有真实的 Claude Code 请求才更新 headers + if (clientHeaders && Object.keys(clientHeaders).length > 0 && this.isRealClaudeCodeRequest(requestBody, clientHeaders)) { + await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders); + } } // 记录成功的API调用 @@ -145,7 +184,7 @@ class ClaudeRelayService { } // 🔄 处理请求体 - _processRequestBody(body) { + _processRequestBody(body, clientHeaders = {}) { if (!body) return body; // 深拷贝请求体 @@ -157,7 +196,36 @@ class ClaudeRelayService { // 移除cache_control中的ttl字段 this._stripTtlFromCacheControl(processedBody); - // 只有在配置了系统提示时才添加 + // 判断是否是真实的 Claude Code 请求 + const isRealClaudeCode = this.isRealClaudeCodeRequest(processedBody, clientHeaders); + + // 如果不是真实的 Claude Code 请求,需要设置 Claude Code 系统提示词 + if (!isRealClaudeCode) { + const claudeCodePrompt = { + type: 'text', + text: this.claudeCodeSystemPrompt + }; + + if (processedBody.system) { + if (Array.isArray(processedBody.system)) { + // 检查是否已经有 Claude Code 系统提示词 + const hasClaudeCodePrompt = processedBody.system.some(item => + item && item.text && item.text.includes(this.claudeCodeSystemPrompt) + ); + + if (!hasClaudeCodePrompt) { + // 添加 Claude Code 系统提示词到开头 + processedBody.system.unshift(claudeCodePrompt); + } + } else { + throw new Error('system field must be an array'); + } + } else { + processedBody.system = [claudeCodePrompt]; + } + } + + // 处理原有的系统提示(如果配置了) if (this.systemPrompt && this.systemPrompt.trim()) { const systemPrompt = { type: 'text', @@ -173,7 +241,13 @@ class ClaudeRelayService { if (!hasValidContent) { processedBody.system = [systemPrompt]; } else { - processedBody.system.unshift(systemPrompt); + // 不要重复添加相同的系统提示 + const hasSystemPrompt = processedBody.system.some(item => + item && item.text && item.text === this.systemPrompt + ); + if (!hasSystemPrompt) { + processedBody.system.push(systemPrompt); + } } } else { throw new Error('system field must be an array'); @@ -311,12 +385,22 @@ class ClaudeRelayService { 'transfer-encoding' ]; + // 应该保留的 headers(用于会话一致性和追踪) + const allowedHeaders = [ + 'x-request-id' + ]; + const filteredHeaders = {}; // 转发客户端的非敏感 headers Object.keys(clientHeaders || {}).forEach(key => { const lowerKey = key.toLowerCase(); - if (!sensitiveHeaders.includes(lowerKey)) { + // 如果在允许列表中,直接保留 + if (allowedHeaders.includes(lowerKey)) { + filteredHeaders[key] = clientHeaders[key]; + } + // 如果不在敏感列表中,也保留 + else if (!sensitiveHeaders.includes(lowerKey)) { filteredHeaders[key] = clientHeaders[key]; } }); @@ -325,12 +409,32 @@ class ClaudeRelayService { } // 🔗 发送请求到Claude API - async _makeClaudeRequest(body, accessToken, proxyAgent, clientHeaders, onRequest) { - return new Promise((resolve, reject) => { - const url = new URL(this.claudeApiUrl); + async _makeClaudeRequest(body, accessToken, proxyAgent, clientHeaders, accountId, onRequest, requestOptions = {}) { + const url = new URL(this.claudeApiUrl); + + // 获取过滤后的客户端 headers + const filteredHeaders = this._filterClientHeaders(clientHeaders); + + // 判断是否是真实的 Claude Code 请求 + const isRealClaudeCode = this.isRealClaudeCodeRequest(body, clientHeaders); + + // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers + let finalHeaders = { ...filteredHeaders }; + + if (!isRealClaudeCode) { + // 获取该账号存储的 Claude Code headers + const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId); - // 获取过滤后的客户端 headers - const filteredHeaders = this._filterClientHeaders(clientHeaders); + // 只添加客户端没有提供的 headers + Object.keys(claudeCodeHeaders).forEach(key => { + const lowerKey = key.toLowerCase(); + if (!finalHeaders[key] && !finalHeaders[lowerKey]) { + finalHeaders[key] = claudeCodeHeaders[key]; + } + }); + } + + return new Promise((resolve, reject) => { const options = { hostname: url.hostname, @@ -341,19 +445,21 @@ class ClaudeRelayService { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, 'anthropic-version': this.apiVersion, - ...filteredHeaders + ...finalHeaders }, agent: proxyAgent, timeout: config.proxy.timeout }; // 如果客户端没有提供 User-Agent,使用默认值 - if (!filteredHeaders['User-Agent'] && !filteredHeaders['user-agent']) { - options.headers['User-Agent'] = 'claude-cli/1.0.53 (external, cli)'; + if (!options.headers['User-Agent'] && !options.headers['user-agent']) { + options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)'; } - if (this.betaHeader) { - options.headers['anthropic-beta'] = this.betaHeader; + // 使用自定义的 betaHeader 或默认值 + const betaHeader = requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader; + if (betaHeader) { + options.headers['anthropic-beta'] = betaHeader; } const req = https.request(options, (res) => { @@ -445,7 +551,7 @@ class ClaudeRelayService { } // 🌊 处理流式响应(带usage数据捕获) - async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback) { + async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback, streamTransformer = null, options = {}) { try { // 调试日志:查看API Key数据(流式请求) logger.info('🔍 [Stream] API Key data received:', { @@ -488,14 +594,14 @@ class ClaudeRelayService { // 获取有效的访问token const accessToken = await claudeAccountService.getValidAccessToken(accountId); - // 处理请求体 - const processedBody = this._processRequestBody(requestBody); + // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) + const processedBody = this._processRequestBody(requestBody, clientHeaders); // 获取代理配置 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, options); } catch (error) { logger.error('❌ Claude stream relay with usage capture failed:', error); throw error; @@ -503,13 +609,32 @@ 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, requestOptions = {}) { + // 获取过滤后的客户端 headers + const filteredHeaders = this._filterClientHeaders(clientHeaders); + + // 判断是否是真实的 Claude Code 请求 + const isRealClaudeCode = this.isRealClaudeCodeRequest(body, clientHeaders); + + // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers + let finalHeaders = { ...filteredHeaders }; + + if (!isRealClaudeCode) { + // 获取该账号存储的 Claude Code headers + const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId); + + // 只添加客户端没有提供的 headers + Object.keys(claudeCodeHeaders).forEach(key => { + const lowerKey = key.toLowerCase(); + if (!finalHeaders[key] && !finalHeaders[lowerKey]) { + finalHeaders[key] = claudeCodeHeaders[key]; + } + }); + } + return new Promise((resolve, reject) => { const url = new URL(this.claudeApiUrl); - // 获取过滤后的客户端 headers - const filteredHeaders = this._filterClientHeaders(clientHeaders); - const options = { hostname: url.hostname, port: url.port || 443, @@ -519,19 +644,21 @@ class ClaudeRelayService { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, 'anthropic-version': this.apiVersion, - ...filteredHeaders + ...finalHeaders }, agent: proxyAgent, timeout: config.proxy.timeout }; // 如果客户端没有提供 User-Agent,使用默认值 - if (!filteredHeaders['User-Agent'] && !filteredHeaders['user-agent']) { - options.headers['User-Agent'] = 'claude-cli/1.0.53 (external, cli)'; + if (!options.headers['User-Agent'] && !options.headers['user-agent']) { + options.headers['User-Agent'] = 'claude-cli/1.0.57 (external, cli)'; } - if (this.betaHeader) { - options.headers['anthropic-beta'] = this.betaHeader; + // 使用自定义的 betaHeader 或默认值 + const betaHeader = requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader; + if (betaHeader) { + options.headers['anthropic-beta'] = betaHeader; } const req = https.request(options, (res) => { @@ -559,7 +686,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 +747,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(); @@ -631,6 +773,11 @@ class ClaudeRelayService { if (isRateLimited) { await claudeAccountService.removeAccountRateLimit(accountId); } + + // 只有真实的 Claude Code 请求才更新 headers(流式请求) + if (clientHeaders && Object.keys(clientHeaders).length > 0 && this.isRealClaudeCodeRequest(body, clientHeaders)) { + await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders); + } } logger.debug('🌊 Claude stream response with usage capture completed'); @@ -721,7 +868,7 @@ class ClaudeRelayService { } // 🌊 发送流式请求到Claude API - async _makeClaudeStreamRequest(body, accessToken, proxyAgent, clientHeaders, responseStream) { + async _makeClaudeStreamRequest(body, accessToken, proxyAgent, clientHeaders, responseStream, requestOptions = {}) { return new Promise((resolve, reject) => { const url = new URL(this.claudeApiUrl); @@ -748,8 +895,10 @@ class ClaudeRelayService { options.headers['User-Agent'] = 'claude-cli/1.0.53 (external, cli)'; } - if (this.betaHeader) { - options.headers['anthropic-beta'] = this.betaHeader; + // 使用自定义的 betaHeader 或默认值 + const betaHeader = requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader; + if (betaHeader) { + options.headers['anthropic-beta'] = betaHeader; } const req = https.request(options, (res) => { diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js new file mode 100644 index 00000000..a0a76dda --- /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 配置 - 这些是公开的 Gemini CLI 凭据 +const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; +const OAUTH_CLIENT_SECRET = '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'; +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:45462'; + 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..48131405 --- /dev/null +++ b/src/services/openaiToClaude.js @@ -0,0 +1,445 @@ +/** + * 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 + }; + + // Claude Code 必需的系统消息 + const claudeCodeSystemMessage = 'You are Claude Code, Anthropic\'s official CLI for Claude.'; + + claudeRequest.system = claudeCodeSystemMessage; + + // 处理停止序列 + 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 - 原始请求的模型名 + * @param {String} sessionId - 会话ID + * @returns {String} OpenAI 格式的 SSE 数据块 + */ + convertStreamChunk(chunk, requestModel, sessionId) { + if (!chunk || chunk.trim() === '') return ''; + + // 解析 SSE 数据 + const lines = chunk.split('\n'); + let convertedChunks = []; + let hasMessageStop = false; + + 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); + + // 检查是否是 message_stop 事件 + if (claudeEvent.type === 'message_stop') { + hasMessageStop = true; + } + + const openaiChunk = this._convertStreamEvent(claudeEvent, requestModel, sessionId); + if (openaiChunk) { + convertedChunks.push(`data: ${JSON.stringify(openaiChunk)}\n\n`); + } + } catch (e) { + // 跳过无法解析的数据,不传递非JSON格式的行 + continue; + } + } + // 忽略 event: 行和空行,OpenAI 格式不包含这些 + } + + // 如果收到 message_stop 事件,添加 [DONE] 标记 + if (hasMessageStop) { + convertedChunks.push('data: [DONE]\n\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') { + const imageUrl = item.image_url.url; + + // 检查是否是 base64 格式的图片 + if (imageUrl.startsWith('data:')) { + // 解析 data URL: data:image/jpeg;base64,/9j/4AAQ... + const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/); + if (matches) { + const mediaType = matches[1]; // e.g., 'image/jpeg', 'image/png' + const base64Data = matches[2]; + + return { + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: base64Data + } + }; + } else { + // 如果格式不正确,尝试使用默认处理 + logger.warn('⚠️ Invalid base64 image format, using default parsing'); + return { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: imageUrl.split(',')[1] || '' + } + }; + } + } else { + // 如果是 URL 格式的图片,Claude 不支持直接 URL,需要报错 + logger.error('❌ URL images are not supported by Claude API, only base64 format is accepted'); + throw new Error('Claude API only supports base64 encoded images, not URLs. Please convert the image to base64 format.'); + } + } + 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, sessionId) { + const timestamp = Math.floor(Date.now() / 1000); + const baseChunk = { + id: sessionId, + object: 'chat.completion.chunk', + created: timestamp, + model: requestModel || 'gpt-4', + choices: [{ + index: 0, + delta: {}, + finish_reason: null + }] + }; + + // 根据事件类型处理 + if (event.type === 'message_start') { + // 处理消息开始事件,发送角色信息 + baseChunk.choices[0].delta.role = 'assistant'; + return baseChunk; + } else 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.content_block.type === 'tool_use') { + // 开始工具调用 + baseChunk.choices[0].delta.tool_calls = [{ + index: event.index || 0, + id: event.content_block.id, + type: 'function', + function: { + name: event.content_block.name, + arguments: '' + } + }]; + } + } 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.delta.type === 'input_json_delta') { + // 工具调用参数的增量更新 + baseChunk.choices[0].delta.tool_calls = [{ + index: event.index || 0, + function: { + arguments: event.delta.partial_json || '' + } + }]; + } + } 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') { + // message_stop 事件不需要返回 chunk,[DONE] 标记会在 convertStreamChunk 中添加 + return null; + } else { + // 忽略其他类型的事件 + return null; + } + + 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..f825062e --- /dev/null +++ b/src/services/tokenRefreshService.js @@ -0,0 +1,144 @@ +const redis = require('../models/redis'); +const logger = require('../utils/logger'); +const { v4: uuidv4 } = require('uuid'); + +/** + * 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/logger.js b/src/utils/logger.js index 9f42879f..0572a31a 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -241,21 +241,22 @@ const originalError = logger.error; const originalWarn = logger.warn; const originalInfo = logger.info; -logger.error = function(message, metadata = {}) { +logger.error = function(message, ...args) { logger.stats.errors++; - return originalError.call(this, message, metadata); + return originalError.call(this, message, ...args); }; -logger.warn = function(message, metadata = {}) { +logger.warn = function(message, ...args) { logger.stats.warnings++; - return originalWarn.call(this, message, metadata); + return originalWarn.call(this, message, ...args); }; -logger.info = function(message, metadata = {}) { - if (metadata.type === 'request') { +logger.info = function(message, ...args) { + // 检查是否是请求类型的日志 + if (args.length > 0 && typeof args[0] === 'object' && args[0].type === 'request') { logger.stats.requests++; } - return originalInfo.call(this, message, metadata); + return originalInfo.call(this, message, ...args); }; // 📈 获取日志统计 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..ad86a090 --- /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 } = 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..ed63f0ed 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,30 @@ const app = createApp({ confirmPassword: '' }, + // 确认弹窗相关 + showConfirmModal: false, + confirmModal: { + title: '', + message: '', + confirmText: '继续', + cancelText: '取消', + onConfirm: null, + onCancel: null + }, + + // 版本管理相关 + versionInfo: { + current: '', // 当前版本 + latest: '', // 最新版本 + hasUpdate: false, // 是否有更新 + checkingUpdate: false, // 正在检查更新 + lastChecked: null, // 上次检查时间 + releaseInfo: null, // 最新版本的发布信息 + githubRepo: 'wei-shaw/claude-relay-service', // GitHub仓库 + showReleaseNotes: false, // 是否显示发布说明 + autoCheckInterval: null, // 自动检查定时器 + noUpdateMessage: false // 显示"已是最新版"提醒 + } } }, @@ -267,6 +309,9 @@ const app = createApp({ // 加载当前用户信息 this.loadCurrentUser(); + // 加载版本信息 + this.loadCurrentVersion(); + // 初始化日期筛选器和图表数据 this.initializeDateFilter(); @@ -293,6 +338,10 @@ const app = createApp({ beforeUnmount() { this.cleanupCharts(); + // 清理版本检查定时器 + if (this.versionInfo.autoCheckInterval) { + clearInterval(this.versionInfo.autoCheckInterval); + } }, watch: { @@ -308,10 +357,52 @@ const app = createApp({ this.loadCurrentTabData(); }, immediate: false + }, + 'geminiOauthData.code': { + handler(newValue) { + if (newValue) { + this.handleGeminiAuthCodeInput(newValue); + } + } } }, 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 +411,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 +515,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 +526,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 +537,7 @@ const app = createApp({ this.showEditAccountModal = false; this.editAccountForm = { id: '', + platform: 'claude', name: '', description: '', accountType: 'shared', @@ -452,12 +548,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 +591,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 +642,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 +690,7 @@ const app = createApp({ // 重置账户表单 resetAccountForm() { this.accountForm = { + platform: 'claude', name: '', description: '', addType: 'oauth', @@ -559,7 +701,8 @@ const app = createApp({ proxyHost: '', proxyPort: '', proxyUsername: '', - proxyPassword: '' + proxyPassword: '', + projectId: '' // 重置项目编号 }; this.oauthStep = 1; this.oauthData = { @@ -567,10 +710,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 +762,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 +780,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 +813,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 +921,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 +1013,172 @@ 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; + } + }, + + // 处理 Gemini OAuth 授权码输入 + handleGeminiAuthCodeInput(value, isUserTyping = false) { + if (!value || typeof value !== 'string') return; + + const trimmedValue = value.trim(); + + // 如果内容为空,不处理 + if (!trimmedValue) return; + + // 检查是否是 URL 格式(包含 http:// 或 https://) + const isUrl = trimmedValue.startsWith('http://') || trimmedValue.startsWith('https://'); + + // 如果是 URL 格式 + if (isUrl) { + // 检查是否是正确的 localhost:45462 开头的 URL + if (trimmedValue.startsWith('http://localhost:45462')) { + try { + const url = new URL(trimmedValue); + const code = url.searchParams.get('code'); + + if (code) { + // 成功提取授权码 + this.geminiOauthData.code = code; + this.showToast('成功提取授权码!', 'success', '提取成功'); + console.log('Successfully extracted authorization code from URL'); + } else { + // URL 中没有 code 参数 + this.showToast('URL 中未找到授权码参数,请检查链接是否正确', 'error', '提取失败'); + } + } catch (error) { + // URL 解析失败 + console.error('Failed to parse URL:', error); + this.showToast('链接格式错误,请检查是否为完整的 URL', 'error', '解析失败'); + } + } else { + // 错误的 URL(不是 localhost:45462 开头) + this.showToast('请粘贴以 http://localhost:45462 开头的链接', 'error', '链接错误'); + } + } + // 如果不是 URL,保持原值(兼容直接输入授权码) + }, // 根据当前标签页加载数据 loadCurrentTabData() { @@ -958,6 +1347,130 @@ const app = createApp({ } }, + // 版本管理相关方法 + async loadCurrentVersion() { + try { + const response = await fetch('/health'); + const data = await response.json(); + + if (data.version) { + // 从健康检查端点获取当前版本 + this.versionInfo.current = data.version; + + // 检查更新 + await this.checkForUpdates(); + + // 设置自动检查更新(每小时检查一次) + this.versionInfo.autoCheckInterval = setInterval(() => { + this.checkForUpdates(); + }, 3600000); // 1小时 + } + } catch (error) { + console.error('Error loading current version:', error); + this.versionInfo.current = '未知'; + } + }, + + async checkForUpdates() { + if (this.versionInfo.checkingUpdate) { + return; + } + + this.versionInfo.checkingUpdate = true; + + try { + // 使用后端接口检查更新 + const response = await fetch('/admin/check-updates', { + headers: { + 'Authorization': `Bearer ${this.authToken}` + } + }); + + if (response.ok) { + const result = await response.json(); + const data = result.data; + + this.versionInfo.current = data.current; + this.versionInfo.latest = data.latest; + this.versionInfo.hasUpdate = data.hasUpdate; + this.versionInfo.releaseInfo = data.releaseInfo; + this.versionInfo.lastChecked = new Date(); + + // 保存到localStorage + localStorage.setItem('versionInfo', JSON.stringify({ + current: data.current, + latest: data.latest, + lastChecked: this.versionInfo.lastChecked, + hasUpdate: data.hasUpdate, + releaseInfo: data.releaseInfo + })); + + // 如果没有更新,显示提醒 + if (!data.hasUpdate) { + this.versionInfo.noUpdateMessage = true; + // 3秒后自动隐藏提醒 + setTimeout(() => { + this.versionInfo.noUpdateMessage = false; + }, 3000); + } + + if (data.cached && data.warning) { + console.warn('Version check warning:', data.warning); + } + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } catch (error) { + console.error('Error checking for updates:', error); + + // 尝试从localStorage读取缓存的版本信息 + const cached = localStorage.getItem('versionInfo'); + if (cached) { + const cachedInfo = JSON.parse(cached); + this.versionInfo.current = cachedInfo.current || this.versionInfo.current; + this.versionInfo.latest = cachedInfo.latest; + this.versionInfo.hasUpdate = cachedInfo.hasUpdate; + this.versionInfo.releaseInfo = cachedInfo.releaseInfo; + this.versionInfo.lastChecked = new Date(cachedInfo.lastChecked); + } + } finally { + this.versionInfo.checkingUpdate = false; + } + }, + + compareVersions(current, latest) { + // 比较语义化版本号 + const parseVersion = (v) => { + const parts = v.split('.').map(Number); + return { + major: parts[0] || 0, + minor: parts[1] || 0, + patch: parts[2] || 0 + }; + }; + + const currentV = parseVersion(current); + const latestV = parseVersion(latest); + + if (currentV.major !== latestV.major) { + return currentV.major - latestV.major; + } + if (currentV.minor !== latestV.minor) { + return currentV.minor - latestV.minor; + } + return currentV.patch - latestV.patch; + }, + + formatVersionDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }, + // 用户菜单相关方法 openChangePasswordModal() { this.userMenuOpen = false; @@ -1160,18 +1673,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 { @@ -1218,6 +1763,8 @@ const app = createApp({ rateLimitWindow: this.apiKeyForm.rateLimitWindow && this.apiKeyForm.rateLimitWindow.trim() ? parseInt(this.apiKeyForm.rateLimitWindow) : null, rateLimitRequests: this.apiKeyForm.rateLimitRequests && this.apiKeyForm.rateLimitRequests.trim() ? parseInt(this.apiKeyForm.rateLimitRequests) : null, claudeAccountId: this.apiKeyForm.claudeAccountId || null, + geminiAccountId: this.apiKeyForm.geminiAccountId || null, + permissions: this.apiKeyForm.permissions || 'all', enableModelRestriction: this.apiKeyForm.enableModelRestriction, restrictedModels: this.apiKeyForm.restrictedModels }) @@ -1253,7 +1800,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, { @@ -1284,6 +1837,8 @@ const app = createApp({ rateLimitWindow: key.rateLimitWindow || '', rateLimitRequests: key.rateLimitRequests || '', claudeAccountId: key.claudeAccountId || '', + geminiAccountId: key.geminiAccountId || '', + permissions: key.permissions || 'all', enableModelRestriction: key.enableModelRestriction || false, restrictedModels: key.restrictedModels ? [...key.restrictedModels] : [], modelInput: '' @@ -1301,6 +1856,8 @@ const app = createApp({ rateLimitWindow: '', rateLimitRequests: '', claudeAccountId: '', + geminiAccountId: '', + permissions: 'all', enableModelRestriction: false, restrictedModels: [], modelInput: '' @@ -1322,6 +1879,8 @@ const app = createApp({ rateLimitWindow: this.editApiKeyForm.rateLimitWindow && this.editApiKeyForm.rateLimitWindow.toString().trim() !== '' ? parseInt(this.editApiKeyForm.rateLimitWindow) : 0, rateLimitRequests: this.editApiKeyForm.rateLimitRequests && this.editApiKeyForm.rateLimitRequests.toString().trim() !== '' ? parseInt(this.editApiKeyForm.rateLimitRequests) : 0, claudeAccountId: this.editApiKeyForm.claudeAccountId || null, + geminiAccountId: this.editApiKeyForm.geminiAccountId || null, + permissions: this.editApiKeyForm.permissions || 'all', enableModelRestriction: this.editApiKeyForm.enableModelRestriction, restrictedModels: this.editApiKeyForm.restrictedModels }) @@ -1350,6 +1909,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 +1923,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 +1957,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 +2028,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 +2609,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 +2670,7 @@ const app = createApp({ position: 'left', title: { display: true, - text: 'Token 数量' + text: this.apiKeysTrendMetric === 'tokens' ? 'Token 数量' : '请求次数' }, ticks: { callback: function(value) { @@ -2087,10 +2708,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..f73f12d9 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -4,16 +4,33 @@ Claude Relay Service - 管理后台 - + + + + + + + + + + + + - - - - + + + + + + + + - - - + + + + + @@ -79,7 +96,24 @@
-

Claude Relay Service

+
+

Claude Relay Service

+ +
+ v{{ versionInfo.current || '...' }} + + + + 新版本 + +
+

管理后台

@@ -97,10 +131,54 @@
+ +
+
+ 当前版本 + v{{ versionInfo.current || '...' }} +
+
+
+ + 有新版本 + + v{{ versionInfo.latest }} +
+ + 查看更新 + +
+
+ 检查更新中... +
+
+ + +
+

+ 当前已是最新版本 +

+
+ +
+
+
+ + +
+
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key,显示使用量前 10 个 @@ -770,8 +875,8 @@
-

Claude 账户管理

-

管理您的 Claude 账户和代理配置

+

账户管理

+

管理您的 Claude 和 Gemini 账户及代理配置

+ + + Gemini + + + Claude + + @@ -1801,39 +1917,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 +2000,78 @@ >
+
+ +
+ + + +
+

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

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

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

@@ -1984,40 +2183,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 +2257,78 @@

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

+
+ +
+ + + +
+

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

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

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

@@ -2222,7 +2504,7 @@
- + + + +
+ + +
+

+ + 支持粘贴完整链接,系统会自动提取授权码 +

+

+ + 也可以直接粘贴授权码(code参数的值) +

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