Merge branch 'dev'

This commit is contained in:
shaw
2025-07-23 11:20:36 +08:00
30 changed files with 5492 additions and 257 deletions

6
.github/secret_scanning.yml vendored Normal file
View File

@@ -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'

View File

@@ -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

3
.gitignore vendored
View File

@@ -17,6 +17,9 @@ pnpm-debug.log*
data/
!data/.gitkeep
# Redis data directory
redis_data/
# Logs directory
logs/
*.log

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.1.6

View File

@@ -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:

220
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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\'',

View File

@@ -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;

View File

@@ -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);

275
src/routes/geminiRoutes.js Normal file
View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -370,4 +370,6 @@ router.get('/style.css', (req, res) => {
serveWhitelistedFile(req, res, 'style.css');
});
// 🔑 Gemini OAuth 回调页面
module.exports = router;

View File

@@ -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();
// 导出实例和单独的方法
const apiKeyService = new ApiKeyService();
// 为了方便其他服务调用,导出 recordUsage 方法
apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService);
module.exports = apiKeyService;

View File

@@ -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();

View File

@@ -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();

View File

@@ -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) => {

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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();

View File

@@ -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<boolean>} 是否成功获取锁
*/
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<boolean>} 锁是否存在
*/
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<number>} 剩余秒数,-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;

View File

@@ -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);
};
// 📈 获取日志统计

View File

@@ -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/',

95
src/utils/tokenMask.js Normal file
View File

@@ -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<string>} 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
};

View File

