mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 添加多模型支持和OpenAI兼容接口
- 新增 Gemini 模型支持和账户管理功能 - 实现 OpenAI 格式到 Claude/Gemini 的请求转换 - 添加自动 token 刷新服务,支持提前刷新策略 - 增强 Web 管理界面,支持 Gemini 账户管理 - 优化 token 显示,添加掩码功能 - 完善日志记录和错误处理 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
220
package-lock.json
generated
220
package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"google-auth-library": "^10.1.0",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"https-proxy-agent": "^7.0.2",
|
"https-proxy-agent": "^7.0.2",
|
||||||
"inquirer": "^9.2.15",
|
"inquirer": "^9.2.15",
|
||||||
@@ -1814,6 +1815,15 @@
|
|||||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -1967,6 +1977,12 @@
|
|||||||
"ieee754": "^1.1.13"
|
"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": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -2479,6 +2495,15 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -2639,6 +2664,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -3065,6 +3099,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/external-editor": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/external-editor/-/external-editor-3.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/external-editor/-/external-editor-3.1.0.tgz",
|
||||||
@@ -3148,6 +3188,29 @@
|
|||||||
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
|
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
@@ -3282,6 +3345,18 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/formidable": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmmirror.com/formidable/-/formidable-2.1.5.tgz",
|
"resolved": "https://registry.npmmirror.com/formidable/-/formidable-2.1.5.tgz",
|
||||||
@@ -3347,6 +3422,34 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@@ -3478,6 +3581,33 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -3504,6 +3634,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -4718,6 +4861,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/json-buffer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
@@ -4759,6 +4911,27 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -5134,6 +5307,44 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
@@ -6933,6 +7144,15 @@
|
|||||||
"defaults": "^1.0.3"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-relay-service",
|
"name": "claude-relay-service",
|
||||||
"version": "1.0.0",
|
"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",
|
"main": "src/app.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/app.js",
|
"start": "node src/app.js",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"google-auth-library": "^10.1.0",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"https-proxy-agent": "^7.0.2",
|
"https-proxy-agent": "^7.0.2",
|
||||||
"inquirer": "^9.2.15",
|
"inquirer": "^9.2.15",
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const pricingService = require('./services/pricingService');
|
|||||||
const apiRoutes = require('./routes/api');
|
const apiRoutes = require('./routes/api');
|
||||||
const adminRoutes = require('./routes/admin');
|
const adminRoutes = require('./routes/admin');
|
||||||
const webRoutes = require('./routes/web');
|
const webRoutes = require('./routes/web');
|
||||||
|
const geminiRoutes = require('./routes/geminiRoutes');
|
||||||
|
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes');
|
||||||
|
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes');
|
||||||
|
|
||||||
// Import middleware
|
// Import middleware
|
||||||
const {
|
const {
|
||||||
@@ -97,6 +100,9 @@ class Application {
|
|||||||
this.app.use('/api', apiRoutes);
|
this.app.use('/api', apiRoutes);
|
||||||
this.app.use('/admin', adminRoutes);
|
this.app.use('/admin', adminRoutes);
|
||||||
this.app.use('/web', webRoutes);
|
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) => {
|
this.app.get('/', (req, res) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const apiKeyService = require('../services/apiKeyService');
|
const apiKeyService = require('../services/apiKeyService');
|
||||||
const claudeAccountService = require('../services/claudeAccountService');
|
const claudeAccountService = require('../services/claudeAccountService');
|
||||||
|
const geminiAccountService = require('../services/geminiAccountService');
|
||||||
const redis = require('../models/redis');
|
const redis = require('../models/redis');
|
||||||
const { authenticateAdmin } = require('../middleware/auth');
|
const { authenticateAdmin } = require('../middleware/auth');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -32,6 +33,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
tokenLimit,
|
tokenLimit,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
claudeAccountId,
|
claudeAccountId,
|
||||||
|
geminiAccountId,
|
||||||
|
permissions,
|
||||||
concurrencyLimit,
|
concurrencyLimit,
|
||||||
rateLimitWindow,
|
rateLimitWindow,
|
||||||
rateLimitRequests,
|
rateLimitRequests,
|
||||||
@@ -84,6 +87,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
tokenLimit,
|
tokenLimit,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
claudeAccountId,
|
claudeAccountId,
|
||||||
|
geminiAccountId,
|
||||||
|
permissions,
|
||||||
concurrencyLimit,
|
concurrencyLimit,
|
||||||
rateLimitWindow,
|
rateLimitWindow,
|
||||||
rateLimitRequests,
|
rateLimitRequests,
|
||||||
@@ -103,7 +108,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { keyId } = req.params;
|
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 = {};
|
const updates = {};
|
||||||
@@ -141,6 +146,19 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
updates.claudeAccountId = claudeAccountId || '';
|
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 (enableModelRestriction !== undefined) {
|
||||||
if (typeof enableModelRestriction !== 'boolean') {
|
if (typeof enableModelRestriction !== 'boolean') {
|
||||||
@@ -381,15 +399,189 @@ router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🤖 Gemini 账户管理
|
||||||
|
|
||||||
|
// 生成 Gemini OAuth 授权 URL
|
||||||
|
router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { state } = req.body;
|
||||||
|
|
||||||
|
// 构建 redirect_uri,使用当前服务的地址
|
||||||
|
const protocol = req.protocol;
|
||||||
|
const host = req.get('host');
|
||||||
|
const redirectUri = `${protocol}://${host}/web/auth_gemini`;
|
||||||
|
|
||||||
|
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`);
|
||||||
|
|
||||||
|
const { authUrl, state: authState } = await geminiAccountService.generateAuthUrl(state, redirectUri);
|
||||||
|
|
||||||
|
// 创建 OAuth 会话
|
||||||
|
const sessionId = authState;
|
||||||
|
await redis.setOAuthSession(sessionId, {
|
||||||
|
state: authState,
|
||||||
|
type: 'gemini',
|
||||||
|
redirectUri, // 保存 redirect_uri 用于 token 交换
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Generated Gemini OAuth URL with session: ${sessionId}`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
authUrl,
|
||||||
|
sessionId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to generate Gemini auth URL:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to generate auth URL', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 轮询 Gemini OAuth 授权状态
|
||||||
|
router.post('/gemini-accounts/poll-auth-status', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.body;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(400).json({ error: 'Session ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await geminiAccountService.pollAuthorizationStatus(sessionId);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`);
|
||||||
|
res.json({ success: true, data: { tokens: result.tokens } });
|
||||||
|
} else {
|
||||||
|
res.json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to poll Gemini auth status:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to poll auth status', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 交换 Gemini 授权码
|
||||||
|
router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { code, sessionId } = req.body;
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return res.status(400).json({ error: 'Authorization code is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果提供了 sessionId,从会话中获取 redirect_uri
|
||||||
|
let redirectUri = null;
|
||||||
|
if (sessionId) {
|
||||||
|
const oauthSession = await redis.getOAuthSession(sessionId);
|
||||||
|
if (oauthSession && oauthSession.redirectUri) {
|
||||||
|
redirectUri = oauthSession.redirectUri;
|
||||||
|
logger.info(`Using redirect_uri from session: ${redirectUri}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await geminiAccountService.exchangeCodeForTokens(code, redirectUri);
|
||||||
|
|
||||||
|
// 清理 OAuth 会话
|
||||||
|
if (sessionId) {
|
||||||
|
await redis.deleteOAuthSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success('✅ Successfully exchanged Gemini authorization code');
|
||||||
|
res.json({ success: true, data: { tokens } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to exchange Gemini authorization code:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to exchange code', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取所有 Gemini 账户
|
||||||
|
router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const accounts = await geminiAccountService.getAllAccounts();
|
||||||
|
res.json({ success: true, data: accounts });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Gemini accounts:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get accounts', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建新的 Gemini 账户
|
||||||
|
router.post('/gemini-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const accountData = req.body;
|
||||||
|
|
||||||
|
// 输入验证
|
||||||
|
if (!accountData.name) {
|
||||||
|
return res.status(400).json({ error: 'Account name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccount = await geminiAccountService.createAccount(accountData);
|
||||||
|
|
||||||
|
logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`);
|
||||||
|
res.json({ success: true, data: newAccount });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create Gemini account:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create account', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新 Gemini 账户
|
||||||
|
router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
const updatedAccount = await geminiAccountService.updateAccount(accountId, updates);
|
||||||
|
|
||||||
|
logger.success(`📝 Admin updated Gemini account: ${accountId}`);
|
||||||
|
res.json({ success: true, data: updatedAccount });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Gemini account:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update account', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除 Gemini 账户
|
||||||
|
router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
|
||||||
|
await geminiAccountService.deleteAccount(accountId);
|
||||||
|
|
||||||
|
logger.success(`🗑️ Admin deleted Gemini account: ${accountId}`);
|
||||||
|
res.json({ success: true, message: 'Gemini account deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete Gemini account:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete account', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 刷新 Gemini 账户 token
|
||||||
|
router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
|
||||||
|
const result = await geminiAccountService.refreshAccountToken(accountId);
|
||||||
|
|
||||||
|
logger.success(`🔄 Admin refreshed token for Gemini account: ${accountId}`);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to refresh Gemini account token:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to refresh token', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 📊 系统统计
|
// 📊 系统统计
|
||||||
|
|
||||||
// 获取系统概览
|
// 获取系统概览
|
||||||
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [, apiKeys, accounts, todayStats, systemAverages] = await Promise.all([
|
const [, apiKeys, claudeAccounts, geminiAccounts, todayStats, systemAverages] = await Promise.all([
|
||||||
redis.getSystemStats(),
|
redis.getSystemStats(),
|
||||||
apiKeyService.getAllApiKeys(),
|
apiKeyService.getAllApiKeys(),
|
||||||
claudeAccountService.getAllAccounts(),
|
claudeAccountService.getAllAccounts(),
|
||||||
|
geminiAccountService.getAllAccounts(),
|
||||||
redis.getTodayStats(),
|
redis.getTodayStats(),
|
||||||
redis.getSystemAverages()
|
redis.getSystemAverages()
|
||||||
]);
|
]);
|
||||||
@@ -404,16 +596,21 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
const totalAllTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.allTokens || 0), 0);
|
const totalAllTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.allTokens || 0), 0);
|
||||||
|
|
||||||
const activeApiKeys = apiKeys.filter(key => key.isActive).length;
|
const activeApiKeys = apiKeys.filter(key => key.isActive).length;
|
||||||
const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active').length;
|
const activeClaudeAccounts = claudeAccounts.filter(acc => acc.isActive && acc.status === 'active').length;
|
||||||
const rateLimitedAccounts = accounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).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 = {
|
const dashboard = {
|
||||||
overview: {
|
overview: {
|
||||||
totalApiKeys: apiKeys.length,
|
totalApiKeys: apiKeys.length,
|
||||||
activeApiKeys,
|
activeApiKeys,
|
||||||
totalClaudeAccounts: accounts.length,
|
totalClaudeAccounts: claudeAccounts.length,
|
||||||
activeClaudeAccounts: activeAccounts,
|
activeClaudeAccounts: activeClaudeAccounts,
|
||||||
rateLimitedClaudeAccounts: rateLimitedAccounts,
|
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts,
|
||||||
|
totalGeminiAccounts: geminiAccounts.length,
|
||||||
|
activeGeminiAccounts: activeGeminiAccounts,
|
||||||
|
rateLimitedGeminiAccounts: rateLimitedGeminiAccounts,
|
||||||
totalTokensUsed,
|
totalTokensUsed,
|
||||||
totalRequestsUsed,
|
totalRequestsUsed,
|
||||||
totalInputTokensUsed,
|
totalInputTokensUsed,
|
||||||
@@ -437,7 +634,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
},
|
},
|
||||||
systemHealth: {
|
systemHealth: {
|
||||||
redisConnected: redis.isConnected,
|
redisConnected: redis.isConnected,
|
||||||
claudeAccountsHealthy: activeAccounts > 0,
|
claudeAccountsHealthy: activeClaudeAccounts > 0,
|
||||||
|
geminiAccountsHealthy: activeGeminiAccounts > 0,
|
||||||
uptime: process.uptime()
|
uptime: process.uptime()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1072,7 +1270,8 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
hourData.apiKeys[apiKeyId] = {
|
hourData.apiKeys[apiKeyId] = {
|
||||||
name: apiKeyMap.get(apiKeyId).name,
|
name: apiKeyMap.get(apiKeyId).name,
|
||||||
tokens: totalTokens
|
tokens: totalTokens,
|
||||||
|
requests: parseInt(data.requests) || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1116,7 +1315,8 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
dayData.apiKeys[apiKeyId] = {
|
dayData.apiKeys[apiKeyId] = {
|
||||||
name: apiKeyMap.get(apiKeyId).name,
|
name: apiKeyMap.get(apiKeyId).name,
|
||||||
tokens: totalTokens
|
tokens: totalTokens,
|
||||||
|
requests: parseInt(data.requests) || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
275
src/routes/geminiRoutes.js
Normal file
275
src/routes/geminiRoutes.js
Normal 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.apiKeyData;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取请求参数
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
model = 'gemini-2.0-flash-exp',
|
||||||
|
temperature = 0.7,
|
||||||
|
max_tokens = 4096,
|
||||||
|
stream = false
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 验证必需参数
|
||||||
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message: 'Messages array is required',
|
||||||
|
type: 'invalid_request_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成会话哈希用于粘性会话
|
||||||
|
const sessionHash = generateSessionHash(req);
|
||||||
|
|
||||||
|
// 选择可用的 Gemini 账户
|
||||||
|
const account = await geminiAccountService.selectAvailableAccount(
|
||||||
|
apiKeyData.id,
|
||||||
|
sessionHash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: {
|
||||||
|
message: 'No available Gemini accounts',
|
||||||
|
type: 'service_unavailable'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`);
|
||||||
|
|
||||||
|
// 标记账户被使用
|
||||||
|
await geminiAccountService.markAccountUsed(account.id);
|
||||||
|
|
||||||
|
// 创建中止控制器
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
// 处理客户端断开连接
|
||||||
|
req.on('close', () => {
|
||||||
|
if (abortController && !abortController.signal.aborted) {
|
||||||
|
logger.info('Client disconnected, aborting Gemini request');
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送请求到 Gemini
|
||||||
|
const geminiResponse = await sendGeminiRequest({
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
temperature,
|
||||||
|
maxTokens: max_tokens,
|
||||||
|
stream,
|
||||||
|
accessToken: account.accessToken,
|
||||||
|
proxy: account.proxy,
|
||||||
|
apiKeyId: apiKeyData.id,
|
||||||
|
signal: abortController.signal,
|
||||||
|
projectId: account.projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
// 设置流式响应头
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
// 流式传输响应
|
||||||
|
for await (const chunk of geminiResponse) {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
res.write(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
// 非流式响应
|
||||||
|
res.json(geminiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info(`Gemini request completed in ${duration}ms`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Gemini request error:', error);
|
||||||
|
|
||||||
|
// 处理速率限制
|
||||||
|
if (error.status === 429) {
|
||||||
|
if (req.apiKeyData && req.account) {
|
||||||
|
await geminiAccountService.setAccountRateLimited(req.account.id, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回错误响应
|
||||||
|
const status = error.status || 500;
|
||||||
|
const errorResponse = {
|
||||||
|
error: error.error || {
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
type: 'api_error'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(status).json(errorResponse);
|
||||||
|
} finally {
|
||||||
|
// 清理资源
|
||||||
|
if (abortController) {
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取可用模型列表
|
||||||
|
router.get('/models', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKeyData;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择账户获取模型列表
|
||||||
|
const account = await geminiAccountService.selectAvailableAccount(apiKeyData.id);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
// 返回默认模型列表
|
||||||
|
return res.json({
|
||||||
|
object: 'list',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'gemini-2.0-flash-exp',
|
||||||
|
object: 'model',
|
||||||
|
created: Date.now() / 1000,
|
||||||
|
owned_by: 'google'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模型列表
|
||||||
|
const models = await getAvailableModels(account.accessToken, account.proxy);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
object: 'list',
|
||||||
|
data: models
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get Gemini models:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to retrieve models',
|
||||||
|
type: 'api_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用情况统计(与 Claude 共用)
|
||||||
|
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const usage = req.apiKeyData.usage;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
object: 'usage',
|
||||||
|
total_tokens: usage.total.tokens,
|
||||||
|
total_requests: usage.total.requests,
|
||||||
|
daily_tokens: usage.daily.tokens,
|
||||||
|
daily_requests: usage.daily.requests,
|
||||||
|
monthly_tokens: usage.monthly.tokens,
|
||||||
|
monthly_requests: usage.monthly.requests
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get usage stats:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to retrieve usage statistics',
|
||||||
|
type: 'api_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Key 信息(与 Claude 共用)
|
||||||
|
router.get('/key-info', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const keyData = req.apiKeyData;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: keyData.id,
|
||||||
|
name: keyData.name,
|
||||||
|
permissions: keyData.permissions || 'all',
|
||||||
|
token_limit: keyData.tokenLimit,
|
||||||
|
tokens_used: keyData.usage.total.tokens,
|
||||||
|
tokens_remaining: keyData.tokenLimit > 0
|
||||||
|
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
||||||
|
: null,
|
||||||
|
rate_limit: {
|
||||||
|
window: keyData.rateLimitWindow,
|
||||||
|
requests: keyData.rateLimitRequests
|
||||||
|
},
|
||||||
|
concurrency_limit: keyData.concurrencyLimit,
|
||||||
|
model_restrictions: {
|
||||||
|
enabled: keyData.enableModelRestriction,
|
||||||
|
models: keyData.restrictedModels
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get key info:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to retrieve API key information',
|
||||||
|
type: 'api_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
380
src/routes/openaiClaudeRoutes.js
Normal file
380
src/routes/openaiClaudeRoutes.js
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
/**
|
||||||
|
* OpenAI 兼容的 Claude API 路由
|
||||||
|
* 提供 OpenAI 格式的 API 接口,内部转发到 Claude
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { authenticateApiKey } = require('../middleware/auth');
|
||||||
|
const claudeRelayService = require('../services/claudeRelayService');
|
||||||
|
const openaiToClaude = require('../services/openaiToClaude');
|
||||||
|
const apiKeyService = require('../services/apiKeyService');
|
||||||
|
|
||||||
|
// 加载模型定价数据
|
||||||
|
let modelPricingData = {};
|
||||||
|
try {
|
||||||
|
const pricingPath = path.join(__dirname, '../../data/model_pricing.json');
|
||||||
|
const pricingContent = fs.readFileSync(pricingPath, 'utf8');
|
||||||
|
modelPricingData = JSON.parse(pricingContent);
|
||||||
|
logger.info('✅ Model pricing data loaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to load model pricing data:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 辅助函数:检查 API Key 权限
|
||||||
|
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||||
|
const permissions = apiKeyData.permissions || 'all';
|
||||||
|
return permissions === 'all' || permissions === requiredPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 OpenAI 兼容的聊天完成端点
|
||||||
|
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let abortController = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKeyData;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'claude')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Claude',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录原始请求
|
||||||
|
logger.debug('📥 Received OpenAI format request:', {
|
||||||
|
model: req.body.model,
|
||||||
|
messageCount: req.body.messages?.length,
|
||||||
|
stream: req.body.stream,
|
||||||
|
maxTokens: req.body.max_tokens
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换 OpenAI 请求为 Claude 格式
|
||||||
|
const claudeRequest = openaiToClaude.convertRequest(req.body);
|
||||||
|
|
||||||
|
// 检查模型限制
|
||||||
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||||
|
if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: `Model ${req.body.model} is not allowed for this API key`,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'model_not_allowed'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理流式请求
|
||||||
|
if (claudeRequest.stream) {
|
||||||
|
logger.info(`🌊 Processing OpenAI stream request for model: ${req.body.model}`);
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
|
||||||
|
// 创建中止控制器
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
// 处理客户端断开
|
||||||
|
req.on('close', () => {
|
||||||
|
if (abortController && !abortController.signal.aborted) {
|
||||||
|
logger.info('🔌 Client disconnected, aborting Claude request');
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用转换后的响应流
|
||||||
|
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||||
|
claudeRequest,
|
||||||
|
apiKeyData,
|
||||||
|
res,
|
||||||
|
req.headers,
|
||||||
|
(usage) => {
|
||||||
|
usageData = usage;
|
||||||
|
// 记录使用统计
|
||||||
|
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||||
|
const inputTokens = usage.input_tokens || 0;
|
||||||
|
const outputTokens = usage.output_tokens || 0;
|
||||||
|
const cacheCreateTokens = usage.cache_creation_input_tokens || 0;
|
||||||
|
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
||||||
|
const model = usage.model || claudeRequest.model;
|
||||||
|
|
||||||
|
apiKeyService.recordUsage(
|
||||||
|
apiKeyData.id,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
cacheCreateTokens,
|
||||||
|
cacheReadTokens,
|
||||||
|
model
|
||||||
|
).catch(error => {
|
||||||
|
logger.error('❌ Failed to record usage:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 流转换器
|
||||||
|
(chunk) => {
|
||||||
|
return openaiToClaude.convertStreamChunk(chunk, req.body.model);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 非流式请求
|
||||||
|
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`);
|
||||||
|
|
||||||
|
// 发送请求到 Claude
|
||||||
|
const claudeResponse = await claudeRelayService.relayRequest(
|
||||||
|
claudeRequest,
|
||||||
|
apiKeyData,
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
req.headers
|
||||||
|
);
|
||||||
|
|
||||||
|
// 解析 Claude 响应
|
||||||
|
let claudeData;
|
||||||
|
try {
|
||||||
|
claudeData = JSON.parse(claudeResponse.body);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to parse Claude response:', error);
|
||||||
|
return res.status(502).json({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid response from Claude API',
|
||||||
|
type: 'api_error',
|
||||||
|
code: 'invalid_response'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理错误响应
|
||||||
|
if (claudeResponse.statusCode >= 400) {
|
||||||
|
return res.status(claudeResponse.statusCode).json({
|
||||||
|
error: {
|
||||||
|
message: claudeData.error?.message || 'Claude API error',
|
||||||
|
type: claudeData.error?.type || 'api_error',
|
||||||
|
code: claudeData.error?.code || 'unknown_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 OpenAI 格式
|
||||||
|
const openaiResponse = openaiToClaude.convertResponse(claudeData, req.body.model);
|
||||||
|
|
||||||
|
// 记录使用统计
|
||||||
|
if (claudeData.usage) {
|
||||||
|
const usage = claudeData.usage;
|
||||||
|
apiKeyService.recordUsage(
|
||||||
|
apiKeyData.id,
|
||||||
|
usage.input_tokens || 0,
|
||||||
|
usage.output_tokens || 0,
|
||||||
|
usage.cache_creation_input_tokens || 0,
|
||||||
|
usage.cache_read_input_tokens || 0,
|
||||||
|
claudeRequest.model
|
||||||
|
).catch(error => {
|
||||||
|
logger.error('❌ Failed to record usage:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回 OpenAI 格式响应
|
||||||
|
res.json(openaiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ OpenAI-Claude request error:', error);
|
||||||
|
|
||||||
|
const status = error.status || 500;
|
||||||
|
res.status(status).json({
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// 清理资源
|
||||||
|
if (abortController) {
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 📋 OpenAI 兼容的模型列表端点
|
||||||
|
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKeyData;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'claude')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Claude',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude 模型列表 - 只返回 opus-4 和 sonnet-4
|
||||||
|
let models = [
|
||||||
|
{
|
||||||
|
id: 'claude-opus-4-20250514',
|
||||||
|
object: 'model',
|
||||||
|
created: 1736726400, // 2025-01-13
|
||||||
|
owned_by: 'anthropic'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-sonnet-4-20250514',
|
||||||
|
object: 'model',
|
||||||
|
created: 1736726400, // 2025-01-13
|
||||||
|
owned_by: 'anthropic'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 如果启用了模型限制,过滤模型列表
|
||||||
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||||
|
models = models.filter(model => apiKeyData.restrictedModels.includes(model.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
object: 'list',
|
||||||
|
data: models
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get OpenAI-Claude models:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to retrieve models',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 📄 OpenAI 兼容的模型详情端点
|
||||||
|
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKeyData;
|
||||||
|
const modelId = req.params.model;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'claude')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Claude',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模型限制
|
||||||
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||||
|
if (!apiKeyData.restrictedModels.includes(modelId)) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: `Model '${modelId}' not found`,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'model_not_found'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 model_pricing.json 获取模型信息
|
||||||
|
const modelData = modelPricingData[modelId];
|
||||||
|
|
||||||
|
// 构建标准 OpenAI 格式的模型响应
|
||||||
|
let modelInfo;
|
||||||
|
|
||||||
|
if (modelData) {
|
||||||
|
// 如果在 pricing 文件中找到了模型
|
||||||
|
modelInfo = {
|
||||||
|
id: modelId,
|
||||||
|
object: 'model',
|
||||||
|
created: 1736726400, // 2025-01-13
|
||||||
|
owned_by: 'anthropic',
|
||||||
|
permission: [],
|
||||||
|
root: modelId,
|
||||||
|
parent: null
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 如果没找到,返回默认信息(但仍保持正确格式)
|
||||||
|
modelInfo = {
|
||||||
|
id: modelId,
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'anthropic',
|
||||||
|
permission: [],
|
||||||
|
root: modelId,
|
||||||
|
parent: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(modelInfo);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get model details:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to retrieve model details',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔧 OpenAI 兼容的 completions 端点(传统格式,转换为 chat 格式)
|
||||||
|
router.post('/v1/completions', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// 将传统 completions 格式转换为 chat 格式
|
||||||
|
const chatRequest = {
|
||||||
|
model: req.body.model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: req.body.prompt
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens: req.body.max_tokens,
|
||||||
|
temperature: req.body.temperature,
|
||||||
|
top_p: req.body.top_p,
|
||||||
|
stream: req.body.stream,
|
||||||
|
stop: req.body.stop
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 chat completions 处理
|
||||||
|
req.body = chatRequest;
|
||||||
|
|
||||||
|
// 调用 chat completions 端点
|
||||||
|
return router.handle(req, res);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ OpenAI completions error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to process completion request',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
297
src/routes/openaiGeminiRoutes.js
Normal file
297
src/routes/openaiGeminiRoutes.js
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { authenticateApiKey } = require('../middleware/auth');
|
||||||
|
const geminiAccountService = require('../services/geminiAccountService');
|
||||||
|
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// 生成会话哈希
|
||||||
|
function generateSessionHash(req) {
|
||||||
|
const sessionData = [
|
||||||
|
req.headers['user-agent'],
|
||||||
|
req.ip,
|
||||||
|
req.headers['authorization']?.substring(0, 20)
|
||||||
|
].filter(Boolean).join(':');
|
||||||
|
|
||||||
|
return crypto.createHash('sha256').update(sessionData).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 API Key 权限
|
||||||
|
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||||
|
const permissions = apiKeyData.permissions || 'all';
|
||||||
|
return permissions === 'all' || permissions === requiredPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 兼容的聊天完成端点
|
||||||
|
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let abortController = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKeyData;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取请求参数
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
model = 'gemini-2.0-flash-exp',
|
||||||
|
temperature = 0.7,
|
||||||
|
max_tokens = 4096,
|
||||||
|
stream = false,
|
||||||
|
n = 1,
|
||||||
|
stop = null,
|
||||||
|
presence_penalty = 0,
|
||||||
|
frequency_penalty = 0,
|
||||||
|
logit_bias = null,
|
||||||
|
user = null
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 验证必需参数
|
||||||
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message: 'Messages array is required',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'invalid_request'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模型限制
|
||||||
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||||
|
if (!apiKeyData.restrictedModels.includes(model)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: `Model ${model} is not allowed for this API key`,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'model_not_allowed'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成会话哈希用于粘性会话
|
||||||
|
const sessionHash = generateSessionHash(req);
|
||||||
|
|
||||||
|
// 选择可用的 Gemini 账户
|
||||||
|
const account = await geminiAccountService.selectAvailableAccount(
|
||||||
|
apiKeyData.id,
|
||||||
|
sessionHash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: {
|
||||||
|
message: 'No available Gemini accounts',
|
||||||
|
type: 'service_unavailable',
|
||||||
|
code: 'service_unavailable'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`);
|
||||||
|
|
||||||
|
// 标记账户被使用
|
||||||
|
await geminiAccountService.markAccountUsed(account.id);
|
||||||
|
|
||||||
|
// 创建中止控制器
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
// 处理客户端断开连接
|
||||||
|
req.on('close', () => {
|
||||||
|
if (abortController && !abortController.signal.aborted) {
|
||||||
|
logger.info('Client disconnected, aborting Gemini request');
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送请求到 Gemini(已经返回 OpenAI 格式)
|
||||||
|
const geminiResponse = await sendGeminiRequest({
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
temperature,
|
||||||
|
maxTokens: max_tokens,
|
||||||
|
stream,
|
||||||
|
accessToken: account.accessToken,
|
||||||
|
proxy: account.proxy,
|
||||||
|
apiKeyId: apiKeyData.id,
|
||||||
|
signal: abortController.signal,
|
||||||
|
projectId: account.projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
// 设置流式响应头
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
// 流式传输响应
|
||||||
|
for await (const chunk of geminiResponse) {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
res.write(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
// 非流式响应
|
||||||
|
res.json(geminiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info(`OpenAI-Gemini request completed in ${duration}ms`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('OpenAI-Gemini request error:', error);
|
||||||
|
|
||||||
|
// 处理速率限制
|
||||||
|
if (error.status === 429) {
|
||||||
|
if (req.apiKeyData && req.account) {
|
||||||
|
await geminiAccountService.setAccountRateLimited(req.account.id, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回 OpenAI 格式的错误响应
|
||||||
|
const status = error.status || 500;
|
||||||
|
const errorResponse = {
|
||||||
|
error: error.error || {
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(status).json(errorResponse);
|
||||||
|
} finally {
|
||||||
|
// 清理资源
|
||||||
|
if (abortController) {
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenAI 兼容的模型列表端点
|
||||||
|
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKeyData;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择账户获取模型列表
|
||||||
|
const account = await geminiAccountService.selectAvailableAccount(apiKeyData.id);
|
||||||
|
|
||||||
|
let models = [];
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
// 获取实际的模型列表
|
||||||
|
models = await getAvailableModels(account.accessToken, account.proxy);
|
||||||
|
} else {
|
||||||
|
// 返回默认模型列表
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
id: 'gemini-2.0-flash-exp',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果启用了模型限制,过滤模型列表
|
||||||
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||||
|
models = models.filter(model => apiKeyData.restrictedModels.includes(model.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
object: 'list',
|
||||||
|
data: models
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get OpenAI-Gemini models:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to retrieve models',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenAI 兼容的模型详情端点
|
||||||
|
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKeyData;
|
||||||
|
const modelId = req.params.model;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模型限制
|
||||||
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||||
|
if (!apiKeyData.restrictedModels.includes(modelId)) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: `Model '${modelId}' not found`,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'model_not_found'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回模型信息
|
||||||
|
res.json({
|
||||||
|
id: modelId,
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google',
|
||||||
|
permission: [],
|
||||||
|
root: modelId,
|
||||||
|
parent: null
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get model details:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to retrieve model details',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -370,4 +370,175 @@ router.get('/style.css', (req, res) => {
|
|||||||
serveWhitelistedFile(req, res, 'style.css');
|
serveWhitelistedFile(req, res, 'style.css');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔑 Gemini OAuth 回调页面
|
||||||
|
router.get('/auth_gemini', (req, res) => {
|
||||||
|
try {
|
||||||
|
const code = req.query.code || '';
|
||||||
|
const state = req.query.state || '';
|
||||||
|
const error = req.query.error || '';
|
||||||
|
const errorDescription = req.query.error_description || '';
|
||||||
|
|
||||||
|
// 简单的 HTML 页面,用于显示授权码
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gemini 授权回调</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.code-box {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.copy-button {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.copy-button:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
.copy-button:active {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
.instructions {
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
${error ? `
|
||||||
|
<h1 class="error">授权失败</h1>
|
||||||
|
<div class="error">
|
||||||
|
<p><strong>错误:</strong> ${error}</p>
|
||||||
|
${errorDescription ? `<p><strong>描述:</strong> ${errorDescription}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="instructions">
|
||||||
|
<p>请关闭此页面并返回管理界面重试。</p>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<h1 class="success">授权成功</h1>
|
||||||
|
<p>请复制下面的授权码:</p>
|
||||||
|
<div class="code-box" id="codeBox">
|
||||||
|
${code}
|
||||||
|
</div>
|
||||||
|
<button class="copy-button" onclick="copyCode()">复制授权码</button>
|
||||||
|
|
||||||
|
<div class="instructions">
|
||||||
|
<p><strong>接下来的步骤:</strong></p>
|
||||||
|
<div class="step">1. 点击上方按钮复制授权码</div>
|
||||||
|
<div class="step">2. 返回到管理界面的创建账户页面</div>
|
||||||
|
<div class="step">3. 将授权码粘贴到"授权码"输入框中</div>
|
||||||
|
<div class="step">4. 点击"使用授权码创建账户"按钮完成创建</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyCode() {
|
||||||
|
const code = document.getElementById('codeBox').innerText;
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
const button = document.querySelector('.copy-button');
|
||||||
|
const originalText = button.innerText;
|
||||||
|
button.innerText = '已复制!';
|
||||||
|
button.style.background = '#22c55e';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerText = originalText;
|
||||||
|
button.style.background = '#3b82f6';
|
||||||
|
}, 2000);
|
||||||
|
}).catch(err => {
|
||||||
|
// 降级方案
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(document.getElementById('codeBox'));
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
const button = document.querySelector('.copy-button');
|
||||||
|
button.innerText = '已复制!';
|
||||||
|
button.style.background = '#22c55e';
|
||||||
|
} catch (e) {
|
||||||
|
alert('复制失败,请手动选择并复制授权码');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动选中授权码文本
|
||||||
|
window.onload = function() {
|
||||||
|
const codeBox = document.getElementById('codeBox');
|
||||||
|
if (codeBox && !${!!error}) {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(codeBox);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(html);
|
||||||
|
|
||||||
|
logger.info(`📄 Served Gemini OAuth callback page: ${error ? 'error' : 'success'}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error serving Gemini OAuth callback:', error);
|
||||||
|
res.status(500).send('Internal server error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -17,6 +17,8 @@ class ApiKeyService {
|
|||||||
tokenLimit = config.limits.defaultTokenLimit,
|
tokenLimit = config.limits.defaultTokenLimit,
|
||||||
expiresAt = null,
|
expiresAt = null,
|
||||||
claudeAccountId = null,
|
claudeAccountId = null,
|
||||||
|
geminiAccountId = null,
|
||||||
|
permissions = 'all', // 'claude', 'gemini', 'all'
|
||||||
isActive = true,
|
isActive = true,
|
||||||
concurrencyLimit = 0,
|
concurrencyLimit = 0,
|
||||||
rateLimitWindow = null,
|
rateLimitWindow = null,
|
||||||
@@ -41,6 +43,8 @@ class ApiKeyService {
|
|||||||
rateLimitRequests: String(rateLimitRequests ?? 0),
|
rateLimitRequests: String(rateLimitRequests ?? 0),
|
||||||
isActive: String(isActive),
|
isActive: String(isActive),
|
||||||
claudeAccountId: claudeAccountId || '',
|
claudeAccountId: claudeAccountId || '',
|
||||||
|
geminiAccountId: geminiAccountId || '',
|
||||||
|
permissions: permissions || 'all',
|
||||||
enableModelRestriction: String(enableModelRestriction),
|
enableModelRestriction: String(enableModelRestriction),
|
||||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -65,6 +69,8 @@ class ApiKeyService {
|
|||||||
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
|
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
|
||||||
isActive: keyData.isActive === 'true',
|
isActive: keyData.isActive === 'true',
|
||||||
claudeAccountId: keyData.claudeAccountId,
|
claudeAccountId: keyData.claudeAccountId,
|
||||||
|
geminiAccountId: keyData.geminiAccountId,
|
||||||
|
permissions: keyData.permissions,
|
||||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||||
createdAt: keyData.createdAt,
|
createdAt: keyData.createdAt,
|
||||||
@@ -122,6 +128,8 @@ class ApiKeyService {
|
|||||||
id: keyData.id,
|
id: keyData.id,
|
||||||
name: keyData.name,
|
name: keyData.name,
|
||||||
claudeAccountId: keyData.claudeAccountId,
|
claudeAccountId: keyData.claudeAccountId,
|
||||||
|
geminiAccountId: keyData.geminiAccountId,
|
||||||
|
permissions: keyData.permissions || 'all',
|
||||||
tokenLimit: parseInt(keyData.tokenLimit),
|
tokenLimit: parseInt(keyData.tokenLimit),
|
||||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||||
@@ -152,6 +160,7 @@ class ApiKeyService {
|
|||||||
key.currentConcurrency = await redis.getConcurrency(key.id);
|
key.currentConcurrency = await redis.getConcurrency(key.id);
|
||||||
key.isActive = key.isActive === 'true';
|
key.isActive = key.isActive === 'true';
|
||||||
key.enableModelRestriction = key.enableModelRestriction === 'true';
|
key.enableModelRestriction = key.enableModelRestriction === 'true';
|
||||||
|
key.permissions = key.permissions || 'all'; // 兼容旧数据
|
||||||
try {
|
try {
|
||||||
key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [];
|
key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [];
|
||||||
} catch (e) {
|
} 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 };
|
const updatedData = { ...keyData };
|
||||||
|
|
||||||
for (const [field, value] of Object.entries(updates)) {
|
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;
|
||||||
@@ -6,6 +6,15 @@ const axios = require('axios');
|
|||||||
const redis = require('../models/redis');
|
const redis = require('../models/redis');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const config = require('../../config/config');
|
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 {
|
class ClaudeAccountService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -101,6 +110,8 @@ class ClaudeAccountService {
|
|||||||
|
|
||||||
// 🔄 刷新Claude账户token
|
// 🔄 刷新Claude账户token
|
||||||
async refreshAccountToken(accountId) {
|
async refreshAccountToken(accountId) {
|
||||||
|
let lockAcquired = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accountData = await redis.getClaudeAccount(accountId);
|
const accountData = await redis.getClaudeAccount(accountId);
|
||||||
|
|
||||||
@@ -114,6 +125,35 @@ class ClaudeAccountService {
|
|||||||
throw new Error('No refresh token available - manual token update required');
|
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
|
// 创建代理agent
|
||||||
const agent = this._createProxyAgent(accountData.proxy);
|
const agent = this._createProxyAgent(accountData.proxy);
|
||||||
|
|
||||||
@@ -125,7 +165,7 @@ class ClaudeAccountService {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json, text/plain, */*',
|
'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',
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
'Referer': 'https://claude.ai/',
|
'Referer': 'https://claude.ai/',
|
||||||
'Origin': 'https://claude.ai'
|
'Origin': 'https://claude.ai'
|
||||||
@@ -147,7 +187,15 @@ class ClaudeAccountService {
|
|||||||
|
|
||||||
await redis.setClaudeAccount(accountId, accountData);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -158,17 +206,23 @@ class ClaudeAccountService {
|
|||||||
throw new Error(`Token refresh failed with status: ${response.status}`);
|
throw new Error(`Token refresh failed with status: ${response.status}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Failed to refresh token for account ${accountId}:`, error);
|
// 记录刷新失败
|
||||||
|
|
||||||
// 更新错误状态
|
|
||||||
const accountData = await redis.getClaudeAccount(accountId);
|
const accountData = await redis.getClaudeAccount(accountId);
|
||||||
if (accountData) {
|
if (accountData) {
|
||||||
|
logRefreshError(accountId, accountData.name, 'claude', error);
|
||||||
accountData.status = 'error';
|
accountData.status = 'error';
|
||||||
accountData.errorMessage = error.message;
|
accountData.errorMessage = error.message;
|
||||||
await redis.setClaudeAccount(accountId, accountData);
|
await redis.setClaudeAccount(accountId, accountData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.error(`❌ Failed to refresh token for account ${accountId}:`, error);
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// 释放锁
|
||||||
|
if (lockAcquired) {
|
||||||
|
await tokenRefreshService.releaseRefreshLock(accountId, 'claude');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,8 +242,12 @@ class ClaudeAccountService {
|
|||||||
// 检查token是否过期
|
// 检查token是否过期
|
||||||
const expiresAt = parseInt(accountData.expiresAt);
|
const expiresAt = parseInt(accountData.expiresAt);
|
||||||
const now = Date.now();
|
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...`);
|
logger.info(`🔄 Token expired/expiring for account ${accountId}, attempting refresh...`);
|
||||||
try {
|
try {
|
||||||
const refreshResult = await this.refreshAccountToken(accountId);
|
const refreshResult = await this.refreshAccountToken(accountId);
|
||||||
@@ -275,6 +333,9 @@ class ClaudeAccountService {
|
|||||||
const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth', 'accountType'];
|
const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth', 'accountType'];
|
||||||
const updatedData = { ...accountData };
|
const updatedData = { ...accountData };
|
||||||
|
|
||||||
|
// 检查是否新增了 refresh token
|
||||||
|
const oldRefreshToken = this._decryptSensitiveData(accountData.refreshToken);
|
||||||
|
|
||||||
for (const [field, value] of Object.entries(updates)) {
|
for (const [field, value] of Object.entries(updates)) {
|
||||||
if (allowedUpdates.includes(field)) {
|
if (allowedUpdates.includes(field)) {
|
||||||
if (['email', 'password', 'refreshToken'].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();
|
updatedData.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🌊 处理流式响应(带usage数据捕获)
|
// 🌊 处理流式响应(带usage数据捕获)
|
||||||
async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback) {
|
async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback, streamTransformer = null) {
|
||||||
try {
|
try {
|
||||||
// 调试日志:查看API Key数据(流式请求)
|
// 调试日志:查看API Key数据(流式请求)
|
||||||
logger.info('🔍 [Stream] API Key data received:', {
|
logger.info('🔍 [Stream] API Key data received:', {
|
||||||
@@ -495,7 +495,7 @@ class ClaudeRelayService {
|
|||||||
const proxyAgent = await this._getProxyAgent(accountId);
|
const proxyAgent = await this._getProxyAgent(accountId);
|
||||||
|
|
||||||
// 发送流式请求并捕获usage数据
|
// 发送流式请求并捕获usage数据
|
||||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash);
|
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash, streamTransformer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Claude stream relay with usage capture failed:', error);
|
logger.error('❌ Claude stream relay with usage capture failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -503,7 +503,7 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🌊 发送流式请求到Claude API(带usage数据捕获)
|
// 🌊 发送流式请求到Claude API(带usage数据捕获)
|
||||||
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash) {
|
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash, streamTransformer = null) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = new URL(this.claudeApiUrl);
|
const url = new URL(this.claudeApiUrl);
|
||||||
|
|
||||||
@@ -559,7 +559,15 @@ class ClaudeRelayService {
|
|||||||
// 转发已处理的完整行到客户端
|
// 转发已处理的完整行到客户端
|
||||||
if (lines.length > 0) {
|
if (lines.length > 0) {
|
||||||
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '');
|
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) {
|
for (const line of lines) {
|
||||||
@@ -612,7 +620,14 @@ class ClaudeRelayService {
|
|||||||
res.on('end', async () => {
|
res.on('end', async () => {
|
||||||
// 处理缓冲区中剩余的数据
|
// 处理缓冲区中剩余的数据
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
responseStream.write(buffer);
|
if (streamTransformer) {
|
||||||
|
const transformed = streamTransformer(buffer);
|
||||||
|
if (transformed) {
|
||||||
|
responseStream.write(transformed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
responseStream.write(buffer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
responseStream.end();
|
responseStream.end();
|
||||||
|
|
||||||
|
|||||||
673
src/services/geminiAccountService.js
Normal file
673
src/services/geminiAccountService.js
Normal 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 配置
|
||||||
|
const OAUTH_CLIENT_ID = config.gemini.oauthClientId;
|
||||||
|
const OAUTH_CLIENT_SECRET = config.gemini.oauthClientSecret;
|
||||||
|
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform'];
|
||||||
|
|
||||||
|
// 加密相关常量
|
||||||
|
const ALGORITHM = 'aes-256-cbc';
|
||||||
|
const ENCRYPTION_KEY = Buffer.from(config.security.encryptionKey, 'hex');
|
||||||
|
const IV_LENGTH = 16;
|
||||||
|
|
||||||
|
// Gemini 账户键前缀
|
||||||
|
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:';
|
||||||
|
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts';
|
||||||
|
const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:';
|
||||||
|
|
||||||
|
// 加密函数
|
||||||
|
function encrypt(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
||||||
|
let encrypted = cipher.update(text);
|
||||||
|
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||||
|
return iv.toString('hex') + ':' + encrypted.toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密函数
|
||||||
|
function decrypt(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
try {
|
||||||
|
const textParts = text.split(':');
|
||||||
|
const iv = Buffer.from(textParts.shift(), 'hex');
|
||||||
|
const encryptedText = Buffer.from(textParts.join(':'), 'hex');
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
||||||
|
let decrypted = decipher.update(encryptedText);
|
||||||
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||||
|
return decrypted.toString();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Decryption error:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 OAuth2 客户端
|
||||||
|
function createOAuth2Client(redirectUri = null) {
|
||||||
|
// 如果没有提供 redirectUri,使用默认值
|
||||||
|
const uri = redirectUri || 'http://localhost:8085';
|
||||||
|
return new OAuth2Client(
|
||||||
|
OAUTH_CLIENT_ID,
|
||||||
|
OAUTH_CLIENT_SECRET,
|
||||||
|
uri
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成授权 URL
|
||||||
|
async function generateAuthUrl(state = null, redirectUri = null) {
|
||||||
|
const oAuth2Client = createOAuth2Client(redirectUri);
|
||||||
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
|
access_type: 'offline',
|
||||||
|
scope: OAUTH_SCOPES,
|
||||||
|
prompt: 'select_account',
|
||||||
|
state: state || uuidv4()
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
authUrl,
|
||||||
|
state: state || authUrl.split('state=')[1].split('&')[0]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轮询检查 OAuth 授权状态
|
||||||
|
async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2000) {
|
||||||
|
let attempts = 0;
|
||||||
|
const client = redisClient.getClientSafe();
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
try {
|
||||||
|
const sessionData = await client.get(`oauth_session:${sessionId}`);
|
||||||
|
if (!sessionData) {
|
||||||
|
throw new Error('OAuth session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = JSON.parse(sessionData);
|
||||||
|
if (session.code) {
|
||||||
|
// 授权码已获取,交换 tokens
|
||||||
|
const tokens = await exchangeCodeForTokens(session.code);
|
||||||
|
|
||||||
|
// 清理 session
|
||||||
|
await client.del(`oauth_session:${sessionId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
tokens
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.error) {
|
||||||
|
// 授权失败
|
||||||
|
await client.del(`oauth_session:${sessionId}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: session.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待下一次轮询
|
||||||
|
await new Promise(resolve => setTimeout(resolve, interval));
|
||||||
|
attempts++;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error polling authorization status:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时
|
||||||
|
await client.del(`oauth_session:${sessionId}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Authorization timeout'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交换授权码获取 tokens
|
||||||
|
async function exchangeCodeForTokens(code, redirectUri = null) {
|
||||||
|
const oAuth2Client = createOAuth2Client(redirectUri);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tokens } = await oAuth2Client.getToken(code);
|
||||||
|
|
||||||
|
// 转换为兼容格式
|
||||||
|
return {
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
scope: tokens.scope || OAUTH_SCOPES.join(' '),
|
||||||
|
token_type: tokens.token_type || 'Bearer',
|
||||||
|
expiry_date: tokens.expiry_date || Date.now() + (tokens.expires_in * 1000)
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error exchanging code for tokens:', error);
|
||||||
|
throw new Error('Failed to exchange authorization code');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新访问令牌
|
||||||
|
async function refreshAccessToken(refreshToken) {
|
||||||
|
const oAuth2Client = createOAuth2Client();
|
||||||
|
|
||||||
|
try {
|
||||||
|
oAuth2Client.setCredentials({
|
||||||
|
refresh_token: refreshToken
|
||||||
|
});
|
||||||
|
|
||||||
|
const { credentials } = await oAuth2Client.refreshAccessToken();
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: credentials.access_token,
|
||||||
|
refresh_token: credentials.refresh_token || refreshToken,
|
||||||
|
scope: credentials.scope || OAUTH_SCOPES.join(' '),
|
||||||
|
token_type: credentials.token_type || 'Bearer',
|
||||||
|
expiry_date: credentials.expiry_date
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error refreshing access token:', error);
|
||||||
|
throw new Error('Failed to refresh access token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Gemini 账户
|
||||||
|
async function createAccount(accountData) {
|
||||||
|
const id = uuidv4();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// 处理凭证数据
|
||||||
|
let geminiOauth = null;
|
||||||
|
let accessToken = '';
|
||||||
|
let refreshToken = '';
|
||||||
|
let expiresAt = '';
|
||||||
|
|
||||||
|
if (accountData.geminiOauth || accountData.accessToken) {
|
||||||
|
// 如果提供了完整的 OAuth 数据
|
||||||
|
if (accountData.geminiOauth) {
|
||||||
|
geminiOauth = typeof accountData.geminiOauth === 'string'
|
||||||
|
? accountData.geminiOauth
|
||||||
|
: JSON.stringify(accountData.geminiOauth);
|
||||||
|
|
||||||
|
const oauthData = typeof accountData.geminiOauth === 'string'
|
||||||
|
? JSON.parse(accountData.geminiOauth)
|
||||||
|
: accountData.geminiOauth;
|
||||||
|
|
||||||
|
accessToken = oauthData.access_token || '';
|
||||||
|
refreshToken = oauthData.refresh_token || '';
|
||||||
|
expiresAt = oauthData.expiry_date
|
||||||
|
? new Date(oauthData.expiry_date).toISOString()
|
||||||
|
: '';
|
||||||
|
} else {
|
||||||
|
// 如果只提供了 access token
|
||||||
|
accessToken = accountData.accessToken;
|
||||||
|
refreshToken = accountData.refreshToken || '';
|
||||||
|
|
||||||
|
// 构造完整的 OAuth 数据
|
||||||
|
geminiOauth = JSON.stringify({
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
scope: accountData.scope || OAUTH_SCOPES.join(' '),
|
||||||
|
token_type: accountData.tokenType || 'Bearer',
|
||||||
|
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
|
||||||
|
});
|
||||||
|
|
||||||
|
expiresAt = new Date(accountData.expiryDate || Date.now() + 3600000).toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = {
|
||||||
|
id,
|
||||||
|
platform: 'gemini', // 标识为 Gemini 账户
|
||||||
|
name: accountData.name || 'Gemini Account',
|
||||||
|
description: accountData.description || '',
|
||||||
|
accountType: accountData.accountType || 'shared',
|
||||||
|
isActive: 'true',
|
||||||
|
status: 'active',
|
||||||
|
|
||||||
|
// OAuth 相关字段(加密存储)
|
||||||
|
geminiOauth: geminiOauth ? encrypt(geminiOauth) : '',
|
||||||
|
accessToken: accessToken ? encrypt(accessToken) : '',
|
||||||
|
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
||||||
|
expiresAt,
|
||||||
|
scopes: accountData.scopes || OAUTH_SCOPES.join(' '),
|
||||||
|
|
||||||
|
// 代理设置
|
||||||
|
proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '',
|
||||||
|
|
||||||
|
// 项目编号(Google Cloud/Workspace 账号需要)
|
||||||
|
projectId: accountData.projectId || '',
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
lastUsedAt: '',
|
||||||
|
lastRefreshAt: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存到 Redis
|
||||||
|
const client = redisClient.getClientSafe();
|
||||||
|
await client.hset(
|
||||||
|
`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`,
|
||||||
|
account
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果是共享账户,添加到共享账户集合
|
||||||
|
if (account.accountType === 'shared') {
|
||||||
|
await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Created Gemini account: ${id}`);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账户
|
||||||
|
async function getAccount(accountId) {
|
||||||
|
const client = redisClient.getClientSafe();
|
||||||
|
const accountData = await client.hgetall(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`);
|
||||||
|
|
||||||
|
if (!accountData || Object.keys(accountData).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密敏感字段
|
||||||
|
if (accountData.geminiOauth) {
|
||||||
|
accountData.geminiOauth = decrypt(accountData.geminiOauth);
|
||||||
|
}
|
||||||
|
if (accountData.accessToken) {
|
||||||
|
accountData.accessToken = decrypt(accountData.accessToken);
|
||||||
|
}
|
||||||
|
if (accountData.refreshToken) {
|
||||||
|
accountData.refreshToken = decrypt(accountData.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新账户
|
||||||
|
async function updateAccount(accountId, updates) {
|
||||||
|
const existingAccount = await getAccount(accountId);
|
||||||
|
if (!existingAccount) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
updates.updatedAt = now;
|
||||||
|
|
||||||
|
// 检查是否新增了 refresh token
|
||||||
|
const oldRefreshToken = existingAccount.refreshToken ? decrypt(existingAccount.refreshToken) : '';
|
||||||
|
let needUpdateExpiry = false;
|
||||||
|
|
||||||
|
// 加密敏感字段
|
||||||
|
if (updates.geminiOauth) {
|
||||||
|
updates.geminiOauth = encrypt(
|
||||||
|
typeof updates.geminiOauth === 'string'
|
||||||
|
? updates.geminiOauth
|
||||||
|
: JSON.stringify(updates.geminiOauth)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (updates.accessToken) {
|
||||||
|
updates.accessToken = encrypt(updates.accessToken);
|
||||||
|
}
|
||||||
|
if (updates.refreshToken) {
|
||||||
|
updates.refreshToken = encrypt(updates.refreshToken);
|
||||||
|
// 如果之前没有 refresh token,现在有了,标记需要更新过期时间
|
||||||
|
if (!oldRefreshToken && updates.refreshToken) {
|
||||||
|
needUpdateExpiry = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新账户类型时处理共享账户集合
|
||||||
|
const client = redisClient.getClientSafe();
|
||||||
|
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
||||||
|
if (updates.accountType === 'shared') {
|
||||||
|
await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, accountId);
|
||||||
|
} else {
|
||||||
|
await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果新增了 refresh token,更新过期时间为10分钟
|
||||||
|
if (needUpdateExpiry) {
|
||||||
|
const newExpiry = new Date(Date.now() + (10 * 60 * 1000)).toISOString();
|
||||||
|
updates.expiresAt = newExpiry;
|
||||||
|
logger.info(`🔄 New refresh token added for Gemini account ${accountId}, setting expiry to 10 minutes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token
|
||||||
|
if (updates.geminiOauth && !oldRefreshToken) {
|
||||||
|
const oauthData = typeof updates.geminiOauth === 'string'
|
||||||
|
? JSON.parse(decrypt(updates.geminiOauth))
|
||||||
|
: updates.geminiOauth;
|
||||||
|
|
||||||
|
if (oauthData.refresh_token) {
|
||||||
|
// 如果 expiry_date 设置的时间过长(超过1小时),调整为10分钟
|
||||||
|
const providedExpiry = oauthData.expiry_date || 0;
|
||||||
|
const now = Date.now();
|
||||||
|
const oneHour = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
if (providedExpiry - now > oneHour) {
|
||||||
|
const newExpiry = new Date(now + (10 * 60 * 1000)).toISOString();
|
||||||
|
updates.expiresAt = newExpiry;
|
||||||
|
logger.info(`🔄 Adjusted expiry time to 10 minutes for Gemini account ${accountId} with refresh token`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.hset(
|
||||||
|
`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`,
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Updated Gemini account: ${accountId}`);
|
||||||
|
return { ...existingAccount, ...updates };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除账户
|
||||||
|
async function deleteAccount(accountId) {
|
||||||
|
const account = await getAccount(accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Redis 删除
|
||||||
|
const client = redisClient.getClientSafe();
|
||||||
|
await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`);
|
||||||
|
|
||||||
|
// 从共享账户集合中移除
|
||||||
|
if (account.accountType === 'shared') {
|
||||||
|
await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理会话映射
|
||||||
|
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`);
|
||||||
|
for (const key of sessionMappings) {
|
||||||
|
const mappedAccountId = await client.get(key);
|
||||||
|
if (mappedAccountId === accountId) {
|
||||||
|
await client.del(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Deleted Gemini account: ${accountId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有账户
|
||||||
|
async function getAllAccounts() {
|
||||||
|
const client = redisClient.getClientSafe();
|
||||||
|
const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`);
|
||||||
|
const accounts = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const accountData = await client.hgetall(key);
|
||||||
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
|
// 不解密敏感字段,只返回基本信息
|
||||||
|
accounts.push({
|
||||||
|
...accountData,
|
||||||
|
geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '',
|
||||||
|
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
|
||||||
|
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择可用账户(支持专属和共享账户)
|
||||||
|
async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||||
|
// 首先检查是否有粘性会话
|
||||||
|
const client = redisClient.getClientSafe();
|
||||||
|
if (sessionHash) {
|
||||||
|
const mappedAccountId = await client.get(
|
||||||
|
`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mappedAccountId) {
|
||||||
|
const account = await getAccount(mappedAccountId);
|
||||||
|
if (account && account.isActive === 'true' && !isTokenExpired(account)) {
|
||||||
|
logger.debug(`Using sticky session account: ${mappedAccountId}`);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 API Key 信息
|
||||||
|
const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`);
|
||||||
|
|
||||||
|
// 检查是否绑定了 Gemini 账户
|
||||||
|
if (apiKeyData.geminiAccountId) {
|
||||||
|
const account = await getAccount(apiKeyData.geminiAccountId);
|
||||||
|
if (account && account.isActive === 'true') {
|
||||||
|
// 检查 token 是否过期
|
||||||
|
const isExpired = isTokenExpired(account);
|
||||||
|
|
||||||
|
// 记录token使用情况
|
||||||
|
logTokenUsage(account.id, account.name, 'gemini', account.expiresAt, isExpired);
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
await refreshAccountToken(account.id);
|
||||||
|
return await getAccount(account.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建粘性会话映射
|
||||||
|
if (sessionHash) {
|
||||||
|
await client.setex(
|
||||||
|
`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`,
|
||||||
|
3600, // 1小时过期
|
||||||
|
account.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从共享账户池选择
|
||||||
|
const sharedAccountIds = await client.smembers(SHARED_GEMINI_ACCOUNTS_KEY);
|
||||||
|
const availableAccounts = [];
|
||||||
|
|
||||||
|
for (const accountId of sharedAccountIds) {
|
||||||
|
const account = await getAccount(accountId);
|
||||||
|
if (account && account.isActive === 'true' && !isRateLimited(account)) {
|
||||||
|
availableAccounts.push(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableAccounts.length === 0) {
|
||||||
|
throw new Error('No available Gemini accounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择最少使用的账户
|
||||||
|
availableAccounts.sort((a, b) => {
|
||||||
|
const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0;
|
||||||
|
const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0;
|
||||||
|
return aLastUsed - bLastUsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedAccount = availableAccounts[0];
|
||||||
|
|
||||||
|
// 检查并刷新 token
|
||||||
|
const isExpired = isTokenExpired(selectedAccount);
|
||||||
|
|
||||||
|
// 记录token使用情况
|
||||||
|
logTokenUsage(selectedAccount.id, selectedAccount.name, 'gemini', selectedAccount.expiresAt, isExpired);
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
await refreshAccountToken(selectedAccount.id);
|
||||||
|
return await getAccount(selectedAccount.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建粘性会话映射
|
||||||
|
if (sessionHash) {
|
||||||
|
await client.setex(
|
||||||
|
`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`,
|
||||||
|
3600,
|
||||||
|
selectedAccount.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 token 是否过期
|
||||||
|
function isTokenExpired(account) {
|
||||||
|
if (!account.expiresAt) return true;
|
||||||
|
|
||||||
|
const expiryTime = new Date(account.expiresAt).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const buffer = 10 * 1000; // 10秒缓冲
|
||||||
|
|
||||||
|
return now >= (expiryTime - buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查账户是否被限流
|
||||||
|
function isRateLimited(account) {
|
||||||
|
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
||||||
|
const limitedAt = new Date(account.rateLimitedAt).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const limitDuration = 60 * 60 * 1000; // 1小时
|
||||||
|
|
||||||
|
return now < (limitedAt + limitDuration);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新账户 token
|
||||||
|
async function refreshAccountToken(accountId) {
|
||||||
|
let lockAcquired = false;
|
||||||
|
let account = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
account = await getAccount(accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account.refreshToken) {
|
||||||
|
throw new Error('No refresh token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试获取分布式锁
|
||||||
|
lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'gemini');
|
||||||
|
|
||||||
|
if (!lockAcquired) {
|
||||||
|
// 如果无法获取锁,说明另一个进程正在刷新
|
||||||
|
logger.info(`🔒 Token refresh already in progress for Gemini account: ${account.name} (${accountId})`);
|
||||||
|
logRefreshSkipped(accountId, account.name, 'gemini', 'already_locked');
|
||||||
|
|
||||||
|
// 等待一段时间后返回,期望其他进程已完成刷新
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// 重新获取账户数据(可能已被其他进程刷新)
|
||||||
|
const updatedAccount = await getAccount(accountId);
|
||||||
|
if (updatedAccount && updatedAccount.accessToken) {
|
||||||
|
const accessToken = decrypt(updatedAccount.accessToken);
|
||||||
|
return {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
|
||||||
|
expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0,
|
||||||
|
scope: updatedAccount.scope || OAUTH_SCOPES.join(' '),
|
||||||
|
token_type: 'Bearer'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Token refresh in progress by another process');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录开始刷新
|
||||||
|
logRefreshStart(accountId, account.name, 'gemini', 'manual_refresh');
|
||||||
|
logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`);
|
||||||
|
|
||||||
|
const newTokens = await refreshAccessToken(decrypt(account.refreshToken));
|
||||||
|
|
||||||
|
// 更新账户信息
|
||||||
|
const updates = {
|
||||||
|
accessToken: newTokens.access_token,
|
||||||
|
refreshToken: newTokens.refresh_token || account.refreshToken,
|
||||||
|
expiresAt: new Date(newTokens.expiry_date).toISOString(),
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
geminiOauth: JSON.stringify(newTokens)
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateAccount(accountId, updates);
|
||||||
|
|
||||||
|
// 记录刷新成功
|
||||||
|
logRefreshSuccess(accountId, account.name, 'gemini', {
|
||||||
|
accessToken: newTokens.access_token,
|
||||||
|
refreshToken: newTokens.refresh_token,
|
||||||
|
expiresAt: newTokens.expiry_date,
|
||||||
|
scopes: newTokens.scope
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Refreshed token for Gemini account: ${accountId} - Access Token: ${maskToken(newTokens.access_token)}`);
|
||||||
|
|
||||||
|
return newTokens;
|
||||||
|
} catch (error) {
|
||||||
|
// 记录刷新失败
|
||||||
|
logRefreshError(accountId, account ? account.name : 'Unknown', 'gemini', error);
|
||||||
|
|
||||||
|
logger.error(`Failed to refresh token for account ${accountId}:`, error);
|
||||||
|
|
||||||
|
// 标记账户为错误状态
|
||||||
|
await updateAccount(accountId, {
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: error.message
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// 释放锁
|
||||||
|
if (lockAcquired) {
|
||||||
|
await tokenRefreshService.releaseRefreshLock(accountId, 'gemini');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记账户被使用
|
||||||
|
async function markAccountUsed(accountId) {
|
||||||
|
await updateAccount(accountId, {
|
||||||
|
lastUsedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置账户限流状态
|
||||||
|
async function setAccountRateLimited(accountId, isLimited = true) {
|
||||||
|
const updates = isLimited ? {
|
||||||
|
rateLimitStatus: 'limited',
|
||||||
|
rateLimitedAt: new Date().toISOString()
|
||||||
|
} : {
|
||||||
|
rateLimitStatus: '',
|
||||||
|
rateLimitedAt: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateAccount(accountId, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateAuthUrl,
|
||||||
|
pollAuthorizationStatus,
|
||||||
|
exchangeCodeForTokens,
|
||||||
|
refreshAccessToken,
|
||||||
|
createAccount,
|
||||||
|
getAccount,
|
||||||
|
updateAccount,
|
||||||
|
deleteAccount,
|
||||||
|
getAllAccounts,
|
||||||
|
selectAvailableAccount,
|
||||||
|
refreshAccountToken,
|
||||||
|
markAccountUsed,
|
||||||
|
setAccountRateLimited,
|
||||||
|
isTokenExpired,
|
||||||
|
OAUTH_CLIENT_ID,
|
||||||
|
OAUTH_SCOPES
|
||||||
|
};
|
||||||
379
src/services/geminiRelayService.js
Normal file
379
src/services/geminiRelayService.js
Normal 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
|
||||||
|
};
|
||||||
381
src/services/openaiToClaude.js
Normal file
381
src/services/openaiToClaude.js
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* OpenAI 到 Claude 格式转换服务
|
||||||
|
* 处理 OpenAI API 格式与 Claude API 格式之间的转换
|
||||||
|
*/
|
||||||
|
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
class OpenAIToClaudeConverter {
|
||||||
|
constructor() {
|
||||||
|
// 停止原因映射
|
||||||
|
this.stopReasonMapping = {
|
||||||
|
'end_turn': 'stop',
|
||||||
|
'max_tokens': 'length',
|
||||||
|
'stop_sequence': 'stop',
|
||||||
|
'tool_use': 'tool_calls'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 OpenAI 请求格式转换为 Claude 格式
|
||||||
|
* @param {Object} openaiRequest - OpenAI 格式的请求
|
||||||
|
* @returns {Object} Claude 格式的请求
|
||||||
|
*/
|
||||||
|
convertRequest(openaiRequest) {
|
||||||
|
const claudeRequest = {
|
||||||
|
model: openaiRequest.model, // 直接使用提供的模型名,不进行映射
|
||||||
|
messages: this._convertMessages(openaiRequest.messages),
|
||||||
|
max_tokens: openaiRequest.max_tokens || 4096,
|
||||||
|
temperature: openaiRequest.temperature,
|
||||||
|
top_p: openaiRequest.top_p,
|
||||||
|
stream: openaiRequest.stream || false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理系统消息
|
||||||
|
const systemMessage = this._extractSystemMessage(openaiRequest.messages);
|
||||||
|
if (systemMessage) {
|
||||||
|
claudeRequest.system = systemMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理停止序列
|
||||||
|
if (openaiRequest.stop) {
|
||||||
|
claudeRequest.stop_sequences = Array.isArray(openaiRequest.stop)
|
||||||
|
? openaiRequest.stop
|
||||||
|
: [openaiRequest.stop];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理工具调用
|
||||||
|
if (openaiRequest.tools) {
|
||||||
|
claudeRequest.tools = this._convertTools(openaiRequest.tools);
|
||||||
|
if (openaiRequest.tool_choice) {
|
||||||
|
claudeRequest.tool_choice = this._convertToolChoice(openaiRequest.tool_choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 特有的参数已在转换过程中被忽略
|
||||||
|
// 包括: n, presence_penalty, frequency_penalty, logit_bias, user
|
||||||
|
|
||||||
|
logger.debug('📝 Converted OpenAI request to Claude format:', {
|
||||||
|
model: claudeRequest.model,
|
||||||
|
messageCount: claudeRequest.messages.length,
|
||||||
|
hasSystem: !!claudeRequest.system,
|
||||||
|
stream: claudeRequest.stream
|
||||||
|
});
|
||||||
|
|
||||||
|
return claudeRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Claude 响应格式转换为 OpenAI 格式
|
||||||
|
* @param {Object} claudeResponse - Claude 格式的响应
|
||||||
|
* @param {String} requestModel - 原始请求的模型名
|
||||||
|
* @returns {Object} OpenAI 格式的响应
|
||||||
|
*/
|
||||||
|
convertResponse(claudeResponse, requestModel) {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const openaiResponse = {
|
||||||
|
id: `chatcmpl-${this._generateId()}`,
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: timestamp,
|
||||||
|
model: requestModel || 'gpt-4',
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: this._convertClaudeMessage(claudeResponse),
|
||||||
|
finish_reason: this._mapStopReason(claudeResponse.stop_reason)
|
||||||
|
}],
|
||||||
|
usage: this._convertUsage(claudeResponse.usage)
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug('📝 Converted Claude response to OpenAI format:', {
|
||||||
|
responseId: openaiResponse.id,
|
||||||
|
finishReason: openaiResponse.choices[0].finish_reason,
|
||||||
|
usage: openaiResponse.usage
|
||||||
|
});
|
||||||
|
|
||||||
|
return openaiResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换流式响应的单个数据块
|
||||||
|
* @param {String} chunk - Claude SSE 数据块
|
||||||
|
* @param {String} requestModel - 原始请求的模型名
|
||||||
|
* @returns {String} OpenAI 格式的 SSE 数据块
|
||||||
|
*/
|
||||||
|
convertStreamChunk(chunk, requestModel) {
|
||||||
|
if (!chunk || chunk.trim() === '') return '';
|
||||||
|
|
||||||
|
// 解析 SSE 数据
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
let convertedChunks = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.substring(6);
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
convertedChunks.push('data: [DONE]\n\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const claudeEvent = JSON.parse(data);
|
||||||
|
const openaiChunk = this._convertStreamEvent(claudeEvent, requestModel);
|
||||||
|
if (openaiChunk) {
|
||||||
|
convertedChunks.push(`data: ${JSON.stringify(openaiChunk)}\n\n`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 如果不是 JSON,原样传递
|
||||||
|
convertedChunks.push(line + '\n');
|
||||||
|
}
|
||||||
|
} else if (line.startsWith('event:') || line === '') {
|
||||||
|
// 保留事件类型行和空行
|
||||||
|
convertedChunks.push(line + '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertedChunks.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取系统消息
|
||||||
|
*/
|
||||||
|
_extractSystemMessage(messages) {
|
||||||
|
const systemMessages = messages.filter(msg => msg.role === 'system');
|
||||||
|
if (systemMessages.length === 0) return null;
|
||||||
|
|
||||||
|
// 合并所有系统消息
|
||||||
|
return systemMessages.map(msg => msg.content).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换消息格式
|
||||||
|
*/
|
||||||
|
_convertMessages(messages) {
|
||||||
|
const claudeMessages = [];
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
// 跳过系统消息(已经在 system 字段处理)
|
||||||
|
if (msg.role === 'system') continue;
|
||||||
|
|
||||||
|
// 转换角色名称
|
||||||
|
const role = msg.role === 'user' ? 'user' : 'assistant';
|
||||||
|
|
||||||
|
// 转换消息内容
|
||||||
|
let content;
|
||||||
|
if (typeof msg.content === 'string') {
|
||||||
|
content = msg.content;
|
||||||
|
} else if (Array.isArray(msg.content)) {
|
||||||
|
// 处理多模态内容
|
||||||
|
content = this._convertMultimodalContent(msg.content);
|
||||||
|
} else {
|
||||||
|
content = JSON.stringify(msg.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claudeMsg = {
|
||||||
|
role: role,
|
||||||
|
content: content
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理工具调用
|
||||||
|
if (msg.tool_calls) {
|
||||||
|
claudeMsg.content = this._convertToolCalls(msg.tool_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理工具响应
|
||||||
|
if (msg.role === 'tool') {
|
||||||
|
claudeMsg.role = 'user';
|
||||||
|
claudeMsg.content = [{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: msg.tool_call_id,
|
||||||
|
content: msg.content
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeMessages.push(claudeMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return claudeMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换多模态内容
|
||||||
|
*/
|
||||||
|
_convertMultimodalContent(content) {
|
||||||
|
return content.map(item => {
|
||||||
|
if (item.type === 'text') {
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
text: item.text
|
||||||
|
};
|
||||||
|
} else if (item.type === 'image_url') {
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
source: {
|
||||||
|
type: 'base64',
|
||||||
|
media_type: 'image/jpeg', // 默认类型
|
||||||
|
data: item.image_url.url.split(',')[1] // 假设是 base64
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换工具定义
|
||||||
|
*/
|
||||||
|
_convertTools(tools) {
|
||||||
|
return tools.map(tool => {
|
||||||
|
if (tool.type === 'function') {
|
||||||
|
return {
|
||||||
|
name: tool.function.name,
|
||||||
|
description: tool.function.description,
|
||||||
|
input_schema: tool.function.parameters
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return tool;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换工具选择
|
||||||
|
*/
|
||||||
|
_convertToolChoice(toolChoice) {
|
||||||
|
if (toolChoice === 'none') return { type: 'none' };
|
||||||
|
if (toolChoice === 'auto') return { type: 'auto' };
|
||||||
|
if (toolChoice === 'required') return { type: 'any' };
|
||||||
|
if (toolChoice.type === 'function') {
|
||||||
|
return {
|
||||||
|
type: 'tool',
|
||||||
|
name: toolChoice.function.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: 'auto' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换工具调用
|
||||||
|
*/
|
||||||
|
_convertToolCalls(toolCalls) {
|
||||||
|
return toolCalls.map(tc => ({
|
||||||
|
type: 'tool_use',
|
||||||
|
id: tc.id,
|
||||||
|
name: tc.function.name,
|
||||||
|
input: JSON.parse(tc.function.arguments)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换 Claude 消息为 OpenAI 格式
|
||||||
|
*/
|
||||||
|
_convertClaudeMessage(claudeResponse) {
|
||||||
|
const message = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理内容
|
||||||
|
if (claudeResponse.content) {
|
||||||
|
if (typeof claudeResponse.content === 'string') {
|
||||||
|
message.content = claudeResponse.content;
|
||||||
|
} else if (Array.isArray(claudeResponse.content)) {
|
||||||
|
// 提取文本内容和工具调用
|
||||||
|
const textParts = [];
|
||||||
|
const toolCalls = [];
|
||||||
|
|
||||||
|
for (const item of claudeResponse.content) {
|
||||||
|
if (item.type === 'text') {
|
||||||
|
textParts.push(item.text);
|
||||||
|
} else if (item.type === 'tool_use') {
|
||||||
|
toolCalls.push({
|
||||||
|
id: item.id,
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: item.name,
|
||||||
|
arguments: JSON.stringify(item.input)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message.content = textParts.join('') || null;
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
message.tool_calls = toolCalls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换停止原因
|
||||||
|
*/
|
||||||
|
_mapStopReason(claudeReason) {
|
||||||
|
return this.stopReasonMapping[claudeReason] || 'stop';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换使用统计
|
||||||
|
*/
|
||||||
|
_convertUsage(claudeUsage) {
|
||||||
|
if (!claudeUsage) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt_tokens: claudeUsage.input_tokens || 0,
|
||||||
|
completion_tokens: claudeUsage.output_tokens || 0,
|
||||||
|
total_tokens: (claudeUsage.input_tokens || 0) + (claudeUsage.output_tokens || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换流式事件
|
||||||
|
*/
|
||||||
|
_convertStreamEvent(event, requestModel) {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const baseChunk = {
|
||||||
|
id: `chatcmpl-${this._generateId()}`,
|
||||||
|
object: 'chat.completion.chunk',
|
||||||
|
created: timestamp,
|
||||||
|
model: requestModel || 'gpt-4',
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: {},
|
||||||
|
finish_reason: null
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据事件类型处理
|
||||||
|
if (event.type === 'content_block_start' && event.content_block) {
|
||||||
|
if (event.content_block.type === 'text') {
|
||||||
|
baseChunk.choices[0].delta.content = event.content_block.text || '';
|
||||||
|
}
|
||||||
|
} else if (event.type === 'content_block_delta' && event.delta) {
|
||||||
|
if (event.delta.type === 'text_delta') {
|
||||||
|
baseChunk.choices[0].delta.content = event.delta.text || '';
|
||||||
|
}
|
||||||
|
} else if (event.type === 'message_delta' && event.delta) {
|
||||||
|
if (event.delta.stop_reason) {
|
||||||
|
baseChunk.choices[0].finish_reason = this._mapStopReason(event.delta.stop_reason);
|
||||||
|
}
|
||||||
|
if (event.usage) {
|
||||||
|
baseChunk.usage = this._convertUsage(event.usage);
|
||||||
|
}
|
||||||
|
} else if (event.type === 'message_stop') {
|
||||||
|
baseChunk.choices[0].finish_reason = 'stop';
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseChunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机 ID
|
||||||
|
*/
|
||||||
|
_generateId() {
|
||||||
|
return Math.random().toString(36).substring(2, 15) +
|
||||||
|
Math.random().toString(36).substring(2, 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new OpenAIToClaudeConverter();
|
||||||
147
src/services/tokenRefreshService.js
Normal file
147
src/services/tokenRefreshService.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
const redis = require('../models/redis');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const {
|
||||||
|
logRefreshSkipped
|
||||||
|
} = require('../utils/tokenRefreshLogger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token 刷新锁服务
|
||||||
|
* 提供分布式锁机制,避免并发刷新问题
|
||||||
|
*/
|
||||||
|
class TokenRefreshService {
|
||||||
|
constructor() {
|
||||||
|
this.lockTTL = 60; // 锁的TTL: 60秒(token刷新通常在30秒内完成)
|
||||||
|
this.lockValue = new Map(); // 存储每个锁的唯一值
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分布式锁
|
||||||
|
* 使用唯一标识符作为值,避免误释放其他进程的锁
|
||||||
|
*/
|
||||||
|
async acquireLock(lockKey) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
const lockId = uuidv4();
|
||||||
|
const result = await client.set(lockKey, lockId, 'NX', 'EX', this.lockTTL);
|
||||||
|
|
||||||
|
if (result === 'OK') {
|
||||||
|
this.lockValue.set(lockKey, lockId);
|
||||||
|
logger.debug(`🔒 Acquired lock ${lockKey} with ID ${lockId}, TTL: ${this.lockTTL}s`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to acquire lock ${lockKey}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放分布式锁
|
||||||
|
* 使用 Lua 脚本确保只释放自己持有的锁
|
||||||
|
*/
|
||||||
|
async releaseLock(lockKey) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
const lockId = this.lockValue.get(lockKey);
|
||||||
|
|
||||||
|
if (!lockId) {
|
||||||
|
logger.warn(`⚠️ No lock ID found for ${lockKey}, skipping release`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lua 脚本:只有当值匹配时才删除
|
||||||
|
const luaScript = `
|
||||||
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("del", KEYS[1])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await client.eval(luaScript, 1, lockKey, lockId);
|
||||||
|
|
||||||
|
if (result === 1) {
|
||||||
|
this.lockValue.delete(lockKey);
|
||||||
|
logger.debug(`🔓 Released lock ${lockKey} with ID ${lockId}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ Lock ${lockKey} was not released - value mismatch or already expired`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to release lock ${lockKey}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取刷新锁
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型 (claude/gemini)
|
||||||
|
* @returns {Promise<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;
|
||||||
@@ -148,7 +148,7 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
|
|||||||
const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, {
|
const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'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': 'application/json, text/plain, */*',
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
'Referer': 'https://claude.ai/',
|
'Referer': 'https://claude.ai/',
|
||||||
|
|||||||
95
src/utils/tokenMask.js
Normal file
95
src/utils/tokenMask.js
Normal 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
|
||||||
|
};
|
||||||
178
src/utils/tokenRefreshLogger.js
Normal file
178
src/utils/tokenRefreshLogger.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { maskToken, formatTokenRefreshLog } = require('./tokenMask');
|
||||||
|
|
||||||
|
// 确保日志目录存在
|
||||||
|
const logDir = path.join(process.cwd(), 'logs');
|
||||||
|
if (!fs.existsSync(logDir)) {
|
||||||
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建专用的 token 刷新日志记录器
|
||||||
|
const tokenRefreshLogger = winston.createLogger({
|
||||||
|
level: 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp({
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss.SSS'
|
||||||
|
}),
|
||||||
|
winston.format.json(),
|
||||||
|
winston.format.printf(info => {
|
||||||
|
return JSON.stringify(info, null, 2);
|
||||||
|
})
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
// 文件传输 - 每日轮转
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logDir, 'token-refresh.log'),
|
||||||
|
maxsize: 10 * 1024 * 1024, // 10MB
|
||||||
|
maxFiles: 30, // 保留30天
|
||||||
|
tailable: true
|
||||||
|
}),
|
||||||
|
// 错误单独记录
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logDir, 'token-refresh-error.log'),
|
||||||
|
level: 'error',
|
||||||
|
maxsize: 10 * 1024 * 1024,
|
||||||
|
maxFiles: 30
|
||||||
|
})
|
||||||
|
],
|
||||||
|
// 错误处理
|
||||||
|
exitOnError: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 在开发环境添加控制台输出
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
tokenRefreshLogger.add(new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.simple()
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 token 刷新开始
|
||||||
|
*/
|
||||||
|
function logRefreshStart(accountId, accountName, platform = 'claude', reason = '') {
|
||||||
|
tokenRefreshLogger.info({
|
||||||
|
event: 'token_refresh_start',
|
||||||
|
accountId,
|
||||||
|
accountName,
|
||||||
|
platform,
|
||||||
|
reason,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 token 刷新成功
|
||||||
|
*/
|
||||||
|
function logRefreshSuccess(accountId, accountName, platform = 'claude', tokenData = {}) {
|
||||||
|
const maskedTokenData = {
|
||||||
|
accessToken: tokenData.accessToken ? maskToken(tokenData.accessToken) : '[NOT_PROVIDED]',
|
||||||
|
refreshToken: tokenData.refreshToken ? maskToken(tokenData.refreshToken) : '[NOT_PROVIDED]',
|
||||||
|
expiresAt: tokenData.expiresAt || tokenData.expiry_date || '[NOT_PROVIDED]',
|
||||||
|
scopes: tokenData.scopes || tokenData.scope || '[NOT_PROVIDED]'
|
||||||
|
};
|
||||||
|
|
||||||
|
tokenRefreshLogger.info({
|
||||||
|
event: 'token_refresh_success',
|
||||||
|
accountId,
|
||||||
|
accountName,
|
||||||
|
platform,
|
||||||
|
tokenData: maskedTokenData,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 token 刷新失败
|
||||||
|
*/
|
||||||
|
function logRefreshError(accountId, accountName, platform = 'claude', error, attemptNumber = 1) {
|
||||||
|
const errorInfo = {
|
||||||
|
message: error.message || error.toString(),
|
||||||
|
code: error.code || 'UNKNOWN',
|
||||||
|
statusCode: error.response?.status || 'N/A',
|
||||||
|
responseData: error.response?.data || 'N/A'
|
||||||
|
};
|
||||||
|
|
||||||
|
tokenRefreshLogger.error({
|
||||||
|
event: 'token_refresh_error',
|
||||||
|
accountId,
|
||||||
|
accountName,
|
||||||
|
platform,
|
||||||
|
error: errorInfo,
|
||||||
|
attemptNumber,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 token 刷新跳过(由于并发锁)
|
||||||
|
*/
|
||||||
|
function logRefreshSkipped(accountId, accountName, platform = 'claude', reason = 'locked') {
|
||||||
|
tokenRefreshLogger.info({
|
||||||
|
event: 'token_refresh_skipped',
|
||||||
|
accountId,
|
||||||
|
accountName,
|
||||||
|
platform,
|
||||||
|
reason,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 token 使用情况
|
||||||
|
*/
|
||||||
|
function logTokenUsage(accountId, accountName, platform = 'claude', expiresAt, isExpired) {
|
||||||
|
tokenRefreshLogger.debug({
|
||||||
|
event: 'token_usage_check',
|
||||||
|
accountId,
|
||||||
|
accountName,
|
||||||
|
platform,
|
||||||
|
expiresAt,
|
||||||
|
isExpired,
|
||||||
|
remainingMinutes: expiresAt ? Math.floor((new Date(expiresAt) - Date.now()) / 60000) : 'N/A',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录批量刷新任务
|
||||||
|
*/
|
||||||
|
function logBatchRefreshStart(totalAccounts, platform = 'all') {
|
||||||
|
tokenRefreshLogger.info({
|
||||||
|
event: 'batch_refresh_start',
|
||||||
|
totalAccounts,
|
||||||
|
platform,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录批量刷新结果
|
||||||
|
*/
|
||||||
|
function logBatchRefreshComplete(results) {
|
||||||
|
tokenRefreshLogger.info({
|
||||||
|
event: 'batch_refresh_complete',
|
||||||
|
results: {
|
||||||
|
total: results.total || 0,
|
||||||
|
success: results.success || 0,
|
||||||
|
failed: results.failed || 0,
|
||||||
|
skipped: results.skipped || 0
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
logger: tokenRefreshLogger,
|
||||||
|
logRefreshStart,
|
||||||
|
logRefreshSuccess,
|
||||||
|
logRefreshError,
|
||||||
|
logRefreshSkipped,
|
||||||
|
logTokenUsage,
|
||||||
|
logBatchRefreshStart,
|
||||||
|
logBatchRefreshComplete
|
||||||
|
};
|
||||||
533
web/admin/app.js
533
web/admin/app.js
@@ -23,7 +23,7 @@ const app = createApp({
|
|||||||
tabs: [
|
tabs: [
|
||||||
{ key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' },
|
{ key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||||
{ key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' },
|
{ key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' },
|
||||||
{ key: 'accounts', name: 'Claude账户', icon: 'fas fa-user-circle' },
|
{ key: 'accounts', name: '账户管理', icon: 'fas fa-user-circle' },
|
||||||
{ key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' }
|
{ key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' }
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -86,6 +86,7 @@ const app = createApp({
|
|||||||
topApiKeys: [],
|
topApiKeys: [],
|
||||||
totalApiKeys: 0
|
totalApiKeys: 0
|
||||||
},
|
},
|
||||||
|
apiKeysTrendMetric: 'requests', // 'requests' 或 'tokens' - 默认显示请求次数
|
||||||
|
|
||||||
// 统一的日期筛选
|
// 统一的日期筛选
|
||||||
dateFilter: {
|
dateFilter: {
|
||||||
@@ -120,6 +121,8 @@ const app = createApp({
|
|||||||
rateLimitWindow: '',
|
rateLimitWindow: '',
|
||||||
rateLimitRequests: '',
|
rateLimitRequests: '',
|
||||||
claudeAccountId: '',
|
claudeAccountId: '',
|
||||||
|
geminiAccountId: '',
|
||||||
|
permissions: 'all', // 'claude', 'gemini', 'all'
|
||||||
enableModelRestriction: false,
|
enableModelRestriction: false,
|
||||||
restrictedModels: [],
|
restrictedModels: [],
|
||||||
modelInput: ''
|
modelInput: ''
|
||||||
@@ -163,6 +166,8 @@ const app = createApp({
|
|||||||
rateLimitWindow: '',
|
rateLimitWindow: '',
|
||||||
rateLimitRequests: '',
|
rateLimitRequests: '',
|
||||||
claudeAccountId: '',
|
claudeAccountId: '',
|
||||||
|
geminiAccountId: '',
|
||||||
|
permissions: 'all',
|
||||||
enableModelRestriction: false,
|
enableModelRestriction: false,
|
||||||
restrictedModels: [],
|
restrictedModels: [],
|
||||||
modelInput: ''
|
modelInput: ''
|
||||||
@@ -174,6 +179,7 @@ const app = createApp({
|
|||||||
showCreateAccountModal: false,
|
showCreateAccountModal: false,
|
||||||
createAccountLoading: false,
|
createAccountLoading: false,
|
||||||
accountForm: {
|
accountForm: {
|
||||||
|
platform: 'claude', // 'claude' 或 'gemini'
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
addType: 'oauth', // 'oauth' 或 'manual'
|
addType: 'oauth', // 'oauth' 或 'manual'
|
||||||
@@ -184,7 +190,8 @@ const app = createApp({
|
|||||||
proxyHost: '',
|
proxyHost: '',
|
||||||
proxyPort: '',
|
proxyPort: '',
|
||||||
proxyUsername: '',
|
proxyUsername: '',
|
||||||
proxyPassword: ''
|
proxyPassword: '',
|
||||||
|
projectId: '' // Gemini 项目编号
|
||||||
},
|
},
|
||||||
|
|
||||||
// 编辑账户相关
|
// 编辑账户相关
|
||||||
@@ -192,6 +199,7 @@ const app = createApp({
|
|||||||
editAccountLoading: false,
|
editAccountLoading: false,
|
||||||
editAccountForm: {
|
editAccountForm: {
|
||||||
id: '',
|
id: '',
|
||||||
|
platform: 'claude',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
accountType: 'shared',
|
accountType: 'shared',
|
||||||
@@ -202,7 +210,8 @@ const app = createApp({
|
|||||||
proxyHost: '',
|
proxyHost: '',
|
||||||
proxyPort: '',
|
proxyPort: '',
|
||||||
proxyUsername: '',
|
proxyUsername: '',
|
||||||
proxyPassword: ''
|
proxyPassword: '',
|
||||||
|
projectId: '' // Gemini 项目编号
|
||||||
},
|
},
|
||||||
|
|
||||||
// OAuth 相关
|
// OAuth 相关
|
||||||
@@ -214,6 +223,15 @@ const app = createApp({
|
|||||||
callbackUrl: ''
|
callbackUrl: ''
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Gemini OAuth 相关
|
||||||
|
geminiOauthPolling: false,
|
||||||
|
geminiOauthInterval: null,
|
||||||
|
geminiOauthData: {
|
||||||
|
sessionId: '',
|
||||||
|
authUrl: '',
|
||||||
|
code: ''
|
||||||
|
},
|
||||||
|
|
||||||
// 用户菜单和账户修改相关
|
// 用户菜单和账户修改相关
|
||||||
userMenuOpen: false,
|
userMenuOpen: false,
|
||||||
currentUser: {
|
currentUser: {
|
||||||
@@ -228,6 +246,16 @@ const app = createApp({
|
|||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 确认弹窗相关
|
||||||
|
showConfirmModal: false,
|
||||||
|
confirmModal: {
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
confirmText: '继续',
|
||||||
|
cancelText: '取消',
|
||||||
|
onConfirm: null,
|
||||||
|
onCancel: null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -312,6 +340,41 @@ const app = createApp({
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
// 显示确认弹窗
|
||||||
|
showConfirm(title, message, confirmText = '继续', cancelText = '取消') {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.confirmModal = {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText,
|
||||||
|
cancelText,
|
||||||
|
onConfirm: () => {
|
||||||
|
this.showConfirmModal = false;
|
||||||
|
resolve(true);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
this.showConfirmModal = false;
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.showConfirmModal = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 处理确认弹窗确定按钮
|
||||||
|
handleConfirmOk() {
|
||||||
|
if (this.confirmModal.onConfirm) {
|
||||||
|
this.confirmModal.onConfirm();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 处理确认弹窗取消按钮
|
||||||
|
handleConfirmCancel() {
|
||||||
|
if (this.confirmModal.onCancel) {
|
||||||
|
this.confirmModal.onCancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 获取绑定账号名称
|
// 获取绑定账号名称
|
||||||
getBoundAccountName(accountId) {
|
getBoundAccountName(accountId) {
|
||||||
const account = this.accounts.find(acc => acc.id === accountId);
|
const account = this.accounts.find(acc => acc.id === accountId);
|
||||||
@@ -320,7 +383,9 @@ const app = createApp({
|
|||||||
|
|
||||||
// 获取绑定到特定账号的API Key数量
|
// 获取绑定到特定账号的API Key数量
|
||||||
getBoundApiKeysCount(accountId) {
|
getBoundApiKeysCount(accountId) {
|
||||||
return this.apiKeys.filter(key => key.claudeAccountId === accountId).length;
|
return this.apiKeys.filter(key =>
|
||||||
|
key.claudeAccountId === accountId || key.geminiAccountId === accountId
|
||||||
|
).length;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 添加限制模型
|
// 添加限制模型
|
||||||
@@ -422,6 +487,7 @@ const app = createApp({
|
|||||||
openEditAccountModal(account) {
|
openEditAccountModal(account) {
|
||||||
this.editAccountForm = {
|
this.editAccountForm = {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
|
platform: account.platform || 'claude',
|
||||||
name: account.name,
|
name: account.name,
|
||||||
description: account.description || '',
|
description: account.description || '',
|
||||||
accountType: account.accountType || 'shared',
|
accountType: account.accountType || 'shared',
|
||||||
@@ -432,7 +498,8 @@ const app = createApp({
|
|||||||
proxyHost: account.proxy ? account.proxy.host : '',
|
proxyHost: account.proxy ? account.proxy.host : '',
|
||||||
proxyPort: account.proxy ? account.proxy.port : '',
|
proxyPort: account.proxy ? account.proxy.port : '',
|
||||||
proxyUsername: account.proxy ? account.proxy.username : '',
|
proxyUsername: account.proxy ? account.proxy.username : '',
|
||||||
proxyPassword: account.proxy ? account.proxy.password : ''
|
proxyPassword: account.proxy ? account.proxy.password : '',
|
||||||
|
projectId: account.projectId || '' // 添加项目编号
|
||||||
};
|
};
|
||||||
this.showEditAccountModal = true;
|
this.showEditAccountModal = true;
|
||||||
},
|
},
|
||||||
@@ -442,6 +509,7 @@ const app = createApp({
|
|||||||
this.showEditAccountModal = false;
|
this.showEditAccountModal = false;
|
||||||
this.editAccountForm = {
|
this.editAccountForm = {
|
||||||
id: '',
|
id: '',
|
||||||
|
platform: 'claude',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
accountType: 'shared',
|
accountType: 'shared',
|
||||||
@@ -452,12 +520,29 @@ const app = createApp({
|
|||||||
proxyHost: '',
|
proxyHost: '',
|
||||||
proxyPort: '',
|
proxyPort: '',
|
||||||
proxyUsername: '',
|
proxyUsername: '',
|
||||||
proxyPassword: ''
|
proxyPassword: '',
|
||||||
|
projectId: '' // 重置项目编号
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// 更新账户
|
// 更新账户
|
||||||
async updateAccount() {
|
async updateAccount() {
|
||||||
|
// 对于Gemini账户,检查项目编号
|
||||||
|
if (this.editAccountForm.platform === 'gemini') {
|
||||||
|
if (!this.editAccountForm.projectId || this.editAccountForm.projectId.trim() === '') {
|
||||||
|
// 使用自定义确认弹窗
|
||||||
|
const confirmed = await this.showConfirm(
|
||||||
|
'项目编号未填写',
|
||||||
|
'您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目编号。\n如果您使用的是普通个人账号,可以继续不填写。',
|
||||||
|
'继续保存',
|
||||||
|
'返回填写'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.editAccountLoading = true;
|
this.editAccountLoading = true;
|
||||||
try {
|
try {
|
||||||
// 验证账户类型切换
|
// 验证账户类型切换
|
||||||
@@ -478,19 +563,42 @@ const app = createApp({
|
|||||||
let updateData = {
|
let updateData = {
|
||||||
name: this.editAccountForm.name,
|
name: this.editAccountForm.name,
|
||||||
description: this.editAccountForm.description,
|
description: this.editAccountForm.description,
|
||||||
accountType: this.editAccountForm.accountType
|
accountType: this.editAccountForm.accountType,
|
||||||
|
projectId: this.editAccountForm.projectId || '' // 添加项目编号
|
||||||
};
|
};
|
||||||
|
|
||||||
// 只在有值时才更新 token
|
// 只在有值时才更新 token
|
||||||
if (this.editAccountForm.accessToken.trim()) {
|
if (this.editAccountForm.accessToken.trim()) {
|
||||||
// 构建新的 OAuth 数据
|
if (this.editAccountForm.platform === 'gemini') {
|
||||||
const newOauthData = {
|
// Gemini OAuth 数据格式
|
||||||
accessToken: this.editAccountForm.accessToken,
|
// 如果有 Refresh Token,设置10分钟过期;否则设置1年
|
||||||
refreshToken: this.editAccountForm.refreshToken || '',
|
const expiresInMs = this.editAccountForm.refreshToken
|
||||||
expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000), // 默认设置1年后过期
|
? (10 * 60 * 1000) // 10分钟
|
||||||
scopes: ['user:inference']
|
: (365 * 24 * 60 * 60 * 1000); // 1年
|
||||||
};
|
|
||||||
updateData.claudeAiOauth = newOauthData;
|
const newOauthData = {
|
||||||
|
access_token: this.editAccountForm.accessToken,
|
||||||
|
refresh_token: this.editAccountForm.refreshToken || '',
|
||||||
|
scope: 'https://www.googleapis.com/auth/cloud-platform',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expiry_date: Date.now() + expiresInMs
|
||||||
|
};
|
||||||
|
updateData.geminiOauth = newOauthData;
|
||||||
|
} else {
|
||||||
|
// Claude OAuth 数据格式
|
||||||
|
// 如果有 Refresh Token,设置10分钟过期;否则设置1年
|
||||||
|
const expiresInMs = this.editAccountForm.refreshToken
|
||||||
|
? (10 * 60 * 1000) // 10分钟
|
||||||
|
: (365 * 24 * 60 * 60 * 1000); // 1年
|
||||||
|
|
||||||
|
const newOauthData = {
|
||||||
|
accessToken: this.editAccountForm.accessToken,
|
||||||
|
refreshToken: this.editAccountForm.refreshToken || '',
|
||||||
|
expiresAt: Date.now() + expiresInMs,
|
||||||
|
scopes: ['user:inference']
|
||||||
|
};
|
||||||
|
updateData.claudeAiOauth = newOauthData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新代理配置
|
// 更新代理配置
|
||||||
@@ -506,7 +614,12 @@ const app = createApp({
|
|||||||
updateData.proxy = null;
|
updateData.proxy = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/admin/claude-accounts/${this.editAccountForm.id}`, {
|
// 根据平台选择端点
|
||||||
|
const endpoint = this.editAccountForm.platform === 'gemini'
|
||||||
|
? `/admin/gemini-accounts/${this.editAccountForm.id}`
|
||||||
|
: `/admin/claude-accounts/${this.editAccountForm.id}`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -549,6 +662,7 @@ const app = createApp({
|
|||||||
// 重置账户表单
|
// 重置账户表单
|
||||||
resetAccountForm() {
|
resetAccountForm() {
|
||||||
this.accountForm = {
|
this.accountForm = {
|
||||||
|
platform: 'claude',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
addType: 'oauth',
|
addType: 'oauth',
|
||||||
@@ -559,7 +673,8 @@ const app = createApp({
|
|||||||
proxyHost: '',
|
proxyHost: '',
|
||||||
proxyPort: '',
|
proxyPort: '',
|
||||||
proxyUsername: '',
|
proxyUsername: '',
|
||||||
proxyPassword: ''
|
proxyPassword: '',
|
||||||
|
projectId: '' // 重置项目编号
|
||||||
};
|
};
|
||||||
this.oauthStep = 1;
|
this.oauthStep = 1;
|
||||||
this.oauthData = {
|
this.oauthData = {
|
||||||
@@ -567,10 +682,37 @@ const app = createApp({
|
|||||||
authUrl: '',
|
authUrl: '',
|
||||||
callbackUrl: ''
|
callbackUrl: ''
|
||||||
};
|
};
|
||||||
|
this.geminiOauthData = {
|
||||||
|
sessionId: '',
|
||||||
|
authUrl: '',
|
||||||
|
code: ''
|
||||||
|
};
|
||||||
|
// 停止 Gemini OAuth 轮询
|
||||||
|
if (this.geminiOauthInterval) {
|
||||||
|
clearInterval(this.geminiOauthInterval);
|
||||||
|
this.geminiOauthInterval = null;
|
||||||
|
}
|
||||||
|
this.geminiOauthPolling = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
// OAuth步骤前进
|
// OAuth步骤前进
|
||||||
nextOAuthStep() {
|
async nextOAuthStep() {
|
||||||
|
// 对于Gemini账户,检查项目编号
|
||||||
|
if (this.accountForm.platform === 'gemini' && this.oauthStep === 1 && this.accountForm.addType === 'oauth') {
|
||||||
|
if (!this.accountForm.projectId || this.accountForm.projectId.trim() === '') {
|
||||||
|
// 使用自定义确认弹窗
|
||||||
|
const confirmed = await this.showConfirm(
|
||||||
|
'项目编号未填写',
|
||||||
|
'您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目编号。\n如果您使用的是普通个人账号,可以继续不填写。',
|
||||||
|
'继续',
|
||||||
|
'返回填写'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.oauthStep < 3) {
|
if (this.oauthStep < 3) {
|
||||||
this.oauthStep++;
|
this.oauthStep++;
|
||||||
}
|
}
|
||||||
@@ -592,7 +734,11 @@ const app = createApp({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/admin/claude-accounts/generate-auth-url', {
|
const endpoint = this.accountForm.platform === 'gemini'
|
||||||
|
? '/admin/gemini-accounts/generate-auth-url'
|
||||||
|
: '/admin/claude-accounts/generate-auth-url';
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -606,8 +752,14 @@ const app = createApp({
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.oauthData.authUrl = data.data.authUrl;
|
if (this.accountForm.platform === 'gemini') {
|
||||||
this.oauthData.sessionId = data.data.sessionId;
|
this.geminiOauthData.authUrl = data.data.authUrl;
|
||||||
|
this.geminiOauthData.sessionId = data.data.sessionId;
|
||||||
|
// 不再自动开始轮询,改为手动输入授权码
|
||||||
|
} else {
|
||||||
|
this.oauthData.authUrl = data.data.authUrl;
|
||||||
|
this.oauthData.sessionId = data.data.sessionId;
|
||||||
|
}
|
||||||
this.showToast('授权链接生成成功!', 'success', '生成成功');
|
this.showToast('授权链接生成成功!', 'success', '生成成功');
|
||||||
} else {
|
} else {
|
||||||
this.showToast(data.message || '生成失败', 'error', '生成失败');
|
this.showToast(data.message || '生成失败', 'error', '生成失败');
|
||||||
@@ -633,6 +785,12 @@ const app = createApp({
|
|||||||
|
|
||||||
// 创建OAuth账户
|
// 创建OAuth账户
|
||||||
async createOAuthAccount() {
|
async createOAuthAccount() {
|
||||||
|
// 如果是 Gemini,不应该调用这个方法
|
||||||
|
if (this.accountForm.platform === 'gemini') {
|
||||||
|
console.error('createOAuthAccount should not be called for Gemini');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.createAccountLoading = true;
|
this.createAccountLoading = true;
|
||||||
try {
|
try {
|
||||||
// 首先交换authorization code获取token
|
// 首先交换authorization code获取token
|
||||||
@@ -735,28 +893,65 @@ const app = createApp({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建手动 OAuth 数据
|
// 根据平台构建 OAuth 数据
|
||||||
const manualOauthData = {
|
let endpoint, bodyData;
|
||||||
accessToken: this.accountForm.accessToken,
|
|
||||||
refreshToken: this.accountForm.refreshToken || '',
|
|
||||||
expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000), // 默认设置1年后过期
|
|
||||||
scopes: ['user:inference'] // 默认权限
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建账户
|
if (this.accountForm.platform === 'gemini') {
|
||||||
const createResponse = await fetch('/admin/claude-accounts', {
|
// Gemini 账户
|
||||||
method: 'POST',
|
// 如果有 Refresh Token,设置10分钟过期;否则设置1年
|
||||||
headers: {
|
const expiresInMs = this.accountForm.refreshToken
|
||||||
'Content-Type': 'application/json',
|
? (10 * 60 * 1000) // 10分钟
|
||||||
'Authorization': 'Bearer ' + this.authToken
|
: (365 * 24 * 60 * 60 * 1000); // 1年
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
const geminiOauthData = {
|
||||||
|
access_token: this.accountForm.accessToken,
|
||||||
|
refresh_token: this.accountForm.refreshToken || '',
|
||||||
|
scope: 'https://www.googleapis.com/auth/cloud-platform',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expiry_date: Date.now() + expiresInMs
|
||||||
|
};
|
||||||
|
|
||||||
|
endpoint = '/admin/gemini-accounts';
|
||||||
|
bodyData = {
|
||||||
|
name: this.accountForm.name,
|
||||||
|
description: this.accountForm.description,
|
||||||
|
geminiOauth: geminiOauthData,
|
||||||
|
proxy: proxy,
|
||||||
|
accountType: this.accountForm.accountType,
|
||||||
|
projectId: this.accountForm.projectId || '' // 添加项目编号
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Claude 账户
|
||||||
|
// 如果有 Refresh Token,设置10分钟过期;否则设置1年
|
||||||
|
const expiresInMs = this.accountForm.refreshToken
|
||||||
|
? (10 * 60 * 1000) // 10分钟
|
||||||
|
: (365 * 24 * 60 * 60 * 1000); // 1年
|
||||||
|
|
||||||
|
const manualOauthData = {
|
||||||
|
accessToken: this.accountForm.accessToken,
|
||||||
|
refreshToken: this.accountForm.refreshToken || '',
|
||||||
|
expiresAt: Date.now() + expiresInMs,
|
||||||
|
scopes: ['user:inference'] // 默认权限
|
||||||
|
};
|
||||||
|
|
||||||
|
endpoint = '/admin/claude-accounts';
|
||||||
|
bodyData = {
|
||||||
name: this.accountForm.name,
|
name: this.accountForm.name,
|
||||||
description: this.accountForm.description,
|
description: this.accountForm.description,
|
||||||
claudeAiOauth: manualOauthData,
|
claudeAiOauth: manualOauthData,
|
||||||
proxy: proxy,
|
proxy: proxy,
|
||||||
accountType: this.accountForm.accountType
|
accountType: this.accountForm.accountType
|
||||||
})
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建账户
|
||||||
|
const createResponse = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + this.authToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify(bodyData)
|
||||||
});
|
});
|
||||||
|
|
||||||
const createData = await createResponse.json();
|
const createData = await createResponse.json();
|
||||||
@@ -790,6 +985,131 @@ const app = createApp({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Gemini OAuth 轮询
|
||||||
|
async startGeminiOAuthPolling() {
|
||||||
|
if (this.geminiOauthPolling) return;
|
||||||
|
|
||||||
|
this.geminiOauthPolling = true;
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 30; // 最多轮询 30 次(60秒)
|
||||||
|
|
||||||
|
this.geminiOauthInterval = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/gemini-accounts/poll-auth-status', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + this.authToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: this.geminiOauthData.sessionId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// 授权成功
|
||||||
|
this.stopGeminiOAuthPolling();
|
||||||
|
this.geminiOauthData.code = 'authorized';
|
||||||
|
|
||||||
|
// 自动创建账户
|
||||||
|
await this.createGeminiOAuthAccount(data.data.tokens);
|
||||||
|
} else if (data.error === 'Authorization timeout' || attempts >= maxAttempts) {
|
||||||
|
// 超时
|
||||||
|
this.stopGeminiOAuthPolling();
|
||||||
|
this.showToast('授权超时,请重试', 'error', '授权超时');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Polling error:', error);
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
this.stopGeminiOAuthPolling();
|
||||||
|
this.showToast('轮询失败,请检查网络连接', 'error', '网络错误');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 2000); // 每2秒轮询一次
|
||||||
|
},
|
||||||
|
|
||||||
|
stopGeminiOAuthPolling() {
|
||||||
|
if (this.geminiOauthInterval) {
|
||||||
|
clearInterval(this.geminiOauthInterval);
|
||||||
|
this.geminiOauthInterval = null;
|
||||||
|
}
|
||||||
|
this.geminiOauthPolling = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建 Gemini OAuth 账户
|
||||||
|
async createGeminiOAuthAccount() {
|
||||||
|
this.createAccountLoading = true;
|
||||||
|
try {
|
||||||
|
// 首先交换授权码获取 tokens
|
||||||
|
const tokenResponse = await fetch('/admin/gemini-accounts/exchange-code', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + this.authToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: this.geminiOauthData.code,
|
||||||
|
sessionId: this.geminiOauthData.sessionId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
|
||||||
|
if (!tokenData.success) {
|
||||||
|
this.showToast(tokenData.message || '授权码交换失败', 'error', '交换失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建代理配置
|
||||||
|
let proxy = null;
|
||||||
|
if (this.accountForm.proxyType) {
|
||||||
|
proxy = {
|
||||||
|
type: this.accountForm.proxyType,
|
||||||
|
host: this.accountForm.proxyHost,
|
||||||
|
port: parseInt(this.accountForm.proxyPort),
|
||||||
|
username: this.accountForm.proxyUsername || null,
|
||||||
|
password: this.accountForm.proxyPassword || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建账户
|
||||||
|
const response = await fetch('/admin/gemini-accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + this.authToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: this.accountForm.name,
|
||||||
|
description: this.accountForm.description,
|
||||||
|
geminiOauth: tokenData.data.tokens,
|
||||||
|
proxy: proxy,
|
||||||
|
accountType: this.accountForm.accountType,
|
||||||
|
projectId: this.accountForm.projectId || '' // 添加项目编号
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showToast('Gemini OAuth账户创建成功!', 'success', '账户创建成功');
|
||||||
|
this.closeCreateAccountModal();
|
||||||
|
await this.loadAccounts();
|
||||||
|
} else {
|
||||||
|
this.showToast(data.message || 'Account creation failed', 'error', 'Creation Failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating Gemini OAuth account:', error);
|
||||||
|
this.showToast('创建失败,请检查网络连接', 'error', '网络错误', 8000);
|
||||||
|
} finally {
|
||||||
|
this.createAccountLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// 根据当前标签页加载数据
|
// 根据当前标签页加载数据
|
||||||
loadCurrentTabData() {
|
loadCurrentTabData() {
|
||||||
@@ -1160,18 +1480,50 @@ const app = createApp({
|
|||||||
async loadAccounts() {
|
async loadAccounts() {
|
||||||
this.accountsLoading = true;
|
this.accountsLoading = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/admin/claude-accounts', {
|
// 并行加载 Claude 和 Gemini 账户
|
||||||
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
const [claudeResponse, geminiResponse] = await Promise.all([
|
||||||
});
|
fetch('/admin/claude-accounts', {
|
||||||
const data = await response.json();
|
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
||||||
|
}),
|
||||||
|
fetch('/admin/gemini-accounts', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
if (data.success) {
|
const [claudeData, geminiData] = await Promise.all([
|
||||||
this.accounts = data.data || [];
|
claudeResponse.json(),
|
||||||
// 为每个账号计算绑定的API Key数量
|
geminiResponse.json()
|
||||||
this.accounts.forEach(account => {
|
]);
|
||||||
account.boundApiKeysCount = this.apiKeys.filter(key => key.claudeAccountId === account.id).length;
|
|
||||||
});
|
// 合并账户数据
|
||||||
|
const allAccounts = [];
|
||||||
|
|
||||||
|
if (claudeData.success) {
|
||||||
|
const claudeAccounts = (claudeData.data || []).map(acc => ({
|
||||||
|
...acc,
|
||||||
|
platform: 'claude'
|
||||||
|
}));
|
||||||
|
allAccounts.push(...claudeAccounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (geminiData.success) {
|
||||||
|
const geminiAccounts = (geminiData.data || []).map(acc => ({
|
||||||
|
...acc,
|
||||||
|
platform: 'gemini'
|
||||||
|
}));
|
||||||
|
allAccounts.push(...geminiAccounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.accounts = allAccounts;
|
||||||
|
|
||||||
|
// 为每个账号计算绑定的API Key数量
|
||||||
|
this.accounts.forEach(account => {
|
||||||
|
if (account.platform === 'claude') {
|
||||||
|
account.boundApiKeysCount = this.apiKeys.filter(key => key.claudeAccountId === account.id).length;
|
||||||
|
} else {
|
||||||
|
account.boundApiKeysCount = this.apiKeys.filter(key => key.geminiAccountId === account.id).length;
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load accounts:', error);
|
console.error('Failed to load accounts:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1253,7 +1605,13 @@ const app = createApp({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deleteApiKey(keyId) {
|
async deleteApiKey(keyId) {
|
||||||
if (!confirm('确定要删除这个 API Key 吗?')) return;
|
const confirmed = await this.showConfirm(
|
||||||
|
'删除 API Key',
|
||||||
|
'确定要删除这个 API Key 吗?\n\n此操作不可撤销,删除后将无法恢复。',
|
||||||
|
'确认删除',
|
||||||
|
'取消'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/admin/api-keys/' + keyId, {
|
const response = await fetch('/admin/api-keys/' + keyId, {
|
||||||
@@ -1350,6 +1708,13 @@ const app = createApp({
|
|||||||
await this.loadApiKeys();
|
await this.loadApiKeys();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查找账户以确定平台类型
|
||||||
|
const account = this.accounts.find(acc => acc.id === accountId);
|
||||||
|
if (!account) {
|
||||||
|
this.showToast('账户不存在', 'error', '删除失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否有API Key绑定到此账号
|
// 检查是否有API Key绑定到此账号
|
||||||
const boundKeysCount = this.getBoundApiKeysCount(accountId);
|
const boundKeysCount = this.getBoundApiKeysCount(accountId);
|
||||||
if (boundKeysCount > 0) {
|
if (boundKeysCount > 0) {
|
||||||
@@ -1357,10 +1722,22 @@ const app = createApp({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm('确定要删除这个 Claude 账户吗?')) return;
|
const platformName = account.platform === 'gemini' ? 'Gemini' : 'Claude';
|
||||||
|
const confirmed = await this.showConfirm(
|
||||||
|
`删除 ${platformName} 账户`,
|
||||||
|
`确定要删除这个 ${platformName} 账户吗?\n\n账户名称:${account.name}\n此操作不可撤销,删除后将无法恢复。`,
|
||||||
|
'确认删除',
|
||||||
|
'取消'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
// 根据平台选择端点
|
||||||
|
const endpoint = account.platform === 'gemini'
|
||||||
|
? `/admin/gemini-accounts/${accountId}`
|
||||||
|
: `/admin/claude-accounts/${accountId}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/admin/claude-accounts/' + accountId, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
||||||
});
|
});
|
||||||
@@ -1379,6 +1756,40 @@ const app = createApp({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 刷新账户 Token
|
||||||
|
async refreshAccountToken(accountId) {
|
||||||
|
const account = this.accounts.find(acc => acc.id === accountId);
|
||||||
|
if (!account) {
|
||||||
|
this.showToast('账户不存在', 'error', '刷新失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据平台选择端点
|
||||||
|
const endpoint = account.platform === 'gemini'
|
||||||
|
? `/admin/gemini-accounts/${accountId}/refresh`
|
||||||
|
: `/admin/claude-accounts/${accountId}/refresh`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const platformName = account.platform === 'gemini' ? 'Gemini' : 'Claude';
|
||||||
|
this.showToast(`${platformName} Token 刷新成功`, 'success', '刷新成功');
|
||||||
|
await this.loadAccounts();
|
||||||
|
} else {
|
||||||
|
this.showToast(data.message || '刷新失败', 'error', '刷新失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing token:', error);
|
||||||
|
this.showToast('刷新失败,请检查网络连接', 'error', '网络错误');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// API Key 展示相关方法
|
// API Key 展示相关方法
|
||||||
toggleApiKeyVisibility() {
|
toggleApiKeyVisibility() {
|
||||||
this.newApiKey.showFullKey = !this.newApiKey.showFullKey;
|
this.newApiKey.showFullKey = !this.newApiKey.showFullKey;
|
||||||
@@ -1416,9 +1827,15 @@ const app = createApp({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
closeNewApiKeyModal() {
|
async closeNewApiKeyModal() {
|
||||||
// 显示确认提示
|
// 显示确认提示
|
||||||
if (confirm('关闭后将无法再次查看完整的API Key,请确保已经妥善保存。确定要关闭吗?')) {
|
const confirmed = await this.showConfirm(
|
||||||
|
'关闭 API Key',
|
||||||
|
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?',
|
||||||
|
'我已保存',
|
||||||
|
'取消'
|
||||||
|
);
|
||||||
|
if (confirmed) {
|
||||||
this.showNewApiKeyModal = false;
|
this.showNewApiKeyModal = false;
|
||||||
this.newApiKey = { key: '', name: '', description: '', showFullKey: false };
|
this.newApiKey = { key: '', name: '', description: '', showFullKey: false };
|
||||||
}
|
}
|
||||||
@@ -1991,7 +2408,10 @@ const app = createApp({
|
|||||||
// 只显示前10个使用量最多的API Key
|
// 只显示前10个使用量最多的API Key
|
||||||
this.apiKeysTrendData.topApiKeys.forEach((apiKeyId, index) => {
|
this.apiKeysTrendData.topApiKeys.forEach((apiKeyId, index) => {
|
||||||
const data = this.apiKeysTrendData.data.map(item => {
|
const data = this.apiKeysTrendData.data.map(item => {
|
||||||
return item.apiKeys[apiKeyId] ? item.apiKeys[apiKeyId].tokens : 0;
|
if (!item.apiKeys[apiKeyId]) return 0;
|
||||||
|
return this.apiKeysTrendMetric === 'tokens'
|
||||||
|
? item.apiKeys[apiKeyId].tokens
|
||||||
|
: item.apiKeys[apiKeyId].requests || 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取API Key名称
|
// 获取API Key名称
|
||||||
@@ -2049,7 +2469,7 @@ const app = createApp({
|
|||||||
position: 'left',
|
position: 'left',
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Token 数量'
|
text: this.apiKeysTrendMetric === 'tokens' ? 'Token 数量' : '请求次数'
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
callback: function(value) {
|
callback: function(value) {
|
||||||
@@ -2087,10 +2507,11 @@ const app = createApp({
|
|||||||
}
|
}
|
||||||
return tooltipItems[0].label;
|
return tooltipItems[0].label;
|
||||||
},
|
},
|
||||||
label: function(context) {
|
label: (context) => {
|
||||||
const label = context.dataset.label || '';
|
const label = context.dataset.label || '';
|
||||||
const value = context.parsed.y;
|
const value = context.parsed.y;
|
||||||
return label + ': ' + value.toLocaleString() + ' tokens';
|
const unit = this.apiKeysTrendMetric === 'tokens' ? ' tokens' : ' 次';
|
||||||
|
return label + ': ' + value.toLocaleString() + unit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@
|
|||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<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-3xl font-bold text-gray-900">{{ dashboardData.totalAccounts }}</p>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
活跃: {{ dashboardData.activeAccounts || 0 }}
|
活跃: {{ dashboardData.activeAccounts || 0 }}
|
||||||
@@ -406,10 +406,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Keys Token消耗趋势图 -->
|
<!-- API Keys 使用趋势图 -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="card p-6">
|
<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">
|
<div class="mb-4 text-sm text-gray-600">
|
||||||
<span v-if="apiKeysTrendData.totalApiKeys > 10">
|
<span v-if="apiKeysTrendData.totalApiKeys > 10">
|
||||||
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key,显示使用量前 10 个
|
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key,显示使用量前 10 个
|
||||||
@@ -770,8 +797,8 @@
|
|||||||
<div class="card p-6">
|
<div class="card p-6">
|
||||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
|
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-bold text-gray-900 mb-2">Claude 账户管理</h3>
|
<h3 class="text-xl font-bold text-gray-900 mb-2">账户管理</h3>
|
||||||
<p class="text-gray-600">管理您的 Claude 账户和代理配置</p>
|
<p class="text-gray-600">管理您的 Claude 和 Gemini 账户及代理配置</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click.stop="openCreateAccountModal"
|
@click.stop="openCreateAccountModal"
|
||||||
@@ -799,6 +826,7 @@
|
|||||||
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
||||||
<tr>
|
<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>
|
||||||
<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 +857,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span v-if="account.scopes && account.scopes.length > 0"
|
<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">
|
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
|
||||||
@@ -1801,39 +1839,65 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- 速率限制设置 -->
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (可选)</label>
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
|
||||||
<input
|
<div class="flex items-start gap-3 mb-3">
|
||||||
v-model="apiKeyForm.rateLimitWindow"
|
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
type="number"
|
<i class="fas fa-tachometer-alt text-white text-sm"></i>
|
||||||
min="1"
|
</div>
|
||||||
placeholder="留空表示无限制"
|
<div class="flex-1">
|
||||||
class="form-input w-full"
|
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置 (可选)</h4>
|
||||||
>
|
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
|
||||||
<p class="text-xs text-gray-500 mt-2">设置时间窗口(分钟),在此时间内限制请求次数或Token使用量</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">请求次数限制 (可选)</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
|
||||||
<input
|
<input
|
||||||
v-model="apiKeyForm.rateLimitRequests"
|
v-model="apiKeyForm.rateLimitWindow"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
placeholder="留空表示无限制"
|
placeholder="留空表示无限制"
|
||||||
class="form-input w-full"
|
class="form-input w-full"
|
||||||
>
|
>
|
||||||
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数</p>
|
<p class="text-xs text-gray-500 mt-2">设置一个时间段(以分钟为单位),用于计算速率限制</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内 Token 限制 (可选)</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
|
||||||
<input
|
<input
|
||||||
v-model="apiKeyForm.tokenLimit"
|
v-model="apiKeyForm.rateLimitRequests"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="留空表示无限制"
|
min="1"
|
||||||
class="form-input w-full"
|
placeholder="留空表示无限制"
|
||||||
>
|
class="form-input w-full"
|
||||||
<p class="text-xs text-gray-500 mt-2">设置在时间窗口内的最大 Token 使用量(需同时设置时间窗口)</p>
|
>
|
||||||
|
<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> 时间窗口=10,Token限制=50000</p>
|
||||||
|
<p class="ml-4">→ 每10分钟内最多消耗50,000个Token</p>
|
||||||
|
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30,请求次数限制=50,Token限制=100000</p>
|
||||||
|
<p class="ml-4">→ 每30分钟内最多50次请求且总Token不超过100,000</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1858,21 +1922,78 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定 (可选)</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定 (可选)</label>
|
||||||
<select
|
<div class="grid grid-cols-1 gap-3">
|
||||||
v-model="apiKeyForm.claudeAccountId"
|
<div>
|
||||||
class="form-input w-full"
|
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
||||||
>
|
<select
|
||||||
<option value="">使用共享账号池</option>
|
v-model="apiKeyForm.claudeAccountId"
|
||||||
<option
|
class="form-input w-full"
|
||||||
v-for="account in dedicatedAccounts"
|
:disabled="apiKeyForm.permissions === 'gemini'"
|
||||||
:key="account.id"
|
>
|
||||||
:value="account.id"
|
<option value="">使用共享账号池</option>
|
||||||
>
|
<option
|
||||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
v-for="account in dedicatedAccounts.filter(a => a.platform === 'claude')"
|
||||||
</option>
|
:key="account.id"
|
||||||
</select>
|
: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>
|
<p class="text-xs text-gray-500 mt-2">选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1984,40 +2105,66 @@
|
|||||||
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
|
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- 速率限制设置 -->
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口</label>
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
|
||||||
<input
|
<div class="flex items-start gap-3 mb-3">
|
||||||
v-model="editApiKeyForm.rateLimitWindow"
|
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
type="number"
|
<i class="fas fa-tachometer-alt text-white text-sm"></i>
|
||||||
min="1"
|
</div>
|
||||||
placeholder="留空表示无限制"
|
<div class="flex-1">
|
||||||
class="form-input w-full"
|
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置</h4>
|
||||||
>
|
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
|
||||||
<p class="text-xs text-gray-500 mt-2">设置时间窗口(分钟),在此时间内限制请求次数或Token使用量</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">请求次数限制</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
|
||||||
<input
|
<input
|
||||||
v-model="editApiKeyForm.rateLimitRequests"
|
v-model="editApiKeyForm.rateLimitWindow"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
placeholder="留空表示无限制"
|
placeholder="留空表示无限制"
|
||||||
class="form-input w-full"
|
class="form-input w-full"
|
||||||
>
|
>
|
||||||
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数</p>
|
<p class="text-xs text-gray-500 mt-2">设置一个时间段(以分钟为单位),用于计算速率限制</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内 Token 限制</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
|
||||||
<input
|
<input
|
||||||
v-model="editApiKeyForm.tokenLimit"
|
v-model="editApiKeyForm.rateLimitRequests"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="1"
|
||||||
placeholder="0 表示无限制"
|
placeholder="留空表示无限制"
|
||||||
class="form-input w-full"
|
class="form-input w-full"
|
||||||
>
|
>
|
||||||
<p class="text-xs text-gray-500 mt-2">设置在时间窗口内的最大 Token 使用量(需同时设置时间窗口),0 或留空表示无限制</p>
|
<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> 时间窗口=10,Token限制=50000</p>
|
||||||
|
<p class="ml-4">→ 每10分钟内最多消耗50,000个Token</p>
|
||||||
|
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30,请求次数限制=50,Token限制=100000</p>
|
||||||
|
<p class="ml-4">→ 每30分钟内最多50次请求且总Token不超过100,000</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -2032,21 +2179,78 @@
|
|||||||
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制</p>
|
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定</label>
|
||||||
<select
|
<div class="grid grid-cols-1 gap-3">
|
||||||
v-model="editApiKeyForm.claudeAccountId"
|
<div>
|
||||||
class="form-input w-full"
|
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
||||||
>
|
<select
|
||||||
<option value="">使用共享账号池</option>
|
v-model="editApiKeyForm.claudeAccountId"
|
||||||
<option
|
class="form-input w-full"
|
||||||
v-for="account in dedicatedAccounts"
|
:disabled="editApiKeyForm.permissions === 'gemini'"
|
||||||
:key="account.id"
|
>
|
||||||
:value="account.id"
|
<option value="">使用共享账号池</option>
|
||||||
>
|
<option
|
||||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
v-for="account in dedicatedAccounts.filter(a => a.platform === 'claude')"
|
||||||
</option>
|
:key="account.id"
|
||||||
</select>
|
: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>
|
<p class="text-xs text-gray-500 mt-2">修改绑定账号将影响此API Key的请求路由</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2222,7 +2426,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 创建 Claude 账户模态框 -->
|
<!-- 创建账户模态框 -->
|
||||||
<div v-if="showCreateAccountModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
<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="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">
|
<div class="flex items-center justify-between mb-6">
|
||||||
@@ -2230,7 +2434,7 @@
|
|||||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
<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>
|
<i class="fas fa-user-circle text-white"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-gray-900">添加 Claude 账户</h3>
|
<h3 class="text-xl font-bold text-gray-900">添加账户</h3>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="closeCreateAccountModal"
|
@click="closeCreateAccountModal"
|
||||||
@@ -2264,6 +2468,30 @@
|
|||||||
<!-- 步骤1: 基本信息和代理设置 -->
|
<!-- 步骤1: 基本信息和代理设置 -->
|
||||||
<div v-if="oauthStep === 1">
|
<div v-if="oauthStep === 1">
|
||||||
<div class="space-y-6">
|
<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>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">添加方式</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-3">添加方式</label>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
@@ -2336,6 +2564,35 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">⚠️ 注意:不要复制项目ID(Project ID),要复制项目编号!</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2"><strong>提示:</strong>如果您的账号是普通个人账号(未绑定 Google Cloud),请留空此字段。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 手动输入 Token 字段 -->
|
<!-- 手动输入 Token 字段 -->
|
||||||
<div v-if="accountForm.addType === 'manual'" class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200">
|
<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">
|
<div class="flex items-start gap-3 mb-4">
|
||||||
@@ -2344,16 +2601,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h5 class="font-semibold text-blue-900 mb-2">手动输入 Token</h5>
|
<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">
|
<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">
|
<p class="text-sm text-blue-900 font-medium mb-1">
|
||||||
<i class="fas fa-folder-open mr-1"></i>
|
<i class="fas fa-folder-open mr-1"></i>
|
||||||
获取 Access Token 的方法:
|
获取 Access Token 的方法:
|
||||||
</p>
|
</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 Code 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.claude/.credentials.json</code> 文件中的凭证,
|
||||||
请勿使用 Claude 官网 API Keys 页面的密钥。
|
请勿使用 Claude 官网 API Keys 页面的密钥。
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<p class="text-xs text-blue-600">💡 如果未填写 Refresh Token,Token 过期后需要手动更新。</p>
|
<p class="text-xs text-blue-600">💡 如果未填写 Refresh Token,Token 过期后需要手动更新。</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2365,7 +2630,7 @@
|
|||||||
v-model="accountForm.accessToken"
|
v-model="accountForm.accessToken"
|
||||||
rows="4"
|
rows="4"
|
||||||
class="form-input w-full resize-none font-mono text-sm"
|
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
|
required
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -2376,7 +2641,7 @@
|
|||||||
v-model="accountForm.refreshToken"
|
v-model="accountForm.refreshToken"
|
||||||
rows="4"
|
rows="4"
|
||||||
class="form-input w-full resize-none font-mono text-sm"
|
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>
|
></textarea>
|
||||||
<p class="text-xs text-gray-500 mt-2">如果有 Refresh Token,填写后系统可以自动刷新过期的 Access Token</p>
|
<p class="text-xs text-gray-500 mt-2">如果有 Refresh Token,填写后系统可以自动刷新过期的 Access Token</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2384,7 +2649,10 @@
|
|||||||
|
|
||||||
<div class="border-t pt-6">
|
<div class="border-t pt-6">
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">代理设置 (可选)</label>
|
<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
|
<select
|
||||||
v-model="accountForm.proxyType"
|
v-model="accountForm.proxyType"
|
||||||
class="form-input w-full"
|
class="form-input w-full"
|
||||||
@@ -2472,7 +2740,8 @@
|
|||||||
|
|
||||||
<!-- 步骤2: OAuth 授权 -->
|
<!-- 步骤2: OAuth 授权 -->
|
||||||
<div v-if="oauthStep === 2">
|
<div v-if="oauthStep === 2">
|
||||||
<div class="space-y-6">
|
<!-- Claude OAuth 流程 -->
|
||||||
|
<div v-if="accountForm.platform === 'claude'" class="space-y-6">
|
||||||
<!-- 获取授权URL -->
|
<!-- 获取授权URL -->
|
||||||
<div v-if="!oauthData.authUrl" class="text-center py-8">
|
<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">
|
<div class="w-16 h-16 mx-auto mb-4 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
@@ -2555,6 +2824,90 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>授权完成后,页面会显示授权码</li>
|
||||||
|
<li>复制授权码并粘贴到下方输入框中</li>
|
||||||
|
</ol>
|
||||||
|
</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>授权码
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="geminiOauthData.code"
|
||||||
|
rows="3"
|
||||||
|
class="form-input w-full resize-none font-mono text-sm"
|
||||||
|
placeholder="粘贴从授权页面复制的授权码..."
|
||||||
|
></textarea>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
授权完成后,从回调页面复制授权码并粘贴到此处
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-6">
|
<div class="flex gap-3 pt-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2563,7 +2916,9 @@
|
|||||||
>
|
>
|
||||||
<i class="fas fa-arrow-left mr-2"></i>上一步
|
<i class="fas fa-arrow-left mr-2"></i>上一步
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Claude 完成按钮 -->
|
||||||
<button
|
<button
|
||||||
|
v-if="accountForm.platform === 'claude'"
|
||||||
type="button"
|
type="button"
|
||||||
@click="createOAuthAccount()"
|
@click="createOAuthAccount()"
|
||||||
:disabled="!oauthData.callbackUrl || !oauthData.authUrl || createAccountLoading"
|
:disabled="!oauthData.callbackUrl || !oauthData.authUrl || createAccountLoading"
|
||||||
@@ -2573,12 +2928,24 @@
|
|||||||
<i v-else class="fas fa-check mr-2"></i>
|
<i v-else class="fas fa-check mr-2"></i>
|
||||||
{{ createAccountLoading ? '创建中...' : '完成创建' }}
|
{{ createAccountLoading ? '创建中...' : '完成创建' }}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 编辑 Claude 账户模态框 -->
|
<!-- 编辑账户模态框 -->
|
||||||
<div v-if="showEditAccountModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
<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="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">
|
<div class="flex items-center justify-between mb-6">
|
||||||
@@ -2586,7 +2953,7 @@
|
|||||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
<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>
|
<i class="fas fa-edit text-white"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-gray-900">编辑 Claude 账户</h3>
|
<h3 class="text-xl font-bold text-gray-900">编辑账户</h3>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="closeEditAccountModal"
|
@click="closeEditAccountModal"
|
||||||
@@ -2656,6 +3023,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Token 更新区域 -->
|
<!-- 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">⚠️ 注意:不要复制项目ID(Project 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="bg-amber-50 p-4 rounded-lg border border-amber-200">
|
||||||
<div class="flex items-start gap-3 mb-4">
|
<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">
|
<div class="w-8 h-8 bg-amber-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
|
||||||
@@ -2868,6 +3264,36 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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 通知组件 -->
|
<!-- Toast 通知组件 -->
|
||||||
<div v-for="(toast, index) in toasts" :key="toast.id"
|
<div v-for="(toast, index) in toasts" :key="toast.id"
|
||||||
|
|||||||
Reference in New Issue
Block a user