@@ -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
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,16 +4,33 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Relay Service - 管理后台</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- 预连接到CDN域名加速资源加载 -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<!-- 使用更快的CDN资源保持版本一致 -->
<!-- Vue 3.3.4 (必须先加载不使用defer) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.3.4/vue.global.prod.min.js" crossorigin="anonymous"></script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<!-- Element Plus -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.4.4/index.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.4.4/index.full.min.js"></script>
<!-- Chart.js 4.4.0 (独立库,可以延迟加载) -->
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.js" crossorigin="anonymous"></script>
<!-- Element Plus 2.4.4 (依赖Vue所以在Vue之后加载) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.4.4/index.min.css" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.4.4/index.full.min.js" crossorigin="anonymous"></script>
<!-- Element Plus 中文语言包 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.4.4/locale/zh-cn.min.js"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.4.4/locale/zh-cn.min.js" crossorigin="anonymous"></script>
<!-- Font Awesome 6.5.1 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="/web/style.css">
</head>
<body>
@@ -79,7 +96,24 @@
<i class="fas fa-cloud text-xl text-gray-700"></i>
</div>
<div class="flex flex-col justify-center min-h-[48px]">
<h1 class="text-2xl font-bold text-white header-title leading-tight">Claude Relay Service</h1>
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-white header-title leading-tight">Claude Relay Service</h1>
<!-- 版本信息 -->
<div class="flex items-center gap-2">
<span class="text-sm text-gray-400 font-mono">v{{ versionInfo.current || '...' }}</span>
<!-- 更新提示 -->
<a
v-if="versionInfo.hasUpdate"
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
target="_blank"
class="inline-flex items-center gap-1 px-2 py-0.5 bg-green-500 border border-green-600 rounded-full text-xs text-white hover:bg-green-600 transition-colors animate-pulse"
title="有新版本可用"
>
<i class="fas fa-arrow-up text-[10px]"></i>
<span>新版本</span>
</a>
</div>
</div>
<p class="text-gray-600 text-sm leading-tight mt-0.5">管理后台</p>
</div>
</div>
@@ -97,10 +131,54 @@
<!-- 悬浮菜单 -->
<div
v-if="userMenuOpen"
class="absolute right-0 top-full mt-2 w-48 bg-white rounded-xl shadow-xl border border-gray-200 py-2 user-menu-dropdown"
class="absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-xl border border-gray-200 py-2 user-menu-dropdown"
style="z-index: 999999;"
@click.stop
>
<!-- 版本信息 -->
<div class="px-4 py-3 border-b border-gray-100">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500">当前版本</span>
<span class="font-mono text-gray-700">v{{ versionInfo.current || '...' }}</span>
</div>
<div v-if="versionInfo.hasUpdate" class="mt-2">
<div class="flex items-center justify-between text-sm mb-2">
<span class="text-green-600 font-medium">
<i class="fas fa-arrow-up mr-1"></i>有新版本
</span>
<span class="font-mono text-green-600">v{{ versionInfo.latest }}</span>
</div>
<a
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
target="_blank"
class="block w-full text-center px-3 py-1.5 bg-green-500 text-white text-sm rounded-lg hover:bg-green-600 transition-colors"
>
<i class="fas fa-external-link-alt mr-1"></i>查看更新
</a>
</div>
<div v-else-if="versionInfo.checkingUpdate" class="mt-2 text-center text-xs text-gray-500">
<i class="fas fa-spinner fa-spin mr-1"></i>检查更新中...
</div>
<div v-else class="mt-2 text-center">
<!-- 已是最新版提醒 -->
<transition name="fade" mode="out-in">
<div v-if="versionInfo.noUpdateMessage" key="message" class="px-3 py-1.5 bg-green-100 border border-green-200 rounded-lg inline-block">
<p class="text-xs text-green-700 font-medium">
<i class="fas fa-check-circle mr-1"></i>当前已是最新版本
</p>
</div>
<button
v-else
key="button"
@click="checkForUpdates()"
class="text-xs text-blue-500 hover:text-blue-700 transition-colors"
>
<i class="fas fa-sync-alt mr-1"></i>检查更新
</button>
</transition>
</div>
</div>
<button
@click="openChangePasswordModal"
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
@@ -158,7 +236,7 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">Claude账户</p>
<p class="text-sm font-semibold text-gray-600 mb-1">服务账户</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalAccounts }}</p>
<p class="text-xs text-gray-500 mt-1">
活跃: {{ dashboardData.activeAccounts || 0 }}
@@ -406,10 +484,37 @@
</div>
</div>
<!-- API Keys Token消耗趋势图 -->
<!-- API Keys 使用趋势图 -->
<div class="mb-8">
<div class="card p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">API Keys Token 消耗趋势</h3>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">API Keys 使用趋势</h3>
<!-- 维度切换按钮 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
@click="apiKeysTrendMetric = 'requests'; updateApiKeysUsageTrendChart()"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
apiKeysTrendMetric === 'requests'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
<i class="fas fa-exchange-alt mr-1"></i>请求次数
</button>
<button
@click="apiKeysTrendMetric = 'tokens'; updateApiKeysUsageTrendChart()"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
apiKeysTrendMetric === 'tokens'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
<i class="fas fa-coins mr-1"></i>Token 数量
</button>
</div>
</div>
<div class="mb-4 text-sm text-gray-600">
<span v-if="apiKeysTrendData.totalApiKeys > 10">
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key显示使用量前 10 个
@@ -770,8 +875,8 @@
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Claude 账户管理</h3>
<p class="text-gray-600">管理您的 Claude 账户代理配置</p>
<h3 class="text-xl font-bold text-gray-900 mb-2">账户管理</h3>
<p class="text-gray-600">管理您的 Claude 和 Gemini 账户代理配置</p>
</div>
<button
@click.stop="openCreateAccountModal"
@@ -799,6 +904,7 @@
<thead class="bg-gray-50/80 backdrop-blur-sm">
<tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">名称</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">平台</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">类型</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">状态</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">代理</th>
@@ -829,6 +935,16 @@
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-if="account.platform === 'gemini'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
<i class="fas fa-robot mr-1"></i>Gemini
</span>
<span v-else
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-800">
<i class="fas fa-brain mr-1"></i>Claude
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-if="account.scopes && account.scopes.length > 0"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
@@ -1801,39 +1917,65 @@
>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (可选)</label>
<input
v-model="apiKeyForm.rateLimitWindow"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置时间窗口分钟在此时间内限制请求次数或Token使用量</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">请求次数限制 (可选)</label>
<input
v-model="apiKeyForm.rateLimitRequests"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内 Token 限制 (可选)</label>
<input
v-model="apiKeyForm.tokenLimit"
type="number"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置在时间窗口内的最大 Token 使用量(需同时设置时间窗口)</p>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
<div class="flex items-start gap-3 mb-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-sm"></i>
</div>
<div class="flex-1">
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置 (可选)</h4>
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
<input
v-model="apiKeyForm.rateLimitWindow"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置一个时间段(以分钟为单位),用于计算速率限制</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
<input
v-model="apiKeyForm.rateLimitRequests"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数(需要先设置时间窗口)</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的 Token 使用量限制</label>
<input
v-model="apiKeyForm.tokenLimit"
type="number"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许消耗的最大 Token 数量(需要先设置时间窗口)</p>
</div>
<!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-3 mt-3">
<h5 class="text-sm font-semibold text-blue-800 mb-2">💡 使用示例</h5>
<div class="text-xs text-blue-700 space-y-1">
<p><strong>示例1:</strong> 时间窗口=60请求次数限制=100</p>
<p class="ml-4">→ 每60分钟内最多允许100次请求</p>
<p class="mt-2"><strong>示例2:</strong> 时间窗口=10Token限制=50000</p>
<p class="ml-4">→ 每10分钟内最多消耗50,000个Token</p>
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30请求次数限制=50Token限制=100000</p>
<p class="ml-4">→ 每30分钟内最多50次请求且总Token不超过100,000</p>
</div>
</div>
</div>
<div>
@@ -1858,21 +2000,78 @@
></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="apiKeyForm.permissions"
value="all"
class="mr-2"
>
<span class="text-sm text-gray-700">全部服务</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="apiKeyForm.permissions"
value="claude"
class="mr-2"
>
<span class="text-sm text-gray-700">仅 Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="apiKeyForm.permissions"
value="gemini"
class="mr-2"
>
<span class="text-sm text-gray-700">仅 Gemini</span>
</label>
</div>
<p class="text-xs text-gray-500 mt-2">控制此 API Key 可以访问哪些服务</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定 (可选)</label>
<select
v-model="apiKeyForm.claudeAccountId"
class="form-input w-full"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
<div class="grid grid-cols-1 gap-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
<select
v-model="apiKeyForm.claudeAccountId"
class="form-input w-full"
:disabled="apiKeyForm.permissions === 'gemini'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts.filter(a => a.platform === 'claude')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
<select
v-model="apiKeyForm.geminiAccountId"
class="form-input w-full"
:disabled="apiKeyForm.permissions === 'claude'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts.filter(a => a.platform === 'gemini')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">选择专属账号后此API Key将只使用该账号不选择则使用共享账号池</p>
</div>
@@ -1984,40 +2183,66 @@
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口</label>
<input
v-model="editApiKeyForm.rateLimitWindow"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置时间窗口分钟在此时间内限制请求次数或Token使用量</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">请求次数限制</label>
<input
v-model="editApiKeyForm.rateLimitRequests"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内 Token 限制</label>
<input
v-model="editApiKeyForm.tokenLimit"
type="number"
min="0"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置在时间窗口内的最大 Token 使用量需同时设置时间窗口0 或留空表示无限制</p>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
<div class="flex items-start gap-3 mb-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-sm"></i>
</div>
<div class="flex-1">
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置</h4>
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
<input
v-model="editApiKeyForm.rateLimitWindow"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置一个时间段(以分钟为单位),用于计算速率限制</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
<input
v-model="editApiKeyForm.rateLimitRequests"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数(需要先设置时间窗口)</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的 Token 使用量限制</label>
<input
v-model="editApiKeyForm.tokenLimit"
type="number"
min="0"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许消耗的最大 Token 数量需要先设置时间窗口0 或留空表示无限制</p>
</div>
<!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-3 mt-3">
<h5 class="text-sm font-semibold text-blue-800 mb-2">💡 使用示例</h5>
<div class="text-xs text-blue-700 space-y-1">
<p><strong>示例1:</strong> 时间窗口=60请求次数限制=100</p>
<p class="ml-4">→ 每60分钟内最多允许100次请求</p>
<p class="mt-2"><strong>示例2:</strong> 时间窗口=10Token限制=50000</p>
<p class="ml-4">→ 每10分钟内最多消耗50,000个Token</p>
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30请求次数限制=50Token限制=100000</p>
<p class="ml-4">→ 每30分钟内最多50次请求且总Token不超过100,000</p>
</div>
</div>
</div>
<div>
@@ -2032,21 +2257,78 @@
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数0 或留空表示无限制</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="editApiKeyForm.permissions"
value="all"
class="mr-2"
>
<span class="text-sm text-gray-700">全部服务</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="editApiKeyForm.permissions"
value="claude"
class="mr-2"
>
<span class="text-sm text-gray-700">仅 Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="editApiKeyForm.permissions"
value="gemini"
class="mr-2"
>
<span class="text-sm text-gray-700">仅 Gemini</span>
</label>
</div>
<p class="text-xs text-gray-500 mt-2">控制此 API Key 可以访问哪些服务</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定</label>
<select
v-model="editApiKeyForm.claudeAccountId"
class="form-input w-full"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
<div class="grid grid-cols-1 gap-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
<select
v-model="editApiKeyForm.claudeAccountId"
class="form-input w-full"
:disabled="editApiKeyForm.permissions === 'gemini'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts.filter(a => a.platform === 'claude')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
<select
v-model="editApiKeyForm.geminiAccountId"
class="form-input w-full"
:disabled="editApiKeyForm.permissions === 'claude'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts.filter(a => a.platform === 'gemini')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">修改绑定账号将影响此API Key的请求路由</p>
</div>
@@ -2222,7 +2504,7 @@
</div>
</div>
<!-- 创建 Claude 账户模态框 -->
<!-- 创建账户模态框 -->
<div v-if="showCreateAccountModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
@@ -2230,7 +2512,7 @@
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<i class="fas fa-user-circle text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">添加 Claude 账户</h3>
<h3 class="text-xl font-bold text-gray-900">添加账户</h3>
</div>
<button
@click="closeCreateAccountModal"
@@ -2264,6 +2546,30 @@
<!-- 步骤1: 基本信息和代理设置 -->
<div v-if="oauthStep === 1">
<div class="space-y-6">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">平台</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="accountForm.platform"
value="claude"
class="mr-2"
>
<span class="text-sm text-gray-700">Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="accountForm.platform"
value="gemini"
class="mr-2"
>
<span class="text-sm text-gray-700">Gemini</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">添加方式</label>
<div class="flex gap-4">
@@ -2336,6 +2642,35 @@
</p>
</div>
<!-- Gemini 项目编号字段 -->
<div v-if="accountForm.platform === 'gemini'">
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
<input
v-model="accountForm.projectId"
type="text"
class="form-input w-full"
placeholder="例如123456789012纯数字"
>
<div class="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-start gap-2">
<i class="fas fa-info-circle text-yellow-600 mt-0.5"></i>
<div class="text-xs text-yellow-700">
<p class="font-medium mb-1">Google Cloud/Workspace 账号需要提供项目编号</p>
<p>某些 Google 账号(特别是绑定了 Google Cloud 的账号)会被识别为 Workspace 账号,需要提供额外的项目编号。</p>
<div class="mt-2 p-2 bg-white rounded border border-yellow-300">
<p class="font-medium mb-1">如何获取项目编号:</p>
<ol class="list-decimal list-inside space-y-1 ml-2">
<li>访问 <a href="https://console.cloud.google.com/welcome" target="_blank" class="text-blue-600 hover:underline font-medium">Google Cloud Console</a></li>
<li>复制<span class="font-semibold text-red-600">项目编号Project Number</span>通常是12位纯数字</li>
<li class="text-red-600">⚠️ 注意不要复制项目IDProject ID要复制项目编号</li>
</ol>
</div>
<p class="mt-2"><strong>提示:</strong>如果您的账号是普通个人账号(未绑定 Google Cloud请留空此字段。</p>
</div>
</div>
</div>
</div>
<!-- 手动输入 Token 字段 -->
<div v-if="accountForm.addType === 'manual'" class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200">
<div class="flex items-start gap-3 mb-4">
@@ -2344,16 +2679,24 @@
</div>
<div>
<h5 class="font-semibold text-blue-900 mb-2">手动输入 Token</h5>
<p class="text-sm text-blue-800 mb-2">请输入有效的 Claude Access Token。如果您有 Refresh Token建议也一并填写以支持自动刷新。</p>
<p v-if="accountForm.platform === 'claude'" class="text-sm text-blue-800 mb-2">
请输入有效的 Claude Access Token。如果您有 Refresh Token建议也一并填写以支持自动刷新。
</p>
<p v-else-if="accountForm.platform === 'gemini'" class="text-sm text-blue-800 mb-2">
请输入有效的 Gemini Access Token。如果您有 Refresh Token建议也一并填写以支持自动刷新。
</p>
<div class="bg-white/80 rounded-lg p-3 mt-2 mb-2 border border-blue-300">
<p class="text-sm text-blue-900 font-medium mb-1">
<i class="fas fa-folder-open mr-1"></i>
获取 Access Token 的方法:
</p>
<p class="text-xs text-blue-800">
<p v-if="accountForm.platform === 'claude'" class="text-xs text-blue-800">
请从已登录 Claude Code 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.claude/.credentials.json</code> 文件中的凭证,
请勿使用 Claude 官网 API Keys 页面的密钥。
</p>
<p v-else-if="accountForm.platform === 'gemini'" class="text-xs text-blue-800">
请从已登录 Gemini CLI 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.config/gemini/credentials.json</code> 文件中的凭证。
</p>
</div>
<p class="text-xs text-blue-600">💡 如果未填写 Refresh TokenToken 过期后需要手动更新。</p>
</div>
@@ -2365,7 +2708,7 @@
v-model="accountForm.accessToken"
rows="4"
class="form-input w-full resize-none font-mono text-sm"
placeholder="sk-ant-oat01-..."
:placeholder="accountForm.platform === 'claude' ? 'sk-ant-oat01-...' : 'ya29.a0A...'"
required
></textarea>
</div>
@@ -2376,7 +2719,7 @@
v-model="accountForm.refreshToken"
rows="4"
class="form-input w-full resize-none font-mono text-sm"
placeholder="sk-ant-ort01-..."
:placeholder="accountForm.platform === 'claude' ? 'sk-ant-ort01-...' : '1//0g...'"
></textarea>
<p class="text-xs text-gray-500 mt-2">如果有 Refresh Token填写后系统可以自动刷新过期的 Access Token</p>
</div>
@@ -2384,7 +2727,10 @@
<div class="border-t pt-6">
<label class="block text-sm font-semibold text-gray-700 mb-3">代理设置 (可选)</label>
<p class="text-sm text-gray-500 mb-4">如果需要使用代理访问Claude服务请配置代理信息。OAuth授权也将通过此代理进行。</p>
<p class="text-sm text-gray-500 mb-4">
<span v-if="accountForm.platform === 'claude'">如果需要使用代理访问Claude服务请配置代理信息。OAuth授权也将通过此代理进行。</span>
<span v-else-if="accountForm.platform === 'gemini'">如果需要使用代理访问Gemini服务请配置代理信息。OAuth授权也将通过此代理进行。</span>
</p>
<select
v-model="accountForm.proxyType"
class="form-input w-full"
@@ -2472,7 +2818,8 @@
<!-- 步骤2: OAuth 授权 -->
<div v-if="oauthStep === 2">
<div class="space-y-6">
<!-- Claude OAuth 流程 -->
<div v-if="accountForm.platform === 'claude'" class="space-y-6">
<!-- 获取授权URL -->
<div v-if="!oauthData.authUrl" class="text-center py-8">
<div class="w-16 h-16 mx-auto mb-4 bg-blue-100 rounded-full flex items-center justify-center">
@@ -2555,6 +2902,101 @@
</div>
</div>
<!-- Gemini OAuth 流程 -->
<div v-else-if="accountForm.platform === 'gemini'" class="space-y-6">
<!-- 获取授权URL -->
<div v-if="!geminiOauthData.authUrl" class="text-center py-8">
<div class="w-16 h-16 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
<i class="fas fa-link text-green-600 text-2xl"></i>
</div>
<h5 class="text-lg font-semibold text-gray-900 mb-2">获取授权链接</h5>
<p class="text-gray-600 mb-6">点击下方按钮生成Gemini OAuth授权链接</p>
<button
@click="generateAuthUrl()"
:disabled="authUrlLoading"
class="btn btn-primary px-8 py-3 font-semibold"
>
<div v-if="authUrlLoading" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-magic mr-2"></i>
{{ authUrlLoading ? '生成中...' : '生成授权链接' }}
</button>
</div>
<!-- 显示授权URL和轮询状态 -->
<div v-if="geminiOauthData.authUrl">
<div class="bg-green-50 border border-green-200 rounded-xl p-6 mb-6">
<div class="flex items-start gap-3 mb-4">
<div class="w-8 h-8 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
<i class="fas fa-info text-white text-sm"></i>
</div>
<div>
<h5 class="font-semibold text-green-900 mb-2">操作说明</h5>
<ol class="text-sm text-green-800 space-y-1 list-decimal list-inside">
<li>点击下方的授权链接在新页面中完成Google账号登录</li>
<li>点击"登录"按钮后可能会加载很慢(这是正常的)</li>
<li>如果超过1分钟还在加载请按 F5 刷新页面</li>
<li>授权完成后会跳转到 http://localhost:45462 (可能显示无法访问)</li>
<li>复制浏览器地址栏的完整链接并粘贴到下方输入框</li>
</ol>
<div class="mt-3 text-xs text-green-700 bg-green-100 rounded-lg p-3">
<i class="fas fa-lightbulb mr-1"></i>
<strong>提示:</strong>如果页面一直无法跳转可以打开浏览器开发者工具F12F5刷新一下授权页再点击页面的登录按钮在"网络"标签中找到以 localhost:45462 开头的请求复制其完整URL。
</div>
</div>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">授权链接</label>
<div class="flex gap-2">
<input
:value="geminiOauthData.authUrl"
readonly
class="form-input flex-1 font-mono text-sm bg-gray-50"
>
<button
@click="copyToClipboard(geminiOauthData.authUrl)"
class="btn btn-primary px-4 py-2 flex items-center gap-2"
>
<i class="fas fa-copy"></i>复制
</button>
<a
:href="geminiOauthData.authUrl"
target="_blank"
class="btn btn-success px-4 py-2 flex items-center gap-2"
>
<i class="fas fa-external-link-alt"></i>打开
</a>
</div>
</div>
<!-- 授权码输入框 -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">
<i class="fas fa-key text-green-500 mr-2"></i>复制oauth后的链接
</label>
<textarea
v-model="geminiOauthData.code"
rows="3"
class="form-input w-full resize-none font-mono text-sm"
placeholder="粘贴以 http://localhost:45462 开头的完整链接..."
></textarea>
<div class="mt-2 space-y-1">
<p class="text-xs text-gray-600">
<i class="fas fa-check-circle text-green-500 mr-1"></i>
支持粘贴完整链接,系统会自动提取授权码
</p>
<p class="text-xs text-gray-600">
<i class="fas fa-check-circle text-green-500 mr-1"></i>
也可以直接粘贴授权码code参数的值
</p>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-3 pt-6">
<button
type="button"
@@ -2563,7 +3005,9 @@
>
<i class="fas fa-arrow-left mr-2"></i>上一步
</button>
<!-- Claude 完成按钮 -->
<button
v-if="accountForm.platform === 'claude'"
type="button"
@click="createOAuthAccount()"
:disabled="!oauthData.callbackUrl || !oauthData.authUrl || createAccountLoading"
@@ -2573,12 +3017,24 @@
<i v-else class="fas fa-check mr-2"></i>
{{ createAccountLoading ? '创建中...' : '完成创建' }}
</button>
<!-- Gemini 完成按钮 -->
<button
v-else-if="accountForm.platform === 'gemini'"
type="button"
@click="createGeminiOAuthAccount()"
:disabled="!geminiOauthData.code || !geminiOauthData.authUrl || createAccountLoading"
class="btn btn-success flex-1 py-3 px-6 font-semibold"
>
<div v-if="createAccountLoading" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-check mr-2"></i>
{{ createAccountLoading ? '创建中...' : '使用授权码创建账户' }}
</button>
</div>
</div>
</div>
</div>
<!-- 编辑 Claude 账户模态框 -->
<!-- 编辑账户模态框 -->
<div v-if="showEditAccountModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
@@ -2586,7 +3042,7 @@
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
<i class="fas fa-edit text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">编辑 Claude 账户</h3>
<h3 class="text-xl font-bold text-gray-900">编辑账户</h3>
</div>
<button
@click="closeEditAccountModal"
@@ -2656,6 +3112,35 @@
</div>
<!-- Token 更新区域 -->
<!-- Gemini 项目编号字段(编辑模式) -->
<div v-if="editAccountForm.platform === 'gemini'">
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
<input
v-model="editAccountForm.projectId"
type="text"
class="form-input w-full"
placeholder="例如123456789012纯数字"
>
<div class="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-start gap-2">
<i class="fas fa-info-circle text-yellow-600 mt-0.5"></i>
<div class="text-xs text-yellow-700">
<p class="font-medium mb-1">Google Cloud/Workspace 账号需要提供项目编号</p>
<p>如果您的账号被识别为 Workspace 账号,请提供项目编号。留空将尝试自动检测。</p>
<div class="mt-2 p-2 bg-white rounded border border-yellow-300">
<p class="font-medium mb-1">如何获取项目编号:</p>
<ol class="list-decimal list-inside space-y-1 ml-2">
<li>访问 <a href="https://console.cloud.google.com/welcome" target="_blank" class="text-blue-600 hover:underline font-medium">Google Cloud Console</a></li>
<li>复制<span class="font-semibold text-red-600">项目编号Project Number</span>通常是12位纯数字</li>
<li class="text-red-600">⚠️ 注意不要复制项目IDProject ID要复制项目编号</li>
</ol>
</div>
<p class="mt-2"><strong>提示:</strong>如果您的账号是普通个人账号(未绑定 Google Cloud请留空此字段。</p>
</div>
</div>
</div>
</div>
<div class="bg-amber-50 p-4 rounded-lg border border-amber-200">
<div class="flex items-start gap-3 mb-4">
<div class="w-8 h-8 bg-amber-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
@@ -2868,6 +3353,36 @@
</form>
</div>
</div>
<!-- 确认弹窗 -->
<div v-if="showConfirmModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-6 mx-auto">
<div class="flex items-start gap-4 mb-6">
<div class="w-12 h-12 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-full flex items-center justify-center flex-shrink-0">
<i class="fas fa-exclamation text-white text-xl"></i>
</div>
<div class="flex-1">
<h3 class="text-lg font-bold text-gray-900 mb-2">{{ confirmModal.title }}</h3>
<p class="text-gray-600 text-sm leading-relaxed whitespace-pre-line">{{ confirmModal.message }}</p>
</div>
</div>
<div class="flex gap-3">
<button
@click="handleConfirmCancel"
class="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200 transition-colors"
>
{{ confirmModal.cancelText || '取消' }}
</button>
<button
@click="handleConfirmOk"
class="flex-1 px-4 py-2.5 bg-gradient-to-r from-yellow-500 to-orange-500 text-white rounded-xl font-medium hover:from-yellow-600 hover:to-orange-600 transition-colors shadow-sm"
>
{{ confirmModal.confirmText || '继续' }}
</button>
</div>
</div>
</div>
<!-- Toast 通知组件 -->
<div v-for="(toast, index) in toasts" :key="toast.id"

View File

@@ -433,4 +433,30 @@ body::before {
.modal-scroll-content {
max-height: calc(85vh - 120px);
}
}
/* 版本更新提醒动画 */
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-pulse {
animation: pulse 2s infinite;
}
/* 用户菜单下拉框优化 */
.user-menu-dropdown {
min-width: 240px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}