mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ebef1b116 | ||
|
|
35f755246e | ||
|
|
338d44faee | ||
|
|
968398ffa5 | ||
|
|
645ab43675 | ||
|
|
1027a2e3e2 | ||
|
|
0f5321b0ef | ||
|
|
c7d7bf47d6 | ||
|
|
ebc30b6026 | ||
|
|
d5a7af2d7d | ||
|
|
81a3e26e27 | ||
|
|
64db4a270d | ||
|
|
ca027ecb90 | ||
|
|
21e6944abb | ||
|
|
4ea3d4830f | ||
|
|
3000632d4e | ||
|
|
9e3a4cf45a | ||
|
|
eb992697b6 | ||
|
|
35ab34d687 | ||
|
|
bc4b050c69 | ||
|
|
189d53d793 | ||
|
|
b148537428 | ||
|
|
9d1a451027 | ||
|
|
ba815de08f | ||
|
|
b26027731e | ||
|
|
f535b35a1c | ||
|
|
962e01b080 | ||
|
|
fcc6ac4e22 | ||
|
|
3a03147ac9 | ||
|
|
94f239b56a | ||
|
|
b07873772c | ||
|
|
549c95eb80 | ||
|
|
b397954ea4 | ||
|
|
ed835d0c28 | ||
|
|
28b27e6a7b | ||
|
|
810fe9fe90 | ||
|
|
141b07db78 | ||
|
|
1dad810d15 | ||
|
|
4723328be4 | ||
|
|
944ef096b3 | ||
|
|
114e9facee | ||
|
|
e20ce86ad4 | ||
|
|
6caabb5444 | ||
|
|
b924c3c559 | ||
|
|
6682e0a982 | ||
|
|
b9c088ce58 | ||
|
|
2ff74c21d2 | ||
|
|
8a4dadbbc0 | ||
|
|
adf2890f65 | ||
|
|
7d892a69f1 | ||
|
|
a749ddfede | ||
|
|
dbd4fb19cf | ||
|
|
39ba345a43 | ||
|
|
2693fd77b7 | ||
|
|
3cc3219a90 | ||
|
|
1b834ffcdb | ||
|
|
41999f56b4 | ||
|
|
b81c2b946f | ||
|
|
0a59a0f9d4 | ||
|
|
c4448db6ab | ||
|
|
c67d2bce9d | ||
|
|
a345812cd7 | ||
|
|
a0cbafd759 | ||
|
|
3c64038fa7 | ||
|
|
45b81bd478 | ||
|
|
fc57133230 | ||
|
|
1f06af4a56 | ||
|
|
6165fad090 | ||
|
|
d53a399d41 | ||
|
|
3f98267738 | ||
|
|
e187b8946a | ||
|
|
8917019a78 | ||
|
|
9960f237b8 | ||
|
|
b6da77cabe | ||
|
|
e561387e81 | ||
|
|
982cca1020 | ||
|
|
18a493e805 | ||
|
|
534fbf6ac2 | ||
|
|
b6f3459522 | ||
|
|
e56d797d87 | ||
|
|
4c6879a9c2 | ||
|
|
1c8084a3b1 | ||
|
|
f6f4b5cfec | ||
|
|
26ca696b91 | ||
|
|
ce496ed9e6 | ||
|
|
f6ed420401 | ||
|
|
33ea26f2ac |
53
.env.example
53
.env.example
@@ -33,6 +33,59 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages
|
||||
CLAUDE_API_VERSION=2023-06-01
|
||||
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
|
||||
|
||||
# 🤖 Gemini OAuth / Antigravity 配置(可选)
|
||||
# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret,可在此覆盖
|
||||
# GEMINI_OAUTH_CLIENT_ID=
|
||||
# GEMINI_OAUTH_CLIENT_SECRET=
|
||||
# Gemini CLI OAuth redirect_uri(可选,默认 https://codeassist.google.com/authcode)
|
||||
# GEMINI_OAUTH_REDIRECT_URI=
|
||||
# ANTIGRAVITY_OAUTH_CLIENT_ID=
|
||||
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||
# Antigravity OAuth redirect_uri(可选,默认 http://localhost:45462;用于避免 redirect_uri_mismatch)
|
||||
# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462
|
||||
# Antigravity 上游地址(可选,默认 sandbox)
|
||||
# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com
|
||||
# Antigravity User-Agent(可选)
|
||||
# ANTIGRAVITY_USER_AGENT=antigravity/1.11.3 windows/amd64
|
||||
|
||||
# Claude Code(Anthropic Messages API)路由分流(无需额外环境变量):
|
||||
# - /api -> Claude 账号池(默认)
|
||||
# - /antigravity/api -> Antigravity OAuth
|
||||
# - /gemini-cli/api -> Gemini CLI OAuth
|
||||
|
||||
# ============================================================================
|
||||
# 🐛 调试 Dump 配置(可选)
|
||||
# ============================================================================
|
||||
# 以下开启后会在项目根目录写入 .jsonl 调试文件,便于排查问题。
|
||||
# ⚠️ 生产环境建议关闭,避免磁盘占用。
|
||||
#
|
||||
# 📄 输出文件列表:
|
||||
# - anthropic-requests-dump.jsonl (客户端请求)
|
||||
# - anthropic-responses-dump.jsonl (返回给客户端的响应)
|
||||
# - anthropic-tools-dump.jsonl (工具定义快照)
|
||||
# - antigravity-upstream-requests-dump.jsonl (发往上游的请求)
|
||||
# - antigravity-upstream-responses-dump.jsonl (上游 SSE 响应)
|
||||
#
|
||||
# 📌 开关配置:
|
||||
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
|
||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
|
||||
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
|
||||
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
|
||||
# ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true
|
||||
#
|
||||
# 📏 单条记录大小上限(字节),默认 2MB:
|
||||
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
|
||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
|
||||
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
|
||||
#
|
||||
# 📦 整个 Dump 文件大小上限(字节),超过后自动轮转为 .bak 文件,默认 10MB:
|
||||
# DUMP_MAX_FILE_SIZE_BYTES=10485760
|
||||
#
|
||||
# 🔧 工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务
|
||||
# (仅 /antigravity/api 分流生效)
|
||||
# ANTHROPIC_TOOL_ERROR_CONTINUE=true
|
||||
|
||||
|
||||
# 🚫 529错误处理配置
|
||||
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
||||
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
|
||||
|
||||
29
README.md
29
README.md
@@ -1,5 +1,10 @@
|
||||
# Claude Relay Service
|
||||
|
||||
> [!CAUTION]
|
||||
> **安全更新通知**:v1.1.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
|
||||
>
|
||||
> **请立即更新到 v1.1.249+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
@@ -389,6 +394,9 @@ docker-compose.yml 已包含:
|
||||
|
||||
**Claude Code 设置环境变量:**
|
||||
|
||||
|
||||
**使用标准 Claude 账号池**
|
||||
|
||||
默认使用标准 Claude 账号池:
|
||||
|
||||
```bash
|
||||
@@ -396,6 +404,24 @@ export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||
```
|
||||
|
||||
**使用 Antigravity 账户池**
|
||||
|
||||
适用于通过 Antigravity 渠道使用 Claude 模型(如 `claude-opus-4-5` 等)。
|
||||
|
||||
```bash
|
||||
# 1. 设置 Base URL 为 Antigravity 专用路径
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
||||
|
||||
# 2. 设置 API Key(在后台创建,权限需包含 'all' 或 'gemini')
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||
|
||||
# 3. 指定模型名称(直接使用短名,无需前缀!)
|
||||
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||
|
||||
# 4. 启动
|
||||
claude
|
||||
```
|
||||
|
||||
**VSCode Claude 插件配置:**
|
||||
|
||||
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
|
||||
@@ -599,8 +625,9 @@ gpt-5 # Codex使用固定模型ID
|
||||
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
||||
- 根据不同的路由前缀自动识别账号类型
|
||||
- `/claude/` - 使用Claude账号池
|
||||
- `/antigravity/api/` - 使用Antigravity账号池(推荐用于Claude Code)
|
||||
- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用)
|
||||
- `/gemini/` - 使用Gemini账号池
|
||||
- `/gemini/` - 使用Gemini账号池
|
||||
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
||||
- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池(只建议api调用或Droid Cli中使用)
|
||||
- 支持所有标准API端点(messages、models等)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Claude Relay Service
|
||||
|
||||
> [!CAUTION]
|
||||
> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
|
||||
>
|
||||
> **Please update to v1.1.249+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
21
SECURITY.md
Normal file
21
SECURITY.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Use this section to tell people about which versions of your project are
|
||||
currently being supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 5.1.x | :white_check_mark: |
|
||||
| 5.0.x | :x: |
|
||||
| 4.0.x | :white_check_mark: |
|
||||
| < 4.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Use this section to tell people how to report a vulnerability.
|
||||
|
||||
Tell them where to go, how often they can expect to get an update on a
|
||||
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||
declined, etc.
|
||||
@@ -205,6 +205,14 @@ const config = {
|
||||
hotReload: process.env.HOT_RELOAD === 'true'
|
||||
},
|
||||
|
||||
// 💰 账户余额相关配置
|
||||
accountBalance: {
|
||||
// 是否允许执行自定义余额脚本(安全开关)
|
||||
// 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启
|
||||
// 默认保持开启;如需禁用请显式设置:BALANCE_SCRIPT_ENABLED=false
|
||||
enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false'
|
||||
},
|
||||
|
||||
// 📬 用户消息队列配置
|
||||
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
|
||||
userMessageQueue: {
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -892,7 +892,6 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -3001,7 +3000,6 @@
|
||||
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3083,7 +3081,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3539,7 +3536,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001737",
|
||||
"electron-to-chromium": "^1.5.211",
|
||||
@@ -4427,7 +4423,6 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4484,7 +4479,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -7592,7 +7586,6 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -9111,7 +9104,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.2",
|
||||
|
||||
94
src/app.js
94
src/app.js
@@ -52,6 +52,16 @@ class Application {
|
||||
await redis.connect()
|
||||
logger.success('✅ Redis connected successfully')
|
||||
|
||||
// 💳 初始化账户余额查询服务(Provider 注册)
|
||||
try {
|
||||
const accountBalanceService = require('./services/accountBalanceService')
|
||||
const { registerAllProviders } = require('./services/balanceProviders')
|
||||
registerAllProviders(accountBalanceService)
|
||||
logger.info('✅ 账户余额查询服务已初始化')
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message)
|
||||
}
|
||||
|
||||
// 💰 初始化价格服务
|
||||
logger.info('🔄 Initializing pricing service...')
|
||||
await pricingService.initialize()
|
||||
@@ -68,6 +78,10 @@ class Application {
|
||||
logger.info('🔄 Initializing admin credentials...')
|
||||
await this.initializeAdmin()
|
||||
|
||||
// 🔒 安全启动:清理无效/伪造的管理员会话
|
||||
logger.info('🔒 Cleaning up invalid admin sessions...')
|
||||
await this.cleanupInvalidSessions()
|
||||
|
||||
// 💰 初始化费用数据
|
||||
logger.info('💰 Checking cost data initialization...')
|
||||
const costInitService = require('./services/costInitService')
|
||||
@@ -80,6 +94,15 @@ class Application {
|
||||
)
|
||||
}
|
||||
|
||||
// 💰 启动回填:本周 Claude 周费用(用于 API Key 维度周限额)
|
||||
try {
|
||||
logger.info('💰 Backfilling current-week Claude weekly cost...')
|
||||
const weeklyClaudeCostInitService = require('./services/weeklyClaudeCostInitService')
|
||||
await weeklyClaudeCostInitService.backfillCurrentWeekClaudeCosts()
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Weekly Claude cost backfill failed (startup continues):', error.message)
|
||||
}
|
||||
|
||||
// 🕐 初始化Claude账户会话窗口
|
||||
logger.info('🕐 Initializing Claude account session windows...')
|
||||
const claudeAccountService = require('./services/claudeAccountService')
|
||||
@@ -165,7 +188,7 @@ class Application {
|
||||
// 🔧 基础中间件
|
||||
this.app.use(
|
||||
express.json({
|
||||
limit: '10mb',
|
||||
limit: '100mb',
|
||||
verify: (req, res, buf, encoding) => {
|
||||
// 验证JSON格式
|
||||
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
||||
@@ -174,7 +197,7 @@ class Application {
|
||||
}
|
||||
})
|
||||
)
|
||||
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
||||
this.app.use(express.urlencoded({ extended: true, limit: '100mb' }))
|
||||
this.app.use(securityMiddleware)
|
||||
|
||||
// 🎯 信任代理
|
||||
@@ -264,6 +287,25 @@ class Application {
|
||||
this.app.use('/api', apiRoutes)
|
||||
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
||||
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
||||
// Anthropic (Claude Code) 路由:按路径强制分流到 Gemini OAuth 账户
|
||||
// - /antigravity/api/v1/messages -> Antigravity OAuth
|
||||
// - /gemini-cli/api/v1/messages -> Gemini CLI OAuth
|
||||
this.app.use(
|
||||
'/antigravity/api',
|
||||
(req, res, next) => {
|
||||
req._anthropicVendor = 'antigravity'
|
||||
next()
|
||||
},
|
||||
apiRoutes
|
||||
)
|
||||
this.app.use(
|
||||
'/gemini-cli/api',
|
||||
(req, res, next) => {
|
||||
req._anthropicVendor = 'gemini-cli'
|
||||
next()
|
||||
},
|
||||
apiRoutes
|
||||
)
|
||||
this.app.use('/admin', adminRoutes)
|
||||
this.app.use('/users', userRoutes)
|
||||
// 使用 web 路由(包含 auth 和页面重定向)
|
||||
@@ -426,6 +468,54 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔒 清理无效/伪造的管理员会话(安全启动检查)
|
||||
async cleanupInvalidSessions() {
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
|
||||
// 获取所有 session:* 键
|
||||
const sessionKeys = await client.keys('session:*')
|
||||
|
||||
let validCount = 0
|
||||
let invalidCount = 0
|
||||
|
||||
for (const key of sessionKeys) {
|
||||
// 跳过 admin_credentials(系统凭据)
|
||||
if (key === 'session:admin_credentials') {
|
||||
continue
|
||||
}
|
||||
|
||||
const sessionData = await client.hgetall(key)
|
||||
|
||||
// 检查会话完整性:必须有 username 和 loginTime
|
||||
const hasUsername = !!sessionData.username
|
||||
const hasLoginTime = !!sessionData.loginTime
|
||||
|
||||
if (!hasUsername || !hasLoginTime) {
|
||||
// 无效会话 - 可能是漏洞利用创建的伪造会话
|
||||
invalidCount++
|
||||
logger.security(
|
||||
`🔒 Removing invalid session: ${key} (username: ${hasUsername}, loginTime: ${hasLoginTime})`
|
||||
)
|
||||
await client.del(key)
|
||||
} else {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidCount > 0) {
|
||||
logger.security(`🔒 Startup security check: Removed ${invalidCount} invalid sessions`)
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
|
||||
)
|
||||
} catch (error) {
|
||||
// 清理失败不应阻止服务启动
|
||||
logger.error('❌ Failed to cleanup invalid sessions:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 Redis健康检查
|
||||
async checkRedisHealth() {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ const logger = require('../utils/logger')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const geminiApiAccountService = require('../services/geminiApiAccountService')
|
||||
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
|
||||
const { sendAntigravityRequest } = require('../services/antigravityRelayService')
|
||||
const crypto = require('crypto')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
@@ -86,8 +87,7 @@ function generateSessionHash(req) {
|
||||
* 检查 API Key 权限
|
||||
*/
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||
const permissions = apiKeyData?.permissions || 'all'
|
||||
return permissions === 'all' || permissions === requiredPermission
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -508,20 +508,37 @@ async function handleMessages(req, res) {
|
||||
// OAuth 账户:使用现有的 sendGeminiRequest
|
||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||
const effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
|
||||
geminiResponse = await sendGeminiRequest({
|
||||
messages,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: max_tokens,
|
||||
stream,
|
||||
accessToken: account.accessToken,
|
||||
proxy: account.proxy,
|
||||
apiKeyId: apiKeyData.id,
|
||||
signal: abortController.signal,
|
||||
projectId: effectiveProjectId,
|
||||
accountId: account.id
|
||||
})
|
||||
if (oauthProvider === 'antigravity') {
|
||||
geminiResponse = await sendAntigravityRequest({
|
||||
messages,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: max_tokens,
|
||||
stream,
|
||||
accessToken: account.accessToken,
|
||||
proxy: account.proxy,
|
||||
apiKeyId: apiKeyData.id,
|
||||
signal: abortController.signal,
|
||||
projectId: effectiveProjectId,
|
||||
accountId: account.id
|
||||
})
|
||||
} else {
|
||||
geminiResponse = await sendGeminiRequest({
|
||||
messages,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: max_tokens,
|
||||
stream,
|
||||
accessToken: account.accessToken,
|
||||
proxy: account.proxy,
|
||||
apiKeyId: apiKeyData.id,
|
||||
signal: abortController.signal,
|
||||
projectId: effectiveProjectId,
|
||||
accountId: account.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
@@ -754,8 +771,16 @@ async function handleModels(req, res) {
|
||||
]
|
||||
}
|
||||
} else {
|
||||
// OAuth 账户:使用 OAuth token 获取模型列表
|
||||
models = await getAvailableModels(account.accessToken, account.proxy)
|
||||
// OAuth 账户:根据 OAuth provider 选择上游
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
models =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||
account.accessToken,
|
||||
account.proxy,
|
||||
account.refreshToken
|
||||
)
|
||||
: await getAvailableModels(account.accessToken, account.proxy)
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -837,7 +862,7 @@ async function handleKeyInfo(req, res) {
|
||||
res.json({
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: keyData.permissions,
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_remaining:
|
||||
@@ -927,7 +952,8 @@ function handleSimpleEndpoint(apiMethod) {
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
|
||||
// 直接转发请求体,不做特殊处理
|
||||
@@ -1006,7 +1032,12 @@ async function handleLoadCodeAssist(req, res) {
|
||||
// 解析账户的代理配置
|
||||
const proxyConfig = parseProxyConfig(account)
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
|
||||
// 智能处理项目ID
|
||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||
@@ -1104,7 +1135,12 @@ async function handleOnboardUser(req, res) {
|
||||
// 解析账户的代理配置
|
||||
const proxyConfig = parseProxyConfig(account)
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
|
||||
// 智能处理项目ID
|
||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||
@@ -1152,6 +1188,110 @@ async function handleOnboardUser(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 retrieveUserQuota 请求
|
||||
* POST /v1internal:retrieveUserQuota
|
||||
*
|
||||
* 功能:查询用户在各个Gemini模型上的配额使用情况
|
||||
* 请求体:{ "project": "项目ID" }
|
||||
* 响应:{ "buckets": [...] }
|
||||
*/
|
||||
async function handleRetrieveUserQuota(req, res) {
|
||||
try {
|
||||
// 1. 权限检查
|
||||
if (!ensureGeminiPermission(req, res)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 2. 会话哈希
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 3. 账户选择
|
||||
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||
const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
requestedModel
|
||||
)
|
||||
const { accountId, accountType } = schedulerResult
|
||||
|
||||
// 4. 账户类型验证 - v1internal 路由只支持 OAuth 账户
|
||||
if (accountType === 'gemini-api') {
|
||||
logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`)
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message:
|
||||
'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.',
|
||||
type: 'invalid_account_type'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 获取账户
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Gemini account not found',
|
||||
type: 'account_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
const { accessToken, refreshToken, projectId } = account
|
||||
|
||||
// 6. 从请求体提取项目字段(注意:字段名是 "project",不是 "cloudaicompanionProject")
|
||||
const requestProject = req.body.project
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.info(`RetrieveUserQuota request (${version})`, {
|
||||
requestedProject: requestProject || null,
|
||||
accountProject: projectId || null,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
// 7. 解析账户的代理配置
|
||||
const proxyConfig = parseProxyConfig(account)
|
||||
|
||||
// 8. 获取OAuth客户端
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 9. 智能处理项目ID(与其他 v1internal 接口保持一致)
|
||||
const effectiveProject = projectId || requestProject || null
|
||||
|
||||
logger.info('📋 retrieveUserQuota项目ID处理逻辑', {
|
||||
accountProjectId: projectId,
|
||||
requestProject,
|
||||
effectiveProject,
|
||||
decision: projectId ? '使用账户配置' : requestProject ? '使用请求参数' : '不使用项目ID'
|
||||
})
|
||||
|
||||
// 10. 构建请求体(注入 effectiveProject)
|
||||
const requestBody = { ...req.body }
|
||||
if (effectiveProject) {
|
||||
requestBody.project = effectiveProject
|
||||
}
|
||||
|
||||
// 11. 调用底层服务转发请求
|
||||
const response = await geminiAccountService.forwardToCodeAssist(
|
||||
client,
|
||||
'retrieveUserQuota',
|
||||
requestBody,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
res.json(response)
|
||||
} catch (error) {
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.error(`Error in retrieveUserQuota endpoint (${version})`, {
|
||||
error: error.message
|
||||
})
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 countTokens 请求
|
||||
*/
|
||||
@@ -1256,7 +1396,8 @@ async function handleCountTokens(req, res) {
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
|
||||
}
|
||||
@@ -1366,13 +1507,20 @@ async function handleGenerateContent(req, res) {
|
||||
// 解析账户的代理配置
|
||||
const proxyConfig = parseProxyConfig(account)
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
|
||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
|
||||
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
||||
if (!effectiveProjectId) {
|
||||
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
|
||||
try {
|
||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||
@@ -1388,6 +1536,12 @@ async function handleGenerateContent(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!effectiveProjectId && oauthProvider === 'antigravity') {
|
||||
// Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存
|
||||
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
|
||||
}
|
||||
|
||||
// 如果还是没有项目ID,返回错误
|
||||
if (!effectiveProjectId) {
|
||||
return res.status(403).json({
|
||||
@@ -1410,14 +1564,24 @@ async function handleGenerateContent(req, res) {
|
||||
: '从loadCodeAssist获取'
|
||||
})
|
||||
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
proxyConfig
|
||||
)
|
||||
const response =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.generateContentAntigravity(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
proxyConfig
|
||||
)
|
||||
: await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 记录使用统计
|
||||
if (response?.response?.usageMetadata) {
|
||||
@@ -1578,13 +1742,20 @@ async function handleStreamGenerateContent(req, res) {
|
||||
// 解析账户的代理配置
|
||||
const proxyConfig = parseProxyConfig(account)
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
|
||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
|
||||
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
||||
if (!effectiveProjectId) {
|
||||
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
|
||||
try {
|
||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||
@@ -1600,6 +1771,11 @@ async function handleStreamGenerateContent(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!effectiveProjectId && oauthProvider === 'antigravity') {
|
||||
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
|
||||
}
|
||||
|
||||
// 如果还是没有项目ID,返回错误
|
||||
if (!effectiveProjectId) {
|
||||
return res.status(403).json({
|
||||
@@ -1622,15 +1798,26 @@ async function handleStreamGenerateContent(req, res) {
|
||||
: '从loadCodeAssist获取'
|
||||
})
|
||||
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
const streamResponse =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.generateContentStreamAntigravity(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
: await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
@@ -1978,15 +2165,23 @@ async function handleStandardGenerateContent(req, res) {
|
||||
} else {
|
||||
// OAuth 账户
|
||||
const { accessToken, refreshToken } = account
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig
|
||||
proxyConfig,
|
||||
oauthProvider
|
||||
)
|
||||
|
||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||
|
||||
if (!effectiveProjectId) {
|
||||
if (oauthProvider === 'antigravity') {
|
||||
if (!effectiveProjectId) {
|
||||
// Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存
|
||||
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
|
||||
}
|
||||
} else if (!effectiveProjectId) {
|
||||
try {
|
||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||
@@ -2024,14 +2219,25 @@ async function handleStandardGenerateContent(req, res) {
|
||||
|
||||
const userPromptId = `${crypto.randomUUID()}########0`
|
||||
|
||||
response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
proxyConfig
|
||||
)
|
||||
if (oauthProvider === 'antigravity') {
|
||||
response = await geminiAccountService.generateContentAntigravity(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
proxyConfig
|
||||
)
|
||||
} else {
|
||||
response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
proxyConfig
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录使用统计
|
||||
@@ -2263,12 +2469,20 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
|
||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||
|
||||
if (!effectiveProjectId) {
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
if (!effectiveProjectId) {
|
||||
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
|
||||
}
|
||||
} else if (!effectiveProjectId) {
|
||||
try {
|
||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||
@@ -2306,15 +2520,27 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
|
||||
const userPromptId = `${crypto.randomUUID()}########0`
|
||||
|
||||
streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
if (oauthProvider === 'antigravity') {
|
||||
streamResponse = await geminiAccountService.generateContentStreamAntigravity(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
} else {
|
||||
streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
@@ -2576,6 +2802,7 @@ module.exports = {
|
||||
handleSimpleEndpoint,
|
||||
handleLoadCodeAssist,
|
||||
handleOnboardUser,
|
||||
handleRetrieveUserQuota,
|
||||
handleCountTokens,
|
||||
handleGenerateContent,
|
||||
handleStreamGenerateContent,
|
||||
|
||||
@@ -9,6 +9,7 @@ const ClientValidator = require('../validators/clientValidator')
|
||||
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||
const { calculateWaitTimeStats } = require('../utils/statsHelper')
|
||||
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||
|
||||
// 工具函数
|
||||
function sleep(ms) {
|
||||
@@ -1239,20 +1240,20 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
|
||||
// 检查 Claude 周费用限制
|
||||
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
|
||||
if (weeklyOpusCostLimit > 0) {
|
||||
// 从请求中获取模型信息
|
||||
const requestBody = req.body || {}
|
||||
const model = requestBody.model || ''
|
||||
|
||||
// 判断是否为 Opus 模型
|
||||
if (model && model.toLowerCase().includes('claude-opus')) {
|
||||
// 判断是否为 Claude 模型
|
||||
if (isClaudeFamilyModel(model)) {
|
||||
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
|
||||
|
||||
if (weeklyOpusCost >= weeklyOpusCostLimit) {
|
||||
logger.security(
|
||||
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${
|
||||
`💰 Weekly Claude cost limit exceeded for key: ${validation.keyData.id} (${
|
||||
validation.keyData.name
|
||||
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||
)
|
||||
@@ -1266,17 +1267,17 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
resetDate.setHours(0, 0, 0, 0)
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Weekly Opus cost limit exceeded',
|
||||
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
||||
error: 'Weekly Claude cost limit exceeded',
|
||||
message: `已达到 Claude 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
||||
currentCost: weeklyOpusCost,
|
||||
costLimit: weeklyOpusCostLimit,
|
||||
resetAt: resetDate.toISOString() // 下周一重置
|
||||
})
|
||||
}
|
||||
|
||||
// 记录当前 Opus 费用使用情况
|
||||
// 记录当前 Claude 费用使用情况
|
||||
logger.api(
|
||||
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${
|
||||
`💰 Claude weekly cost usage for key: ${validation.keyData.id} (${
|
||||
validation.keyData.name
|
||||
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||
)
|
||||
@@ -1389,6 +1390,18 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话必须字段(防止伪造会话绕过认证)
|
||||
if (!adminSession.username || !adminSession.loginTime) {
|
||||
logger.security(
|
||||
`🔒 Corrupted admin session from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
|
||||
)
|
||||
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查会话活跃性(可选:检查最后活动时间)
|
||||
const now = new Date()
|
||||
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime)
|
||||
@@ -1422,7 +1435,6 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
|
||||
// 设置管理员信息(只包含必要信息)
|
||||
req.admin = {
|
||||
id: adminSession.adminId || 'admin',
|
||||
username: adminSession.username,
|
||||
sessionId: token,
|
||||
loginTime: adminSession.loginTime
|
||||
@@ -1555,17 +1567,25 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
||||
try {
|
||||
const adminSession = await redis.getSession(adminToken)
|
||||
if (adminSession && Object.keys(adminSession).length > 0) {
|
||||
req.admin = {
|
||||
id: adminSession.adminId || 'admin',
|
||||
username: adminSession.username,
|
||||
sessionId: adminToken,
|
||||
loginTime: adminSession.loginTime
|
||||
}
|
||||
req.userType = 'admin'
|
||||
// 🔒 安全修复:验证会话必须字段(与 authenticateAdmin 保持一致)
|
||||
if (!adminSession.username || !adminSession.loginTime) {
|
||||
logger.security(
|
||||
`🔒 Corrupted admin session in authenticateUserOrAdmin from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
|
||||
)
|
||||
await redis.deleteSession(adminToken) // 清理无效/伪造的会话
|
||||
// 不返回 401,继续尝试用户认证
|
||||
} else {
|
||||
req.admin = {
|
||||
username: adminSession.username,
|
||||
sessionId: adminToken,
|
||||
loginTime: adminSession.loginTime
|
||||
}
|
||||
req.userType = 'admin'
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
return next()
|
||||
const authDuration = Date.now() - startTime
|
||||
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
return next()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Admin authentication failed, trying user authentication:', error.message)
|
||||
@@ -2031,7 +2051,7 @@ const globalRateLimit = async (req, res, next) =>
|
||||
|
||||
// 📊 请求大小限制中间件
|
||||
const requestSizeLimit = (req, res, next) => {
|
||||
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '60', 10)
|
||||
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '100', 10)
|
||||
const maxSize = MAX_SIZE_MB * 1024 * 1024
|
||||
const contentLength = parseInt(req.headers['content-length'] || '0')
|
||||
|
||||
@@ -2040,7 +2060,7 @@ const requestSizeLimit = (req, res, next) => {
|
||||
return res.status(413).json({
|
||||
error: 'Payload Too Large',
|
||||
message: 'Request body size exceeds limit',
|
||||
limit: '10MB'
|
||||
limit: `${MAX_SIZE_MB}MB`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1081,8 +1081,13 @@ class RedisClient {
|
||||
// 💰 获取本周 Opus 费用
|
||||
async getWeeklyOpusCost(keyId) {
|
||||
const currentWeek = getWeekStringInTimezone()
|
||||
const costKey = `usage:opus:weekly:${keyId}:${currentWeek}`
|
||||
const cost = await this.client.get(costKey)
|
||||
const costKey = `usage:claude:weekly:${keyId}:${currentWeek}`
|
||||
let cost = await this.client.get(costKey)
|
||||
// 向后兼容:如果新 key 不存在,则回退读取旧的(仅 Opus 口径)周费用 key。
|
||||
if (cost === null || cost === undefined) {
|
||||
const legacyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
|
||||
cost = await this.client.get(legacyKey)
|
||||
}
|
||||
const result = parseFloat(cost || 0)
|
||||
logger.debug(
|
||||
`💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}`
|
||||
@@ -1090,11 +1095,12 @@ class RedisClient {
|
||||
return result
|
||||
}
|
||||
|
||||
// 💰 增加本周 Opus 费用
|
||||
// 💰 增加本周 Claude 费用
|
||||
async incrementWeeklyOpusCost(keyId, amount) {
|
||||
const currentWeek = getWeekStringInTimezone()
|
||||
const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
|
||||
const totalKey = `usage:opus:total:${keyId}`
|
||||
// 注意:尽管函数名沿用旧的 Opus 命名,但当前实现统计的是 Claude 系列模型的“周费用”。
|
||||
const weeklyKey = `usage:claude:weekly:${keyId}:${currentWeek}`
|
||||
const totalKey = `usage:claude:total:${keyId}`
|
||||
|
||||
logger.debug(
|
||||
`💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, amount: $${amount}`
|
||||
@@ -1111,6 +1117,16 @@ class RedisClient {
|
||||
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
|
||||
}
|
||||
|
||||
// 💰 覆盖设置本周 Claude 费用(用于启动回填/迁移)
|
||||
async setWeeklyClaudeCost(keyId, amount, weekString = null) {
|
||||
const currentWeek = weekString || getWeekStringInTimezone()
|
||||
const weeklyKey = `usage:claude:weekly:${keyId}:${currentWeek}`
|
||||
|
||||
await this.client.set(weeklyKey, String(amount || 0))
|
||||
// 保留 2 周,足够覆盖“当前周 + 上周”查看/回填
|
||||
await this.client.expire(weeklyKey, 14 * 24 * 3600)
|
||||
}
|
||||
|
||||
// 💰 计算账户的每日费用(基于模型使用)
|
||||
async getAccountDailyCost(accountId) {
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
@@ -1521,6 +1537,123 @@ class RedisClient {
|
||||
return await this.client.del(key)
|
||||
}
|
||||
|
||||
// 💰 账户余额缓存(API 查询结果)
|
||||
async setAccountBalance(platform, accountId, balanceData, ttl = 3600) {
|
||||
const key = `account_balance:${platform}:${accountId}`
|
||||
|
||||
const payload = {
|
||||
balance:
|
||||
balanceData && balanceData.balance !== null && balanceData.balance !== undefined
|
||||
? String(balanceData.balance)
|
||||
: '',
|
||||
currency: balanceData?.currency || 'USD',
|
||||
lastRefreshAt: balanceData?.lastRefreshAt || new Date().toISOString(),
|
||||
queryMethod: balanceData?.queryMethod || 'api',
|
||||
status: balanceData?.status || 'success',
|
||||
errorMessage: balanceData?.errorMessage || balanceData?.error || '',
|
||||
rawData: balanceData?.rawData ? JSON.stringify(balanceData.rawData) : '',
|
||||
quota: balanceData?.quota ? JSON.stringify(balanceData.quota) : ''
|
||||
}
|
||||
|
||||
await this.client.hset(key, payload)
|
||||
await this.client.expire(key, ttl)
|
||||
}
|
||||
|
||||
async getAccountBalance(platform, accountId) {
|
||||
const key = `account_balance:${platform}:${accountId}`
|
||||
const [data, ttlSeconds] = await Promise.all([this.client.hgetall(key), this.client.ttl(key)])
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let rawData = null
|
||||
if (data.rawData) {
|
||||
try {
|
||||
rawData = JSON.parse(data.rawData)
|
||||
} catch (error) {
|
||||
rawData = null
|
||||
}
|
||||
}
|
||||
|
||||
let quota = null
|
||||
if (data.quota) {
|
||||
try {
|
||||
quota = JSON.parse(data.quota)
|
||||
} catch (error) {
|
||||
quota = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
balance: data.balance ? parseFloat(data.balance) : null,
|
||||
currency: data.currency || 'USD',
|
||||
lastRefreshAt: data.lastRefreshAt || null,
|
||||
queryMethod: data.queryMethod || null,
|
||||
status: data.status || null,
|
||||
errorMessage: data.errorMessage || '',
|
||||
rawData,
|
||||
quota,
|
||||
ttlSeconds: Number.isFinite(ttlSeconds) ? ttlSeconds : null
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 账户余额缓存(本地统计)
|
||||
async setLocalBalance(platform, accountId, statisticsData, ttl = 300) {
|
||||
const key = `account_balance_local:${platform}:${accountId}`
|
||||
|
||||
await this.client.hset(key, {
|
||||
estimatedBalance: JSON.stringify(statisticsData || {}),
|
||||
lastCalculated: new Date().toISOString()
|
||||
})
|
||||
await this.client.expire(key, ttl)
|
||||
}
|
||||
|
||||
async getLocalBalance(platform, accountId) {
|
||||
const key = `account_balance_local:${platform}:${accountId}`
|
||||
const data = await this.client.hgetall(key)
|
||||
|
||||
if (!data || !data.estimatedBalance) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data.estimatedBalance)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccountBalance(platform, accountId) {
|
||||
const key = `account_balance:${platform}:${accountId}`
|
||||
const localKey = `account_balance_local:${platform}:${accountId}`
|
||||
await this.client.del(key, localKey)
|
||||
}
|
||||
|
||||
// 🧩 账户余额脚本配置
|
||||
async setBalanceScriptConfig(platform, accountId, scriptConfig) {
|
||||
const key = `account_balance_script:${platform}:${accountId}`
|
||||
await this.client.set(key, JSON.stringify(scriptConfig || {}))
|
||||
}
|
||||
|
||||
async getBalanceScriptConfig(platform, accountId) {
|
||||
const key = `account_balance_script:${platform}:${accountId}`
|
||||
const raw = await this.client.get(key)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBalanceScriptConfig(platform, accountId) {
|
||||
const key = `account_balance_script:${platform}:${accountId}`
|
||||
return await this.client.del(key)
|
||||
}
|
||||
|
||||
// 📈 系统统计
|
||||
async getSystemStats() {
|
||||
const keys = await Promise.all([
|
||||
|
||||
214
src/routes/admin/accountBalance.js
Normal file
214
src/routes/admin/accountBalance.js
Normal file
@@ -0,0 +1,214 @@
|
||||
const express = require('express')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const accountBalanceService = require('../../services/accountBalanceService')
|
||||
const balanceScriptService = require('../../services/balanceScriptService')
|
||||
const { isBalanceScriptEnabled } = require('../../utils/featureFlags')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
const ensureValidPlatform = (rawPlatform) => {
|
||||
const normalized = accountBalanceService.normalizePlatform(rawPlatform)
|
||||
if (!normalized) {
|
||||
return { ok: false, status: 400, error: '缺少 platform 参数' }
|
||||
}
|
||||
|
||||
const supported = accountBalanceService.getSupportedPlatforms()
|
||||
if (!supported.includes(normalized)) {
|
||||
return { ok: false, status: 400, error: `不支持的平台: ${normalized}` }
|
||||
}
|
||||
|
||||
return { ok: true, platform: normalized }
|
||||
}
|
||||
|
||||
// 1) 获取账户余额(默认本地统计优先,可选触发 Provider)
|
||||
// GET /admin/accounts/:accountId/balance?platform=xxx&queryApi=false
|
||||
router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform, queryApi } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const balance = await accountBalanceService.getAccountBalance(accountId, valid.platform, {
|
||||
queryApi
|
||||
})
|
||||
|
||||
if (!balance) {
|
||||
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||
}
|
||||
|
||||
return res.json(balance)
|
||||
} catch (error) {
|
||||
logger.error('获取账户余额失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 2) 强制刷新账户余额(强制触发查询:优先脚本;Provider 仅为降级)
|
||||
// POST /admin/accounts/:accountId/balance/refresh
|
||||
// Body: { platform: 'xxx' }
|
||||
router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.body || {}
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
logger.info(`手动刷新余额: ${valid.platform}:${accountId}`)
|
||||
|
||||
const balance = await accountBalanceService.refreshAccountBalance(accountId, valid.platform)
|
||||
if (!balance) {
|
||||
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||
}
|
||||
|
||||
return res.json(balance)
|
||||
} catch (error) {
|
||||
logger.error('刷新账户余额失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 3) 批量获取平台所有账户余额
|
||||
// GET /admin/accounts/balance/platform/:platform?queryApi=false
|
||||
router.get('/accounts/balance/platform/:platform', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform } = req.params
|
||||
const { queryApi } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const balances = await accountBalanceService.getAllAccountsBalance(valid.platform, { queryApi })
|
||||
|
||||
return res.json({ success: true, data: balances })
|
||||
} catch (error) {
|
||||
logger.error('批量获取余额失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 4) 获取余额汇总(Dashboard 用)
|
||||
// GET /admin/accounts/balance/summary
|
||||
router.get('/accounts/balance/summary', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const summary = await accountBalanceService.getBalanceSummary()
|
||||
return res.json({ success: true, data: summary })
|
||||
} catch (error) {
|
||||
logger.error('获取余额汇总失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 5) 清除缓存
|
||||
// DELETE /admin/accounts/:accountId/balance/cache?platform=xxx
|
||||
router.delete('/accounts/:accountId/balance/cache', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
await accountBalanceService.clearCache(accountId, valid.platform)
|
||||
|
||||
return res.json({ success: true, message: '缓存已清除' })
|
||||
} catch (error) {
|
||||
logger.error('清除缓存失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 6) 获取/保存/测试余额脚本配置(单账户)
|
||||
router.get('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const config = await accountBalanceService.redis.getBalanceScriptConfig(
|
||||
valid.platform,
|
||||
accountId
|
||||
)
|
||||
return res.json({ success: true, data: config || null })
|
||||
} catch (error) {
|
||||
logger.error('获取余额脚本配置失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const payload = req.body || {}
|
||||
await accountBalanceService.redis.setBalanceScriptConfig(valid.platform, accountId, payload)
|
||||
return res.json({ success: true, data: payload })
|
||||
} catch (error) {
|
||||
logger.error('保存余额脚本配置失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
if (!isBalanceScriptEnabled()) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)'
|
||||
})
|
||||
}
|
||||
|
||||
const payload = req.body || {}
|
||||
const { scriptBody } = payload
|
||||
if (!scriptBody) {
|
||||
return res.status(400).json({ success: false, error: '脚本内容不能为空' })
|
||||
}
|
||||
|
||||
const result = await balanceScriptService.execute({
|
||||
scriptBody,
|
||||
timeoutSeconds: payload.timeoutSeconds || 10,
|
||||
variables: {
|
||||
baseUrl: payload.baseUrl || '',
|
||||
apiKey: payload.apiKey || '',
|
||||
token: payload.token || '',
|
||||
accountId,
|
||||
platform: valid.platform,
|
||||
extra: payload.extra || ''
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('测试余额脚本失败', error)
|
||||
return res.status(400).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -8,6 +8,43 @@ const config = require('../../../config/config')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 有效的权限值列表
|
||||
const VALID_PERMISSIONS = ['claude', 'gemini', 'openai', 'droid']
|
||||
|
||||
/**
|
||||
* 验证权限数组格式
|
||||
* @param {any} permissions - 权限值(可以是数组或其他)
|
||||
* @returns {string|null} - 返回错误消息,null 表示验证通过
|
||||
*/
|
||||
function validatePermissions(permissions) {
|
||||
// 空值或未定义表示全部服务
|
||||
if (permissions === undefined || permissions === null || permissions === '') {
|
||||
return null
|
||||
}
|
||||
// 兼容旧格式字符串
|
||||
if (typeof permissions === 'string') {
|
||||
if (permissions === 'all' || VALID_PERMISSIONS.includes(permissions)) {
|
||||
return null
|
||||
}
|
||||
return `Invalid permissions value. Must be an array of: ${VALID_PERMISSIONS.join(', ')}`
|
||||
}
|
||||
// 新格式数组
|
||||
if (Array.isArray(permissions)) {
|
||||
// 空数组表示全部服务
|
||||
if (permissions.length === 0) {
|
||||
return null
|
||||
}
|
||||
// 验证数组中的每个值
|
||||
for (const perm of permissions) {
|
||||
if (!VALID_PERMISSIONS.includes(perm)) {
|
||||
return `Invalid permission value "${perm}". Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
||||
}
|
||||
|
||||
// 👥 用户管理 (用于API Key分配)
|
||||
|
||||
// 获取所有用户列表(用于API Key分配)
|
||||
@@ -1382,16 +1419,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证服务权限字段
|
||||
if (
|
||||
permissions !== undefined &&
|
||||
permissions !== null &&
|
||||
permissions !== '' &&
|
||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
})
|
||||
// 验证服务权限字段(支持数组格式)
|
||||
const permissionsError = validatePermissions(permissions)
|
||||
if (permissionsError) {
|
||||
return res.status(400).json({ error: permissionsError })
|
||||
}
|
||||
|
||||
const newKey = await apiKeyService.generateApiKey({
|
||||
@@ -1481,15 +1512,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
|
||||
}
|
||||
|
||||
if (
|
||||
permissions !== undefined &&
|
||||
permissions !== null &&
|
||||
permissions !== '' &&
|
||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
})
|
||||
// 验证服务权限字段(支持数组格式)
|
||||
const batchPermissionsError = validatePermissions(permissions)
|
||||
if (batchPermissionsError) {
|
||||
return res.status(400).json({ error: batchPermissionsError })
|
||||
}
|
||||
|
||||
// 生成批量API Keys
|
||||
@@ -1592,13 +1618,12 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
updates.permissions !== undefined &&
|
||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
})
|
||||
// 验证服务权限字段(支持数组格式)
|
||||
if (updates.permissions !== undefined) {
|
||||
const updatePermissionsError = validatePermissions(updates.permissions)
|
||||
if (updatePermissionsError) {
|
||||
return res.status(400).json({ error: updatePermissionsError })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -1873,11 +1898,10 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
if (permissions !== undefined) {
|
||||
// 验证权限值
|
||||
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
})
|
||||
// 验证服务权限字段(支持数组格式)
|
||||
const singlePermissionsError = validatePermissions(permissions)
|
||||
if (singlePermissionsError) {
|
||||
return res.status(400).json({ error: singlePermissionsError })
|
||||
}
|
||||
updates.permissions = permissions
|
||||
}
|
||||
|
||||
41
src/routes/admin/balanceScripts.js
Normal file
41
src/routes/admin/balanceScripts.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const express = require('express')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const balanceScriptService = require('../../services/balanceScriptService')
|
||||
const router = express.Router()
|
||||
|
||||
// 获取全部脚本配置列表
|
||||
router.get('/balance-scripts', authenticateAdmin, (req, res) => {
|
||||
const items = balanceScriptService.listConfigs()
|
||||
return res.json({ success: true, data: items })
|
||||
})
|
||||
|
||||
// 获取单个脚本配置
|
||||
router.get('/balance-scripts/:name', authenticateAdmin, (req, res) => {
|
||||
const { name } = req.params
|
||||
const config = balanceScriptService.getConfig(name || 'default')
|
||||
return res.json({ success: true, data: config })
|
||||
})
|
||||
|
||||
// 保存脚本配置
|
||||
router.put('/balance-scripts/:name', authenticateAdmin, (req, res) => {
|
||||
try {
|
||||
const { name } = req.params
|
||||
const saved = balanceScriptService.saveConfig(name || 'default', req.body || {})
|
||||
return res.json({ success: true, data: saved })
|
||||
} catch (error) {
|
||||
return res.status(400).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 测试脚本(不落库)
|
||||
router.post('/balance-scripts/:name/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params
|
||||
const result = await balanceScriptService.testScript(name || 'default', req.body || {})
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
return res.status(400).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -122,6 +122,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
||||
description,
|
||||
region,
|
||||
awsCredentials,
|
||||
bearerToken,
|
||||
defaultModel,
|
||||
priority,
|
||||
accountType,
|
||||
@@ -145,9 +146,9 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
// 验证credentialType的有效性
|
||||
if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) {
|
||||
if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
||||
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,10 +157,11 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
||||
description: description || '',
|
||||
region: region || 'us-east-1',
|
||||
awsCredentials,
|
||||
bearerToken,
|
||||
defaultModel,
|
||||
priority: priority || 50,
|
||||
accountType: accountType || 'shared',
|
||||
credentialType: credentialType || 'default'
|
||||
credentialType: credentialType || 'access_key'
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
@@ -206,10 +208,10 @@ router.put('/:accountId', authenticateAdmin, async (req, res) => {
|
||||
// 验证credentialType的有效性
|
||||
if (
|
||||
mappedUpdates.credentialType &&
|
||||
!['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
||||
!['access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
||||
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -349,22 +351,15 @@ router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试Bedrock账户连接
|
||||
// 测试Bedrock账户连接(SSE 流式)
|
||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const result = await bedrockAccountService.testAccount(accountId)
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: 'Account test failed', message: result.error })
|
||||
}
|
||||
|
||||
logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`)
|
||||
return res.json({ success: true, data: result.data })
|
||||
await bedrockAccountService.testAccountConnection(accountId, res)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to test Bedrock account:', error)
|
||||
return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message })
|
||||
// 错误已在服务层处理,这里仅做日志记录
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -6,13 +6,11 @@ const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||
const ccrAccountService = require('../../services/ccrAccountService')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const CostCalculator = require('../../utils/costCalculator')
|
||||
const pricingService = require('../../services/pricingService')
|
||||
const config = require('../../../config/config')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -11,14 +11,19 @@ const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
const router = express.Router()
|
||||
|
||||
// 🤖 Gemini OAuth 账户管理
|
||||
function getDefaultRedirectUri(oauthProvider) {
|
||||
if (oauthProvider === 'antigravity') {
|
||||
return process.env.ANTIGRAVITY_OAUTH_REDIRECT_URI || 'http://localhost:45462'
|
||||
}
|
||||
return process.env.GEMINI_OAUTH_REDIRECT_URI || 'https://codeassist.google.com/authcode'
|
||||
}
|
||||
|
||||
// 生成 Gemini OAuth 授权 URL
|
||||
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { state, proxy } = req.body // 接收代理配置
|
||||
const { state, proxy, oauthProvider } = req.body // 接收代理配置与OAuth Provider
|
||||
|
||||
// 使用新的 codeassist.google.com 回调地址
|
||||
const redirectUri = 'https://codeassist.google.com/authcode'
|
||||
const redirectUri = getDefaultRedirectUri(oauthProvider)
|
||||
|
||||
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
|
||||
|
||||
@@ -26,8 +31,9 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
authUrl,
|
||||
state: authState,
|
||||
codeVerifier,
|
||||
redirectUri: finalRedirectUri
|
||||
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy)
|
||||
redirectUri: finalRedirectUri,
|
||||
oauthProvider: resolvedOauthProvider
|
||||
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy, oauthProvider)
|
||||
|
||||
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
|
||||
const sessionId = authState
|
||||
@@ -37,6 +43,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
redirectUri: finalRedirectUri,
|
||||
codeVerifier, // 保存 PKCE code verifier
|
||||
proxy: proxy || null, // 保存代理配置
|
||||
oauthProvider: resolvedOauthProvider,
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
@@ -45,7 +52,8 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
success: true,
|
||||
data: {
|
||||
authUrl,
|
||||
sessionId
|
||||
sessionId,
|
||||
oauthProvider: resolvedOauthProvider
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -80,13 +88,14 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
|
||||
// 交换 Gemini 授权码
|
||||
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { code, sessionId, proxy: requestProxy } = req.body
|
||||
const { code, sessionId, proxy: requestProxy, oauthProvider } = req.body
|
||||
let resolvedOauthProvider = oauthProvider
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Authorization code is required' })
|
||||
}
|
||||
|
||||
let redirectUri = 'https://codeassist.google.com/authcode'
|
||||
let redirectUri = getDefaultRedirectUri(resolvedOauthProvider)
|
||||
let codeVerifier = null
|
||||
let proxyConfig = null
|
||||
|
||||
@@ -97,11 +106,16 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
const {
|
||||
redirectUri: sessionRedirectUri,
|
||||
codeVerifier: sessionCodeVerifier,
|
||||
proxy
|
||||
proxy,
|
||||
oauthProvider: sessionOauthProvider
|
||||
} = sessionData
|
||||
redirectUri = sessionRedirectUri || redirectUri
|
||||
codeVerifier = sessionCodeVerifier
|
||||
proxyConfig = proxy // 获取代理配置
|
||||
if (!resolvedOauthProvider && sessionOauthProvider) {
|
||||
// 会话里保存的 provider 仅作为兜底
|
||||
resolvedOauthProvider = sessionOauthProvider
|
||||
}
|
||||
logger.info(
|
||||
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
|
||||
)
|
||||
@@ -120,7 +134,8 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
code,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
proxyConfig // 传递代理配置
|
||||
proxyConfig, // 传递代理配置
|
||||
resolvedOauthProvider
|
||||
)
|
||||
|
||||
// 清理 OAuth 会话
|
||||
@@ -129,7 +144,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
logger.success('✅ Successfully exchanged Gemini authorization code')
|
||||
return res.json({ success: true, data: { tokens } })
|
||||
return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
||||
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
|
||||
|
||||
@@ -21,6 +21,7 @@ const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts')
|
||||
const droidAccountsRoutes = require('./droidAccounts')
|
||||
const dashboardRoutes = require('./dashboard')
|
||||
const usageStatsRoutes = require('./usageStats')
|
||||
const accountBalanceRoutes = require('./accountBalance')
|
||||
const systemRoutes = require('./system')
|
||||
const concurrencyRoutes = require('./concurrency')
|
||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||
@@ -37,6 +38,7 @@ router.use('/', openaiResponsesAccountsRoutes)
|
||||
router.use('/', droidAccountsRoutes)
|
||||
router.use('/', dashboardRoutes)
|
||||
router.use('/', usageStatsRoutes)
|
||||
router.use('/', accountBalanceRoutes)
|
||||
router.use('/', systemRoutes)
|
||||
router.use('/', concurrencyRoutes)
|
||||
router.use('/', claudeRelayConfigRoutes)
|
||||
|
||||
@@ -8,6 +8,7 @@ const geminiApiAccountService = require('../../services/geminiApiAccountService'
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
@@ -25,6 +26,7 @@ const accountTypeNames = {
|
||||
gemini: 'Gemini',
|
||||
'gemini-api': 'Gemini API',
|
||||
droid: 'Droid',
|
||||
bedrock: 'AWS Bedrock',
|
||||
unknown: '未知渠道'
|
||||
}
|
||||
|
||||
@@ -37,7 +39,8 @@ const resolveAccountByPlatform = async (accountId, platform) => {
|
||||
openai: openaiAccountService,
|
||||
'openai-responses': openaiResponsesAccountService,
|
||||
droid: droidAccountService,
|
||||
ccr: ccrAccountService
|
||||
ccr: ccrAccountService,
|
||||
bedrock: bedrockAccountService
|
||||
}
|
||||
|
||||
if (platform && serviceMap[platform]) {
|
||||
@@ -161,7 +164,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
||||
'openai-responses',
|
||||
'gemini',
|
||||
'gemini-api',
|
||||
'droid'
|
||||
'droid',
|
||||
'bedrock'
|
||||
]
|
||||
if (!allowedPlatforms.includes(platform)) {
|
||||
return res.status(400).json({
|
||||
@@ -174,7 +178,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
||||
openai: 'openai',
|
||||
'openai-responses': 'openai-responses',
|
||||
'gemini-api': 'gemini-api',
|
||||
droid: 'droid'
|
||||
droid: 'droid',
|
||||
bedrock: 'bedrock'
|
||||
}
|
||||
|
||||
const fallbackModelMap = {
|
||||
@@ -184,7 +189,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
||||
'openai-responses': 'gpt-4o-mini-2024-07-18',
|
||||
gemini: 'gemini-1.5-flash',
|
||||
'gemini-api': 'gemini-2.0-flash',
|
||||
droid: 'unknown'
|
||||
droid: 'unknown',
|
||||
bedrock: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0'
|
||||
}
|
||||
|
||||
// 获取账户信息以获取创建时间
|
||||
@@ -215,6 +221,11 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
||||
case 'droid':
|
||||
accountData = await droidAccountService.getAccount(accountId)
|
||||
break
|
||||
case 'bedrock': {
|
||||
const result = await bedrockAccountService.getAccount(accountId)
|
||||
accountData = result?.success ? result.data : null
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (accountData && accountData.createdAt) {
|
||||
@@ -882,7 +893,7 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
|
||||
|
||||
const allowedGroups = ['claude', 'openai', 'gemini', 'droid']
|
||||
const allowedGroups = ['claude', 'openai', 'gemini', 'droid', 'bedrock']
|
||||
if (!allowedGroups.includes(group)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
@@ -894,7 +905,8 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
||||
claude: 'Claude账户',
|
||||
openai: 'OpenAI账户',
|
||||
gemini: 'Gemini账户',
|
||||
droid: 'Droid账户'
|
||||
droid: 'Droid账户',
|
||||
bedrock: 'Bedrock账户'
|
||||
}
|
||||
|
||||
// 拉取各平台账号列表
|
||||
@@ -988,6 +1000,18 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
||||
platform: 'droid'
|
||||
}
|
||||
})
|
||||
} else if (group === 'bedrock') {
|
||||
const result = await bedrockAccountService.getAllAccounts()
|
||||
const bedrockAccounts = result?.success ? result.data : []
|
||||
accounts = bedrockAccounts.map((account) => {
|
||||
const id = String(account.id || '')
|
||||
const shortId = id ? id.slice(0, 8) : '未知'
|
||||
return {
|
||||
id,
|
||||
name: account.name || `Bedrock账号 ${shortId}`,
|
||||
platform: 'bedrock'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
|
||||
@@ -20,6 +20,11 @@ const {
|
||||
sendMockWarmupStream
|
||||
} = require('../utils/warmupInterceptor')
|
||||
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
||||
const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump')
|
||||
const {
|
||||
handleAnthropicMessagesToGemini,
|
||||
handleAnthropicCountTokensToGemini
|
||||
} = require('../services/anthropicGeminiBridgeService')
|
||||
const router = express.Router()
|
||||
|
||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||
@@ -117,16 +122,18 @@ async function handleMessagesRequest(req, res) {
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
|
||||
// Claude 服务权限校验,阻止未授权的 Key
|
||||
if (
|
||||
req.apiKey.permissions &&
|
||||
req.apiKey.permissions !== 'all' &&
|
||||
req.apiKey.permissions !== 'claude'
|
||||
) {
|
||||
const forcedVendor = req._anthropicVendor || null
|
||||
const requiredService =
|
||||
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
|
||||
|
||||
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: '此 API Key 无权访问 Claude 服务'
|
||||
message:
|
||||
requiredService === 'gemini'
|
||||
? '此 API Key 无权访问 Gemini 服务'
|
||||
: '此 API Key 无权访问 Claude 服务'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -175,6 +182,25 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
logger.api('📥 /v1/messages request received', {
|
||||
model: req.body.model || null,
|
||||
forcedVendor,
|
||||
stream: req.body.stream === true
|
||||
})
|
||||
|
||||
dumpAnthropicMessagesRequest(req, {
|
||||
route: '/v1/messages',
|
||||
forcedVendor,
|
||||
model: req.body?.model || null,
|
||||
stream: req.body?.stream === true
|
||||
})
|
||||
|
||||
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
||||
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
|
||||
const baseModel = (req.body.model || '').trim()
|
||||
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
|
||||
}
|
||||
|
||||
// 检查是否为流式请求
|
||||
const isStream = req.body.stream === true
|
||||
|
||||
@@ -390,11 +416,18 @@ async function handleMessagesRequest(req, res) {
|
||||
// 根据账号类型选择对应的转发服务并调用
|
||||
if (accountType === 'claude-official') {
|
||||
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||
// 🧹 内存优化:提取需要的值,避免闭包捕获整个 req 对象
|
||||
const _apiKeyId = req.apiKey.id
|
||||
const _rateLimitInfo = req.rateLimitInfo
|
||||
const _requestBody = req.body // 传递后清除引用
|
||||
const _apiKey = req.apiKey
|
||||
const _headers = req.headers
|
||||
|
||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
_requestBody,
|
||||
_apiKey,
|
||||
res,
|
||||
req.headers,
|
||||
_headers,
|
||||
(usageData) => {
|
||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||
logger.info(
|
||||
@@ -444,13 +477,13 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude')
|
||||
.recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, 'claude')
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record stream usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
_rateLimitInfo,
|
||||
{
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -475,11 +508,18 @@ async function handleMessagesRequest(req, res) {
|
||||
)
|
||||
} else if (accountType === 'claude-console') {
|
||||
// Claude Console账号使用Console转发服务(需要传递accountId)
|
||||
// 🧹 内存优化:提取需要的值
|
||||
const _apiKeyIdConsole = req.apiKey.id
|
||||
const _rateLimitInfoConsole = req.rateLimitInfo
|
||||
const _requestBodyConsole = req.body
|
||||
const _apiKeyConsole = req.apiKey
|
||||
const _headersConsole = req.headers
|
||||
|
||||
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
_requestBodyConsole,
|
||||
_apiKeyConsole,
|
||||
res,
|
||||
req.headers,
|
||||
_headersConsole,
|
||||
(usageData) => {
|
||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||
logger.info(
|
||||
@@ -530,7 +570,7 @@ async function handleMessagesRequest(req, res) {
|
||||
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(
|
||||
req.apiKey.id,
|
||||
_apiKeyIdConsole,
|
||||
usageObject,
|
||||
model,
|
||||
usageAccountId,
|
||||
@@ -541,7 +581,7 @@ async function handleMessagesRequest(req, res) {
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
_rateLimitInfoConsole,
|
||||
{
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -567,6 +607,11 @@ async function handleMessagesRequest(req, res) {
|
||||
)
|
||||
} else if (accountType === 'bedrock') {
|
||||
// Bedrock账号使用Bedrock转发服务
|
||||
// 🧹 内存优化:提取需要的值
|
||||
const _apiKeyIdBedrock = req.apiKey.id
|
||||
const _rateLimitInfoBedrock = req.rateLimitInfo
|
||||
const _requestBodyBedrock = req.body
|
||||
|
||||
try {
|
||||
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
|
||||
if (!bedrockAccountResult.success) {
|
||||
@@ -574,7 +619,7 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
const result = await bedrockRelayService.handleStreamRequest(
|
||||
req.body,
|
||||
_requestBodyBedrock,
|
||||
bedrockAccountResult.data,
|
||||
res
|
||||
)
|
||||
@@ -585,13 +630,21 @@ async function handleMessagesRequest(req, res) {
|
||||
const outputTokens = result.usage.output_tokens || 0
|
||||
|
||||
apiKeyService
|
||||
.recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId)
|
||||
.recordUsage(
|
||||
_apiKeyIdBedrock,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
0,
|
||||
0,
|
||||
result.model,
|
||||
accountId
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record Bedrock stream usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
_rateLimitInfoBedrock,
|
||||
{
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -616,11 +669,18 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
} else if (accountType === 'ccr') {
|
||||
// CCR账号使用CCR转发服务(需要传递accountId)
|
||||
// 🧹 内存优化:提取需要的值
|
||||
const _apiKeyIdCcr = req.apiKey.id
|
||||
const _rateLimitInfoCcr = req.rateLimitInfo
|
||||
const _requestBodyCcr = req.body
|
||||
const _apiKeyCcr = req.apiKey
|
||||
const _headersCcr = req.headers
|
||||
|
||||
await ccrRelayService.relayStreamRequestWithUsageCapture(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
_requestBodyCcr,
|
||||
_apiKeyCcr,
|
||||
res,
|
||||
req.headers,
|
||||
_headersCcr,
|
||||
(usageData) => {
|
||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||
logger.info(
|
||||
@@ -670,13 +730,13 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr')
|
||||
.recordUsageWithDetails(_apiKeyIdCcr, usageObject, model, usageAccountId, 'ccr')
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record CCR stream usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
_rateLimitInfoCcr,
|
||||
{
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -711,18 +771,26 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
}, 1000) // 1秒后检查
|
||||
} else {
|
||||
// 🧹 内存优化:提取需要的值,避免后续回调捕获整个 req
|
||||
const _apiKeyIdNonStream = req.apiKey.id
|
||||
const _apiKeyNameNonStream = req.apiKey.name
|
||||
const _rateLimitInfoNonStream = req.rateLimitInfo
|
||||
const _requestBodyNonStream = req.body
|
||||
const _apiKeyNonStream = req.apiKey
|
||||
const _headersNonStream = req.headers
|
||||
|
||||
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
|
||||
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
|
||||
logger.warn(
|
||||
`⚠️ Client disconnected before non-stream request could start for key: ${req.apiKey?.name || 'unknown'}`
|
||||
`⚠️ Client disconnected before non-stream request could start for key: ${_apiKeyNameNonStream || 'unknown'}`
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 非流式响应 - 只使用官方真实usage数据
|
||||
logger.info('📄 Starting non-streaming request', {
|
||||
apiKeyId: req.apiKey.id,
|
||||
apiKeyName: req.apiKey.name
|
||||
apiKeyId: _apiKeyIdNonStream,
|
||||
apiKeyName: _apiKeyNameNonStream
|
||||
})
|
||||
|
||||
// 📊 监听 socket 事件以追踪连接状态变化
|
||||
@@ -893,11 +961,11 @@ async function handleMessagesRequest(req, res) {
|
||||
? await claudeAccountService.getAccount(accountId)
|
||||
: await claudeConsoleAccountService.getAccount(accountId)
|
||||
|
||||
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
|
||||
if (account?.interceptWarmup === 'true' && isWarmupRequest(_requestBodyNonStream)) {
|
||||
logger.api(
|
||||
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
|
||||
)
|
||||
return res.json(buildMockWarmupResponse(req.body.model))
|
||||
return res.json(buildMockWarmupResponse(_requestBodyNonStream.model))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -910,11 +978,11 @@ async function handleMessagesRequest(req, res) {
|
||||
if (accountType === 'claude-official') {
|
||||
// 官方Claude账号使用原有的转发服务
|
||||
response = await claudeRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
_requestBodyNonStream,
|
||||
_apiKeyNonStream,
|
||||
req, // clientRequest 用于断开检测,保留但服务层已优化
|
||||
res,
|
||||
req.headers
|
||||
_headersNonStream
|
||||
)
|
||||
} else if (accountType === 'claude-console') {
|
||||
// Claude Console账号使用Console转发服务
|
||||
@@ -922,11 +990,11 @@ async function handleMessagesRequest(req, res) {
|
||||
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
|
||||
)
|
||||
response = await claudeConsoleRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
_requestBodyNonStream,
|
||||
_apiKeyNonStream,
|
||||
req, // clientRequest 保留用于断开检测
|
||||
res,
|
||||
req.headers,
|
||||
_headersNonStream,
|
||||
accountId
|
||||
)
|
||||
} else if (accountType === 'bedrock') {
|
||||
@@ -938,9 +1006,9 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
const result = await bedrockRelayService.handleNonStreamRequest(
|
||||
req.body,
|
||||
_requestBodyNonStream,
|
||||
bedrockAccountResult.data,
|
||||
req.headers
|
||||
_headersNonStream
|
||||
)
|
||||
|
||||
// 构建标准响应格式
|
||||
@@ -970,11 +1038,11 @@ async function handleMessagesRequest(req, res) {
|
||||
// CCR账号使用CCR转发服务
|
||||
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
|
||||
response = await ccrRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
_requestBodyNonStream,
|
||||
_apiKeyNonStream,
|
||||
req, // clientRequest 保留用于断开检测
|
||||
res,
|
||||
req.headers,
|
||||
_headersNonStream,
|
||||
accountId
|
||||
)
|
||||
}
|
||||
@@ -1023,14 +1091,14 @@ async function handleMessagesRequest(req, res) {
|
||||
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
||||
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
||||
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
||||
const rawModel = jsonData.model || req.body.model || 'unknown'
|
||||
const { baseModel } = parseVendorPrefixedModel(rawModel)
|
||||
const model = baseModel || rawModel
|
||||
const rawModel = jsonData.model || _requestBodyNonStream.model || 'unknown'
|
||||
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
|
||||
const model = usageBaseModel || rawModel
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const { accountId: responseAccountId } = response
|
||||
await apiKeyService.recordUsage(
|
||||
req.apiKey.id,
|
||||
_apiKeyIdNonStream,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
@@ -1040,7 +1108,7 @@ async function handleMessagesRequest(req, res) {
|
||||
)
|
||||
|
||||
await queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
_rateLimitInfoNonStream,
|
||||
{
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -1201,6 +1269,65 @@ router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest)
|
||||
// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
// Claude Code / Anthropic baseUrl 的分流:/antigravity/api/v1/models 返回 Antigravity 实时模型列表
|
||||
//(通过 v1internal:fetchAvailableModels),避免依赖静态 modelService 列表。
|
||||
const forcedVendor = req._anthropicVendor || null
|
||||
if (forcedVendor === 'antigravity') {
|
||||
if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: '此 API Key 无权访问 Gemini 服务'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
|
||||
let accountSelection
|
||||
try {
|
||||
accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
null,
|
||||
null,
|
||||
{ oauthProvider: 'antigravity' }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to select Gemini OAuth account (antigravity models):', error)
|
||||
return res.status(503).json({ error: 'No available Gemini OAuth accounts' })
|
||||
}
|
||||
|
||||
const account = await geminiAccountService.getAccount(accountSelection.accountId)
|
||||
if (!account) {
|
||||
return res.status(503).json({ error: 'Gemini OAuth account not found' })
|
||||
}
|
||||
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig =
|
||||
typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const models = await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||
account.accessToken,
|
||||
proxyConfig,
|
||||
account.refreshToken
|
||||
)
|
||||
|
||||
// 可选:根据 API Key 的模型限制过滤(黑名单语义)
|
||||
let filteredModels = models
|
||||
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
|
||||
filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id))
|
||||
}
|
||||
|
||||
return res.json({ object: 'list', data: filteredModels })
|
||||
}
|
||||
|
||||
const modelService = require('../services/modelService')
|
||||
|
||||
// 从 modelService 获取所有支持的模型
|
||||
@@ -1337,20 +1464,27 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
|
||||
|
||||
// 🔢 Token计数端点 - count_tokens beta API
|
||||
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
||||
// 检查权限
|
||||
if (
|
||||
req.apiKey.permissions &&
|
||||
req.apiKey.permissions !== 'all' &&
|
||||
req.apiKey.permissions !== 'claude'
|
||||
) {
|
||||
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
||||
const forcedVendor = req._anthropicVendor || null
|
||||
const requiredService =
|
||||
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
|
||||
|
||||
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: 'This API key does not have permission to access Claude'
|
||||
message:
|
||||
requiredService === 'gemini'
|
||||
? 'This API key does not have permission to access Gemini'
|
||||
: 'This API key does not have permission to access Claude'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (requiredService === 'gemini') {
|
||||
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
|
||||
}
|
||||
|
||||
// 🔗 会话绑定验证(与 messages 端点保持一致)
|
||||
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||
const sessionValidation = await claudeRelayConfigService.validateNewSession(
|
||||
|
||||
@@ -155,7 +155,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: keyData.permissions,
|
||||
// 添加激活相关字段
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
|
||||
@@ -4,12 +4,12 @@ const { authenticateApiKey } = require('../middleware/auth')
|
||||
const droidRelayService = require('../services/droidRelayService')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function hasDroidPermission(apiKeyData) {
|
||||
const permissions = apiKeyData?.permissions || 'all'
|
||||
return permissions === 'all' || permissions === 'droid'
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, 'droid')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
handleStreamGenerateContent,
|
||||
handleLoadCodeAssist,
|
||||
handleOnboardUser,
|
||||
handleRetrieveUserQuota,
|
||||
handleCountTokens,
|
||||
handleStandardGenerateContent,
|
||||
handleStandardStreamGenerateContent,
|
||||
@@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
|
||||
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
||||
|
||||
// ============================================================================
|
||||
// v1internal 独有路由(listExperiments)
|
||||
// v1internal 独有路由
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
@@ -81,6 +82,12 @@ router.post(
|
||||
handleSimpleEndpoint('listExperiments')
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1internal:retrieveUserQuota
|
||||
* 获取用户配额信息(Gemini CLI 0.22.2+ 需要)
|
||||
*/
|
||||
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
|
||||
|
||||
/**
|
||||
* POST /v1beta/models/:modelName:listExperiments
|
||||
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
||||
|
||||
@@ -8,6 +8,7 @@ const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const claudeRelayService = require('../services/claudeRelayService')
|
||||
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||
const openaiToClaude = require('../services/openaiToClaude')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||
@@ -19,8 +20,7 @@ const { getEffectiveModel } = require('../utils/modelHelper')
|
||||
|
||||
// 🔧 辅助函数:检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||
const permissions = apiKeyData.permissions || 'all'
|
||||
return permissions === 'all' || permissions === requiredPermission
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||
}
|
||||
|
||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||
@@ -235,7 +235,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { accountId } = accountSelection
|
||||
const { accountId, accountType } = accountSelection
|
||||
|
||||
// 获取该账号存储的 Claude Code headers
|
||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||
@@ -265,72 +265,105 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
}
|
||||
})
|
||||
|
||||
// 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
(usage) => {
|
||||
// 记录使用统计
|
||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||
const model = usage.model || claudeRequest.model
|
||||
const cacheCreateTokens =
|
||||
(usage.cache_creation && typeof usage.cache_creation === 'object'
|
||||
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) +
|
||||
(usage.cache_creation.ephemeral_1h_input_tokens || 0)
|
||||
: usage.cache_creation_input_tokens || 0) || 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
||||
// 使用转换后的响应流 (根据账户类型选择转发服务)
|
||||
// 创建 usage 回调函数
|
||||
const usageCallback = (usage) => {
|
||||
// 记录使用统计
|
||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||
const model = usage.model || claudeRequest.model
|
||||
const cacheCreateTokens =
|
||||
(usage.cache_creation && typeof usage.cache_creation === 'object'
|
||||
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) +
|
||||
(usage.cache_creation.ephemeral_1h_input_tokens || 0)
|
||||
: usage.cache_creation_input_tokens || 0) || 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
||||
|
||||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(
|
||||
apiKeyData.id,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
model,
|
||||
accountId
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
{
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens
|
||||
},
|
||||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(
|
||||
apiKeyData.id,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
model,
|
||||
'openai-claude-stream'
|
||||
accountId,
|
||||
accountType
|
||||
)
|
||||
}
|
||||
},
|
||||
// 流转换器
|
||||
(() => {
|
||||
// 为每个请求创建独立的会话ID
|
||||
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||||
return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
||||
})(),
|
||||
{
|
||||
betaHeader:
|
||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
{
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
`openai-${accountType}-stream`
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 创建流转换器
|
||||
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||||
const streamTransformer = (chunk) =>
|
||||
openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
||||
|
||||
// 根据账户类型选择转发服务
|
||||
if (accountType === 'claude-console') {
|
||||
// Claude Console 账户使用 Console 转发服务
|
||||
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
usageCallback,
|
||||
accountId,
|
||||
streamTransformer
|
||||
)
|
||||
} else {
|
||||
// Claude Official 账户使用标准转发服务
|
||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
usageCallback,
|
||||
streamTransformer,
|
||||
{
|
||||
betaHeader:
|
||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 非流式请求
|
||||
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
|
||||
|
||||
// 发送请求到 Claude (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
||||
const claudeResponse = await claudeRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
{ betaHeader: 'oauth-2025-04-20' }
|
||||
)
|
||||
// 根据账户类型选择转发服务
|
||||
let claudeResponse
|
||||
if (accountType === 'claude-console') {
|
||||
// Claude Console 账户使用 Console 转发服务
|
||||
claudeResponse = await claudeConsoleRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
accountId
|
||||
)
|
||||
} else {
|
||||
// Claude Official 账户使用标准转发服务
|
||||
claudeResponse = await claudeRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
{ betaHeader: 'oauth-2025-04-20' }
|
||||
)
|
||||
}
|
||||
|
||||
// 解析 Claude 响应
|
||||
let claudeData
|
||||
@@ -376,7 +409,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
apiKeyData.id,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
claudeRequest.model,
|
||||
accountId
|
||||
accountId,
|
||||
accountType
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
@@ -391,7 +425,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
cacheReadTokens
|
||||
},
|
||||
claudeRequest.model,
|
||||
'openai-claude-non-stream'
|
||||
`openai-${accountType}-non-stream`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -402,16 +436,29 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
|
||||
} catch (error) {
|
||||
logger.error('❌ OpenAI-Claude request error:', error)
|
||||
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||
if (error.message === 'Client disconnected') {
|
||||
logger.info('🔌 OpenAI-Claude stream ended: Client disconnected')
|
||||
} else {
|
||||
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'
|
||||
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
|
||||
if (!res.headersSent) {
|
||||
// 客户端断开使用 499 状态码 (Client Closed Request)
|
||||
if (error.message === 'Client disconnected') {
|
||||
res.status(499).end()
|
||||
} else {
|
||||
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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const geminiAccountService = require('../services/geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const { getAvailableModels } = require('../services/geminiRelayService')
|
||||
const crypto = require('crypto')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
// 生成会话哈希
|
||||
function generateSessionHash(req) {
|
||||
@@ -19,10 +20,19 @@ function generateSessionHash(req) {
|
||||
return crypto.createHash('sha256').update(sessionData).digest('hex')
|
||||
}
|
||||
|
||||
function ensureAntigravityProjectId(account) {
|
||||
if (account.projectId) {
|
||||
return account.projectId
|
||||
}
|
||||
if (account.tempProjectId) {
|
||||
return account.tempProjectId
|
||||
}
|
||||
return `ag-${crypto.randomBytes(8).toString('hex')}`
|
||||
}
|
||||
|
||||
// 检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||
const permissions = apiKeyData.permissions || 'all'
|
||||
return permissions === 'all' || permissions === requiredPermission
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||
}
|
||||
|
||||
// 转换 OpenAI 消息格式到 Gemini 格式
|
||||
@@ -335,25 +345,48 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
account.accessToken,
|
||||
account.refreshToken,
|
||||
proxyConfig
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
if (actualStream) {
|
||||
// 流式响应
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
let { projectId } = account
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
projectId = ensureAntigravityProjectId(account)
|
||||
if (!account.projectId && account.tempProjectId !== projectId) {
|
||||
await geminiAccountService.updateTempProjectId(account.id, projectId)
|
||||
account.tempProjectId = projectId
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('StreamGenerateContent request', {
|
||||
model,
|
||||
projectId: account.projectId,
|
||||
projectId,
|
||||
apiKeyId: apiKeyData.id
|
||||
})
|
||||
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
account.projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
const streamResponse =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.generateContentStreamAntigravity(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId,
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
: await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 设置流式响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
@@ -499,7 +532,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
// 记录使用统计
|
||||
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
||||
try {
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
totalUsage.promptTokenCount || 0,
|
||||
@@ -559,20 +591,41 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
})
|
||||
} else {
|
||||
// 非流式响应
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
let { projectId } = account
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
projectId = ensureAntigravityProjectId(account)
|
||||
if (!account.projectId && account.tempProjectId !== projectId) {
|
||||
await geminiAccountService.updateTempProjectId(account.id, projectId)
|
||||
account.tempProjectId = projectId
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('GenerateContent request', {
|
||||
model,
|
||||
projectId: account.projectId,
|
||||
projectId,
|
||||
apiKeyId: apiKeyData.id
|
||||
})
|
||||
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
account.projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
const response =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.generateContentAntigravity(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId,
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
: await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 转换为 OpenAI 格式并返回
|
||||
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false)
|
||||
@@ -580,7 +633,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
// 记录使用统计
|
||||
if (openaiResponse.usage) {
|
||||
try {
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
openaiResponse.usage.prompt_tokens || 0,
|
||||
@@ -604,7 +656,15 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
|
||||
} catch (error) {
|
||||
logger.error('OpenAI-Gemini request error:', error)
|
||||
const statusForLog = error?.status || error?.response?.status
|
||||
logger.error('OpenAI-Gemini request error', {
|
||||
message: error?.message,
|
||||
status: statusForLog,
|
||||
code: error?.code,
|
||||
requestUrl: error?.config?.url,
|
||||
requestMethod: error?.config?.method,
|
||||
upstreamTraceId: error?.response?.headers?.['x-cloudaicompanion-trace-id']
|
||||
})
|
||||
|
||||
// 处理速率限制
|
||||
if (error.status === 429) {
|
||||
@@ -613,17 +673,24 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 返回 OpenAI 格式的错误响应
|
||||
const status = error.status || 500
|
||||
const errorResponse = {
|
||||
error: error.error || {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
|
||||
if (!res.headersSent) {
|
||||
// 客户端断开使用 499 状态码 (Client Closed Request)
|
||||
if (error.message === 'Client disconnected') {
|
||||
res.status(499).end()
|
||||
} else {
|
||||
// 返回 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)
|
||||
}
|
||||
}
|
||||
|
||||
res.status(status).json(errorResponse)
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
@@ -633,8 +700,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
// OpenAI 兼容的模型列表端点
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
// 获取可用模型列表的共享处理器
|
||||
async function handleGetModels(req, res) {
|
||||
try {
|
||||
const apiKeyData = req.apiKey
|
||||
|
||||
@@ -665,8 +732,21 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
let models = []
|
||||
|
||||
if (account) {
|
||||
// 获取实际的模型列表
|
||||
models = await getAvailableModels(account.accessToken, account.proxy)
|
||||
// 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性)
|
||||
try {
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
models =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||
account.accessToken,
|
||||
account.proxy,
|
||||
account.refreshToken
|
||||
)
|
||||
: await getAvailableModels(account.accessToken, account.proxy)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to get Gemini models list from upstream, fallback to default:', error)
|
||||
models = []
|
||||
}
|
||||
} else {
|
||||
// 返回默认模型列表
|
||||
models = [
|
||||
@@ -679,6 +759,17 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
]
|
||||
}
|
||||
|
||||
if (!models || models.length === 0) {
|
||||
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))
|
||||
@@ -698,8 +789,13 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
// OpenAI 兼容的模型列表端点 (带 v1 版)
|
||||
router.get('/v1/models', authenticateApiKey, handleGetModels)
|
||||
|
||||
// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载)
|
||||
router.get('/models', authenticateApiKey, handleGetModels)
|
||||
|
||||
// OpenAI 兼容的模型详情端点
|
||||
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||
|
||||
@@ -20,8 +20,7 @@ function createProxyAgent(proxy) {
|
||||
|
||||
// 检查 API Key 是否具备 OpenAI 权限
|
||||
function checkOpenAIPermissions(apiKeyData) {
|
||||
const permissions = apiKeyData?.permissions || 'all'
|
||||
return permissions === 'all' || permissions === 'openai'
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, 'openai')
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers = {}) {
|
||||
@@ -275,7 +274,9 @@ const handleResponses = async (req, res) => {
|
||||
'text_formatting',
|
||||
'truncation',
|
||||
'text',
|
||||
'service_tier'
|
||||
'service_tier',
|
||||
'prompt_cache_retention',
|
||||
'safety_identifier'
|
||||
]
|
||||
fieldsToRemove.forEach((field) => {
|
||||
delete req.body[field]
|
||||
@@ -905,7 +906,7 @@ router.get('/key-info', authenticateApiKey, async (req, res) => {
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: keyData.permissions,
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_remaining:
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
handleStreamGenerateContent: geminiHandleStreamGenerateContent
|
||||
} = require('../handlers/geminiHandlers')
|
||||
const openaiRoutes = require('./openaiRoutes')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -45,11 +46,11 @@ async function routeToBackend(req, res, requestedModel) {
|
||||
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
|
||||
|
||||
// 检查权限
|
||||
const permissions = req.apiKey.permissions || 'all'
|
||||
const { permissions } = req.apiKey
|
||||
|
||||
if (backend === 'claude') {
|
||||
// Claude 后端:通过 OpenAI 兼容层
|
||||
if (permissions !== 'all' && permissions !== 'claude') {
|
||||
if (!apiKeyService.hasPermission(permissions, 'claude')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Claude',
|
||||
@@ -61,7 +62,7 @@ async function routeToBackend(req, res, requestedModel) {
|
||||
await handleChatCompletion(req, res, req.apiKey)
|
||||
} else if (backend === 'openai') {
|
||||
// OpenAI 后端
|
||||
if (permissions !== 'all' && permissions !== 'openai') {
|
||||
if (!apiKeyService.hasPermission(permissions, 'openai')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access OpenAI',
|
||||
@@ -73,7 +74,7 @@ async function routeToBackend(req, res, requestedModel) {
|
||||
return await openaiRoutes.handleResponses(req, res)
|
||||
} else if (backend === 'gemini') {
|
||||
// Gemini 后端
|
||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
||||
if (!apiKeyService.hasPermission(permissions, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Gemini',
|
||||
|
||||
@@ -164,13 +164,27 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
|
||||
// 获取当前会话
|
||||
const sessionData = await redis.getSession(token)
|
||||
if (!sessionData) {
|
||||
|
||||
// 🔒 安全修复:检查空对象
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(
|
||||
`🔒 Invalid session structure in /auth/change-password from ${req.ip || 'unknown'}`
|
||||
)
|
||||
await redis.deleteSession(token)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前管理员信息
|
||||
const adminData = await redis.getSession('admin_credentials')
|
||||
if (!adminData) {
|
||||
@@ -269,13 +283,25 @@ router.get('/auth/user', async (req, res) => {
|
||||
|
||||
// 获取当前会话
|
||||
const sessionData = await redis.getSession(token)
|
||||
if (!sessionData) {
|
||||
|
||||
// 🔒 安全修复:检查空对象
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取管理员信息
|
||||
const adminData = await redis.getSession('admin_credentials')
|
||||
if (!adminData) {
|
||||
@@ -316,13 +342,24 @@ router.post('/auth/refresh', async (req, res) => {
|
||||
|
||||
const sessionData = await redis.getSession(token)
|
||||
|
||||
if (!sessionData) {
|
||||
// 🔒 安全修复:检查空对象(hgetall 对不存在的 key 返回 {})
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime)
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure detected from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新最后活动时间
|
||||
sessionData.lastActivity = new Date().toISOString()
|
||||
await redis.setSession(token, sessionData, config.security.adminSessionTimeout)
|
||||
|
||||
789
src/services/accountBalanceService.js
Normal file
789
src/services/accountBalanceService.js
Normal file
@@ -0,0 +1,789 @@
|
||||
const redis = require('../models/redis')
|
||||
const balanceScriptService = require('./balanceScriptService')
|
||||
const logger = require('../utils/logger')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||
|
||||
class AccountBalanceService {
|
||||
constructor(options = {}) {
|
||||
this.redis = options.redis || redis
|
||||
this.logger = options.logger || logger
|
||||
|
||||
this.providers = new Map()
|
||||
|
||||
this.CACHE_TTL_SECONDS = 3600
|
||||
this.LOCAL_TTL_SECONDS = 300
|
||||
|
||||
this.LOW_BALANCE_THRESHOLD = 10
|
||||
this.HIGH_USAGE_THRESHOLD_PERCENT = 90
|
||||
this.DEFAULT_CONCURRENCY = 10
|
||||
}
|
||||
|
||||
getSupportedPlatforms() {
|
||||
return [
|
||||
'claude',
|
||||
'claude-console',
|
||||
'gemini',
|
||||
'gemini-api',
|
||||
'openai',
|
||||
'openai-responses',
|
||||
'azure_openai',
|
||||
'bedrock',
|
||||
'droid',
|
||||
'ccr'
|
||||
]
|
||||
}
|
||||
|
||||
normalizePlatform(platform) {
|
||||
if (!platform) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = String(platform).trim().toLowerCase()
|
||||
|
||||
// 兼容实施文档与历史命名
|
||||
if (value === 'claude-official') {
|
||||
return 'claude'
|
||||
}
|
||||
if (value === 'azure-openai') {
|
||||
return 'azure_openai'
|
||||
}
|
||||
|
||||
// 保持前端平台键一致
|
||||
return value
|
||||
}
|
||||
|
||||
registerProvider(platform, provider) {
|
||||
const normalized = this.normalizePlatform(platform)
|
||||
if (!normalized) {
|
||||
throw new Error('registerProvider: 缺少 platform')
|
||||
}
|
||||
if (!provider || typeof provider.queryBalance !== 'function') {
|
||||
throw new Error(`registerProvider: Provider 无效 (${normalized})`)
|
||||
}
|
||||
this.providers.set(normalized, provider)
|
||||
}
|
||||
|
||||
async getAccountBalance(accountId, platform, options = {}) {
|
||||
const normalizedPlatform = this.normalizePlatform(platform)
|
||||
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
return await this._getAccountBalanceForAccount(account, normalizedPlatform, options)
|
||||
}
|
||||
|
||||
async refreshAccountBalance(accountId, platform) {
|
||||
const normalizedPlatform = this.normalizePlatform(platform)
|
||||
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await this._getAccountBalanceForAccount(account, normalizedPlatform, {
|
||||
queryApi: true,
|
||||
useCache: false
|
||||
})
|
||||
}
|
||||
|
||||
async getAllAccountsBalance(platform, options = {}) {
|
||||
const normalizedPlatform = this.normalizePlatform(platform)
|
||||
const accounts = await this.getAllAccountsByPlatform(normalizedPlatform)
|
||||
const queryApi = this._parseBoolean(options.queryApi) || false
|
||||
const useCache = options.useCache !== false
|
||||
|
||||
const results = await this._mapWithConcurrency(
|
||||
accounts,
|
||||
this.DEFAULT_CONCURRENCY,
|
||||
async (acc) => {
|
||||
try {
|
||||
const balance = await this._getAccountBalanceForAccount(acc, normalizedPlatform, {
|
||||
queryApi,
|
||||
useCache
|
||||
})
|
||||
return { ...balance, name: acc.name || '' }
|
||||
} catch (error) {
|
||||
this.logger.error(`批量获取余额失败: ${normalizedPlatform}:${acc?.id}`, error)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
accountId: acc?.id,
|
||||
platform: normalizedPlatform,
|
||||
balance: null,
|
||||
quota: null,
|
||||
statistics: {},
|
||||
source: 'local',
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
cacheExpiresAt: null,
|
||||
status: 'error',
|
||||
error: error.message || '批量查询失败'
|
||||
},
|
||||
name: acc?.name || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async getBalanceSummary() {
|
||||
const platforms = this.getSupportedPlatforms()
|
||||
|
||||
const summary = {
|
||||
totalBalance: 0,
|
||||
totalCost: 0,
|
||||
lowBalanceCount: 0,
|
||||
platforms: {}
|
||||
}
|
||||
|
||||
for (const platform of platforms) {
|
||||
const accounts = await this.getAllAccountsByPlatform(platform)
|
||||
const platformData = {
|
||||
count: accounts.length,
|
||||
totalBalance: 0,
|
||||
totalCost: 0,
|
||||
lowBalanceCount: 0,
|
||||
accounts: []
|
||||
}
|
||||
|
||||
const balances = await this._mapWithConcurrency(
|
||||
accounts,
|
||||
this.DEFAULT_CONCURRENCY,
|
||||
async (acc) => {
|
||||
const balance = await this._getAccountBalanceForAccount(acc, platform, {
|
||||
queryApi: false,
|
||||
useCache: true
|
||||
})
|
||||
return { ...balance, name: acc.name || '' }
|
||||
}
|
||||
)
|
||||
|
||||
for (const item of balances) {
|
||||
platformData.accounts.push(item)
|
||||
|
||||
const amount = item?.data?.balance?.amount
|
||||
const percentage = item?.data?.quota?.percentage
|
||||
const totalCost = Number(item?.data?.statistics?.totalCost || 0)
|
||||
|
||||
const hasAmount = typeof amount === 'number' && Number.isFinite(amount)
|
||||
const isLowBalance = hasAmount && amount < this.LOW_BALANCE_THRESHOLD
|
||||
const isHighUsage =
|
||||
typeof percentage === 'number' &&
|
||||
Number.isFinite(percentage) &&
|
||||
percentage > this.HIGH_USAGE_THRESHOLD_PERCENT
|
||||
|
||||
if (hasAmount) {
|
||||
platformData.totalBalance += amount
|
||||
}
|
||||
|
||||
if (isLowBalance || isHighUsage) {
|
||||
platformData.lowBalanceCount += 1
|
||||
summary.lowBalanceCount += 1
|
||||
}
|
||||
|
||||
platformData.totalCost += totalCost
|
||||
}
|
||||
|
||||
summary.platforms[platform] = platformData
|
||||
summary.totalBalance += platformData.totalBalance
|
||||
summary.totalCost += platformData.totalCost
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
async clearCache(accountId, platform) {
|
||||
const normalizedPlatform = this.normalizePlatform(platform)
|
||||
if (!normalizedPlatform) {
|
||||
throw new Error('缺少 platform 参数')
|
||||
}
|
||||
|
||||
await this.redis.deleteAccountBalance(normalizedPlatform, accountId)
|
||||
this.logger.info(`余额缓存已清除: ${normalizedPlatform}:${accountId}`)
|
||||
}
|
||||
|
||||
async getAccount(accountId, platform) {
|
||||
if (!accountId || !platform) {
|
||||
return null
|
||||
}
|
||||
|
||||
const serviceMap = {
|
||||
claude: require('./claudeAccountService'),
|
||||
'claude-console': require('./claudeConsoleAccountService'),
|
||||
gemini: require('./geminiAccountService'),
|
||||
'gemini-api': require('./geminiApiAccountService'),
|
||||
openai: require('./openaiAccountService'),
|
||||
'openai-responses': require('./openaiResponsesAccountService'),
|
||||
azure_openai: require('./azureOpenaiAccountService'),
|
||||
bedrock: require('./bedrockAccountService'),
|
||||
droid: require('./droidAccountService'),
|
||||
ccr: require('./ccrAccountService')
|
||||
}
|
||||
|
||||
const service = serviceMap[platform]
|
||||
if (!service || typeof service.getAccount !== 'function') {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await service.getAccount(accountId)
|
||||
|
||||
// 处理不同服务返回格式的差异
|
||||
// Bedrock/CCR/Droid 等服务返回 { success, data } 格式
|
||||
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
|
||||
return result.success ? result.data : null
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getAllAccountsByPlatform(platform) {
|
||||
if (!platform) {
|
||||
return []
|
||||
}
|
||||
|
||||
const serviceMap = {
|
||||
claude: require('./claudeAccountService'),
|
||||
'claude-console': require('./claudeConsoleAccountService'),
|
||||
gemini: require('./geminiAccountService'),
|
||||
'gemini-api': require('./geminiApiAccountService'),
|
||||
openai: require('./openaiAccountService'),
|
||||
'openai-responses': require('./openaiResponsesAccountService'),
|
||||
azure_openai: require('./azureOpenaiAccountService'),
|
||||
bedrock: require('./bedrockAccountService'),
|
||||
droid: require('./droidAccountService'),
|
||||
ccr: require('./ccrAccountService')
|
||||
}
|
||||
|
||||
const service = serviceMap[platform]
|
||||
if (!service) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Bedrock 特殊:返回 { success, data }
|
||||
if (platform === 'bedrock' && typeof service.getAllAccounts === 'function') {
|
||||
const result = await service.getAllAccounts()
|
||||
return result?.success ? result.data || [] : []
|
||||
}
|
||||
|
||||
if (platform === 'openai-responses') {
|
||||
return await service.getAllAccounts(true)
|
||||
}
|
||||
|
||||
if (typeof service.getAllAccounts !== 'function') {
|
||||
return []
|
||||
}
|
||||
|
||||
return await service.getAllAccounts()
|
||||
}
|
||||
|
||||
async _getAccountBalanceForAccount(account, platform, options = {}) {
|
||||
const queryMode = this._parseQueryMode(options.queryApi)
|
||||
const useCache = options.useCache !== false
|
||||
|
||||
const accountId = account?.id
|
||||
if (!accountId) {
|
||||
// 如果账户缺少 id,返回空响应而不是抛出错误,避免接口报错和UI错误
|
||||
this.logger.warn('账户缺少 id,返回空余额数据', { account, platform })
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: 'error',
|
||||
errorMessage: '账户数据异常',
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: null,
|
||||
statistics: {},
|
||||
lastRefreshAt: new Date().toISOString()
|
||||
},
|
||||
'unknown',
|
||||
platform,
|
||||
'local',
|
||||
null,
|
||||
{ scriptEnabled: false, scriptConfigured: false }
|
||||
)
|
||||
}
|
||||
|
||||
// 余额脚本配置状态(用于前端控制"刷新余额"按钮)
|
||||
let scriptConfig = null
|
||||
let scriptConfigured = false
|
||||
if (typeof this.redis?.getBalanceScriptConfig === 'function') {
|
||||
scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId)
|
||||
scriptConfigured = !!(
|
||||
scriptConfig &&
|
||||
scriptConfig.scriptBody &&
|
||||
String(scriptConfig.scriptBody).trim().length > 0
|
||||
)
|
||||
}
|
||||
const scriptEnabled = isBalanceScriptEnabled()
|
||||
const scriptMeta = { scriptEnabled, scriptConfigured }
|
||||
|
||||
const localBalance = await this._getBalanceFromLocal(accountId, platform)
|
||||
const localStatistics = localBalance.statistics || {}
|
||||
|
||||
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
|
||||
|
||||
// 安全限制:queryApi=auto 仅用于 Antigravity(gemini + oauthProvider=antigravity)账户
|
||||
const effectiveQueryMode =
|
||||
queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity')
|
||||
? 'local'
|
||||
: queryMode
|
||||
|
||||
// local: 仅本地统计/缓存;auto: 优先缓存,无缓存则尝试远程 Provider(并缓存结果)
|
||||
if (effectiveQueryMode !== 'api') {
|
||||
if (useCache) {
|
||||
const cached = await this.redis.getAccountBalance(platform, accountId)
|
||||
if (cached && cached.status === 'success') {
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: cached.status,
|
||||
errorMessage: cached.errorMessage,
|
||||
balance: quotaFromLocal.balance ?? cached.balance,
|
||||
currency: quotaFromLocal.currency || cached.currency || 'USD',
|
||||
quota: quotaFromLocal.quota || cached.quota || null,
|
||||
statistics: localStatistics,
|
||||
lastRefreshAt: cached.lastRefreshAt
|
||||
},
|
||||
accountId,
|
||||
platform,
|
||||
'cache',
|
||||
cached.ttlSeconds,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveQueryMode === 'local') {
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: 'success',
|
||||
errorMessage: null,
|
||||
balance: quotaFromLocal.balance,
|
||||
currency: quotaFromLocal.currency || 'USD',
|
||||
quota: quotaFromLocal.quota,
|
||||
statistics: localStatistics,
|
||||
lastRefreshAt: localBalance.lastCalculated
|
||||
},
|
||||
accountId,
|
||||
platform,
|
||||
'local',
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计
|
||||
let providerResult
|
||||
|
||||
if (scriptEnabled && scriptConfigured) {
|
||||
providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform)
|
||||
} else {
|
||||
const provider = this.providers.get(platform)
|
||||
if (!provider) {
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: 'error',
|
||||
errorMessage: `不支持的平台: ${platform}`,
|
||||
balance: quotaFromLocal.balance,
|
||||
currency: quotaFromLocal.currency || 'USD',
|
||||
quota: quotaFromLocal.quota,
|
||||
statistics: localStatistics,
|
||||
lastRefreshAt: new Date().toISOString()
|
||||
},
|
||||
accountId,
|
||||
platform,
|
||||
'local',
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
providerResult = await this._getBalanceFromProvider(provider, account)
|
||||
}
|
||||
|
||||
const isRemoteSuccess =
|
||||
providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod)
|
||||
|
||||
// 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h
|
||||
if (isRemoteSuccess) {
|
||||
await this.redis.setAccountBalance(
|
||||
platform,
|
||||
accountId,
|
||||
providerResult,
|
||||
this.CACHE_TTL_SECONDS
|
||||
)
|
||||
}
|
||||
|
||||
const source = isRemoteSuccess ? 'api' : 'local'
|
||||
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: providerResult.status,
|
||||
errorMessage: providerResult.errorMessage,
|
||||
balance: quotaFromLocal.balance ?? providerResult.balance,
|
||||
currency: quotaFromLocal.currency || providerResult.currency || 'USD',
|
||||
quota: quotaFromLocal.quota || providerResult.quota || null,
|
||||
statistics: localStatistics,
|
||||
lastRefreshAt: providerResult.lastRefreshAt
|
||||
},
|
||||
accountId,
|
||||
platform,
|
||||
source,
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
|
||||
async _getBalanceFromScript(scriptConfig, accountId, platform) {
|
||||
try {
|
||||
const result = await balanceScriptService.execute({
|
||||
scriptBody: scriptConfig.scriptBody,
|
||||
timeoutSeconds: scriptConfig.timeoutSeconds || 10,
|
||||
variables: {
|
||||
baseUrl: scriptConfig.baseUrl || '',
|
||||
apiKey: scriptConfig.apiKey || '',
|
||||
token: scriptConfig.token || '',
|
||||
accountId,
|
||||
platform,
|
||||
extra: scriptConfig.extra || ''
|
||||
}
|
||||
})
|
||||
|
||||
const mapped = result?.mapped || {}
|
||||
return {
|
||||
status: mapped.status || 'error',
|
||||
balance: typeof mapped.balance === 'number' ? mapped.balance : null,
|
||||
currency: mapped.currency || 'USD',
|
||||
quota: mapped.quota || null,
|
||||
queryMethod: 'api',
|
||||
rawData: mapped.rawData || result?.response?.data || null,
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
errorMessage: mapped.errorMessage || ''
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: null,
|
||||
queryMethod: 'api',
|
||||
rawData: null,
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
errorMessage: error.message || '脚本执行失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _getBalanceFromProvider(provider, account) {
|
||||
try {
|
||||
const result = await provider.queryBalance(account)
|
||||
return {
|
||||
status: 'success',
|
||||
balance: typeof result?.balance === 'number' ? result.balance : null,
|
||||
currency: result?.currency || 'USD',
|
||||
quota: result?.quota || null,
|
||||
queryMethod: result?.queryMethod || 'api',
|
||||
rawData: result?.rawData || null,
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
errorMessage: ''
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: null,
|
||||
queryMethod: 'api',
|
||||
rawData: null,
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
errorMessage: error.message || '查询失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _getBalanceFromLocal(accountId, platform) {
|
||||
const cached = await this.redis.getLocalBalance(platform, accountId)
|
||||
if (cached && cached.statistics) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const statistics = await this._computeLocalStatistics(accountId)
|
||||
const localBalance = {
|
||||
status: 'success',
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
statistics,
|
||||
queryMethod: 'local',
|
||||
lastCalculated: new Date().toISOString()
|
||||
}
|
||||
|
||||
await this.redis.setLocalBalance(platform, accountId, localBalance, this.LOCAL_TTL_SECONDS)
|
||||
return localBalance
|
||||
}
|
||||
|
||||
async _computeLocalStatistics(accountId) {
|
||||
const safeNumber = (value) => {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : 0
|
||||
}
|
||||
|
||||
try {
|
||||
const usageStats = await this.redis.getAccountUsageStats(accountId)
|
||||
const dailyCost = safeNumber(usageStats?.daily?.cost || 0)
|
||||
const monthlyCost = await this._computeMonthlyCost(accountId)
|
||||
const totalCost = await this._computeTotalCost(accountId)
|
||||
|
||||
return {
|
||||
totalCost,
|
||||
dailyCost,
|
||||
monthlyCost,
|
||||
totalRequests: safeNumber(usageStats?.total?.requests || 0),
|
||||
dailyRequests: safeNumber(usageStats?.daily?.requests || 0),
|
||||
monthlyRequests: safeNumber(usageStats?.monthly?.requests || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(`本地统计计算失败: ${accountId}`, error)
|
||||
return {
|
||||
totalCost: 0,
|
||||
dailyCost: 0,
|
||||
monthlyCost: 0,
|
||||
totalRequests: 0,
|
||||
dailyRequests: 0,
|
||||
monthlyRequests: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _computeMonthlyCost(accountId) {
|
||||
const tzDate = this.redis.getDateInTimezone(new Date())
|
||||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
||||
2,
|
||||
'0'
|
||||
)}`
|
||||
|
||||
const pattern = `account_usage:model:monthly:${accountId}:*:${currentMonth}`
|
||||
return await this._sumModelCostsByKeysPattern(pattern)
|
||||
}
|
||||
|
||||
async _computeTotalCost(accountId) {
|
||||
const pattern = `account_usage:model:monthly:${accountId}:*:*`
|
||||
return await this._sumModelCostsByKeysPattern(pattern)
|
||||
}
|
||||
|
||||
async _sumModelCostsByKeysPattern(pattern) {
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
let totalCost = 0
|
||||
let cursor = '0'
|
||||
const scanCount = 200
|
||||
let iterations = 0
|
||||
const maxIterations = 2000
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount)
|
||||
cursor = nextCursor
|
||||
iterations += 1
|
||||
|
||||
if (!keys || keys.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
keys.forEach((key) => pipeline.hgetall(key))
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const [, data] = results[i] || []
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = String(keys[i]).split(':')
|
||||
const model = parts[4] || 'unknown'
|
||||
|
||||
const usage = {
|
||||
input_tokens: parseInt(data.inputTokens || 0),
|
||||
output_tokens: parseInt(data.outputTokens || 0),
|
||||
cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0),
|
||||
cache_read_input_tokens: parseInt(data.cacheReadTokens || 0)
|
||||
}
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, model)
|
||||
totalCost += costResult.costs.total || 0
|
||||
}
|
||||
|
||||
if (iterations >= maxIterations) {
|
||||
this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`)
|
||||
break
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
return totalCost
|
||||
} catch (error) {
|
||||
this.logger.debug(`汇总模型费用失败: ${pattern}`, error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
_buildQuotaFromLocal(account, statistics) {
|
||||
if (!account || !Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||
return { balance: null, currency: null, quota: null }
|
||||
}
|
||||
|
||||
const dailyQuota = Number(account.dailyQuota || 0)
|
||||
const used = Number(statistics?.dailyCost || 0)
|
||||
|
||||
const resetAt = this._computeNextResetAt(account.quotaResetTime || '00:00')
|
||||
|
||||
// 不限制
|
||||
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: {
|
||||
daily: Infinity,
|
||||
used,
|
||||
remaining: Infinity,
|
||||
percentage: 0,
|
||||
unlimited: true,
|
||||
resetAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, dailyQuota - used)
|
||||
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
|
||||
|
||||
return {
|
||||
balance: remaining,
|
||||
currency: 'USD',
|
||||
quota: {
|
||||
daily: dailyQuota,
|
||||
used,
|
||||
remaining,
|
||||
resetAt,
|
||||
percentage: Math.round(percentage * 100) / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_computeNextResetAt(resetTime) {
|
||||
const now = new Date()
|
||||
const tzNow = this.redis.getDateInTimezone(now)
|
||||
const offsetMs = tzNow.getTime() - now.getTime()
|
||||
|
||||
const [h, m] = String(resetTime || '00:00')
|
||||
.split(':')
|
||||
.map((n) => parseInt(n, 10))
|
||||
|
||||
const resetHour = Number.isFinite(h) ? h : 0
|
||||
const resetMinute = Number.isFinite(m) ? m : 0
|
||||
|
||||
const year = tzNow.getUTCFullYear()
|
||||
const month = tzNow.getUTCMonth()
|
||||
const day = tzNow.getUTCDate()
|
||||
|
||||
let resetAtMs = Date.UTC(year, month, day, resetHour, resetMinute, 0, 0) - offsetMs
|
||||
if (resetAtMs <= now.getTime()) {
|
||||
resetAtMs += 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
return new Date(resetAtMs).toISOString()
|
||||
}
|
||||
|
||||
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null, extraData = {}) {
|
||||
const now = new Date()
|
||||
|
||||
const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null
|
||||
const currency = balanceData.currency || 'USD'
|
||||
|
||||
let cacheExpiresAt = null
|
||||
if (source === 'cache') {
|
||||
const ttl =
|
||||
typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : this.CACHE_TTL_SECONDS
|
||||
cacheExpiresAt = new Date(Date.now() + ttl * 1000).toISOString()
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
platform,
|
||||
balance:
|
||||
typeof amount === 'number'
|
||||
? {
|
||||
amount,
|
||||
currency,
|
||||
formattedAmount: this._formatCurrency(amount, currency)
|
||||
}
|
||||
: null,
|
||||
quota: balanceData.quota || null,
|
||||
statistics: balanceData.statistics || {},
|
||||
source,
|
||||
lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(),
|
||||
cacheExpiresAt,
|
||||
status: balanceData.status || 'success',
|
||||
error: balanceData.errorMessage || null,
|
||||
...(extraData && typeof extraData === 'object' ? extraData : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_formatCurrency(amount, currency = 'USD') {
|
||||
try {
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
|
||||
return 'N/A'
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
||||
} catch (error) {
|
||||
return `$${amount.toFixed(2)}`
|
||||
}
|
||||
}
|
||||
|
||||
_parseBoolean(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
||||
return true
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
||||
return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
_parseQueryMode(value) {
|
||||
if (value === 'auto') {
|
||||
return 'auto'
|
||||
}
|
||||
const parsed = this._parseBoolean(value)
|
||||
return parsed ? 'api' : 'local'
|
||||
}
|
||||
|
||||
async _mapWithConcurrency(items, limit, mapper) {
|
||||
const concurrency = Math.max(1, Number(limit) || 1)
|
||||
const list = Array.isArray(items) ? items : []
|
||||
|
||||
const results = new Array(list.length)
|
||||
let nextIndex = 0
|
||||
|
||||
const workers = new Array(Math.min(concurrency, list.length)).fill(null).map(async () => {
|
||||
while (nextIndex < list.length) {
|
||||
const currentIndex = nextIndex
|
||||
nextIndex += 1
|
||||
results[currentIndex] = await mapper(list[currentIndex], currentIndex)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
const accountBalanceService = new AccountBalanceService()
|
||||
module.exports = accountBalanceService
|
||||
module.exports.AccountBalanceService = AccountBalanceService
|
||||
3083
src/services/anthropicGeminiBridgeService.js
Normal file
3083
src/services/anthropicGeminiBridgeService.js
Normal file
File diff suppressed because it is too large
Load Diff
595
src/services/antigravityClient.js
Normal file
595
src/services/antigravityClient.js
Normal file
@@ -0,0 +1,595 @@
|
||||
const axios = require('axios')
|
||||
const https = require('https')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const {
|
||||
mapAntigravityUpstreamModel,
|
||||
normalizeAntigravityModelInput,
|
||||
getAntigravityModelMetadata
|
||||
} = require('../utils/antigravityModel')
|
||||
const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner')
|
||||
const { dumpAntigravityUpstreamRequest } = require('../utils/antigravityUpstreamDump')
|
||||
|
||||
const keepAliveAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30000,
|
||||
timeout: 120000,
|
||||
maxSockets: 100,
|
||||
maxFreeSockets: 10
|
||||
})
|
||||
|
||||
function getAntigravityApiUrl() {
|
||||
return process.env.ANTIGRAVITY_API_URL || 'https://daily-cloudcode-pa.sandbox.googleapis.com'
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url) {
|
||||
const str = String(url || '').trim()
|
||||
return str.endsWith('/') ? str.slice(0, -1) : str
|
||||
}
|
||||
|
||||
function getAntigravityApiUrlCandidates() {
|
||||
const configured = normalizeBaseUrl(getAntigravityApiUrl())
|
||||
const daily = 'https://daily-cloudcode-pa.sandbox.googleapis.com'
|
||||
const prod = 'https://cloudcode-pa.googleapis.com'
|
||||
|
||||
// 若显式配置了自定义 base url,则只使用该地址(不做 fallback,避免意外路由到别的环境)。
|
||||
if (process.env.ANTIGRAVITY_API_URL) {
|
||||
return [configured]
|
||||
}
|
||||
|
||||
// 默认行为:优先 daily(与旧逻辑一致),失败时再尝试 prod(对齐 CLIProxyAPI)。
|
||||
if (configured === normalizeBaseUrl(daily)) {
|
||||
return [configured, prod]
|
||||
}
|
||||
if (configured === normalizeBaseUrl(prod)) {
|
||||
return [configured, daily]
|
||||
}
|
||||
|
||||
return [configured, prod, daily].filter(Boolean)
|
||||
}
|
||||
|
||||
function getAntigravityHeaders(accessToken, baseUrl) {
|
||||
const resolvedBaseUrl = baseUrl || getAntigravityApiUrl()
|
||||
let host = 'daily-cloudcode-pa.sandbox.googleapis.com'
|
||||
try {
|
||||
host = new URL(resolvedBaseUrl).host || host
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
Host: host,
|
||||
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
requestType: 'agent'
|
||||
}
|
||||
}
|
||||
|
||||
function generateAntigravityProjectId() {
|
||||
return `ag-${uuidv4().replace(/-/g, '').slice(0, 16)}`
|
||||
}
|
||||
|
||||
function generateAntigravitySessionId() {
|
||||
return `sess-${uuidv4()}`
|
||||
}
|
||||
|
||||
function resolveAntigravityProjectId(projectId, requestData) {
|
||||
const candidate = projectId || requestData?.project || requestData?.projectId || null
|
||||
return candidate || generateAntigravityProjectId()
|
||||
}
|
||||
|
||||
function resolveAntigravitySessionId(sessionId, requestData) {
|
||||
const candidate =
|
||||
sessionId || requestData?.request?.sessionId || requestData?.request?.session_id || null
|
||||
return candidate || generateAntigravitySessionId()
|
||||
}
|
||||
|
||||
function buildAntigravityEnvelope({ requestData, projectId, sessionId, userPromptId }) {
|
||||
const model = mapAntigravityUpstreamModel(requestData?.model)
|
||||
const resolvedProjectId = resolveAntigravityProjectId(projectId, requestData)
|
||||
const resolvedSessionId = resolveAntigravitySessionId(sessionId, requestData)
|
||||
const requestPayload = {
|
||||
...(requestData?.request || {})
|
||||
}
|
||||
|
||||
if (requestPayload.session_id !== undefined) {
|
||||
delete requestPayload.session_id
|
||||
}
|
||||
requestPayload.sessionId = resolvedSessionId
|
||||
|
||||
const envelope = {
|
||||
project: resolvedProjectId,
|
||||
requestId: `req-${uuidv4()}`,
|
||||
model,
|
||||
userAgent: 'antigravity',
|
||||
request: {
|
||||
...requestPayload
|
||||
}
|
||||
}
|
||||
|
||||
if (userPromptId) {
|
||||
envelope.user_prompt_id = userPromptId
|
||||
envelope.userPromptId = userPromptId
|
||||
}
|
||||
|
||||
normalizeAntigravityEnvelope(envelope)
|
||||
return { model, envelope }
|
||||
}
|
||||
|
||||
function normalizeAntigravityThinking(model, requestPayload) {
|
||||
if (!requestPayload || typeof requestPayload !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const { generationConfig } = requestPayload
|
||||
if (!generationConfig || typeof generationConfig !== 'object') {
|
||||
return
|
||||
}
|
||||
const { thinkingConfig } = generationConfig
|
||||
if (!thinkingConfig || typeof thinkingConfig !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedModel = normalizeAntigravityModelInput(model)
|
||||
if (thinkingConfig.thinkingLevel && !normalizedModel.startsWith('gemini-3-')) {
|
||||
delete thinkingConfig.thinkingLevel
|
||||
}
|
||||
|
||||
const metadata = getAntigravityModelMetadata(normalizedModel)
|
||||
if (metadata && !metadata.thinking) {
|
||||
delete generationConfig.thinkingConfig
|
||||
return
|
||||
}
|
||||
if (!metadata || !metadata.thinking) {
|
||||
return
|
||||
}
|
||||
|
||||
const budgetRaw = Number(thinkingConfig.thinkingBudget)
|
||||
if (!Number.isFinite(budgetRaw)) {
|
||||
return
|
||||
}
|
||||
let budget = Math.trunc(budgetRaw)
|
||||
|
||||
const minBudget = Number.isFinite(metadata.thinking.min) ? metadata.thinking.min : null
|
||||
const maxBudget = Number.isFinite(metadata.thinking.max) ? metadata.thinking.max : null
|
||||
|
||||
if (maxBudget !== null && budget > maxBudget) {
|
||||
budget = maxBudget
|
||||
}
|
||||
|
||||
let effectiveMax = Number.isFinite(generationConfig.maxOutputTokens)
|
||||
? generationConfig.maxOutputTokens
|
||||
: null
|
||||
let setDefaultMax = false
|
||||
if (!effectiveMax && metadata.maxCompletionTokens) {
|
||||
effectiveMax = metadata.maxCompletionTokens
|
||||
setDefaultMax = true
|
||||
}
|
||||
|
||||
if (effectiveMax && budget >= effectiveMax) {
|
||||
budget = Math.max(0, effectiveMax - 1)
|
||||
}
|
||||
|
||||
if (minBudget !== null && budget >= 0 && budget < minBudget) {
|
||||
delete generationConfig.thinkingConfig
|
||||
return
|
||||
}
|
||||
|
||||
thinkingConfig.thinkingBudget = budget
|
||||
if (setDefaultMax) {
|
||||
generationConfig.maxOutputTokens = effectiveMax
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAntigravityEnvelope(envelope) {
|
||||
if (!envelope || typeof envelope !== 'object') {
|
||||
return
|
||||
}
|
||||
const model = String(envelope.model || '')
|
||||
const requestPayload = envelope.request
|
||||
if (!requestPayload || typeof requestPayload !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (requestPayload.safetySettings !== undefined) {
|
||||
delete requestPayload.safetySettings
|
||||
}
|
||||
|
||||
// 对齐 CLIProxyAPI:有 tools 时默认启用 VALIDATED(除非显式 NONE)
|
||||
if (Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) {
|
||||
const existing = requestPayload?.toolConfig?.functionCallingConfig || null
|
||||
if (existing?.mode !== 'NONE') {
|
||||
const nextCfg = { ...(existing || {}), mode: 'VALIDATED' }
|
||||
requestPayload.toolConfig = { functionCallingConfig: nextCfg }
|
||||
}
|
||||
}
|
||||
|
||||
// 对齐 CLIProxyAPI:非 Claude 模型移除 maxOutputTokens(Antigravity 环境不稳定)
|
||||
normalizeAntigravityThinking(model, requestPayload)
|
||||
if (!model.includes('claude')) {
|
||||
if (requestPayload.generationConfig && typeof requestPayload.generationConfig === 'object') {
|
||||
delete requestPayload.generationConfig.maxOutputTokens
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Claude 模型:parametersJsonSchema -> parameters + schema 清洗(避免 $schema / additionalProperties 等触发 400)
|
||||
if (!Array.isArray(requestPayload.tools)) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const tool of requestPayload.tools) {
|
||||
if (!tool || typeof tool !== 'object') {
|
||||
continue
|
||||
}
|
||||
const decls = Array.isArray(tool.functionDeclarations)
|
||||
? tool.functionDeclarations
|
||||
: Array.isArray(tool.function_declarations)
|
||||
? tool.function_declarations
|
||||
: null
|
||||
|
||||
if (!decls) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const decl of decls) {
|
||||
if (!decl || typeof decl !== 'object') {
|
||||
continue
|
||||
}
|
||||
let schema =
|
||||
decl.parametersJsonSchema !== undefined ? decl.parametersJsonSchema : decl.parameters
|
||||
if (typeof schema === 'string' && schema) {
|
||||
try {
|
||||
schema = JSON.parse(schema)
|
||||
} catch (_) {
|
||||
schema = null
|
||||
}
|
||||
}
|
||||
|
||||
decl.parameters = cleanJsonSchemaForGemini(schema)
|
||||
delete decl.parametersJsonSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function request({
|
||||
accessToken,
|
||||
proxyConfig = null,
|
||||
requestData,
|
||||
projectId = null,
|
||||
sessionId = null,
|
||||
userPromptId = null,
|
||||
stream = false,
|
||||
signal = null,
|
||||
params = null,
|
||||
timeoutMs = null
|
||||
}) {
|
||||
const { model, envelope } = buildAntigravityEnvelope({
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId,
|
||||
userPromptId
|
||||
})
|
||||
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
let endpoints = getAntigravityApiUrlCandidates()
|
||||
|
||||
// Claude 模型在 sandbox(daily) 环境下对 tool_use/tool_result 的兼容性不稳定,优先走 prod。
|
||||
// 保持可配置优先:若用户显式设置了 ANTIGRAVITY_API_URL,则不改变顺序。
|
||||
if (!process.env.ANTIGRAVITY_API_URL && String(model).includes('claude')) {
|
||||
const prodHost = 'cloudcode-pa.googleapis.com'
|
||||
const dailyHost = 'daily-cloudcode-pa.sandbox.googleapis.com'
|
||||
const ordered = []
|
||||
for (const u of endpoints) {
|
||||
if (String(u).includes(prodHost)) {
|
||||
ordered.push(u)
|
||||
}
|
||||
}
|
||||
for (const u of endpoints) {
|
||||
if (!String(u).includes(prodHost)) {
|
||||
ordered.push(u)
|
||||
}
|
||||
}
|
||||
// 去重并保持 prod -> daily 的稳定顺序
|
||||
endpoints = Array.from(new Set(ordered)).sort((a, b) => {
|
||||
const av = String(a)
|
||||
const bv = String(b)
|
||||
const aScore = av.includes(prodHost) ? 0 : av.includes(dailyHost) ? 1 : 2
|
||||
const bScore = bv.includes(prodHost) ? 0 : bv.includes(dailyHost) ? 1 : 2
|
||||
return aScore - bScore
|
||||
})
|
||||
}
|
||||
|
||||
const isRetryable = (error) => {
|
||||
// 处理网络层面的连接重置或超时(常见于长请求被中间节点切断)
|
||||
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
|
||||
return true
|
||||
}
|
||||
|
||||
const status = error?.response?.status
|
||||
if (status === 429) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 400/404 的 “model unavailable / not found” 在不同环境间可能表现不同,允许 fallback。
|
||||
if (status === 400 || status === 404) {
|
||||
const data = error?.response?.data
|
||||
const safeToString = (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
// axios responseType=stream 时,data 可能是 stream(存在循环引用),不能 JSON.stringify
|
||||
if (typeof value === 'object' && typeof value.pipe === 'function') {
|
||||
return ''
|
||||
}
|
||||
if (Buffer.isBuffer(value)) {
|
||||
try {
|
||||
return value.toString('utf8')
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const text = safeToString(data)
|
||||
const msg = (text || '').toLowerCase()
|
||||
return (
|
||||
msg.includes('requested model is currently unavailable') ||
|
||||
msg.includes('tool_use') ||
|
||||
msg.includes('tool_result') ||
|
||||
msg.includes('requested entity was not found') ||
|
||||
msg.includes('not found')
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let lastError = null
|
||||
let retriedAfterDelay = false
|
||||
|
||||
const attemptRequest = async () => {
|
||||
for (let index = 0; index < endpoints.length; index += 1) {
|
||||
const baseUrl = endpoints[index]
|
||||
const url = `${baseUrl}/v1internal:${stream ? 'streamGenerateContent' : 'generateContent'}`
|
||||
|
||||
const axiosConfig = {
|
||||
url,
|
||||
method: 'POST',
|
||||
...(params ? { params } : {}),
|
||||
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||
data: envelope,
|
||||
timeout: stream ? 0 : timeoutMs || 600000,
|
||||
...(stream ? { responseType: 'stream' } : {})
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
if (index === 0) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Antigravity ${stream ? 'streamGenerateContent' : 'generateContent'}: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
axiosConfig.httpsAgent = keepAliveAgent
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
axiosConfig.signal = signal
|
||||
}
|
||||
|
||||
try {
|
||||
dumpAntigravityUpstreamRequest({
|
||||
requestId: envelope.requestId,
|
||||
model,
|
||||
stream,
|
||||
url,
|
||||
baseUrl,
|
||||
params: axiosConfig.params || null,
|
||||
headers: axiosConfig.headers,
|
||||
envelope
|
||||
}).catch(() => {})
|
||||
const response = await axios(axiosConfig)
|
||||
return { model, response }
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const status = error?.response?.status || null
|
||||
|
||||
const hasNext = index + 1 < endpoints.length
|
||||
if (hasNext && isRetryable(error)) {
|
||||
logger.warn('⚠️ Antigravity upstream error, retrying with fallback baseUrl', {
|
||||
status,
|
||||
from: baseUrl,
|
||||
to: endpoints[index + 1],
|
||||
model
|
||||
})
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Antigravity request failed')
|
||||
}
|
||||
|
||||
try {
|
||||
return await attemptRequest()
|
||||
} catch (error) {
|
||||
// 如果是 429 RESOURCE_EXHAUSTED 且尚未重试过,等待 2 秒后重试一次
|
||||
const status = error?.response?.status
|
||||
if (status === 429 && !retriedAfterDelay && !signal?.aborted) {
|
||||
const data = error?.response?.data
|
||||
|
||||
// 安全地将 data 转为字符串,避免 stream 对象导致循环引用崩溃
|
||||
const safeDataToString = (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
// stream 对象存在循环引用,不能 JSON.stringify
|
||||
if (typeof value === 'object' && typeof value.pipe === 'function') {
|
||||
return ''
|
||||
}
|
||||
if (Buffer.isBuffer(value)) {
|
||||
try {
|
||||
return value.toString('utf8')
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const msg = safeDataToString(data)
|
||||
if (
|
||||
msg.toLowerCase().includes('resource_exhausted') ||
|
||||
msg.toLowerCase().includes('no capacity')
|
||||
) {
|
||||
retriedAfterDelay = true
|
||||
logger.warn('⏳ Antigravity 429 RESOURCE_EXHAUSTED, waiting 2s before retry', { model })
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
return await attemptRequest()
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAvailableModels({ accessToken, proxyConfig = null, timeoutMs = 30000 }) {
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
const endpoints = getAntigravityApiUrlCandidates()
|
||||
|
||||
let lastError = null
|
||||
for (let index = 0; index < endpoints.length; index += 1) {
|
||||
const baseUrl = endpoints[index]
|
||||
const url = `${baseUrl}/v1internal:fetchAvailableModels`
|
||||
|
||||
const axiosConfig = {
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||
data: {},
|
||||
timeout: timeoutMs
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
if (index === 0) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Antigravity fetchAvailableModels: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
axiosConfig.httpsAgent = keepAliveAgent
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios(axiosConfig)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const status = error?.response?.status
|
||||
const hasNext = index + 1 < endpoints.length
|
||||
if (hasNext && (status === 429 || status === 404)) {
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Antigravity fetchAvailableModels failed')
|
||||
}
|
||||
|
||||
async function countTokens({
|
||||
accessToken,
|
||||
proxyConfig = null,
|
||||
contents,
|
||||
model,
|
||||
timeoutMs = 30000
|
||||
}) {
|
||||
const upstreamModel = mapAntigravityUpstreamModel(model)
|
||||
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
const endpoints = getAntigravityApiUrlCandidates()
|
||||
|
||||
let lastError = null
|
||||
for (let index = 0; index < endpoints.length; index += 1) {
|
||||
const baseUrl = endpoints[index]
|
||||
const url = `${baseUrl}/v1internal:countTokens`
|
||||
const axiosConfig = {
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||
data: {
|
||||
request: {
|
||||
model: `models/${upstreamModel}`,
|
||||
contents
|
||||
}
|
||||
},
|
||||
timeout: timeoutMs
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
axiosConfig.httpsAgent = proxyAgent
|
||||
axiosConfig.proxy = false
|
||||
if (index === 0) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Antigravity countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
axiosConfig.httpsAgent = keepAliveAgent
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios(axiosConfig)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const status = error?.response?.status
|
||||
const hasNext = index + 1 < endpoints.length
|
||||
if (hasNext && (status === 429 || status === 404)) {
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Antigravity countTokens failed')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAntigravityApiUrl,
|
||||
getAntigravityApiUrlCandidates,
|
||||
getAntigravityHeaders,
|
||||
buildAntigravityEnvelope,
|
||||
request,
|
||||
fetchAvailableModels,
|
||||
countTokens
|
||||
}
|
||||
170
src/services/antigravityRelayService.js
Normal file
170
src/services/antigravityRelayService.js
Normal file
@@ -0,0 +1,170 @@
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService')
|
||||
const { normalizeAntigravityModelInput } = require('../utils/antigravityModel')
|
||||
const antigravityClient = require('./antigravityClient')
|
||||
|
||||
function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) {
|
||||
const requestedModel = normalizeAntigravityModelInput(model)
|
||||
const { contents, systemInstruction } = convertMessagesToGemini(messages)
|
||||
|
||||
const requestData = {
|
||||
model: requestedModel,
|
||||
request: {
|
||||
contents,
|
||||
generationConfig: {
|
||||
temperature,
|
||||
maxOutputTokens: maxTokens,
|
||||
candidateCount: 1,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
},
|
||||
...(sessionId ? { sessionId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
if (systemInstruction) {
|
||||
requestData.request.systemInstruction = { parts: [{ text: systemInstruction }] }
|
||||
}
|
||||
|
||||
return requestData
|
||||
}
|
||||
|
||||
async function* handleStreamResponse(response, model, apiKeyId, accountId) {
|
||||
let buffer = ''
|
||||
let totalUsage = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0
|
||||
}
|
||||
let usageRecorded = false
|
||||
|
||||
try {
|
||||
for await (const chunk of response.data) {
|
||||
buffer += chunk.toString()
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
let jsonData = line
|
||||
if (line.startsWith('data: ')) {
|
||||
jsonData = line.substring(6).trim()
|
||||
}
|
||||
|
||||
if (!jsonData || jsonData === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonData)
|
||||
const payload = data?.response || data
|
||||
|
||||
if (payload?.usageMetadata) {
|
||||
totalUsage = payload.usageMetadata
|
||||
}
|
||||
|
||||
const openaiChunk = convertGeminiResponse(payload, model, true)
|
||||
if (openaiChunk) {
|
||||
yield `data: ${JSON.stringify(openaiChunk)}\n\n`
|
||||
const finishReason = openaiChunk.choices?.[0]?.finish_reason
|
||||
if (finishReason === 'stop') {
|
||||
yield 'data: [DONE]\n\n'
|
||||
|
||||
if (apiKeyId && totalUsage.totalTokenCount > 0) {
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyId,
|
||||
totalUsage.promptTokenCount || 0,
|
||||
totalUsage.candidatesTokenCount || 0,
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
)
|
||||
usageRecorded = true
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore chunk parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!usageRecorded && apiKeyId && totalUsage.totalTokenCount > 0) {
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyId,
|
||||
totalUsage.promptTokenCount || 0,
|
||||
totalUsage.candidatesTokenCount || 0,
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAntigravityRequest({
|
||||
messages,
|
||||
model,
|
||||
temperature = 0.7,
|
||||
maxTokens = 4096,
|
||||
stream = false,
|
||||
accessToken,
|
||||
proxy,
|
||||
apiKeyId,
|
||||
signal,
|
||||
projectId,
|
||||
accountId = null
|
||||
}) {
|
||||
const requestedModel = normalizeAntigravityModelInput(model)
|
||||
|
||||
const requestData = buildRequestData({
|
||||
messages,
|
||||
model: requestedModel,
|
||||
temperature,
|
||||
maxTokens,
|
||||
sessionId: apiKeyId
|
||||
})
|
||||
|
||||
const { response } = await antigravityClient.request({
|
||||
accessToken,
|
||||
proxyConfig: proxy,
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId: apiKeyId,
|
||||
stream,
|
||||
signal,
|
||||
params: { alt: 'sse' }
|
||||
})
|
||||
|
||||
if (stream) {
|
||||
return handleStreamResponse(response, requestedModel, apiKeyId, accountId)
|
||||
}
|
||||
|
||||
const payload = response.data?.response || response.data
|
||||
const openaiResponse = convertGeminiResponse(payload, requestedModel, false)
|
||||
|
||||
if (apiKeyId && openaiResponse?.usage) {
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyId,
|
||||
openaiResponse.usage.prompt_tokens || 0,
|
||||
openaiResponse.usage.completion_tokens || 0,
|
||||
0,
|
||||
0,
|
||||
requestedModel,
|
||||
accountId
|
||||
)
|
||||
}
|
||||
|
||||
return openaiResponse
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendAntigravityRequest
|
||||
}
|
||||
@@ -3,6 +3,7 @@ const { v4: uuidv4 } = require('uuid')
|
||||
const config = require('../../config/config')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||
|
||||
const ACCOUNT_TYPE_CONFIG = {
|
||||
claude: { prefix: 'claude:account:' },
|
||||
@@ -37,6 +38,58 @@ const ACCOUNT_CATEGORY_MAP = {
|
||||
droid: 'droid'
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化权限数据,兼容旧格式(字符串)和新格式(数组)
|
||||
* @param {string|array} permissions - 权限数据
|
||||
* @returns {array} - 权限数组,空数组表示全部服务
|
||||
*/
|
||||
function normalizePermissions(permissions) {
|
||||
if (!permissions) {
|
||||
return [] // 空 = 全部服务
|
||||
}
|
||||
if (Array.isArray(permissions)) {
|
||||
return permissions
|
||||
}
|
||||
// 尝试解析 JSON 字符串(新格式存储)
|
||||
if (typeof permissions === 'string') {
|
||||
if (permissions.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(permissions)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,继续处理为普通字符串
|
||||
}
|
||||
}
|
||||
// 旧格式 'all' 转为空数组
|
||||
if (permissions === 'all') {
|
||||
return []
|
||||
}
|
||||
// 兼容逗号分隔格式(修复历史错误数据,如 "claude,openai")
|
||||
if (permissions.includes(',')) {
|
||||
return permissions
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
// 旧单个字符串转为数组
|
||||
return [permissions]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有访问特定服务的权限
|
||||
* @param {string|array} permissions - 权限数据
|
||||
* @param {string} service - 服务名称(claude/gemini/openai/droid)
|
||||
* @returns {boolean} - 是否有权限
|
||||
*/
|
||||
function hasPermission(permissions, service) {
|
||||
const perms = normalizePermissions(permissions)
|
||||
return perms.length === 0 || perms.includes(service) // 空数组 = 全部服务
|
||||
}
|
||||
|
||||
function normalizeAccountTypeKey(type) {
|
||||
if (!type) {
|
||||
return null
|
||||
@@ -89,7 +142,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId = null,
|
||||
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
||||
droidAccountId = null,
|
||||
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all'
|
||||
permissions = [], // 数组格式,空数组表示全部服务,如 ['claude', 'gemini']
|
||||
isActive = true,
|
||||
concurrencyLimit = 0,
|
||||
rateLimitWindow = null,
|
||||
@@ -132,7 +185,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
||||
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
||||
droidAccountId: droidAccountId || '',
|
||||
permissions: permissions || 'all',
|
||||
permissions: JSON.stringify(normalizePermissions(permissions)),
|
||||
enableModelRestriction: String(enableModelRestriction),
|
||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||
enableClientRestriction: String(enableClientRestriction || false),
|
||||
@@ -186,7 +239,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
permissions: keyData.permissions,
|
||||
permissions: normalizePermissions(keyData.permissions),
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
@@ -338,7 +391,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: normalizePermissions(keyData.permissions),
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||
@@ -467,7 +520,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId,
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: normalizePermissions(keyData.permissions),
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||
@@ -525,7 +578,7 @@ class ApiKeyService {
|
||||
key.isActive = key.isActive === 'true'
|
||||
key.enableModelRestriction = key.enableModelRestriction === 'true'
|
||||
key.enableClientRestriction = key.enableClientRestriction === 'true'
|
||||
key.permissions = key.permissions || 'all' // 兼容旧数据
|
||||
key.permissions = normalizePermissions(key.permissions)
|
||||
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
|
||||
key.totalCostLimit = parseFloat(key.totalCostLimit || 0)
|
||||
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
|
||||
@@ -708,6 +761,9 @@ class ApiKeyService {
|
||||
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
|
||||
// 特殊处理数组字段
|
||||
updatedData[field] = JSON.stringify(value || [])
|
||||
} else if (field === 'permissions') {
|
||||
// 权限字段:规范化后JSON序列化,与createApiKey保持一致
|
||||
updatedData[field] = JSON.stringify(normalizePermissions(value))
|
||||
} else if (
|
||||
field === 'enableModelRestriction' ||
|
||||
field === 'enableClientRestriction' ||
|
||||
@@ -974,6 +1030,9 @@ class ApiKeyService {
|
||||
logger.database(
|
||||
`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`
|
||||
)
|
||||
|
||||
// 记录 Claude 周费用(如果适用)
|
||||
await this.recordClaudeWeeklyCost(keyId, costInfo.costs.total, model, null)
|
||||
} else {
|
||||
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
|
||||
}
|
||||
@@ -1037,35 +1096,31 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户)
|
||||
async recordOpusCost(keyId, cost, model, accountType) {
|
||||
// 📊 记录 Claude 模型周费用(API Key 维度)
|
||||
async recordClaudeWeeklyCost(keyId, cost, model, accountType) {
|
||||
try {
|
||||
// 判断是否为 Opus 模型
|
||||
if (!model || !model.toLowerCase().includes('claude-opus')) {
|
||||
return // 不是 Opus 模型,直接返回
|
||||
// 判断是否为 Claude 系列模型(包含 Bedrock 格式等)
|
||||
if (!isClaudeFamilyModel(model)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 判断是否为 claude、claude-console 或 ccr 账户
|
||||
if (
|
||||
!accountType ||
|
||||
(accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr')
|
||||
) {
|
||||
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`)
|
||||
return // 不是 claude 账户,直接返回
|
||||
}
|
||||
|
||||
// 记录 Opus 周费用
|
||||
// 记录 Claude 周费用
|
||||
await redis.incrementWeeklyOpusCost(keyId, cost)
|
||||
logger.database(
|
||||
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(
|
||||
`💰 Recorded Claude weekly cost for ${keyId}: $${cost.toFixed(
|
||||
6
|
||||
)}, model: ${model}, account type: ${accountType}`
|
||||
)}, model: ${model}${accountType ? `, account type: ${accountType}` : ''}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to record Opus cost:', error)
|
||||
logger.error('❌ Failed to record Claude weekly cost:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 向后兼容:旧名字是 Opus-only 口径;现在周费用统计已扩展为 Claude 全模型口径。
|
||||
async recordOpusCost(keyId, cost, model, accountType) {
|
||||
return this.recordClaudeWeeklyCost(keyId, cost, model, accountType)
|
||||
}
|
||||
|
||||
// 📊 记录使用情况(新版本,支持详细的缓存类型)
|
||||
async recordUsageWithDetails(
|
||||
keyId,
|
||||
@@ -1165,8 +1220,8 @@ class ApiKeyService {
|
||||
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
|
||||
)
|
||||
|
||||
// 记录 Opus 周费用(如果适用)
|
||||
await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType)
|
||||
// 记录 Claude 周费用(如果适用)
|
||||
await this.recordClaudeWeeklyCost(keyId, costInfo.totalCost, model, accountType)
|
||||
|
||||
// 记录详细的缓存费用(如果有)
|
||||
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
|
||||
@@ -1568,7 +1623,7 @@ class ApiKeyService {
|
||||
userId: keyData.userId,
|
||||
userUsername: keyData.userUsername,
|
||||
createdBy: keyData.createdBy,
|
||||
permissions: keyData.permissions,
|
||||
permissions: normalizePermissions(keyData.permissions),
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
||||
// 所有平台账户绑定字段
|
||||
@@ -1820,4 +1875,8 @@ const apiKeyService = new ApiKeyService()
|
||||
// 为了方便其他服务调用,导出 recordUsage 方法
|
||||
apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService)
|
||||
|
||||
// 导出权限辅助函数供路由使用
|
||||
apiKeyService.hasPermission = hasPermission
|
||||
apiKeyService.normalizePermissions = normalizePermissions
|
||||
|
||||
module.exports = apiKeyService
|
||||
|
||||
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
@@ -0,0 +1,133 @@
|
||||
const axios = require('axios')
|
||||
const logger = require('../../utils/logger')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
|
||||
/**
|
||||
* Provider 抽象基类
|
||||
* 各平台 Provider 需继承并实现 queryBalance(account)
|
||||
*/
|
||||
class BaseBalanceProvider {
|
||||
constructor(platform) {
|
||||
this.platform = platform
|
||||
this.logger = logger
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询余额(抽象方法)
|
||||
* @param {object} account - 账户对象
|
||||
* @returns {Promise<object>}
|
||||
* 形如:
|
||||
* {
|
||||
* balance: number|null,
|
||||
* currency?: string,
|
||||
* quota?: { daily, used, remaining, resetAt, percentage, unlimited? },
|
||||
* queryMethod?: 'api'|'field'|'local',
|
||||
* rawData?: any
|
||||
* }
|
||||
*/
|
||||
async queryBalance(_account) {
|
||||
throw new Error('queryBalance 方法必须由子类实现')
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 HTTP 请求方法(支持代理)
|
||||
* @param {string} url
|
||||
* @param {object} options
|
||||
* @param {object} account
|
||||
*/
|
||||
async makeRequest(url, options = {}, account = {}) {
|
||||
const config = {
|
||||
url,
|
||||
method: options.method || 'GET',
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 15000,
|
||||
data: options.data,
|
||||
params: options.params,
|
||||
responseType: options.responseType
|
||||
}
|
||||
|
||||
const proxyConfig = account.proxyConfig || account.proxy
|
||||
if (proxyConfig) {
|
||||
const agent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||
if (agent) {
|
||||
config.httpAgent = agent
|
||||
config.httpsAgent = agent
|
||||
config.proxy = false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios(config)
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
status: response.status,
|
||||
headers: response.headers
|
||||
}
|
||||
} catch (error) {
|
||||
const status = error.response?.status
|
||||
const message = error.response?.data?.message || error.message || '请求失败'
|
||||
this.logger.debug(`余额 Provider HTTP 请求失败: ${url} (${this.platform})`, {
|
||||
status,
|
||||
message
|
||||
})
|
||||
return { success: false, status, error: message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从账户字段读取 dailyQuota / dailyUsage(通用降级方案)
|
||||
* 注意:部分平台 dailyUsage 字段可能不是实时值,最终以 AccountBalanceService 的本地统计为准
|
||||
*/
|
||||
readQuotaFromFields(account) {
|
||||
const dailyQuota = Number(account?.dailyQuota || 0)
|
||||
const dailyUsage = Number(account?.dailyUsage || 0)
|
||||
|
||||
// 无限制
|
||||
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: {
|
||||
daily: Infinity,
|
||||
used: Number.isFinite(dailyUsage) ? dailyUsage : 0,
|
||||
remaining: Infinity,
|
||||
percentage: 0,
|
||||
unlimited: true
|
||||
},
|
||||
queryMethod: 'field'
|
||||
}
|
||||
}
|
||||
|
||||
const used = Number.isFinite(dailyUsage) ? dailyUsage : 0
|
||||
const remaining = Math.max(0, dailyQuota - used)
|
||||
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
|
||||
|
||||
return {
|
||||
balance: remaining,
|
||||
currency: 'USD',
|
||||
quota: {
|
||||
daily: dailyQuota,
|
||||
used,
|
||||
remaining,
|
||||
percentage: Math.round(percentage * 100) / 100
|
||||
},
|
||||
queryMethod: 'field'
|
||||
}
|
||||
}
|
||||
|
||||
parseCurrency(data) {
|
||||
return data?.currency || data?.Currency || 'USD'
|
||||
}
|
||||
|
||||
async safeExecute(fn, fallbackValue = null) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
this.logger.error(`余额 Provider 执行失败: ${this.platform}`, error)
|
||||
return fallbackValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseBalanceProvider
|
||||
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||
const claudeAccountService = require('../claudeAccountService')
|
||||
|
||||
class ClaudeBalanceProvider extends BaseBalanceProvider {
|
||||
constructor() {
|
||||
super('claude')
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude(OAuth):优先尝试获取 OAuth usage(用于配额/使用信息),不强行提供余额金额
|
||||
*/
|
||||
async queryBalance(account) {
|
||||
this.logger.debug(`查询 Claude 余额(OAuth usage): ${account?.id}`)
|
||||
|
||||
// 仅 OAuth 账户可用;失败时降级
|
||||
const usageData = await claudeAccountService.fetchOAuthUsage(account.id).catch(() => null)
|
||||
if (!usageData) {
|
||||
return { balance: null, currency: 'USD', queryMethod: 'local' }
|
||||
}
|
||||
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
queryMethod: 'api',
|
||||
rawData: usageData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClaudeBalanceProvider
|
||||
@@ -0,0 +1,14 @@
|
||||
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||
|
||||
class ClaudeConsoleBalanceProvider extends BaseBalanceProvider {
|
||||
constructor() {
|
||||
super('claude-console')
|
||||
}
|
||||
|
||||
async queryBalance(account) {
|
||||
this.logger.debug(`查询 Claude Console 余额(字段): ${account?.id}`)
|
||||
return this.readQuotaFromFields(account)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClaudeConsoleBalanceProvider
|
||||
250
src/services/balanceProviders/geminiBalanceProvider.js
Normal file
250
src/services/balanceProviders/geminiBalanceProvider.js
Normal file
@@ -0,0 +1,250 @@
|
||||
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||
const antigravityClient = require('../antigravityClient')
|
||||
const geminiAccountService = require('../geminiAccountService')
|
||||
|
||||
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||
|
||||
function clamp01(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
if (value < 0) {
|
||||
return 0
|
||||
}
|
||||
if (value > 1) {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function round2(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
function normalizeQuotaCategory(displayName, modelId) {
|
||||
const name = String(displayName || '')
|
||||
const id = String(modelId || '')
|
||||
|
||||
if (name.includes('Gemini') && name.includes('Pro')) {
|
||||
return 'Gemini Pro'
|
||||
}
|
||||
if (name.includes('Gemini') && name.includes('Flash')) {
|
||||
return 'Gemini Flash'
|
||||
}
|
||||
if (name.includes('Gemini') && name.toLowerCase().includes('image')) {
|
||||
return 'Gemini Image'
|
||||
}
|
||||
|
||||
if (name.includes('Claude') || name.includes('GPT-OSS')) {
|
||||
return 'Claude'
|
||||
}
|
||||
|
||||
if (id.startsWith('gemini-3-pro-') || id.startsWith('gemini-2.5-pro')) {
|
||||
return 'Gemini Pro'
|
||||
}
|
||||
if (id.startsWith('gemini-3-flash') || id.startsWith('gemini-2.5-flash')) {
|
||||
return 'Gemini Flash'
|
||||
}
|
||||
if (id.includes('image')) {
|
||||
return 'Gemini Image'
|
||||
}
|
||||
if (id.includes('claude') || id.includes('gpt-oss')) {
|
||||
return 'Claude'
|
||||
}
|
||||
|
||||
return name || id || 'Unknown'
|
||||
}
|
||||
|
||||
function buildAntigravityQuota(modelsResponse) {
|
||||
const models = modelsResponse && typeof modelsResponse === 'object' ? modelsResponse.models : null
|
||||
|
||||
if (!models || typeof models !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const parseRemainingFraction = (quotaInfo) => {
|
||||
if (!quotaInfo || typeof quotaInfo !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw =
|
||||
quotaInfo.remainingFraction ??
|
||||
quotaInfo.remaining_fraction ??
|
||||
quotaInfo.remaining ??
|
||||
undefined
|
||||
|
||||
const num = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
|
||||
if (!Number.isFinite(num)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return clamp01(num)
|
||||
}
|
||||
|
||||
const allowedCategories = new Set(['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image'])
|
||||
const fixedOrder = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
|
||||
|
||||
const categoryMap = new Map()
|
||||
|
||||
for (const [modelId, modelDataRaw] of Object.entries(models)) {
|
||||
if (!modelDataRaw || typeof modelDataRaw !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const displayName = modelDataRaw.displayName || modelDataRaw.display_name || modelId
|
||||
const quotaInfo = modelDataRaw.quotaInfo || modelDataRaw.quota_info || null
|
||||
|
||||
const remainingFraction = parseRemainingFraction(quotaInfo)
|
||||
if (remainingFraction === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
const remainingPercent = round2(remainingFraction * 100)
|
||||
const usedPercent = round2(100 - remainingPercent)
|
||||
const resetAt = quotaInfo?.resetTime || quotaInfo?.reset_time || null
|
||||
|
||||
const category = normalizeQuotaCategory(displayName, modelId)
|
||||
if (!allowedCategories.has(category)) {
|
||||
continue
|
||||
}
|
||||
const entry = {
|
||||
category,
|
||||
modelId,
|
||||
displayName: String(displayName || modelId || category),
|
||||
remainingPercent,
|
||||
usedPercent,
|
||||
resetAt: typeof resetAt === 'string' && resetAt.trim() ? resetAt : null
|
||||
}
|
||||
|
||||
const existing = categoryMap.get(category)
|
||||
if (!existing || entry.remainingPercent < existing.remainingPercent) {
|
||||
categoryMap.set(category, entry)
|
||||
}
|
||||
}
|
||||
|
||||
const buckets = fixedOrder.map((category) => {
|
||||
const existing = categoryMap.get(category) || null
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
return {
|
||||
category,
|
||||
modelId: '',
|
||||
displayName: category,
|
||||
remainingPercent: null,
|
||||
usedPercent: null,
|
||||
resetAt: null
|
||||
}
|
||||
})
|
||||
|
||||
if (buckets.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const critical = buckets
|
||||
.filter((item) => item.remainingPercent !== null)
|
||||
.reduce((min, item) => {
|
||||
if (!min) {
|
||||
return item
|
||||
}
|
||||
return (item.remainingPercent ?? 0) < (min.remainingPercent ?? 0) ? item : min
|
||||
}, null)
|
||||
|
||||
if (!critical) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: {
|
||||
type: 'antigravity',
|
||||
total: 100,
|
||||
used: critical.usedPercent,
|
||||
remaining: critical.remainingPercent,
|
||||
percentage: critical.usedPercent,
|
||||
resetAt: critical.resetAt,
|
||||
buckets: buckets.map((item) => ({
|
||||
category: item.category,
|
||||
remaining: item.remainingPercent,
|
||||
used: item.usedPercent,
|
||||
percentage: item.usedPercent,
|
||||
resetAt: item.resetAt
|
||||
}))
|
||||
},
|
||||
queryMethod: 'api',
|
||||
rawData: {
|
||||
modelsCount: Object.keys(models).length,
|
||||
bucketCount: buckets.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GeminiBalanceProvider extends BaseBalanceProvider {
|
||||
constructor() {
|
||||
super('gemini')
|
||||
}
|
||||
|
||||
async queryBalance(account) {
|
||||
const oauthProvider = account?.oauthProvider
|
||||
if (oauthProvider !== OAUTH_PROVIDER_ANTIGRAVITY) {
|
||||
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||
return this.readQuotaFromFields(account)
|
||||
}
|
||||
return { balance: null, currency: 'USD', queryMethod: 'local' }
|
||||
}
|
||||
|
||||
const accessToken = String(account?.accessToken || '').trim()
|
||||
const refreshToken = String(account?.refreshToken || '').trim()
|
||||
const proxyConfig = account?.proxyConfig || account?.proxy || null
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Antigravity 账户缺少 accessToken')
|
||||
}
|
||||
|
||||
const fetch = async (token) =>
|
||||
await antigravityClient.fetchAvailableModels({
|
||||
accessToken: token,
|
||||
proxyConfig
|
||||
})
|
||||
|
||||
let data
|
||||
try {
|
||||
data = await fetch(accessToken)
|
||||
} catch (error) {
|
||||
const status = error?.response?.status
|
||||
if ((status === 401 || status === 403) && refreshToken) {
|
||||
const refreshed = await geminiAccountService.refreshAccessToken(
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
OAUTH_PROVIDER_ANTIGRAVITY
|
||||
)
|
||||
const nextToken = String(refreshed?.access_token || '').trim()
|
||||
if (!nextToken) {
|
||||
throw error
|
||||
}
|
||||
data = await fetch(nextToken)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const mapped = buildAntigravityQuota(data)
|
||||
if (!mapped) {
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: null,
|
||||
queryMethod: 'api',
|
||||
rawData: data || null
|
||||
}
|
||||
}
|
||||
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GeminiBalanceProvider
|
||||
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||
|
||||
class GenericBalanceProvider extends BaseBalanceProvider {
|
||||
constructor(platform) {
|
||||
super(platform)
|
||||
}
|
||||
|
||||
async queryBalance(account) {
|
||||
this.logger.debug(`${this.platform} 暂无专用余额 API,实现降级策略`)
|
||||
|
||||
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||
return this.readQuotaFromFields(account)
|
||||
}
|
||||
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
queryMethod: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GenericBalanceProvider
|
||||
25
src/services/balanceProviders/index.js
Normal file
25
src/services/balanceProviders/index.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const ClaudeBalanceProvider = require('./claudeBalanceProvider')
|
||||
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
|
||||
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
|
||||
const GenericBalanceProvider = require('./genericBalanceProvider')
|
||||
const GeminiBalanceProvider = require('./geminiBalanceProvider')
|
||||
|
||||
function registerAllProviders(balanceService) {
|
||||
// Claude
|
||||
balanceService.registerProvider('claude', new ClaudeBalanceProvider())
|
||||
balanceService.registerProvider('claude-console', new ClaudeConsoleBalanceProvider())
|
||||
|
||||
// OpenAI / Codex
|
||||
balanceService.registerProvider('openai-responses', new OpenAIResponsesBalanceProvider())
|
||||
balanceService.registerProvider('openai', new GenericBalanceProvider('openai'))
|
||||
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
|
||||
|
||||
// 其他平台(降级)
|
||||
balanceService.registerProvider('gemini', new GeminiBalanceProvider())
|
||||
balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api'))
|
||||
balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock'))
|
||||
balanceService.registerProvider('droid', new GenericBalanceProvider('droid'))
|
||||
balanceService.registerProvider('ccr', new GenericBalanceProvider('ccr'))
|
||||
}
|
||||
|
||||
module.exports = { registerAllProviders }
|
||||
@@ -0,0 +1,54 @@
|
||||
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||
|
||||
class OpenAIResponsesBalanceProvider extends BaseBalanceProvider {
|
||||
constructor() {
|
||||
super('openai-responses')
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-Responses:
|
||||
* - 优先使用 dailyQuota 字段(如果配置了额度)
|
||||
* - 可选:尝试调用兼容 API(不同服务商实现不一,失败自动降级)
|
||||
*/
|
||||
async queryBalance(account) {
|
||||
this.logger.debug(`查询 OpenAI Responses 余额: ${account?.id}`)
|
||||
|
||||
// 配置了额度时直接返回(字段法)
|
||||
if (account?.dailyQuota && Number(account.dailyQuota) > 0) {
|
||||
return this.readQuotaFromFields(account)
|
||||
}
|
||||
|
||||
// 尝试调用 usage 接口(兼容性不保证)
|
||||
if (account?.apiKey && account?.baseApi) {
|
||||
const baseApi = String(account.baseApi).replace(/\/$/, '')
|
||||
const response = await this.makeRequest(
|
||||
`${baseApi}/v1/usage`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${account.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
account
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
return {
|
||||
balance: null,
|
||||
currency: this.parseCurrency(response.data),
|
||||
queryMethod: 'api',
|
||||
rawData: response.data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
queryMethod: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OpenAIResponsesBalanceProvider
|
||||
210
src/services/balanceScriptService.js
Normal file
210
src/services/balanceScriptService.js
Normal file
@@ -0,0 +1,210 @@
|
||||
const vm = require('vm')
|
||||
const axios = require('axios')
|
||||
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||
|
||||
/**
|
||||
* SSRF防护:检查URL是否访问内网或敏感地址
|
||||
* @param {string} url - 要检查的URL
|
||||
* @returns {boolean} - true表示URL安全
|
||||
*/
|
||||
function isUrlSafe(url) {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
|
||||
// 禁止的协议
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁止访问localhost和私有IP
|
||||
const privatePatterns = [
|
||||
/^localhost$/i,
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./, // AWS metadata
|
||||
/^0\./, // 0.0.0.0
|
||||
/^::1$/,
|
||||
/^fc00:/i,
|
||||
/^fe80:/i,
|
||||
/\.local$/i,
|
||||
/\.internal$/i,
|
||||
/\.localhost$/i
|
||||
]
|
||||
|
||||
for (const pattern of privatePatterns) {
|
||||
if (pattern.test(hostname)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可配置脚本余额查询执行器
|
||||
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
|
||||
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
|
||||
*/
|
||||
class BalanceScriptService {
|
||||
/**
|
||||
* 执行脚本:返回标准余额结构 + 原始响应
|
||||
* @param {object} options
|
||||
* - scriptBody: string
|
||||
* - variables: Record<string,string>
|
||||
* - timeoutSeconds: number
|
||||
*/
|
||||
async execute(options = {}) {
|
||||
if (!isBalanceScriptEnabled()) {
|
||||
const error = new Error('余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)')
|
||||
error.code = 'BALANCE_SCRIPT_DISABLED'
|
||||
throw error
|
||||
}
|
||||
|
||||
const scriptBody = options.scriptBody?.trim()
|
||||
if (!scriptBody) {
|
||||
throw new Error('脚本内容为空')
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(1, (options.timeoutSeconds || 10) * 1000)
|
||||
const sandbox = {
|
||||
console,
|
||||
Math,
|
||||
Date
|
||||
}
|
||||
|
||||
let scriptResult
|
||||
try {
|
||||
const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})`
|
||||
const script = new vm.Script(wrapped)
|
||||
scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs })
|
||||
} catch (error) {
|
||||
throw new Error(`脚本解析失败: ${error.message}`)
|
||||
}
|
||||
|
||||
if (!scriptResult || typeof scriptResult !== 'object') {
|
||||
throw new Error('脚本返回格式无效(需返回 { request, extractor })')
|
||||
}
|
||||
|
||||
const variables = options.variables || {}
|
||||
const request = this.applyTemplates(scriptResult.request || {}, variables)
|
||||
const { extractor } = scriptResult
|
||||
|
||||
if (!request?.url || typeof request.url !== 'string') {
|
||||
throw new Error('脚本 request.url 不能为空')
|
||||
}
|
||||
|
||||
// SSRF防护:验证URL安全性
|
||||
if (!isUrlSafe(request.url)) {
|
||||
throw new Error('脚本 request.url 不安全:禁止访问内网地址、localhost或使用非HTTP(S)协议')
|
||||
}
|
||||
|
||||
if (typeof extractor !== 'function') {
|
||||
throw new Error('脚本 extractor 必须是函数')
|
||||
}
|
||||
|
||||
const axiosConfig = {
|
||||
url: request.url,
|
||||
method: (request.method || 'GET').toUpperCase(),
|
||||
headers: request.headers || {},
|
||||
timeout: timeoutMs
|
||||
}
|
||||
|
||||
if (request.params) {
|
||||
axiosConfig.params = request.params
|
||||
}
|
||||
if (request.body || request.data) {
|
||||
axiosConfig.data = request.body || request.data
|
||||
}
|
||||
|
||||
let httpResponse
|
||||
try {
|
||||
httpResponse = await axios(axiosConfig)
|
||||
} catch (error) {
|
||||
const { response } = error || {}
|
||||
const { status, data } = response || {}
|
||||
throw new Error(
|
||||
`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
const responseData = httpResponse?.data
|
||||
|
||||
let extracted = {}
|
||||
try {
|
||||
extracted = extractor(responseData) || {}
|
||||
} catch (error) {
|
||||
throw new Error(`extractor 执行失败: ${error.message}`)
|
||||
}
|
||||
|
||||
const mapped = this.mapExtractorResult(extracted, responseData)
|
||||
return {
|
||||
mapped,
|
||||
extracted,
|
||||
response: {
|
||||
status: httpResponse?.status,
|
||||
headers: httpResponse?.headers,
|
||||
data: responseData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyTemplates(value, variables) {
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(/{{(\w+)}}/g, (_, key) => {
|
||||
const trimmed = key.trim()
|
||||
return variables[trimmed] !== undefined ? String(variables[trimmed]) : ''
|
||||
})
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.applyTemplates(item, variables))
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const result = {}
|
||||
Object.keys(value).forEach((k) => {
|
||||
result[k] = this.applyTemplates(value[k], variables)
|
||||
})
|
||||
return result
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
mapExtractorResult(result = {}, responseData) {
|
||||
const isValid = result.isValid !== false
|
||||
const remaining = Number(result.remaining)
|
||||
const total = Number(result.total)
|
||||
const used = Number(result.used)
|
||||
const currency = result.unit || 'USD'
|
||||
|
||||
const quota =
|
||||
Number.isFinite(total) || Number.isFinite(used)
|
||||
? {
|
||||
total: Number.isFinite(total) ? total : null,
|
||||
used: Number.isFinite(used) ? used : null,
|
||||
remaining: Number.isFinite(remaining) ? remaining : null,
|
||||
percentage:
|
||||
Number.isFinite(total) && total > 0 && Number.isFinite(used)
|
||||
? (used / total) * 100
|
||||
: null
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
status: isValid ? 'success' : 'error',
|
||||
errorMessage: isValid ? '' : result.invalidMessage || '套餐无效',
|
||||
balance: Number.isFinite(remaining) ? remaining : null,
|
||||
currency,
|
||||
quota,
|
||||
planName: result.planName || null,
|
||||
extra: result.extra || null,
|
||||
rawData: responseData || result.raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new BalanceScriptService()
|
||||
@@ -35,12 +35,13 @@ class BedrockAccountService {
|
||||
description = '',
|
||||
region = process.env.AWS_REGION || 'us-east-1',
|
||||
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
|
||||
bearerToken = null, // AWS Bearer Token for Bedrock API Keys
|
||||
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
isActive = true,
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||||
schedulable = true, // 是否可被调度
|
||||
credentialType = 'default' // 'default', 'access_key', 'bearer_token'
|
||||
credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key)
|
||||
} = options
|
||||
|
||||
const accountId = uuidv4()
|
||||
@@ -71,6 +72,11 @@ class BedrockAccountService {
|
||||
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
|
||||
}
|
||||
|
||||
// 加密存储 Bearer Token
|
||||
if (bearerToken) {
|
||||
accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken })
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
|
||||
|
||||
@@ -106,9 +112,85 @@ class BedrockAccountService {
|
||||
|
||||
const account = JSON.parse(accountData)
|
||||
|
||||
// 解密AWS凭证用于内部使用
|
||||
if (account.awsCredentials) {
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
// 根据凭证类型解密对应的凭证
|
||||
// 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底)
|
||||
try {
|
||||
let accessKeyDecrypted = false
|
||||
let bearerTokenDecrypted = false
|
||||
|
||||
// 第一步:按照 credentialType 尝试解密对应的凭证
|
||||
if (account.credentialType === 'access_key' && account.awsCredentials) {
|
||||
// Access Key 模式:解密 AWS 凭证
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
accessKeyDecrypted = true
|
||||
logger.debug(
|
||||
`🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||
)
|
||||
} else if (account.credentialType === 'bearer_token' && account.bearerToken) {
|
||||
// Bearer Token 模式:解密 Bearer Token
|
||||
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||
account.bearerToken = decrypted.token
|
||||
bearerTokenDecrypted = true
|
||||
logger.debug(
|
||||
`🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||
)
|
||||
} else if (!account.credentialType || account.credentialType === 'default') {
|
||||
// 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证
|
||||
if (account.awsCredentials) {
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
accessKeyDecrypted = true
|
||||
}
|
||||
if (account.bearerToken) {
|
||||
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||
account.bearerToken = decrypted.token
|
||||
bearerTokenDecrypted = true
|
||||
}
|
||||
logger.debug(
|
||||
`🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}`
|
||||
)
|
||||
}
|
||||
|
||||
// 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段
|
||||
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||
logger.warn(
|
||||
`⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}`
|
||||
)
|
||||
if (account.awsCredentials) {
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
accessKeyDecrypted = true
|
||||
logger.warn(
|
||||
`🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'`
|
||||
)
|
||||
}
|
||||
if (account.bearerToken) {
|
||||
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||
account.bearerToken = decrypted.token
|
||||
bearerTokenDecrypted = true
|
||||
logger.warn(
|
||||
`🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证至少解密了一种凭证
|
||||
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||
logger.error(
|
||||
`❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: 'No valid credentials found in account data'
|
||||
}
|
||||
}
|
||||
} catch (decryptError) {
|
||||
logger.error(
|
||||
`❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`,
|
||||
decryptError
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: `Credentials decryption failed: ${decryptError.message}`
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
|
||||
@@ -155,7 +237,11 @@ class BedrockAccountService {
|
||||
updatedAt: account.updatedAt,
|
||||
type: 'bedrock',
|
||||
platform: 'bedrock',
|
||||
hasCredentials: !!account.awsCredentials
|
||||
// 根据凭证类型判断是否有凭证
|
||||
hasCredentials:
|
||||
account.credentialType === 'bearer_token'
|
||||
? !!account.bearerToken
|
||||
: !!account.awsCredentials
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -235,6 +321,15 @@ class BedrockAccountService {
|
||||
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
||||
}
|
||||
|
||||
// 更新 Bearer Token
|
||||
if (updates.bearerToken !== undefined) {
|
||||
if (updates.bearerToken) {
|
||||
account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken })
|
||||
} else {
|
||||
delete account.bearerToken
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
|
||||
if (updates.subscriptionExpiresAt !== undefined) {
|
||||
@@ -345,13 +440,45 @@ class BedrockAccountService {
|
||||
|
||||
const account = accountResult.data
|
||||
|
||||
logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`)
|
||||
logger.info(
|
||||
`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}`
|
||||
)
|
||||
|
||||
// 尝试获取模型列表来测试连接
|
||||
// 验证凭证是否已解密
|
||||
const hasValidCredentials =
|
||||
(account.credentialType === 'access_key' && account.awsCredentials) ||
|
||||
(account.credentialType === 'bearer_token' && account.bearerToken) ||
|
||||
(!account.credentialType && (account.awsCredentials || account.bearerToken))
|
||||
|
||||
if (!hasValidCredentials) {
|
||||
logger.error(
|
||||
`❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: 'No valid credentials found after decryption'
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试创建 Bedrock 客户端来验证凭证格式
|
||||
try {
|
||||
bedrockRelayService._getBedrockClient(account.region, account)
|
||||
logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`)
|
||||
} catch (clientError) {
|
||||
logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError)
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create Bedrock client: ${clientError.message}`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取可用模型列表(硬编码,但至少验证了凭证格式正确)
|
||||
const models = await bedrockRelayService.getAvailableModels(account)
|
||||
|
||||
if (models && models.length > 0) {
|
||||
logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`)
|
||||
logger.info(
|
||||
`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}`
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -376,6 +503,135 @@ class BedrockAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 测试 Bedrock 账户连接(SSE 流式返回,供前端测试页面使用)
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {Object} res - Express response 对象
|
||||
* @param {string} model - 测试使用的模型
|
||||
*/
|
||||
async testAccountConnection(accountId, res, model = null) {
|
||||
const { InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime')
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const accountResult = await this.getAccount(accountId)
|
||||
if (!accountResult.success) {
|
||||
throw new Error(accountResult.error || 'Account not found')
|
||||
}
|
||||
|
||||
const account = accountResult.data
|
||||
|
||||
// 根据账户类型选择合适的测试模型
|
||||
if (!model) {
|
||||
// Access Key 模式使用 Haiku(更快更便宜)
|
||||
model = account.defaultModel || 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🧪 Testing Bedrock account connection: ${account.name} (${accountId}), model: ${model}, credentialType: ${account.credentialType}`
|
||||
)
|
||||
|
||||
// 设置 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')
|
||||
res.status(200)
|
||||
|
||||
// 发送 test_start 事件
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_start' })}\n\n`)
|
||||
|
||||
// 构造测试请求体(Bedrock 格式)
|
||||
const bedrockPayload = {
|
||||
anthropic_version: 'bedrock-2023-05-31',
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'Hello! Please respond with a simple greeting to confirm the connection is working. And tell me who are you?'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 获取 Bedrock 客户端
|
||||
const region = account.region || bedrockRelayService.defaultRegion
|
||||
const client = bedrockRelayService._getBedrockClient(region, account)
|
||||
|
||||
// 创建流式调用命令
|
||||
const command = new InvokeModelWithResponseStreamCommand({
|
||||
modelId: model,
|
||||
body: JSON.stringify(bedrockPayload),
|
||||
contentType: 'application/json',
|
||||
accept: 'application/json'
|
||||
})
|
||||
|
||||
logger.debug(`🌊 Bedrock test stream - model: ${model}, region: ${region}`)
|
||||
|
||||
const startTime = Date.now()
|
||||
const response = await client.send(command)
|
||||
|
||||
// 处理流式响应
|
||||
// let responseText = ''
|
||||
for await (const chunk of response.body) {
|
||||
if (chunk.chunk) {
|
||||
const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes))
|
||||
|
||||
// 提取文本内容
|
||||
if (chunkData.type === 'content_block_delta' && chunkData.delta?.text) {
|
||||
const { text } = chunkData.delta
|
||||
// responseText += text
|
||||
|
||||
// 发送 content 事件
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
|
||||
}
|
||||
|
||||
// 检测错误
|
||||
if (chunkData.type === 'error') {
|
||||
throw new Error(chunkData.error?.message || 'Bedrock API error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`✅ Bedrock test completed - model: ${model}, duration: ${duration}ms`)
|
||||
|
||||
// 发送 message_stop 事件(前端兼容)
|
||||
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
|
||||
|
||||
// 发送 test_complete 事件
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||
|
||||
// 结束响应
|
||||
res.end()
|
||||
|
||||
logger.info(`✅ Test request completed for Bedrock account: ${account.name}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Test Bedrock account connection failed:`, error)
|
||||
|
||||
// 发送错误事件给前端
|
||||
try {
|
||||
// 检查响应流是否仍然可写
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
if (!res.headersSent) {
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.status(200)
|
||||
}
|
||||
const errorMsg = error.message || '测试失败'
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||
res.end()
|
||||
}
|
||||
} catch (writeError) {
|
||||
logger.error('Failed to write error to response stream:', writeError)
|
||||
}
|
||||
|
||||
// 不再重新抛出错误,避免路由层再次处理
|
||||
// throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户订阅是否过期
|
||||
* @param {Object} account - 账户对象
|
||||
|
||||
@@ -48,13 +48,17 @@ class BedrockRelayService {
|
||||
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
|
||||
sessionToken: bedrockAccount.awsCredentials.sessionToken
|
||||
}
|
||||
} else if (bedrockAccount?.bearerToken) {
|
||||
// Bearer Token 模式:AWS SDK >= 3.400.0 会自动检测环境变量
|
||||
clientConfig.token = { token: bedrockAccount.bearerToken }
|
||||
logger.debug(`🔑 使用 Bearer Token 认证 - 账户: ${bedrockAccount.name || 'unknown'}`)
|
||||
} else {
|
||||
// 检查是否有环境变量凭证
|
||||
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||
clientConfig.credentials = fromEnv()
|
||||
} else {
|
||||
throw new Error(
|
||||
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
|
||||
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或Bearer Token,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -339,8 +343,8 @@ class BedrockRelayService {
|
||||
res.write(`event: ${claudeEvent.type}\n`)
|
||||
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
|
||||
|
||||
// 提取使用统计
|
||||
if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) {
|
||||
// 提取使用统计 (usage is reported in message_delta per Claude API spec)
|
||||
if (claudeEvent.type === 'message_delta' && claudeEvent.data.usage) {
|
||||
totalUsage = claudeEvent.data.usage
|
||||
}
|
||||
|
||||
@@ -431,6 +435,18 @@ class BedrockRelayService {
|
||||
_mapToBedrockModel(modelName) {
|
||||
// 标准Claude模型名到Bedrock模型名的映射表
|
||||
const modelMapping = {
|
||||
// Claude 4.5 Opus
|
||||
'claude-opus-4-5': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
|
||||
'claude-opus-4-5-20251101': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
|
||||
|
||||
// Claude 4.5 Sonnet
|
||||
'claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||
'claude-sonnet-4-5-20250929': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||
|
||||
// Claude 4.5 Haiku
|
||||
'claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
'claude-haiku-4-5-20251001': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
|
||||
// Claude Sonnet 4
|
||||
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
@@ -560,14 +576,28 @@ class BedrockRelayService {
|
||||
return {
|
||||
type: 'message_start',
|
||||
data: {
|
||||
type: 'message',
|
||||
id: `msg_${Date.now()}_bedrock`,
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model: this.defaultModel,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: `msg_${Date.now()}_bedrock`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model: this.defaultModel,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bedrockChunk.type === 'content_block_start') {
|
||||
return {
|
||||
type: 'content_block_start',
|
||||
data: {
|
||||
type: 'content_block_start',
|
||||
index: bedrockChunk.index || 0,
|
||||
content_block: bedrockChunk.content_block || { type: 'text', text: '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,16 +606,28 @@ class BedrockRelayService {
|
||||
return {
|
||||
type: 'content_block_delta',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
index: bedrockChunk.index || 0,
|
||||
delta: bedrockChunk.delta || {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bedrockChunk.type === 'content_block_stop') {
|
||||
return {
|
||||
type: 'content_block_stop',
|
||||
data: {
|
||||
type: 'content_block_stop',
|
||||
index: bedrockChunk.index || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bedrockChunk.type === 'message_delta') {
|
||||
return {
|
||||
type: 'message_delta',
|
||||
data: {
|
||||
type: 'message_delta',
|
||||
delta: bedrockChunk.delta || {},
|
||||
usage: bedrockChunk.usage || {}
|
||||
}
|
||||
@@ -596,7 +638,7 @@ class BedrockRelayService {
|
||||
return {
|
||||
type: 'message_stop',
|
||||
data: {
|
||||
usage: bedrockChunk.usage || {}
|
||||
type: 'message_stop'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,51 +21,51 @@ const { isStreamWritable } = require('../utils/streamHelper')
|
||||
class ClaudeRelayService {
|
||||
constructor() {
|
||||
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
|
||||
// 🧹 内存优化:用于存储请求体字符串,避免闭包捕获
|
||||
this.bodyStore = new Map()
|
||||
this._bodyStoreIdCounter = 0
|
||||
this.apiVersion = config.claude.apiVersion
|
||||
this.betaHeader = config.claude.betaHeader
|
||||
this.systemPrompt = config.claude.systemPrompt
|
||||
this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
this.toolNameSuffix = null
|
||||
this.toolNameSuffixGeneratedAt = 0
|
||||
this.toolNameSuffixTtlMs = 60 * 60 * 1000
|
||||
}
|
||||
|
||||
// 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header
|
||||
// 规则:
|
||||
// 1. 如果客户端传递了 anthropic-beta,检查是否包含 oauth-2025-04-20
|
||||
// 2. 如果没有 oauth-2025-04-20,则添加到 claude-code-20250219 后面(如果有的话),否则放在第一位
|
||||
// 3. 如果客户端没传递,则根据模型判断:haiku 不需要 claude-code,其他模型需要
|
||||
_getBetaHeader(modelId, clientBetaHeader) {
|
||||
const OAUTH_BETA = 'oauth-2025-04-20'
|
||||
const CLAUDE_CODE_BETA = 'claude-code-20250219'
|
||||
const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'
|
||||
const TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14'
|
||||
|
||||
// 如果客户端传递了 anthropic-beta
|
||||
if (clientBetaHeader) {
|
||||
// 检查是否已包含 oauth-2025-04-20
|
||||
if (clientBetaHeader.includes(OAUTH_BETA)) {
|
||||
return clientBetaHeader
|
||||
}
|
||||
|
||||
// 需要添加 oauth-2025-04-20
|
||||
const parts = clientBetaHeader.split(',').map((p) => p.trim())
|
||||
|
||||
// 找到 claude-code-20250219 的位置
|
||||
const claudeCodeIndex = parts.findIndex((p) => p === CLAUDE_CODE_BETA)
|
||||
|
||||
if (claudeCodeIndex !== -1) {
|
||||
// 在 claude-code-20250219 后面插入
|
||||
parts.splice(claudeCodeIndex + 1, 0, OAUTH_BETA)
|
||||
} else {
|
||||
// 放在第一位
|
||||
parts.unshift(OAUTH_BETA)
|
||||
}
|
||||
|
||||
return parts.join(',')
|
||||
}
|
||||
|
||||
// 客户端没有传递,根据模型判断
|
||||
const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku')
|
||||
if (isHaikuModel) {
|
||||
return 'oauth-2025-04-20,interleaved-thinking-2025-05-14'
|
||||
const baseBetas = isHaikuModel
|
||||
? [OAUTH_BETA, INTERLEAVED_THINKING_BETA]
|
||||
: [CLAUDE_CODE_BETA, OAUTH_BETA, INTERLEAVED_THINKING_BETA, TOOL_STREAMING_BETA]
|
||||
|
||||
const betaList = []
|
||||
const seen = new Set()
|
||||
const addBeta = (beta) => {
|
||||
if (!beta || seen.has(beta)) {
|
||||
return
|
||||
}
|
||||
seen.add(beta)
|
||||
betaList.push(beta)
|
||||
}
|
||||
return 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||
|
||||
baseBetas.forEach(addBeta)
|
||||
|
||||
if (clientBetaHeader) {
|
||||
clientBetaHeader
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(addBeta)
|
||||
}
|
||||
|
||||
return betaList.join(',')
|
||||
}
|
||||
|
||||
_buildStandardRateLimitMessage(resetTime) {
|
||||
@@ -140,6 +140,235 @@ class ClaudeRelayService {
|
||||
return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1)
|
||||
}
|
||||
|
||||
_isClaudeCodeUserAgent(clientHeaders) {
|
||||
const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent']
|
||||
return typeof userAgent === 'string' && /^claude-cli\/[^\s]+\s+\(/i.test(userAgent)
|
||||
}
|
||||
|
||||
_isActualClaudeCodeRequest(requestBody, clientHeaders) {
|
||||
return this.isRealClaudeCodeRequest(requestBody) && this._isClaudeCodeUserAgent(clientHeaders)
|
||||
}
|
||||
|
||||
_getHeaderValueCaseInsensitive(headers, key) {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const lowerKey = key.toLowerCase()
|
||||
for (const candidate of Object.keys(headers)) {
|
||||
if (candidate.toLowerCase() === lowerKey) {
|
||||
return headers[candidate]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
_isClaudeCodeCredentialError(body) {
|
||||
const message = this._extractErrorMessage(body)
|
||||
if (!message) {
|
||||
return false
|
||||
}
|
||||
const lower = message.toLowerCase()
|
||||
return (
|
||||
lower.includes('only authorized for use with claude code') ||
|
||||
lower.includes('cannot be used for other api requests')
|
||||
)
|
||||
}
|
||||
|
||||
_toPascalCaseToolName(name) {
|
||||
const parts = name.split(/[_-]/).filter(Boolean)
|
||||
if (parts.length === 0) {
|
||||
return name
|
||||
}
|
||||
const pascal = parts
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('')
|
||||
return `${pascal}_tool`
|
||||
}
|
||||
|
||||
_getToolNameSuffix() {
|
||||
const now = Date.now()
|
||||
if (!this.toolNameSuffix || now - this.toolNameSuffixGeneratedAt > this.toolNameSuffixTtlMs) {
|
||||
this.toolNameSuffix = Math.random().toString(36).substring(2, 8)
|
||||
this.toolNameSuffixGeneratedAt = now
|
||||
}
|
||||
return this.toolNameSuffix
|
||||
}
|
||||
|
||||
_toRandomizedToolName(name) {
|
||||
const suffix = this._getToolNameSuffix()
|
||||
return `${name}_${suffix}`
|
||||
}
|
||||
|
||||
_transformToolNamesInRequestBody(body, options = {}) {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const useRandomized = options.useRandomizedToolNames === true
|
||||
const forwardMap = new Map()
|
||||
const reverseMap = new Map()
|
||||
|
||||
const transformName = (name) => {
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
return name
|
||||
}
|
||||
if (forwardMap.has(name)) {
|
||||
return forwardMap.get(name)
|
||||
}
|
||||
const transformed = useRandomized
|
||||
? this._toRandomizedToolName(name)
|
||||
: this._toPascalCaseToolName(name)
|
||||
if (transformed !== name) {
|
||||
forwardMap.set(name, transformed)
|
||||
reverseMap.set(transformed, name)
|
||||
}
|
||||
return transformed
|
||||
}
|
||||
|
||||
if (Array.isArray(body.tools)) {
|
||||
body.tools.forEach((tool) => {
|
||||
if (tool && typeof tool.name === 'string') {
|
||||
tool.name = transformName(tool.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (body.tool_choice && typeof body.tool_choice === 'object') {
|
||||
if (typeof body.tool_choice.name === 'string') {
|
||||
body.tool_choice.name = transformName(body.tool_choice.name)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(body.messages)) {
|
||||
body.messages.forEach((message) => {
|
||||
const content = message?.content
|
||||
if (Array.isArray(content)) {
|
||||
content.forEach((block) => {
|
||||
if (block?.type === 'tool_use' && typeof block.name === 'string') {
|
||||
block.name = transformName(block.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return reverseMap.size > 0 ? reverseMap : null
|
||||
}
|
||||
|
||||
_restoreToolName(name, toolNameMap) {
|
||||
if (!toolNameMap || toolNameMap.size === 0) {
|
||||
return name
|
||||
}
|
||||
return toolNameMap.get(name) || name
|
||||
}
|
||||
|
||||
_restoreToolNamesInContentBlocks(content, toolNameMap) {
|
||||
if (!Array.isArray(content)) {
|
||||
return
|
||||
}
|
||||
|
||||
content.forEach((block) => {
|
||||
if (block?.type === 'tool_use' && typeof block.name === 'string') {
|
||||
block.name = this._restoreToolName(block.name, toolNameMap)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_restoreToolNamesInResponseObject(responseBody, toolNameMap) {
|
||||
if (!responseBody || typeof responseBody !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(responseBody.content)) {
|
||||
this._restoreToolNamesInContentBlocks(responseBody.content, toolNameMap)
|
||||
}
|
||||
|
||||
if (responseBody.message && Array.isArray(responseBody.message.content)) {
|
||||
this._restoreToolNamesInContentBlocks(responseBody.message.content, toolNameMap)
|
||||
}
|
||||
}
|
||||
|
||||
_restoreToolNamesInResponseBody(responseBody, toolNameMap) {
|
||||
if (!responseBody || !toolNameMap || toolNameMap.size === 0) {
|
||||
return responseBody
|
||||
}
|
||||
|
||||
if (typeof responseBody === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody)
|
||||
this._restoreToolNamesInResponseObject(parsed, toolNameMap)
|
||||
return JSON.stringify(parsed)
|
||||
} catch (error) {
|
||||
return responseBody
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof responseBody === 'object') {
|
||||
this._restoreToolNamesInResponseObject(responseBody, toolNameMap)
|
||||
}
|
||||
|
||||
return responseBody
|
||||
}
|
||||
|
||||
_restoreToolNamesInStreamEvent(event, toolNameMap) {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.content_block && event.content_block.type === 'tool_use') {
|
||||
if (typeof event.content_block.name === 'string') {
|
||||
event.content_block.name = this._restoreToolName(event.content_block.name, toolNameMap)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.delta && event.delta.type === 'tool_use') {
|
||||
if (typeof event.delta.name === 'string') {
|
||||
event.delta.name = this._restoreToolName(event.delta.name, toolNameMap)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.message && Array.isArray(event.message.content)) {
|
||||
this._restoreToolNamesInContentBlocks(event.message.content, toolNameMap)
|
||||
}
|
||||
|
||||
if (Array.isArray(event.content)) {
|
||||
this._restoreToolNamesInContentBlocks(event.content, toolNameMap)
|
||||
}
|
||||
}
|
||||
|
||||
_createToolNameStripperStreamTransformer(streamTransformer, toolNameMap) {
|
||||
if (!toolNameMap || toolNameMap.size === 0) {
|
||||
return streamTransformer
|
||||
}
|
||||
|
||||
return (payload) => {
|
||||
const transformed = streamTransformer ? streamTransformer(payload) : payload
|
||||
if (!transformed || typeof transformed !== 'string') {
|
||||
return transformed
|
||||
}
|
||||
|
||||
const lines = transformed.split('\n')
|
||||
const updated = lines.map((line) => {
|
||||
if (!line.startsWith('data:')) {
|
||||
return line
|
||||
}
|
||||
const jsonStr = line.slice(5).trimStart()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
return line
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
this._restoreToolNamesInStreamEvent(data, toolNameMap)
|
||||
return `data: ${JSON.stringify(data)}`
|
||||
} catch (error) {
|
||||
return line
|
||||
}
|
||||
})
|
||||
|
||||
return updated.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 转发请求到Claude API
|
||||
async relayRequest(
|
||||
requestBody,
|
||||
@@ -153,6 +382,7 @@ class ClaudeRelayService {
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
let selectedAccountId = null
|
||||
let bodyStoreIdNonStream = null // 🧹 在 try 块外声明,以便 finally 清理
|
||||
|
||||
try {
|
||||
// 调试日志:查看API Key数据
|
||||
@@ -311,7 +541,12 @@ class ClaudeRelayService {
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||
|
||||
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
|
||||
const processedBody = this._processRequestBody(requestBody, account)
|
||||
// 🧹 内存优化:存储到 bodyStore,避免闭包捕获
|
||||
const originalBodyString = JSON.stringify(processedBody)
|
||||
bodyStoreIdNonStream = ++this._bodyStoreIdCounter
|
||||
this.bodyStore.set(bodyStoreIdNonStream, originalBodyString)
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId)
|
||||
@@ -332,36 +567,59 @@ class ClaudeRelayService {
|
||||
clientResponse.once('close', handleClientDisconnect)
|
||||
}
|
||||
|
||||
// 发送请求到Claude API(传入回调以获取请求对象)
|
||||
// 🔄 403 重试机制:仅对 claude-official 类型账户(OAuth 或 Setup Token)
|
||||
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
|
||||
let retryCount = 0
|
||||
let response
|
||||
let shouldRetry = false
|
||||
const makeRequestWithRetries = async (requestOptions) => {
|
||||
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
|
||||
let retryCount = 0
|
||||
let response
|
||||
let shouldRetry = false
|
||||
|
||||
do {
|
||||
response = await this._makeClaudeRequest(
|
||||
processedBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
accountId,
|
||||
(req) => {
|
||||
upstreamRequest = req
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
// 检查是否需要重试 403
|
||||
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
|
||||
if (shouldRetry) {
|
||||
retryCount++
|
||||
logger.warn(
|
||||
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
|
||||
do {
|
||||
// 🧹 每次重试从 bodyStore 解析新对象,避免闭包捕获
|
||||
let retryRequestBody
|
||||
try {
|
||||
retryRequestBody = JSON.parse(this.bodyStore.get(bodyStoreIdNonStream))
|
||||
} catch (parseError) {
|
||||
logger.error(`❌ Failed to parse body for retry: ${parseError.message}`)
|
||||
throw new Error(`Request body parse failed: ${parseError.message}`)
|
||||
}
|
||||
response = await this._makeClaudeRequest(
|
||||
retryRequestBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
accountId,
|
||||
(req) => {
|
||||
upstreamRequest = req
|
||||
},
|
||||
{
|
||||
...requestOptions,
|
||||
isRealClaudeCodeRequest
|
||||
}
|
||||
)
|
||||
await this._sleep(2000)
|
||||
}
|
||||
} while (shouldRetry)
|
||||
|
||||
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
|
||||
if (shouldRetry) {
|
||||
retryCount++
|
||||
logger.warn(
|
||||
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
|
||||
)
|
||||
await this._sleep(2000)
|
||||
}
|
||||
} while (shouldRetry)
|
||||
|
||||
return { response, retryCount }
|
||||
}
|
||||
|
||||
let requestOptions = options
|
||||
let { response, retryCount } = await makeRequestWithRetries(requestOptions)
|
||||
|
||||
if (
|
||||
this._isClaudeCodeCredentialError(response.body) &&
|
||||
requestOptions.useRandomizedToolNames !== true
|
||||
) {
|
||||
requestOptions = { ...requestOptions, useRandomizedToolNames: true }
|
||||
;({ response, retryCount } = await makeRequestWithRetries(requestOptions))
|
||||
}
|
||||
|
||||
// 如果进行了重试,记录最终结果
|
||||
if (retryCount > 0) {
|
||||
@@ -661,6 +919,10 @@ class ClaudeRelayService {
|
||||
)
|
||||
throw error
|
||||
} finally {
|
||||
// 🧹 清理 bodyStore
|
||||
if (bodyStoreIdNonStream !== null) {
|
||||
this.bodyStore.delete(bodyStoreIdNonStream)
|
||||
}
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
||||
try {
|
||||
@@ -1035,23 +1297,19 @@ class ClaudeRelayService {
|
||||
// 获取过滤后的客户端 headers
|
||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||
|
||||
// 判断是否是真实的 Claude Code 请求
|
||||
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
||||
const isRealClaudeCode =
|
||||
requestOptions.isRealClaudeCodeRequest === undefined
|
||||
? this.isRealClaudeCodeRequest(body)
|
||||
: requestOptions.isRealClaudeCodeRequest === true
|
||||
|
||||
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
||||
let finalHeaders = { ...filteredHeaders }
|
||||
let requestPayload = body
|
||||
|
||||
if (!isRealClaudeCode) {
|
||||
// 获取该账号存储的 Claude Code headers
|
||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||
|
||||
// 只添加客户端没有提供的 headers
|
||||
Object.keys(claudeCodeHeaders).forEach((key) => {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
||||
finalHeaders[key] = claudeCodeHeaders[key]
|
||||
}
|
||||
finalHeaders[key] = claudeCodeHeaders[key]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1073,6 +1331,13 @@ class ClaudeRelayService {
|
||||
requestPayload = extensionResult.body
|
||||
finalHeaders = extensionResult.headers
|
||||
|
||||
let toolNameMap = null
|
||||
if (!isRealClaudeCode) {
|
||||
toolNameMap = this._transformToolNamesInRequestBody(requestPayload, {
|
||||
useRandomizedToolNames: requestOptions.useRandomizedToolNames === true
|
||||
})
|
||||
}
|
||||
|
||||
// 序列化请求体,计算 content-length
|
||||
const bodyString = JSON.stringify(requestPayload)
|
||||
const contentLength = Buffer.byteLength(bodyString, 'utf8')
|
||||
@@ -1098,17 +1363,16 @@ class ClaudeRelayService {
|
||||
|
||||
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
||||
|
||||
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
||||
|
||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||
const modelId = requestPayload?.model || body?.model
|
||||
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
||||
const clientBetaHeader = this._getHeaderValueCaseInsensitive(clientHeaders, 'anthropic-beta')
|
||||
headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
||||
return {
|
||||
requestPayload,
|
||||
bodyString,
|
||||
headers,
|
||||
isRealClaudeCode
|
||||
isRealClaudeCode,
|
||||
toolNameMap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1174,7 +1438,8 @@ class ClaudeRelayService {
|
||||
return prepared.abortResponse
|
||||
}
|
||||
|
||||
const { bodyString, headers } = prepared
|
||||
let { bodyString } = prepared
|
||||
const { headers, isRealClaudeCode, toolNameMap } = prepared
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 支持自定义路径(如 count_tokens)
|
||||
@@ -1226,6 +1491,10 @@ class ClaudeRelayService {
|
||||
responseBody = responseData.toString('utf8')
|
||||
}
|
||||
|
||||
if (!isRealClaudeCode) {
|
||||
responseBody = this._restoreToolNamesInResponseBody(responseBody, toolNameMap)
|
||||
}
|
||||
|
||||
const response = {
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
@@ -1284,6 +1553,8 @@ class ClaudeRelayService {
|
||||
|
||||
// 写入请求体
|
||||
req.write(bodyString)
|
||||
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
|
||||
bodyString = null
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
@@ -1465,7 +1736,12 @@ class ClaudeRelayService {
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||
|
||||
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
|
||||
const processedBody = this._processRequestBody(requestBody, account)
|
||||
// 🧹 内存优化:存储到 bodyStore,不放入 requestOptions 避免闭包捕获
|
||||
const originalBodyString = JSON.stringify(processedBody)
|
||||
const bodyStoreId = ++this._bodyStoreIdCounter
|
||||
this.bodyStore.set(bodyStoreId, originalBodyString)
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId)
|
||||
@@ -1487,7 +1763,11 @@ class ClaudeRelayService {
|
||||
accountType,
|
||||
sessionHash,
|
||||
streamTransformer,
|
||||
options,
|
||||
{
|
||||
...options,
|
||||
bodyStoreId,
|
||||
isRealClaudeCodeRequest
|
||||
},
|
||||
isDedicatedOfficialAccount,
|
||||
// 📬 新增回调:在收到响应头时释放队列锁
|
||||
async () => {
|
||||
@@ -1576,7 +1856,12 @@ class ClaudeRelayService {
|
||||
return prepared.abortResponse
|
||||
}
|
||||
|
||||
const { bodyString, headers } = prepared
|
||||
let { bodyString } = prepared
|
||||
const { headers, toolNameMap } = prepared
|
||||
const toolNameStreamTransformer = this._createToolNameStripperStreamTransformer(
|
||||
streamTransformer,
|
||||
toolNameMap
|
||||
)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.claudeApiUrl)
|
||||
@@ -1684,8 +1969,22 @@ class ClaudeRelayService {
|
||||
|
||||
try {
|
||||
// 递归调用自身进行重试
|
||||
// 🧹 从 bodyStore 获取字符串用于重试
|
||||
if (
|
||||
!requestOptions.bodyStoreId ||
|
||||
!this.bodyStore.has(requestOptions.bodyStoreId)
|
||||
) {
|
||||
throw new Error('529 retry requires valid bodyStoreId')
|
||||
}
|
||||
let retryBody
|
||||
try {
|
||||
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
|
||||
} catch (parseError) {
|
||||
logger.error(`❌ Failed to parse body for 529 retry: ${parseError.message}`)
|
||||
throw new Error(`529 retry body parse failed: ${parseError.message}`)
|
||||
}
|
||||
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
||||
body,
|
||||
retryBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
@@ -1780,11 +2079,48 @@ class ClaudeRelayService {
|
||||
errorData += chunk.toString()
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
res.on('end', async () => {
|
||||
logger.error(
|
||||
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
|
||||
errorData
|
||||
)
|
||||
if (
|
||||
this._isClaudeCodeCredentialError(errorData) &&
|
||||
requestOptions.useRandomizedToolNames !== true &&
|
||||
requestOptions.bodyStoreId &&
|
||||
this.bodyStore.has(requestOptions.bodyStoreId)
|
||||
) {
|
||||
let retryBody
|
||||
try {
|
||||
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
|
||||
} catch (parseError) {
|
||||
logger.error(`❌ Failed to parse body for 403 retry: ${parseError.message}`)
|
||||
reject(new Error(`403 retry body parse failed: ${parseError.message}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
||||
retryBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
responseStream,
|
||||
usageCallback,
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
streamTransformer,
|
||||
{ ...requestOptions, useRandomizedToolNames: true },
|
||||
isDedicatedOfficialAccount,
|
||||
onResponseStart,
|
||||
retryCount
|
||||
)
|
||||
resolve(retryResult)
|
||||
} catch (retryError) {
|
||||
reject(retryError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (this._isOrganizationDisabledError(res.statusCode, errorData)) {
|
||||
;(async () => {
|
||||
try {
|
||||
@@ -1819,7 +2155,7 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||
if (streamTransformer) {
|
||||
if (toolNameStreamTransformer) {
|
||||
responseStream.write(
|
||||
`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`
|
||||
)
|
||||
@@ -1858,6 +2194,11 @@ class ClaudeRelayService {
|
||||
let rateLimitDetected = false // 限流检测标志
|
||||
|
||||
// 监听数据块,解析SSE并寻找usage信息
|
||||
// 🧹 内存优化:在闭包创建前提取需要的值,避免闭包捕获 body 和 requestOptions
|
||||
// body 和 requestOptions 只在闭包外使用,闭包内只引用基本类型
|
||||
const requestedModel = body?.model || 'unknown'
|
||||
const { isRealClaudeCodeRequest } = requestOptions
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
try {
|
||||
const chunkStr = chunk.toString()
|
||||
@@ -1873,8 +2214,8 @@ class ClaudeRelayService {
|
||||
if (isStreamWritable(responseStream)) {
|
||||
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
|
||||
// 如果有流转换器,应用转换
|
||||
if (streamTransformer) {
|
||||
const transformed = streamTransformer(linesToForward)
|
||||
if (toolNameStreamTransformer) {
|
||||
const transformed = toolNameStreamTransformer(linesToForward)
|
||||
if (transformed) {
|
||||
responseStream.write(transformed)
|
||||
}
|
||||
@@ -2007,8 +2348,8 @@ class ClaudeRelayService {
|
||||
try {
|
||||
// 处理缓冲区中剩余的数据
|
||||
if (buffer.trim() && isStreamWritable(responseStream)) {
|
||||
if (streamTransformer) {
|
||||
const transformed = streamTransformer(buffer)
|
||||
if (toolNameStreamTransformer) {
|
||||
const transformed = toolNameStreamTransformer(buffer)
|
||||
if (transformed) {
|
||||
responseStream.write(transformed)
|
||||
}
|
||||
@@ -2063,7 +2404,7 @@ class ClaudeRelayService {
|
||||
|
||||
// 打印原始的usage数据为JSON字符串,避免嵌套问题
|
||||
logger.info(
|
||||
`📊 === Stream Request Usage Summary === Model: ${body.model}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
|
||||
`📊 === Stream Request Usage Summary === Model: ${requestedModel}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
|
||||
)
|
||||
|
||||
// 一般一个请求只会使用一个模型,即使有多个usage事件也应该合并
|
||||
@@ -2073,7 +2414,7 @@ class ClaudeRelayService {
|
||||
output_tokens: totalUsage.output_tokens,
|
||||
cache_creation_input_tokens: totalUsage.cache_creation_input_tokens,
|
||||
cache_read_input_tokens: totalUsage.cache_read_input_tokens,
|
||||
model: allUsageData[allUsageData.length - 1].model || body.model // 使用最后一个模型或请求模型
|
||||
model: allUsageData[allUsageData.length - 1].model || requestedModel // 使用最后一个模型或请求模型
|
||||
}
|
||||
|
||||
// 如果有详细的cache_creation数据,合并它们
|
||||
@@ -2182,15 +2523,15 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 只有真实的 Claude Code 请求才更新 headers(流式请求)
|
||||
if (
|
||||
clientHeaders &&
|
||||
Object.keys(clientHeaders).length > 0 &&
|
||||
this.isRealClaudeCodeRequest(body)
|
||||
) {
|
||||
if (clientHeaders && Object.keys(clientHeaders).length > 0 && isRealClaudeCodeRequest) {
|
||||
await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 清理 bodyStore
|
||||
if (requestOptions.bodyStoreId) {
|
||||
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||
}
|
||||
logger.debug('🌊 Claude stream response with usage capture completed')
|
||||
resolve()
|
||||
})
|
||||
@@ -2247,6 +2588,10 @@ class ClaudeRelayService {
|
||||
)
|
||||
responseStream.end()
|
||||
}
|
||||
// 🧹 清理 bodyStore
|
||||
if (requestOptions.bodyStoreId) {
|
||||
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||
}
|
||||
reject(error)
|
||||
})
|
||||
|
||||
@@ -2276,6 +2621,10 @@ class ClaudeRelayService {
|
||||
)
|
||||
responseStream.end()
|
||||
}
|
||||
// 🧹 清理 bodyStore
|
||||
if (requestOptions.bodyStoreId) {
|
||||
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||
}
|
||||
reject(new Error('Request timeout'))
|
||||
})
|
||||
|
||||
@@ -2289,6 +2638,8 @@ class ClaudeRelayService {
|
||||
|
||||
// 写入请求体
|
||||
req.write(bodyString)
|
||||
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
|
||||
bodyString = null
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,11 +16,62 @@ const {
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const antigravityClient = require('./antigravityClient')
|
||||
|
||||
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
|
||||
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
|
||||
const OAUTH_CLIENT_SECRET = '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'
|
||||
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
||||
// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用
|
||||
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||
|
||||
const OAUTH_PROVIDERS = {
|
||||
[OAUTH_PROVIDER_GEMINI_CLI]: {
|
||||
// Gemini CLI OAuth 配置(公开)
|
||||
clientId:
|
||||
process.env.GEMINI_OAUTH_CLIENT_ID ||
|
||||
'681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com',
|
||||
clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || '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',
|
||||
scopes: ['https://www.googleapis.com/auth/cloud-platform']
|
||||
},
|
||||
[OAUTH_PROVIDER_ANTIGRAVITY]: {
|
||||
// Antigravity OAuth 配置(参考 gcli2api)
|
||||
clientId:
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_ID ||
|
||||
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com',
|
||||
clientSecret:
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || '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',
|
||||
scopes: [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/cclog',
|
||||
'https://www.googleapis.com/auth/experimentsandconfigs'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.GEMINI_OAUTH_CLIENT_SECRET) {
|
||||
logger.warn(
|
||||
'⚠️ GEMINI_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
|
||||
)
|
||||
}
|
||||
if (!process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET) {
|
||||
logger.warn(
|
||||
'⚠️ ANTIGRAVITY_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeOauthProvider(oauthProvider) {
|
||||
if (!oauthProvider) {
|
||||
return OAUTH_PROVIDER_GEMINI_CLI
|
||||
}
|
||||
return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY
|
||||
? OAUTH_PROVIDER_ANTIGRAVITY
|
||||
: OAUTH_PROVIDER_GEMINI_CLI
|
||||
}
|
||||
|
||||
function getOauthProviderConfig(oauthProvider) {
|
||||
const normalized = normalizeOauthProvider(oauthProvider)
|
||||
return OAUTH_PROVIDERS[normalized] || OAUTH_PROVIDERS[OAUTH_PROVIDER_GEMINI_CLI]
|
||||
}
|
||||
|
||||
// 🌐 TCP Keep-Alive Agent 配置
|
||||
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
|
||||
@@ -34,6 +85,117 @@ const keepAliveAgent = new https.Agent({
|
||||
|
||||
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
||||
|
||||
async function fetchAvailableModelsAntigravity(
|
||||
accessToken,
|
||||
proxyConfig = null,
|
||||
refreshToken = null
|
||||
) {
|
||||
try {
|
||||
let effectiveToken = accessToken
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const client = await getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
OAUTH_PROVIDER_ANTIGRAVITY
|
||||
)
|
||||
if (client && client.getAccessToken) {
|
||||
const latest = await client.getAccessToken()
|
||||
if (latest?.token) {
|
||||
effectiveToken = latest.token
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to refresh Antigravity access token for models list:', {
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const data = await antigravityClient.fetchAvailableModels({
|
||||
accessToken: effectiveToken,
|
||||
proxyConfig
|
||||
})
|
||||
const modelsDict = data?.models
|
||||
const created = Math.floor(Date.now() / 1000)
|
||||
|
||||
const models = []
|
||||
const seen = new Set()
|
||||
const {
|
||||
getAntigravityModelAlias,
|
||||
getAntigravityModelMetadata,
|
||||
normalizeAntigravityModelInput
|
||||
} = require('../utils/antigravityModel')
|
||||
|
||||
const pushModel = (modelId) => {
|
||||
if (!modelId || seen.has(modelId)) {
|
||||
return
|
||||
}
|
||||
seen.add(modelId)
|
||||
const metadata = getAntigravityModelMetadata(modelId)
|
||||
const entry = {
|
||||
id: modelId,
|
||||
object: 'model',
|
||||
created,
|
||||
owned_by: 'antigravity'
|
||||
}
|
||||
if (metadata?.name) {
|
||||
entry.name = metadata.name
|
||||
}
|
||||
if (metadata?.maxCompletionTokens) {
|
||||
entry.max_completion_tokens = metadata.maxCompletionTokens
|
||||
}
|
||||
if (metadata?.thinking) {
|
||||
entry.thinking = metadata.thinking
|
||||
}
|
||||
models.push(entry)
|
||||
}
|
||||
|
||||
if (modelsDict && typeof modelsDict === 'object') {
|
||||
for (const modelId of Object.keys(modelsDict)) {
|
||||
const normalized = normalizeAntigravityModelInput(modelId)
|
||||
const alias = getAntigravityModelAlias(normalized)
|
||||
if (!alias) {
|
||||
continue
|
||||
}
|
||||
pushModel(alias)
|
||||
|
||||
if (alias.endsWith('-thinking')) {
|
||||
pushModel(alias.replace(/-thinking$/, ''))
|
||||
}
|
||||
|
||||
if (alias.startsWith('gemini-claude-')) {
|
||||
pushModel(alias.replace(/^gemini-/, ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return models
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch Antigravity models:', error.response?.data || error.message)
|
||||
return [
|
||||
{
|
||||
id: 'gemini-2.5-flash',
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: 'antigravity'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function countTokensAntigravity(client, contents, model, proxyConfig = null) {
|
||||
const { token } = await client.getAccessToken()
|
||||
const response = await antigravityClient.countTokens({
|
||||
accessToken: token,
|
||||
proxyConfig,
|
||||
contents,
|
||||
model
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'gemini-account-salt'
|
||||
@@ -124,14 +286,15 @@ setInterval(
|
||||
)
|
||||
|
||||
// 创建 OAuth2 客户端(支持代理配置)
|
||||
function createOAuth2Client(redirectUri = null, proxyConfig = null) {
|
||||
function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) {
|
||||
// 如果没有提供 redirectUri,使用默认值
|
||||
const uri = redirectUri || 'http://localhost:45462'
|
||||
const oauthConfig = getOauthProviderConfig(oauthProvider)
|
||||
|
||||
// 准备客户端选项
|
||||
const clientOptions = {
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
clientSecret: OAUTH_CLIENT_SECRET,
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
redirectUri: uri
|
||||
}
|
||||
|
||||
@@ -152,10 +315,17 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) {
|
||||
}
|
||||
|
||||
// 生成授权 URL (支持 PKCE 和代理)
|
||||
async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) {
|
||||
async function generateAuthUrl(
|
||||
state = null,
|
||||
redirectUri = null,
|
||||
proxyConfig = null,
|
||||
oauthProvider = null
|
||||
) {
|
||||
// 使用新的 redirect URI
|
||||
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
|
||||
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig)
|
||||
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig, normalizedProvider)
|
||||
|
||||
if (proxyConfig) {
|
||||
logger.info(
|
||||
@@ -172,7 +342,7 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
redirect_uri: finalRedirectUri,
|
||||
access_type: 'offline',
|
||||
scope: OAUTH_SCOPES,
|
||||
scope: oauthConfig.scopes,
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge: codeVerifier.codeChallenge,
|
||||
state: stateValue,
|
||||
@@ -183,7 +353,8 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
|
||||
authUrl,
|
||||
state: stateValue,
|
||||
codeVerifier: codeVerifier.codeVerifier,
|
||||
redirectUri: finalRedirectUri
|
||||
redirectUri: finalRedirectUri,
|
||||
oauthProvider: normalizedProvider
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,11 +415,14 @@ async function exchangeCodeForTokens(
|
||||
code,
|
||||
redirectUri = null,
|
||||
codeVerifier = null,
|
||||
proxyConfig = null
|
||||
proxyConfig = null,
|
||||
oauthProvider = null
|
||||
) {
|
||||
try {
|
||||
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||
// 创建带代理配置的 OAuth2Client
|
||||
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig)
|
||||
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider)
|
||||
|
||||
if (proxyConfig) {
|
||||
logger.info(
|
||||
@@ -274,7 +448,7 @@ async function exchangeCodeForTokens(
|
||||
return {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
scope: tokens.scope || OAUTH_SCOPES.join(' '),
|
||||
scope: tokens.scope || oauthConfig.scopes.join(' '),
|
||||
token_type: tokens.token_type || 'Bearer',
|
||||
expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000
|
||||
}
|
||||
@@ -285,9 +459,11 @@ async function exchangeCodeForTokens(
|
||||
}
|
||||
|
||||
// 刷新访问令牌
|
||||
async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
||||
async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvider = null) {
|
||||
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||
// 创建带代理配置的 OAuth2Client
|
||||
const oAuth2Client = createOAuth2Client(null, proxyConfig)
|
||||
const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider)
|
||||
|
||||
try {
|
||||
// 设置 refresh_token
|
||||
@@ -319,7 +495,7 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
||||
return {
|
||||
access_token: credentials.access_token,
|
||||
refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的
|
||||
scope: credentials.scope || OAUTH_SCOPES.join(' '),
|
||||
scope: credentials.scope || oauthConfig.scopes.join(' '),
|
||||
token_type: credentials.token_type || 'Bearer',
|
||||
expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期
|
||||
}
|
||||
@@ -339,6 +515,8 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
||||
async function createAccount(accountData) {
|
||||
const id = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
const oauthProvider = normalizeOauthProvider(accountData.oauthProvider)
|
||||
const oauthConfig = getOauthProviderConfig(oauthProvider)
|
||||
|
||||
// 处理凭证数据
|
||||
let geminiOauth = null
|
||||
@@ -371,7 +549,7 @@ async function createAccount(accountData) {
|
||||
geminiOauth = JSON.stringify({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
scope: accountData.scope || OAUTH_SCOPES.join(' '),
|
||||
scope: accountData.scope || oauthConfig.scopes.join(' '),
|
||||
token_type: accountData.tokenType || 'Bearer',
|
||||
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
|
||||
})
|
||||
@@ -399,7 +577,8 @@ async function createAccount(accountData) {
|
||||
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
||||
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
|
||||
// 只有OAuth方式才有scopes,手动添加的没有
|
||||
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
|
||||
scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '',
|
||||
oauthProvider,
|
||||
|
||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
||||
@@ -508,6 +687,10 @@ async function updateAccount(accountId, updates) {
|
||||
updates.schedulable = updates.schedulable.toString()
|
||||
}
|
||||
|
||||
if (updates.oauthProvider !== undefined) {
|
||||
updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider)
|
||||
}
|
||||
|
||||
// 加密敏感字段
|
||||
if (updates.geminiOauth) {
|
||||
updates.geminiOauth = encrypt(
|
||||
@@ -885,12 +1068,13 @@ async function refreshAccountToken(accountId) {
|
||||
// 重新获取账户数据(可能已被其他进程刷新)
|
||||
const updatedAccount = await getAccount(accountId)
|
||||
if (updatedAccount && updatedAccount.accessToken) {
|
||||
const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider)
|
||||
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(' '),
|
||||
scope: updatedAccount.scopes || oauthConfig.scopes.join(' '),
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
}
|
||||
@@ -904,7 +1088,11 @@ async function refreshAccountToken(accountId) {
|
||||
|
||||
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
|
||||
// 传入账户的代理配置
|
||||
const newTokens = await refreshAccessToken(account.refreshToken, account.proxy)
|
||||
const newTokens = await refreshAccessToken(
|
||||
account.refreshToken,
|
||||
account.proxy,
|
||||
account.oauthProvider
|
||||
)
|
||||
|
||||
// 更新账户信息
|
||||
const updates = {
|
||||
@@ -1036,14 +1224,15 @@ async function getAccountRateLimitInfo(accountId) {
|
||||
}
|
||||
|
||||
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理)
|
||||
async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
|
||||
const client = createOAuth2Client(null, proxyConfig)
|
||||
async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) {
|
||||
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||
const client = createOAuth2Client(null, proxyConfig, normalizedProvider)
|
||||
|
||||
const creds = {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
scope:
|
||||
'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email',
|
||||
scope: oauthConfig.scopes.join(' '),
|
||||
token_type: 'Bearer',
|
||||
expiry_date: 1754269905646
|
||||
}
|
||||
@@ -1509,6 +1698,43 @@ async function generateContent(
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 调用 Antigravity 上游生成内容(非流式)
|
||||
async function generateContentAntigravity(
|
||||
client,
|
||||
requestData,
|
||||
userPromptId,
|
||||
projectId = null,
|
||||
sessionId = null,
|
||||
proxyConfig = null
|
||||
) {
|
||||
const { token } = await client.getAccessToken()
|
||||
const { model } = antigravityClient.buildAntigravityEnvelope({
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId,
|
||||
userPromptId
|
||||
})
|
||||
|
||||
logger.info('🪐 Antigravity generateContent API调用开始', {
|
||||
model,
|
||||
userPromptId,
|
||||
projectId,
|
||||
sessionId
|
||||
})
|
||||
|
||||
const { response } = await antigravityClient.request({
|
||||
accessToken: token,
|
||||
proxyConfig,
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId,
|
||||
userPromptId,
|
||||
stream: false
|
||||
})
|
||||
logger.info('✅ Antigravity generateContent API调用成功')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 调用 Code Assist API 生成内容(流式)
|
||||
async function generateContentStream(
|
||||
client,
|
||||
@@ -1593,6 +1819,46 @@ async function generateContentStream(
|
||||
return response.data // 返回流对象
|
||||
}
|
||||
|
||||
// 调用 Antigravity 上游生成内容(流式)
|
||||
async function generateContentStreamAntigravity(
|
||||
client,
|
||||
requestData,
|
||||
userPromptId,
|
||||
projectId = null,
|
||||
sessionId = null,
|
||||
signal = null,
|
||||
proxyConfig = null
|
||||
) {
|
||||
const { token } = await client.getAccessToken()
|
||||
const { model } = antigravityClient.buildAntigravityEnvelope({
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId,
|
||||
userPromptId
|
||||
})
|
||||
|
||||
logger.info('🌊 Antigravity streamGenerateContent API调用开始', {
|
||||
model,
|
||||
userPromptId,
|
||||
projectId,
|
||||
sessionId
|
||||
})
|
||||
|
||||
const { response } = await antigravityClient.request({
|
||||
accessToken: token,
|
||||
proxyConfig,
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId,
|
||||
userPromptId,
|
||||
stream: true,
|
||||
signal,
|
||||
params: { alt: 'sse' }
|
||||
})
|
||||
logger.info('✅ Antigravity streamGenerateContent API调用成功,开始流式传输')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 更新账户的临时项目 ID
|
||||
async function updateTempProjectId(accountId, tempProjectId) {
|
||||
if (!tempProjectId) {
|
||||
@@ -1687,10 +1953,12 @@ module.exports = {
|
||||
generateEncryptionKey,
|
||||
decryptCache, // 暴露缓存对象以便测试和监控
|
||||
countTokens,
|
||||
countTokensAntigravity,
|
||||
generateContent,
|
||||
generateContentStream,
|
||||
generateContentAntigravity,
|
||||
generateContentStreamAntigravity,
|
||||
fetchAvailableModelsAntigravity,
|
||||
updateTempProjectId,
|
||||
resetAccountStatus,
|
||||
OAUTH_CLIENT_ID,
|
||||
OAUTH_SCOPES
|
||||
resetAccountStatus
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@ class RateLimitCleanupService {
|
||||
const results = {
|
||||
openai: { checked: 0, cleared: 0, errors: [] },
|
||||
claude: { checked: 0, cleared: 0, errors: [] },
|
||||
claudeConsole: { checked: 0, cleared: 0, errors: [] }
|
||||
claudeConsole: { checked: 0, cleared: 0, errors: [] },
|
||||
tokenRefresh: { checked: 0, refreshed: 0, errors: [] }
|
||||
}
|
||||
|
||||
// 清理 OpenAI 账号
|
||||
@@ -84,21 +85,29 @@ class RateLimitCleanupService {
|
||||
// 清理 Claude Console 账号
|
||||
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
||||
|
||||
// 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
|
||||
await this.proactiveRefreshClaudeTokens(results.tokenRefresh)
|
||||
|
||||
const totalChecked =
|
||||
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
||||
const totalCleared =
|
||||
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
if (totalCleared > 0) {
|
||||
if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) {
|
||||
logger.info(
|
||||
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
|
||||
`✅ Rate limit cleanup completed: ${totalCleared}/${totalChecked} accounts cleared, ${results.tokenRefresh.refreshed} tokens refreshed (${duration}ms)`
|
||||
)
|
||||
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
||||
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
||||
logger.info(
|
||||
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
||||
)
|
||||
if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) {
|
||||
logger.info(
|
||||
` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed`
|
||||
)
|
||||
}
|
||||
|
||||
// 发送 webhook 恢复通知
|
||||
if (this.clearedAccounts.length > 0) {
|
||||
@@ -114,7 +123,8 @@ class RateLimitCleanupService {
|
||||
const allErrors = [
|
||||
...results.openai.errors,
|
||||
...results.claude.errors,
|
||||
...results.claudeConsole.errors
|
||||
...results.claudeConsole.errors,
|
||||
...results.tokenRefresh.errors
|
||||
]
|
||||
if (allErrors.length > 0) {
|
||||
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
||||
@@ -348,6 +358,75 @@ class RateLimitCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动刷新 Claude 账户 Token(防止等待重置期间 Token 过期)
|
||||
* 仅对因限流/配额限制而等待重置的账户执行刷新:
|
||||
* - 429 限流账户(rateLimitAutoStopped=true)
|
||||
* - 5小时限制自动停止账户(fiveHourAutoStopped=true)
|
||||
* 不处理错误状态账户(error/temp_error)
|
||||
*/
|
||||
async proactiveRefreshClaudeTokens(result) {
|
||||
try {
|
||||
const redis = require('../models/redis')
|
||||
const accounts = await redis.getAllClaudeAccounts()
|
||||
const now = Date.now()
|
||||
const refreshAheadMs = 30 * 60 * 1000 // 提前30分钟刷新
|
||||
const recentRefreshMs = 5 * 60 * 1000 // 5分钟内刷新过则跳过
|
||||
|
||||
for (const account of accounts) {
|
||||
// 1. 必须激活
|
||||
if (account.isActive !== 'true') {
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. 必须有 refreshToken
|
||||
if (!account.refreshToken) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. 【优化】仅处理因限流/配额限制而等待重置的账户
|
||||
// 正常调度的账户会在请求时自动刷新,无需主动刷新
|
||||
// 错误状态账户的 Token 可能已失效,刷新也会失败
|
||||
const isWaitingForReset =
|
||||
account.rateLimitAutoStopped === 'true' || // 429 限流
|
||||
account.fiveHourAutoStopped === 'true' // 5小时限制自动停止
|
||||
if (!isWaitingForReset) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 4. 【优化】如果最近 5 分钟内已刷新,跳过(避免重复刷新)
|
||||
const lastRefreshAt = account.lastRefreshAt ? new Date(account.lastRefreshAt).getTime() : 0
|
||||
if (now - lastRefreshAt < recentRefreshMs) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 5. 检查 Token 是否即将过期(30分钟内)
|
||||
const expiresAt = parseInt(account.expiresAt)
|
||||
if (expiresAt && now < expiresAt - refreshAheadMs) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 符合条件,执行刷新
|
||||
result.checked++
|
||||
try {
|
||||
await claudeAccountService.refreshAccountToken(account.id)
|
||||
result.refreshed++
|
||||
logger.info(`🔄 Proactively refreshed token: ${account.name} (${account.id})`)
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
error: error.message
|
||||
})
|
||||
logger.warn(`⚠️ Proactive refresh failed for ${account.name}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to proactively refresh Claude tokens:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发一次清理(供 API 或 CLI 调用)
|
||||
*/
|
||||
|
||||
@@ -4,11 +4,35 @@ const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||
const KNOWN_OAUTH_PROVIDERS = [OAUTH_PROVIDER_GEMINI_CLI, OAUTH_PROVIDER_ANTIGRAVITY]
|
||||
|
||||
function normalizeOauthProvider(oauthProvider) {
|
||||
if (!oauthProvider) {
|
||||
return OAUTH_PROVIDER_GEMINI_CLI
|
||||
}
|
||||
return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY
|
||||
? OAUTH_PROVIDER_ANTIGRAVITY
|
||||
: OAUTH_PROVIDER_GEMINI_CLI
|
||||
}
|
||||
|
||||
class UnifiedGeminiScheduler {
|
||||
constructor() {
|
||||
this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:'
|
||||
}
|
||||
|
||||
_getSessionMappingKey(sessionHash, oauthProvider = null) {
|
||||
if (!sessionHash) {
|
||||
return null
|
||||
}
|
||||
if (!oauthProvider) {
|
||||
return `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||
}
|
||||
const normalized = normalizeOauthProvider(oauthProvider)
|
||||
return `${this.SESSION_MAPPING_PREFIX}${normalized}:${sessionHash}`
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||
_isSchedulable(schedulable) {
|
||||
// 如果是 undefined 或 null,默认为可调度
|
||||
@@ -32,7 +56,8 @@ class UnifiedGeminiScheduler {
|
||||
requestedModel = null,
|
||||
options = {}
|
||||
) {
|
||||
const { allowApiAccounts = false } = options
|
||||
const { allowApiAccounts = false, oauthProvider = null } = options
|
||||
const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null
|
||||
|
||||
try {
|
||||
// 如果API Key绑定了专属账户或分组,优先使用
|
||||
@@ -83,14 +108,23 @@ class UnifiedGeminiScheduler {
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
// 更新账户的最后使用时间
|
||||
await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId)
|
||||
return {
|
||||
accountId: apiKeyData.geminiAccountId,
|
||||
accountType: 'gemini'
|
||||
if (
|
||||
normalizedOauthProvider &&
|
||||
normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider
|
||||
) {
|
||||
logger.warn(
|
||||
`⚠️ Bound Gemini OAuth account ${boundAccount.name} oauthProvider=${normalizeOauthProvider(boundAccount.oauthProvider)} does not match requested oauthProvider=${normalizedOauthProvider}, falling back to pool`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
// 更新账户的最后使用时间
|
||||
await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId)
|
||||
return {
|
||||
accountId: apiKeyData.geminiAccountId,
|
||||
accountType: 'gemini'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
@@ -102,7 +136,7 @@ class UnifiedGeminiScheduler {
|
||||
|
||||
// 如果有会话哈希,检查是否有已映射的账户
|
||||
if (sessionHash) {
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash, normalizedOauthProvider)
|
||||
if (mappedAccount) {
|
||||
// 验证映射的账户是否仍然可用
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
@@ -111,7 +145,7 @@ class UnifiedGeminiScheduler {
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
await this._extendSessionMappingTTL(sessionHash, normalizedOauthProvider)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -132,11 +166,10 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 获取所有可用账户
|
||||
const availableAccounts = await this._getAllAvailableAccounts(
|
||||
apiKeyData,
|
||||
requestedModel,
|
||||
allowApiAccounts
|
||||
)
|
||||
const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel, {
|
||||
allowApiAccounts,
|
||||
oauthProvider: normalizedOauthProvider
|
||||
})
|
||||
|
||||
if (availableAccounts.length === 0) {
|
||||
// 提供更详细的错误信息
|
||||
@@ -160,7 +193,8 @@ class UnifiedGeminiScheduler {
|
||||
await this._setSessionMapping(
|
||||
sessionHash,
|
||||
selectedAccount.accountId,
|
||||
selectedAccount.accountType
|
||||
selectedAccount.accountType,
|
||||
normalizedOauthProvider
|
||||
)
|
||||
logger.info(
|
||||
`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`
|
||||
@@ -189,7 +223,18 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 📋 获取所有可用账户
|
||||
async _getAllAvailableAccounts(apiKeyData, requestedModel = null, allowApiAccounts = false) {
|
||||
async _getAllAvailableAccounts(
|
||||
apiKeyData,
|
||||
requestedModel = null,
|
||||
allowApiAccountsOrOptions = false
|
||||
) {
|
||||
const options =
|
||||
allowApiAccountsOrOptions && typeof allowApiAccountsOrOptions === 'object'
|
||||
? allowApiAccountsOrOptions
|
||||
: { allowApiAccounts: allowApiAccountsOrOptions }
|
||||
const { allowApiAccounts = false, oauthProvider = null } = options
|
||||
const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null
|
||||
|
||||
const availableAccounts = []
|
||||
|
||||
// 如果API Key绑定了专属账户,优先返回
|
||||
@@ -254,6 +299,12 @@ class UnifiedGeminiScheduler {
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
if (
|
||||
normalizedOauthProvider &&
|
||||
normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider
|
||||
) {
|
||||
return availableAccounts
|
||||
}
|
||||
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
||||
if (!isRateLimited) {
|
||||
// 检查模型支持
|
||||
@@ -303,6 +354,12 @@ class UnifiedGeminiScheduler {
|
||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
if (
|
||||
normalizedOauthProvider &&
|
||||
normalizeOauthProvider(account.oauthProvider) !== normalizedOauthProvider
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// 检查是否可调度
|
||||
|
||||
// 检查token是否过期
|
||||
@@ -437,9 +494,10 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 🔗 获取会话映射
|
||||
async _getSessionMapping(sessionHash) {
|
||||
async _getSessionMapping(sessionHash, oauthProvider = null) {
|
||||
const client = redis.getClientSafe()
|
||||
const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
|
||||
const mappingData = key ? await client.get(key) : null
|
||||
|
||||
if (mappingData) {
|
||||
try {
|
||||
@@ -454,27 +512,42 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 💾 设置会话映射
|
||||
async _setSessionMapping(sessionHash, accountId, accountType) {
|
||||
async _setSessionMapping(sessionHash, accountId, accountType, oauthProvider = null) {
|
||||
const client = redis.getClientSafe()
|
||||
const mappingData = JSON.stringify({ accountId, accountType })
|
||||
// 依据配置设置TTL(小时)
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
|
||||
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
await client.setex(key, ttlSeconds, mappingData)
|
||||
}
|
||||
|
||||
// 🗑️ 删除会话映射
|
||||
async _deleteSessionMapping(sessionHash) {
|
||||
const client = redis.getClientSafe()
|
||||
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
if (!sessionHash) {
|
||||
return
|
||||
}
|
||||
|
||||
const keys = [this._getSessionMappingKey(sessionHash)]
|
||||
for (const provider of KNOWN_OAUTH_PROVIDERS) {
|
||||
keys.push(this._getSessionMappingKey(sessionHash, provider))
|
||||
}
|
||||
await client.del(keys.filter(Boolean))
|
||||
}
|
||||
|
||||
// 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置
|
||||
async _extendSessionMappingTTL(sessionHash) {
|
||||
async _extendSessionMappingTTL(sessionHash, oauthProvider = null) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
|
||||
if (!key) {
|
||||
return false
|
||||
}
|
||||
const remainingTTL = await client.ttl(key)
|
||||
|
||||
if (remainingTTL === -2) {
|
||||
|
||||
219
src/services/weeklyClaudeCostInitService.js
Normal file
219
src/services/weeklyClaudeCostInitService.js
Normal file
@@ -0,0 +1,219 @@
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const pricingService = require('./pricingService')
|
||||
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
// 生成配置时区下的 YYYY-MM-DD 字符串。
|
||||
// 注意:入参 date 必须是 redis.getDateInTimezone() 生成的“时区偏移后”的 Date。
|
||||
function formatTzDateYmd(tzDate) {
|
||||
return `${tzDate.getUTCFullYear()}-${pad2(tzDate.getUTCMonth() + 1)}-${pad2(tzDate.getUTCDate())}`
|
||||
}
|
||||
|
||||
class WeeklyClaudeCostInitService {
|
||||
_getCurrentWeekDatesInTimezone() {
|
||||
const tzNow = redis.getDateInTimezone(new Date())
|
||||
const tzToday = new Date(tzNow)
|
||||
tzToday.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
// ISO 周:周一=1 ... 周日=7
|
||||
const isoDay = tzToday.getUTCDay() || 7
|
||||
const tzMonday = new Date(tzToday)
|
||||
tzMonday.setUTCDate(tzToday.getUTCDate() - (isoDay - 1))
|
||||
|
||||
const dates = []
|
||||
for (let d = new Date(tzMonday); d <= tzToday; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
dates.push(formatTzDateYmd(d))
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
_buildWeeklyClaudeKey(keyId, weekString) {
|
||||
return `usage:claude:weekly:${keyId}:${weekString}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动回填:把“本周(周一到今天)Claude 全模型”周费用从按日/按模型统计里反算出来,
|
||||
* 写入 `usage:claude:weekly:*`,保证周限额在重启后不归零。
|
||||
*
|
||||
* 说明:
|
||||
* - 只回填本周,不做历史回填(符合“只要本周数据”诉求)
|
||||
* - 会加分布式锁,避免多实例重复跑
|
||||
* - 会写 done 标记:同一周内重启默认不重复回填(需要时可手动删掉 done key)
|
||||
*/
|
||||
async backfillCurrentWeekClaudeCosts() {
|
||||
const client = redis.getClientSafe()
|
||||
if (!client) {
|
||||
logger.warn('⚠️ 本周 Claude 周费用回填跳过:Redis client 不可用')
|
||||
return { success: false, reason: 'redis_unavailable' }
|
||||
}
|
||||
|
||||
if (!pricingService || !pricingService.pricingData) {
|
||||
logger.warn('⚠️ 本周 Claude 周费用回填跳过:pricing service 未初始化')
|
||||
return { success: false, reason: 'pricing_uninitialized' }
|
||||
}
|
||||
|
||||
const weekString = redis.getWeekStringInTimezone()
|
||||
const doneKey = `init:weekly_claude_cost:${weekString}:done`
|
||||
|
||||
try {
|
||||
const alreadyDone = await client.get(doneKey)
|
||||
if (alreadyDone) {
|
||||
logger.info(`ℹ️ 本周 Claude 周费用回填已完成(${weekString}),跳过`)
|
||||
return { success: true, skipped: true }
|
||||
}
|
||||
} catch (e) {
|
||||
// 尽力而为:读取失败不阻断启动回填流程。
|
||||
}
|
||||
|
||||
const lockKey = `lock:init:weekly_claude_cost:${weekString}`
|
||||
const lockValue = `${process.pid}:${Date.now()}`
|
||||
const lockTtlMs = 15 * 60 * 1000
|
||||
|
||||
const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTtlMs)
|
||||
if (!lockAcquired) {
|
||||
logger.info(`ℹ️ 本周 Claude 周费用回填已在运行(${weekString}),跳过`)
|
||||
return { success: true, skipped: true, reason: 'locked' }
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
logger.info(`💰 开始回填本周 Claude 周费用:${weekString}(仅本周)...`)
|
||||
|
||||
const keyIds = await redis.scanApiKeyIds()
|
||||
const dates = this._getCurrentWeekDatesInTimezone()
|
||||
|
||||
const costByKeyId = new Map()
|
||||
let scannedKeys = 0
|
||||
let matchedClaudeKeys = 0
|
||||
|
||||
const toInt = (v) => {
|
||||
const n = parseInt(v || '0', 10)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
// 扫描“按日 + 按模型”的使用统计 key,并反算 Claude 系列模型的费用。
|
||||
for (const dateStr of dates) {
|
||||
let cursor = '0'
|
||||
const pattern = `usage:*:model:daily:*:${dateStr}`
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000)
|
||||
cursor = nextCursor
|
||||
scannedKeys += keys.length
|
||||
|
||||
const entries = []
|
||||
for (const usageKey of keys) {
|
||||
// usage:{keyId}:model:daily:{model}:{YYYY-MM-DD}
|
||||
const match = usageKey.match(/^usage:([^:]+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const keyId = match[1]
|
||||
const model = match[2]
|
||||
if (!isClaudeFamilyModel(model)) {
|
||||
continue
|
||||
}
|
||||
matchedClaudeKeys++
|
||||
entries.push({ usageKey, keyId, model })
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
for (const entry of entries) {
|
||||
pipeline.hgetall(entry.usageKey)
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i]
|
||||
const [, data] = results[i] || []
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const inputTokens = toInt(data.totalInputTokens || data.inputTokens)
|
||||
const outputTokens = toInt(data.totalOutputTokens || data.outputTokens)
|
||||
const cacheReadTokens = toInt(data.totalCacheReadTokens || data.cacheReadTokens)
|
||||
const cacheCreateTokens = toInt(data.totalCacheCreateTokens || data.cacheCreateTokens)
|
||||
const ephemeral5mTokens = toInt(data.ephemeral5mTokens)
|
||||
const ephemeral1hTokens = toInt(data.ephemeral1hTokens)
|
||||
|
||||
const cacheCreationTotal =
|
||||
ephemeral5mTokens > 0 || ephemeral1hTokens > 0
|
||||
? ephemeral5mTokens + ephemeral1hTokens
|
||||
: cacheCreateTokens
|
||||
|
||||
const usage = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: cacheCreationTotal,
|
||||
cache_read_input_tokens: cacheReadTokens
|
||||
}
|
||||
|
||||
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
|
||||
usage.cache_creation = {
|
||||
ephemeral_5m_input_tokens: ephemeral5mTokens,
|
||||
ephemeral_1h_input_tokens: ephemeral1hTokens
|
||||
}
|
||||
}
|
||||
|
||||
const costInfo = pricingService.calculateCost(usage, entry.model)
|
||||
const cost = costInfo && costInfo.totalCost ? costInfo.totalCost : 0
|
||||
if (cost <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
costByKeyId.set(entry.keyId, (costByKeyId.get(entry.keyId) || 0) + cost)
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
}
|
||||
|
||||
// 为所有 API Key 写入本周 claude:weekly key,避免读取时回退到旧 opus:weekly 造成口径混淆。
|
||||
const ttlSeconds = 14 * 24 * 3600
|
||||
const batchSize = 500
|
||||
for (let i = 0; i < keyIds.length; i += batchSize) {
|
||||
const batch = keyIds.slice(i, i + batchSize)
|
||||
const pipeline = client.pipeline()
|
||||
for (const keyId of batch) {
|
||||
const weeklyKey = this._buildWeeklyClaudeKey(keyId, weekString)
|
||||
const cost = costByKeyId.get(keyId) || 0
|
||||
pipeline.set(weeklyKey, String(cost))
|
||||
pipeline.expire(weeklyKey, ttlSeconds)
|
||||
}
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
// 写入 done 标记(保留略长于 1 周,避免同一周内重启重复回填)。
|
||||
await client.set(doneKey, new Date().toISOString(), 'EX', 10 * 24 * 3600)
|
||||
|
||||
const durationMs = Date.now() - startedAt
|
||||
logger.info(
|
||||
`✅ 本周 Claude 周费用回填完成(${weekString}):keys=${keyIds.length}, scanned=${scannedKeys}, matchedClaude=${matchedClaudeKeys}, filled=${costByKeyId.size}(${durationMs}ms)`
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
weekString,
|
||||
keyCount: keyIds.length,
|
||||
scannedKeys,
|
||||
matchedClaudeKeys,
|
||||
filledKeys: costByKeyId.size,
|
||||
durationMs
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ 本周 Claude 周费用回填失败(${weekString}):`, error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WeeklyClaudeCostInitService()
|
||||
126
src/utils/anthropicRequestDump.js
Normal file
126
src/utils/anthropicRequestDump.js
Normal file
@@ -0,0 +1,126 @@
|
||||
const path = require('path')
|
||||
const logger = require('./logger')
|
||||
const { getProjectRoot } = require('./projectPaths')
|
||||
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||
|
||||
const REQUEST_DUMP_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP'
|
||||
const REQUEST_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES'
|
||||
const REQUEST_DUMP_FILENAME = 'anthropic-requests-dump.jsonl'
|
||||
|
||||
function isEnabled() {
|
||||
const raw = process.env[REQUEST_DUMP_ENV]
|
||||
if (!raw) {
|
||||
return false
|
||||
}
|
||||
return raw === '1' || raw.toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
function getMaxBytes() {
|
||||
const raw = process.env[REQUEST_DUMP_MAX_BYTES_ENV]
|
||||
if (!raw) {
|
||||
return 2 * 1024 * 1024
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 2 * 1024 * 1024
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function maskSecret(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return value
|
||||
}
|
||||
const str = String(value)
|
||||
if (str.length <= 8) {
|
||||
return '***'
|
||||
}
|
||||
return `${str.slice(0, 4)}...${str.slice(-4)}`
|
||||
}
|
||||
|
||||
function sanitizeHeaders(headers) {
|
||||
const sensitive = new Set([
|
||||
'authorization',
|
||||
'proxy-authorization',
|
||||
'x-api-key',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'x-forwarded-for',
|
||||
'x-real-ip'
|
||||
])
|
||||
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(headers || {})) {
|
||||
const key = k.toLowerCase()
|
||||
if (sensitive.has(key)) {
|
||||
out[key] = maskSecret(v)
|
||||
continue
|
||||
}
|
||||
out[key] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function safeJsonStringify(payload, maxBytes) {
|
||||
let json = ''
|
||||
try {
|
||||
json = JSON.stringify(payload)
|
||||
} catch (e) {
|
||||
return JSON.stringify({
|
||||
type: 'anthropic_request_dump_error',
|
||||
error: 'JSON.stringify_failed',
|
||||
message: e?.message || String(e)
|
||||
})
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||
return json
|
||||
}
|
||||
|
||||
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||
return JSON.stringify({
|
||||
type: 'anthropic_request_dump_truncated',
|
||||
maxBytes,
|
||||
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||
partialJson: truncated
|
||||
})
|
||||
}
|
||||
|
||||
async function dumpAnthropicMessagesRequest(req, meta = {}) {
|
||||
if (!isEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxBytes = getMaxBytes()
|
||||
const filename = path.join(getProjectRoot(), REQUEST_DUMP_FILENAME)
|
||||
|
||||
const record = {
|
||||
ts: new Date().toISOString(),
|
||||
requestId: req?.requestId || null,
|
||||
method: req?.method || null,
|
||||
url: req?.originalUrl || req?.url || null,
|
||||
ip: req?.ip || null,
|
||||
meta,
|
||||
headers: sanitizeHeaders(req?.headers || {}),
|
||||
body: req?.body || null
|
||||
}
|
||||
|
||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||
|
||||
try {
|
||||
await safeRotatingAppend(filename, line)
|
||||
} catch (e) {
|
||||
logger.warn('Failed to dump Anthropic request', {
|
||||
filename,
|
||||
requestId: req?.requestId || null,
|
||||
error: e?.message || String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dumpAnthropicMessagesRequest,
|
||||
REQUEST_DUMP_ENV,
|
||||
REQUEST_DUMP_MAX_BYTES_ENV,
|
||||
REQUEST_DUMP_FILENAME
|
||||
}
|
||||
125
src/utils/anthropicResponseDump.js
Normal file
125
src/utils/anthropicResponseDump.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const path = require('path')
|
||||
const logger = require('./logger')
|
||||
const { getProjectRoot } = require('./projectPaths')
|
||||
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||
|
||||
const RESPONSE_DUMP_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP'
|
||||
const RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES'
|
||||
const RESPONSE_DUMP_FILENAME = 'anthropic-responses-dump.jsonl'
|
||||
|
||||
function isEnabled() {
|
||||
const raw = process.env[RESPONSE_DUMP_ENV]
|
||||
if (!raw) {
|
||||
return false
|
||||
}
|
||||
return raw === '1' || raw.toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
function getMaxBytes() {
|
||||
const raw = process.env[RESPONSE_DUMP_MAX_BYTES_ENV]
|
||||
if (!raw) {
|
||||
return 2 * 1024 * 1024
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 2 * 1024 * 1024
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function safeJsonStringify(payload, maxBytes) {
|
||||
let json = ''
|
||||
try {
|
||||
json = JSON.stringify(payload)
|
||||
} catch (e) {
|
||||
return JSON.stringify({
|
||||
type: 'anthropic_response_dump_error',
|
||||
error: 'JSON.stringify_failed',
|
||||
message: e?.message || String(e)
|
||||
})
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||
return json
|
||||
}
|
||||
|
||||
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||
return JSON.stringify({
|
||||
type: 'anthropic_response_dump_truncated',
|
||||
maxBytes,
|
||||
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||
partialJson: truncated
|
||||
})
|
||||
}
|
||||
|
||||
function summarizeAnthropicResponseBody(body) {
|
||||
const content = Array.isArray(body?.content) ? body.content : []
|
||||
const toolUses = content.filter((b) => b && b.type === 'tool_use')
|
||||
const texts = content
|
||||
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
|
||||
return {
|
||||
id: body?.id || null,
|
||||
model: body?.model || null,
|
||||
stop_reason: body?.stop_reason || null,
|
||||
usage: body?.usage || null,
|
||||
content_blocks: content.map((b) => (b ? b.type : null)).filter(Boolean),
|
||||
tool_use_names: toolUses.map((b) => b.name).filter(Boolean),
|
||||
text_preview: texts ? texts.slice(0, 800) : ''
|
||||
}
|
||||
}
|
||||
|
||||
async function dumpAnthropicResponse(req, responseInfo, meta = {}) {
|
||||
if (!isEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxBytes = getMaxBytes()
|
||||
const filename = path.join(getProjectRoot(), RESPONSE_DUMP_FILENAME)
|
||||
|
||||
const record = {
|
||||
ts: new Date().toISOString(),
|
||||
requestId: req?.requestId || null,
|
||||
url: req?.originalUrl || req?.url || null,
|
||||
meta,
|
||||
response: responseInfo
|
||||
}
|
||||
|
||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||
try {
|
||||
await safeRotatingAppend(filename, line)
|
||||
} catch (e) {
|
||||
logger.warn('Failed to dump Anthropic response', {
|
||||
filename,
|
||||
requestId: req?.requestId || null,
|
||||
error: e?.message || String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function dumpAnthropicNonStreamResponse(req, statusCode, body, meta = {}) {
|
||||
return dumpAnthropicResponse(
|
||||
req,
|
||||
{ kind: 'non-stream', statusCode, summary: summarizeAnthropicResponseBody(body), body },
|
||||
meta
|
||||
)
|
||||
}
|
||||
|
||||
async function dumpAnthropicStreamSummary(req, summary, meta = {}) {
|
||||
return dumpAnthropicResponse(req, { kind: 'stream', summary }, meta)
|
||||
}
|
||||
|
||||
async function dumpAnthropicStreamError(req, error, meta = {}) {
|
||||
return dumpAnthropicResponse(req, { kind: 'stream-error', error }, meta)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dumpAnthropicNonStreamResponse,
|
||||
dumpAnthropicStreamSummary,
|
||||
dumpAnthropicStreamError,
|
||||
RESPONSE_DUMP_ENV,
|
||||
RESPONSE_DUMP_MAX_BYTES_ENV,
|
||||
RESPONSE_DUMP_FILENAME
|
||||
}
|
||||
138
src/utils/antigravityModel.js
Normal file
138
src/utils/antigravityModel.js
Normal file
@@ -0,0 +1,138 @@
|
||||
const DEFAULT_ANTIGRAVITY_MODEL = 'gemini-2.5-flash'
|
||||
|
||||
const UPSTREAM_TO_ALIAS = {
|
||||
'rev19-uic3-1p': 'gemini-2.5-computer-use-preview-10-2025',
|
||||
'gemini-3-pro-image': 'gemini-3-pro-image-preview',
|
||||
'gemini-3-pro-high': 'gemini-3-pro-preview',
|
||||
'gemini-3-flash': 'gemini-3-flash-preview',
|
||||
'claude-sonnet-4-5': 'gemini-claude-sonnet-4-5',
|
||||
'claude-sonnet-4-5-thinking': 'gemini-claude-sonnet-4-5-thinking',
|
||||
'claude-opus-4-5-thinking': 'gemini-claude-opus-4-5-thinking',
|
||||
chat_20706: '',
|
||||
chat_23310: '',
|
||||
'gemini-2.5-flash-thinking': '',
|
||||
'gemini-3-pro-low': '',
|
||||
'gemini-2.5-pro': ''
|
||||
}
|
||||
|
||||
const ALIAS_TO_UPSTREAM = {
|
||||
'gemini-2.5-computer-use-preview-10-2025': 'rev19-uic3-1p',
|
||||
'gemini-3-pro-image-preview': 'gemini-3-pro-image',
|
||||
'gemini-3-pro-preview': 'gemini-3-pro-high',
|
||||
'gemini-3-flash-preview': 'gemini-3-flash',
|
||||
'gemini-claude-sonnet-4-5': 'claude-sonnet-4-5',
|
||||
'gemini-claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
|
||||
'gemini-claude-opus-4-5-thinking': 'claude-opus-4-5-thinking'
|
||||
}
|
||||
|
||||
const ANTIGRAVITY_MODEL_METADATA = {
|
||||
'gemini-2.5-flash': {
|
||||
thinking: { min: 0, max: 24576, zeroAllowed: true, dynamicAllowed: true },
|
||||
name: 'models/gemini-2.5-flash'
|
||||
},
|
||||
'gemini-2.5-flash-lite': {
|
||||
thinking: { min: 0, max: 24576, zeroAllowed: true, dynamicAllowed: true },
|
||||
name: 'models/gemini-2.5-flash-lite'
|
||||
},
|
||||
'gemini-2.5-computer-use-preview-10-2025': {
|
||||
name: 'models/gemini-2.5-computer-use-preview-10-2025'
|
||||
},
|
||||
'gemini-3-pro-preview': {
|
||||
thinking: {
|
||||
min: 128,
|
||||
max: 32768,
|
||||
zeroAllowed: false,
|
||||
dynamicAllowed: true,
|
||||
levels: ['low', 'high']
|
||||
},
|
||||
name: 'models/gemini-3-pro-preview'
|
||||
},
|
||||
'gemini-3-pro-image-preview': {
|
||||
thinking: {
|
||||
min: 128,
|
||||
max: 32768,
|
||||
zeroAllowed: false,
|
||||
dynamicAllowed: true,
|
||||
levels: ['low', 'high']
|
||||
},
|
||||
name: 'models/gemini-3-pro-image-preview'
|
||||
},
|
||||
'gemini-3-flash-preview': {
|
||||
thinking: {
|
||||
min: 128,
|
||||
max: 32768,
|
||||
zeroAllowed: false,
|
||||
dynamicAllowed: true,
|
||||
levels: ['minimal', 'low', 'medium', 'high']
|
||||
},
|
||||
name: 'models/gemini-3-flash-preview'
|
||||
},
|
||||
'gemini-claude-sonnet-4-5-thinking': {
|
||||
thinking: { min: 1024, max: 200000, zeroAllowed: false, dynamicAllowed: true },
|
||||
maxCompletionTokens: 64000
|
||||
},
|
||||
'gemini-claude-opus-4-5-thinking': {
|
||||
thinking: { min: 1024, max: 200000, zeroAllowed: false, dynamicAllowed: true },
|
||||
maxCompletionTokens: 64000
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAntigravityModelInput(model, defaultModel = DEFAULT_ANTIGRAVITY_MODEL) {
|
||||
if (!model) {
|
||||
return defaultModel
|
||||
}
|
||||
return model.startsWith('models/') ? model.slice('models/'.length) : model
|
||||
}
|
||||
|
||||
function getAntigravityModelAlias(modelName) {
|
||||
const normalized = normalizeAntigravityModelInput(modelName)
|
||||
if (Object.prototype.hasOwnProperty.call(UPSTREAM_TO_ALIAS, normalized)) {
|
||||
return UPSTREAM_TO_ALIAS[normalized]
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getAntigravityModelMetadata(modelName) {
|
||||
const normalized = normalizeAntigravityModelInput(modelName)
|
||||
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, normalized)) {
|
||||
return ANTIGRAVITY_MODEL_METADATA[normalized]
|
||||
}
|
||||
if (normalized.startsWith('claude-')) {
|
||||
const prefixed = `gemini-${normalized}`
|
||||
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, prefixed)) {
|
||||
return ANTIGRAVITY_MODEL_METADATA[prefixed]
|
||||
}
|
||||
const thinkingAlias = `${prefixed}-thinking`
|
||||
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, thinkingAlias)) {
|
||||
return ANTIGRAVITY_MODEL_METADATA[thinkingAlias]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mapAntigravityUpstreamModel(model) {
|
||||
const normalized = normalizeAntigravityModelInput(model)
|
||||
let upstream = Object.prototype.hasOwnProperty.call(ALIAS_TO_UPSTREAM, normalized)
|
||||
? ALIAS_TO_UPSTREAM[normalized]
|
||||
: normalized
|
||||
|
||||
if (upstream.startsWith('gemini-claude-')) {
|
||||
upstream = upstream.replace(/^gemini-/, '')
|
||||
}
|
||||
|
||||
const mapping = {
|
||||
// Opus:上游更常见的是 thinking 变体(CLIProxyAPI 也按此处理)
|
||||
'claude-opus-4-5': 'claude-opus-4-5-thinking',
|
||||
// Gemini thinking 变体回退
|
||||
'gemini-2.5-flash-thinking': 'gemini-2.5-flash'
|
||||
}
|
||||
|
||||
return mapping[upstream] || upstream
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeAntigravityModelInput,
|
||||
getAntigravityModelAlias,
|
||||
getAntigravityModelMetadata,
|
||||
mapAntigravityUpstreamModel
|
||||
}
|
||||
121
src/utils/antigravityUpstreamDump.js
Normal file
121
src/utils/antigravityUpstreamDump.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const path = require('path')
|
||||
const logger = require('./logger')
|
||||
const { getProjectRoot } = require('./projectPaths')
|
||||
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||
|
||||
const UPSTREAM_REQUEST_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP'
|
||||
const UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES'
|
||||
const UPSTREAM_REQUEST_DUMP_FILENAME = 'antigravity-upstream-requests-dump.jsonl'
|
||||
|
||||
function isEnabled() {
|
||||
const raw = process.env[UPSTREAM_REQUEST_DUMP_ENV]
|
||||
if (!raw) {
|
||||
return false
|
||||
}
|
||||
const normalized = String(raw).trim().toLowerCase()
|
||||
return normalized === '1' || normalized === 'true'
|
||||
}
|
||||
|
||||
function getMaxBytes() {
|
||||
const raw = process.env[UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV]
|
||||
if (!raw) {
|
||||
return 2 * 1024 * 1024
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 2 * 1024 * 1024
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function redact(value) {
|
||||
if (!value) {
|
||||
return value
|
||||
}
|
||||
const s = String(value)
|
||||
if (s.length <= 10) {
|
||||
return '***'
|
||||
}
|
||||
return `${s.slice(0, 3)}...${s.slice(-4)}`
|
||||
}
|
||||
|
||||
function safeJsonStringify(payload, maxBytes) {
|
||||
let json = ''
|
||||
try {
|
||||
json = JSON.stringify(payload)
|
||||
} catch (e) {
|
||||
return JSON.stringify({
|
||||
type: 'antigravity_upstream_dump_error',
|
||||
error: 'JSON.stringify_failed',
|
||||
message: e?.message || String(e)
|
||||
})
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||
return json
|
||||
}
|
||||
|
||||
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||
return JSON.stringify({
|
||||
type: 'antigravity_upstream_dump_truncated',
|
||||
maxBytes,
|
||||
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||
partialJson: truncated
|
||||
})
|
||||
}
|
||||
|
||||
async function dumpAntigravityUpstreamRequest(requestInfo) {
|
||||
if (!isEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxBytes = getMaxBytes()
|
||||
const filename = path.join(getProjectRoot(), UPSTREAM_REQUEST_DUMP_FILENAME)
|
||||
|
||||
const record = {
|
||||
ts: new Date().toISOString(),
|
||||
type: 'antigravity_upstream_request',
|
||||
requestId: requestInfo?.requestId || null,
|
||||
model: requestInfo?.model || null,
|
||||
stream: Boolean(requestInfo?.stream),
|
||||
url: requestInfo?.url || null,
|
||||
baseUrl: requestInfo?.baseUrl || null,
|
||||
params: requestInfo?.params || null,
|
||||
headers: requestInfo?.headers
|
||||
? {
|
||||
Host: requestInfo.headers.Host || requestInfo.headers.host || null,
|
||||
'User-Agent':
|
||||
requestInfo.headers['User-Agent'] || requestInfo.headers['user-agent'] || null,
|
||||
Authorization: (() => {
|
||||
const raw = requestInfo.headers.Authorization || requestInfo.headers.authorization
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const value = String(raw)
|
||||
const m = value.match(/^Bearer\\s+(.+)$/i)
|
||||
const token = m ? m[1] : value
|
||||
return `Bearer ${redact(token)}`
|
||||
})()
|
||||
}
|
||||
: null,
|
||||
envelope: requestInfo?.envelope || null
|
||||
}
|
||||
|
||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||
try {
|
||||
await safeRotatingAppend(filename, line)
|
||||
} catch (e) {
|
||||
logger.warn('Failed to dump Antigravity upstream request', {
|
||||
filename,
|
||||
requestId: requestInfo?.requestId || null,
|
||||
error: e?.message || String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dumpAntigravityUpstreamRequest,
|
||||
UPSTREAM_REQUEST_DUMP_ENV,
|
||||
UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV,
|
||||
UPSTREAM_REQUEST_DUMP_FILENAME
|
||||
}
|
||||
175
src/utils/antigravityUpstreamResponseDump.js
Normal file
175
src/utils/antigravityUpstreamResponseDump.js
Normal file
@@ -0,0 +1,175 @@
|
||||
const path = require('path')
|
||||
const logger = require('./logger')
|
||||
const { getProjectRoot } = require('./projectPaths')
|
||||
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||
|
||||
const UPSTREAM_RESPONSE_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP'
|
||||
const UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP_MAX_BYTES'
|
||||
const UPSTREAM_RESPONSE_DUMP_FILENAME = 'antigravity-upstream-responses-dump.jsonl'
|
||||
|
||||
function isEnabled() {
|
||||
const raw = process.env[UPSTREAM_RESPONSE_DUMP_ENV]
|
||||
if (!raw) {
|
||||
return false
|
||||
}
|
||||
const normalized = String(raw).trim().toLowerCase()
|
||||
return normalized === '1' || normalized === 'true'
|
||||
}
|
||||
|
||||
function getMaxBytes() {
|
||||
const raw = process.env[UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV]
|
||||
if (!raw) {
|
||||
return 2 * 1024 * 1024
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 2 * 1024 * 1024
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function safeJsonStringify(payload, maxBytes) {
|
||||
let json = ''
|
||||
try {
|
||||
json = JSON.stringify(payload)
|
||||
} catch (e) {
|
||||
return JSON.stringify({
|
||||
type: 'antigravity_upstream_response_dump_error',
|
||||
error: 'JSON.stringify_failed',
|
||||
message: e?.message || String(e)
|
||||
})
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||
return json
|
||||
}
|
||||
|
||||
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||
return JSON.stringify({
|
||||
type: 'antigravity_upstream_response_dump_truncated',
|
||||
maxBytes,
|
||||
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||
partialJson: truncated
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 Antigravity 上游 API 的响应
|
||||
* @param {Object} responseInfo - 响应信息
|
||||
* @param {string} responseInfo.requestId - 请求 ID
|
||||
* @param {string} responseInfo.model - 模型名称
|
||||
* @param {number} responseInfo.statusCode - HTTP 状态码
|
||||
* @param {string} responseInfo.statusText - HTTP 状态文本
|
||||
* @param {Object} responseInfo.headers - 响应头
|
||||
* @param {string} responseInfo.responseType - 响应类型 (stream/non-stream/error)
|
||||
* @param {Object} responseInfo.summary - 响应摘要
|
||||
* @param {Object} responseInfo.error - 错误信息(如果有)
|
||||
*/
|
||||
async function dumpAntigravityUpstreamResponse(responseInfo) {
|
||||
if (!isEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxBytes = getMaxBytes()
|
||||
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
|
||||
|
||||
const record = {
|
||||
ts: new Date().toISOString(),
|
||||
type: 'antigravity_upstream_response',
|
||||
requestId: responseInfo?.requestId || null,
|
||||
model: responseInfo?.model || null,
|
||||
statusCode: responseInfo?.statusCode || null,
|
||||
statusText: responseInfo?.statusText || null,
|
||||
responseType: responseInfo?.responseType || null,
|
||||
headers: responseInfo?.headers || null,
|
||||
summary: responseInfo?.summary || null,
|
||||
error: responseInfo?.error || null,
|
||||
rawData: responseInfo?.rawData || null
|
||||
}
|
||||
|
||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||
try {
|
||||
await safeRotatingAppend(filename, line)
|
||||
} catch (e) {
|
||||
logger.warn('Failed to dump Antigravity upstream response', {
|
||||
filename,
|
||||
requestId: responseInfo?.requestId || null,
|
||||
error: e?.message || String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 SSE 流中的每个事件(用于详细调试)
|
||||
*/
|
||||
async function dumpAntigravityStreamEvent(eventInfo) {
|
||||
if (!isEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxBytes = getMaxBytes()
|
||||
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
|
||||
|
||||
const record = {
|
||||
ts: new Date().toISOString(),
|
||||
type: 'antigravity_stream_event',
|
||||
requestId: eventInfo?.requestId || null,
|
||||
eventIndex: eventInfo?.eventIndex || null,
|
||||
eventType: eventInfo?.eventType || null,
|
||||
data: eventInfo?.data || null
|
||||
}
|
||||
|
||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||
try {
|
||||
await safeRotatingAppend(filename, line)
|
||||
} catch (e) {
|
||||
// 静默处理,避免日志过多
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录流式响应的最终摘要
|
||||
*/
|
||||
async function dumpAntigravityStreamSummary(summaryInfo) {
|
||||
if (!isEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxBytes = getMaxBytes()
|
||||
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
|
||||
|
||||
const record = {
|
||||
ts: new Date().toISOString(),
|
||||
type: 'antigravity_stream_summary',
|
||||
requestId: summaryInfo?.requestId || null,
|
||||
model: summaryInfo?.model || null,
|
||||
totalEvents: summaryInfo?.totalEvents || 0,
|
||||
finishReason: summaryInfo?.finishReason || null,
|
||||
hasThinking: summaryInfo?.hasThinking || false,
|
||||
hasToolCalls: summaryInfo?.hasToolCalls || false,
|
||||
toolCallNames: summaryInfo?.toolCallNames || [],
|
||||
usage: summaryInfo?.usage || null,
|
||||
error: summaryInfo?.error || null,
|
||||
textPreview: summaryInfo?.textPreview || null
|
||||
}
|
||||
|
||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||
try {
|
||||
await safeRotatingAppend(filename, line)
|
||||
} catch (e) {
|
||||
logger.warn('Failed to dump Antigravity stream summary', {
|
||||
filename,
|
||||
requestId: summaryInfo?.requestId || null,
|
||||
error: e?.message || String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dumpAntigravityUpstreamResponse,
|
||||
dumpAntigravityStreamEvent,
|
||||
dumpAntigravityStreamSummary,
|
||||
UPSTREAM_RESPONSE_DUMP_ENV,
|
||||
UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV,
|
||||
UPSTREAM_RESPONSE_DUMP_FILENAME
|
||||
}
|
||||
@@ -79,6 +79,11 @@ const PROMPT_DEFINITIONS = {
|
||||
title: 'Claude Code Compact System Prompt Agent SDK2',
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."
|
||||
},
|
||||
claudeOtherSystemPrompt5: {
|
||||
category: 'system',
|
||||
title: 'Claude CLI Billing Header',
|
||||
text: 'x-anthropic-billing-header: cc_version=2.1.15.c5a; cc_entrypoint=cli'
|
||||
},
|
||||
claudeOtherSystemPromptCompact: {
|
||||
category: 'system',
|
||||
title: 'Claude Code Compact System Prompt',
|
||||
|
||||
@@ -55,16 +55,69 @@ function sanitizeUpstreamError(errorData) {
|
||||
return errorData
|
||||
}
|
||||
|
||||
// 深拷贝避免修改原始对象
|
||||
const sanitized = JSON.parse(JSON.stringify(errorData))
|
||||
// AxiosError / Error:返回摘要,避免泄露请求体/headers/token 等敏感信息
|
||||
const looksLikeAxiosError =
|
||||
errorData.isAxiosError ||
|
||||
(errorData.name === 'AxiosError' && (errorData.config || errorData.response))
|
||||
const looksLikeError = errorData instanceof Error || typeof errorData.message === 'string'
|
||||
|
||||
if (looksLikeAxiosError || looksLikeError) {
|
||||
const statusCode = errorData.response?.status
|
||||
const upstreamBody = errorData.response?.data
|
||||
const upstreamMessage = sanitizeErrorMessage(extractErrorMessage(upstreamBody) || '')
|
||||
|
||||
return {
|
||||
name: errorData.name || 'Error',
|
||||
code: errorData.code,
|
||||
statusCode,
|
||||
message: sanitizeErrorMessage(errorData.message || ''),
|
||||
upstreamMessage: upstreamMessage || undefined,
|
||||
upstreamType: upstreamBody?.error?.type || upstreamBody?.error?.status || undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 递归清理嵌套的错误对象
|
||||
const visited = new WeakSet()
|
||||
|
||||
const shouldRedactKey = (key) => {
|
||||
if (!key) {
|
||||
return false
|
||||
}
|
||||
const lowerKey = String(key).toLowerCase()
|
||||
return (
|
||||
lowerKey === 'authorization' ||
|
||||
lowerKey === 'cookie' ||
|
||||
lowerKey.includes('api_key') ||
|
||||
lowerKey.includes('apikey') ||
|
||||
lowerKey.includes('access_token') ||
|
||||
lowerKey.includes('refresh_token') ||
|
||||
lowerKey.endsWith('token') ||
|
||||
lowerKey.includes('secret') ||
|
||||
lowerKey.includes('password')
|
||||
)
|
||||
}
|
||||
|
||||
const sanitizeObject = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (visited.has(obj)) {
|
||||
return '[Circular]'
|
||||
}
|
||||
visited.add(obj)
|
||||
|
||||
// 主动剔除常见“超大且敏感”的字段
|
||||
if (obj.config || obj.request || obj.response) {
|
||||
return '[Redacted]'
|
||||
}
|
||||
|
||||
for (const key in obj) {
|
||||
if (shouldRedactKey(key)) {
|
||||
obj[key] = '[REDACTED]'
|
||||
continue
|
||||
}
|
||||
|
||||
// 清理所有字符串字段,不仅仅是 message
|
||||
if (typeof obj[key] === 'string') {
|
||||
obj[key] = sanitizeErrorMessage(obj[key])
|
||||
@@ -76,7 +129,9 @@ function sanitizeUpstreamError(errorData) {
|
||||
return obj
|
||||
}
|
||||
|
||||
return sanitizeObject(sanitized)
|
||||
// 尽量不修改原对象:浅拷贝后递归清理
|
||||
const clone = Array.isArray(errorData) ? [...errorData] : { ...errorData }
|
||||
return sanitizeObject(clone)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
46
src/utils/featureFlags.js
Normal file
46
src/utils/featureFlags.js
Normal file
@@ -0,0 +1,46 @@
|
||||
let config = {}
|
||||
try {
|
||||
// config/config.js 可能在某些环境不存在(例如仅拷贝了 config.example.js)
|
||||
// 为保证可运行,这里做容错处理
|
||||
// eslint-disable-next-line global-require
|
||||
config = require('../../config/config')
|
||||
} catch (error) {
|
||||
config = {}
|
||||
}
|
||||
|
||||
const parseBooleanEnv = (value) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return false
|
||||
}
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否允许执行"余额脚本"(安全开关)
|
||||
* ⚠️ 安全警告:vm模块非安全沙箱,默认禁用。如需启用请显式设置 BALANCE_SCRIPT_ENABLED=true
|
||||
* 仅在完全信任管理员且了解RCE风险时才启用此功能
|
||||
*/
|
||||
const isBalanceScriptEnabled = () => {
|
||||
if (
|
||||
process.env.BALANCE_SCRIPT_ENABLED !== undefined &&
|
||||
process.env.BALANCE_SCRIPT_ENABLED !== ''
|
||||
) {
|
||||
return parseBooleanEnv(process.env.BALANCE_SCRIPT_ENABLED)
|
||||
}
|
||||
|
||||
const fromConfig =
|
||||
config?.accountBalance?.enableBalanceScript ??
|
||||
config?.features?.balanceScriptEnabled ??
|
||||
config?.security?.enableBalanceScript
|
||||
|
||||
// 默认禁用,需显式启用
|
||||
return typeof fromConfig === 'boolean' ? fromConfig : false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isBalanceScriptEnabled
|
||||
}
|
||||
265
src/utils/geminiSchemaCleaner.js
Normal file
265
src/utils/geminiSchemaCleaner.js
Normal file
@@ -0,0 +1,265 @@
|
||||
function appendHint(description, hint) {
|
||||
if (!hint) {
|
||||
return description || ''
|
||||
}
|
||||
if (!description) {
|
||||
return hint
|
||||
}
|
||||
return `${description} (${hint})`
|
||||
}
|
||||
|
||||
function getRefHint(refValue) {
|
||||
const ref = String(refValue || '')
|
||||
if (!ref) {
|
||||
return ''
|
||||
}
|
||||
const idx = ref.lastIndexOf('/')
|
||||
const name = idx >= 0 ? ref.slice(idx + 1) : ref
|
||||
return name ? `See: ${name}` : ''
|
||||
}
|
||||
|
||||
function normalizeType(typeValue) {
|
||||
if (typeof typeValue === 'string' && typeValue) {
|
||||
return { type: typeValue, hint: '' }
|
||||
}
|
||||
if (!Array.isArray(typeValue) || typeValue.length === 0) {
|
||||
return { type: '', hint: '' }
|
||||
}
|
||||
const raw = typeValue.map((t) => (t === null || t === undefined ? '' : String(t))).filter(Boolean)
|
||||
const hasNull = raw.includes('null')
|
||||
const nonNull = raw.filter((t) => t !== 'null')
|
||||
const primary = nonNull[0] || 'string'
|
||||
const hintParts = []
|
||||
if (nonNull.length > 1) {
|
||||
hintParts.push(`Accepts: ${nonNull.join(' | ')}`)
|
||||
}
|
||||
if (hasNull) {
|
||||
hintParts.push('nullable')
|
||||
}
|
||||
return { type: primary, hint: hintParts.join('; ') }
|
||||
}
|
||||
|
||||
const CONSTRAINT_KEYS = [
|
||||
'minLength',
|
||||
'maxLength',
|
||||
'exclusiveMinimum',
|
||||
'exclusiveMaximum',
|
||||
'pattern',
|
||||
'minItems',
|
||||
'maxItems'
|
||||
]
|
||||
|
||||
function scoreSchema(schema) {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return { score: 0, type: '' }
|
||||
}
|
||||
const t = typeof schema.type === 'string' ? schema.type : ''
|
||||
if (t === 'object' || (schema.properties && typeof schema.properties === 'object')) {
|
||||
return { score: 3, type: t || 'object' }
|
||||
}
|
||||
if (t === 'array' || schema.items) {
|
||||
return { score: 2, type: t || 'array' }
|
||||
}
|
||||
if (t && t !== 'null') {
|
||||
return { score: 1, type: t }
|
||||
}
|
||||
return { score: 0, type: t || 'null' }
|
||||
}
|
||||
|
||||
function pickBestFromAlternatives(alternatives) {
|
||||
let bestIndex = 0
|
||||
let bestScore = -1
|
||||
const types = []
|
||||
for (let i = 0; i < alternatives.length; i += 1) {
|
||||
const alt = alternatives[i]
|
||||
const { score, type } = scoreSchema(alt)
|
||||
if (type) {
|
||||
types.push(type)
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
return { best: alternatives[bestIndex], types: Array.from(new Set(types)).filter(Boolean) }
|
||||
}
|
||||
|
||||
function cleanJsonSchemaForGemini(schema) {
|
||||
if (schema === null || schema === undefined) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
if (typeof schema !== 'object') {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
if (Array.isArray(schema)) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
// $ref:Gemini/Antigravity 不支持,转换为 hint
|
||||
if (typeof schema.$ref === 'string' && schema.$ref) {
|
||||
return {
|
||||
type: 'object',
|
||||
description: appendHint(schema.description || '', getRefHint(schema.$ref)),
|
||||
properties: {}
|
||||
}
|
||||
}
|
||||
|
||||
// anyOf / oneOf:选择最可能的 schema,保留类型提示
|
||||
const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : null
|
||||
const oneOf = Array.isArray(schema.oneOf) ? schema.oneOf : null
|
||||
const alts = anyOf && anyOf.length ? anyOf : oneOf && oneOf.length ? oneOf : null
|
||||
if (alts) {
|
||||
const { best, types } = pickBestFromAlternatives(alts)
|
||||
const cleaned = cleanJsonSchemaForGemini(best)
|
||||
const mergedDescription = appendHint(cleaned.description || '', schema.description || '')
|
||||
const typeHint = types.length > 1 ? `Accepts: ${types.join(' || ')}` : ''
|
||||
return {
|
||||
...cleaned,
|
||||
description: appendHint(mergedDescription, typeHint)
|
||||
}
|
||||
}
|
||||
|
||||
// allOf:合并 properties/required
|
||||
if (Array.isArray(schema.allOf) && schema.allOf.length) {
|
||||
const merged = {}
|
||||
let mergedDesc = schema.description || ''
|
||||
const mergedReq = new Set()
|
||||
const mergedProps = {}
|
||||
for (const item of schema.allOf) {
|
||||
const cleaned = cleanJsonSchemaForGemini(item)
|
||||
if (cleaned.description) {
|
||||
mergedDesc = appendHint(mergedDesc, cleaned.description)
|
||||
}
|
||||
if (Array.isArray(cleaned.required)) {
|
||||
for (const r of cleaned.required) {
|
||||
if (typeof r === 'string' && r) {
|
||||
mergedReq.add(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cleaned.properties && typeof cleaned.properties === 'object') {
|
||||
Object.assign(mergedProps, cleaned.properties)
|
||||
}
|
||||
if (cleaned.type && !merged.type) {
|
||||
merged.type = cleaned.type
|
||||
}
|
||||
if (cleaned.items && !merged.items) {
|
||||
merged.items = cleaned.items
|
||||
}
|
||||
if (Array.isArray(cleaned.enum) && !merged.enum) {
|
||||
merged.enum = cleaned.enum
|
||||
}
|
||||
}
|
||||
if (Object.keys(mergedProps).length) {
|
||||
merged.type = merged.type || 'object'
|
||||
merged.properties = mergedProps
|
||||
const req = Array.from(mergedReq).filter((r) => mergedProps[r])
|
||||
if (req.length) {
|
||||
merged.required = req
|
||||
}
|
||||
}
|
||||
if (mergedDesc) {
|
||||
merged.description = mergedDesc
|
||||
}
|
||||
return cleanJsonSchemaForGemini(merged)
|
||||
}
|
||||
|
||||
const result = {}
|
||||
const constraintHints = []
|
||||
|
||||
// description
|
||||
if (typeof schema.description === 'string') {
|
||||
result.description = schema.description
|
||||
}
|
||||
|
||||
for (const key of CONSTRAINT_KEYS) {
|
||||
const value = schema[key]
|
||||
if (value === undefined || value === null || typeof value === 'object') {
|
||||
continue
|
||||
}
|
||||
constraintHints.push(`${key}: ${value}`)
|
||||
}
|
||||
|
||||
// const -> enum
|
||||
if (schema.const !== undefined && !Array.isArray(schema.enum)) {
|
||||
result.enum = [schema.const]
|
||||
}
|
||||
|
||||
// enum
|
||||
if (Array.isArray(schema.enum)) {
|
||||
const en = schema.enum.filter(
|
||||
(v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
|
||||
)
|
||||
if (en.length) {
|
||||
result.enum = en
|
||||
}
|
||||
}
|
||||
|
||||
// type(flatten 数组 type)
|
||||
const { type: normalizedType, hint: typeHint } = normalizeType(schema.type)
|
||||
if (normalizedType) {
|
||||
result.type = normalizedType
|
||||
}
|
||||
if (typeHint) {
|
||||
result.description = appendHint(result.description || '', typeHint)
|
||||
}
|
||||
|
||||
if (result.enum && result.enum.length > 1 && result.enum.length <= 10) {
|
||||
const list = result.enum.map((item) => String(item)).join(', ')
|
||||
result.description = appendHint(result.description || '', `Allowed: ${list}`)
|
||||
}
|
||||
|
||||
if (constraintHints.length) {
|
||||
result.description = appendHint(result.description || '', constraintHints.join(', '))
|
||||
}
|
||||
|
||||
// additionalProperties:Gemini/Antigravity 不接受布尔值,直接删除并用 hint 记录
|
||||
if (schema.additionalProperties === false) {
|
||||
result.description = appendHint(result.description || '', 'No extra properties allowed')
|
||||
}
|
||||
|
||||
// properties
|
||||
if (
|
||||
schema.properties &&
|
||||
typeof schema.properties === 'object' &&
|
||||
!Array.isArray(schema.properties)
|
||||
) {
|
||||
const props = {}
|
||||
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
||||
props[name] = cleanJsonSchemaForGemini(propSchema)
|
||||
}
|
||||
result.type = result.type || 'object'
|
||||
result.properties = props
|
||||
}
|
||||
|
||||
// items
|
||||
if (schema.items !== undefined) {
|
||||
result.type = result.type || 'array'
|
||||
result.items = cleanJsonSchemaForGemini(schema.items)
|
||||
}
|
||||
|
||||
// required(最后再清理无效字段)
|
||||
if (Array.isArray(schema.required) && result.properties) {
|
||||
const req = schema.required.filter(
|
||||
(r) =>
|
||||
typeof r === 'string' && r && Object.prototype.hasOwnProperty.call(result.properties, r)
|
||||
)
|
||||
if (req.length) {
|
||||
result.required = req
|
||||
}
|
||||
}
|
||||
|
||||
// 只保留 Gemini 兼容字段:其他($schema/$id/$defs/definitions/format/constraints/pattern...)一律丢弃
|
||||
|
||||
if (!result.type) {
|
||||
result.type = result.properties ? 'object' : result.items ? 'array' : 'object'
|
||||
}
|
||||
if (result.type === 'object' && !result.properties) {
|
||||
result.properties = {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanJsonSchemaForGemini
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
* Supports parsing model strings like "ccr,model_name" to extract vendor type and base model.
|
||||
*/
|
||||
|
||||
// 仅保留原仓库既有的模型前缀:CCR 路由
|
||||
// Gemini/Antigravity 采用“路径分流”,避免在 model 字段里混入 vendor 前缀造成混乱
|
||||
const SUPPORTED_VENDOR_PREFIXES = ['ccr']
|
||||
|
||||
/**
|
||||
* Parse vendor-prefixed model string
|
||||
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
|
||||
@@ -19,16 +23,21 @@ function parseVendorPrefixedModel(modelStr) {
|
||||
const trimmed = modelStr.trim()
|
||||
const lowerTrimmed = trimmed.toLowerCase()
|
||||
|
||||
// Check for ccr prefix (case insensitive)
|
||||
if (lowerTrimmed.startsWith('ccr,')) {
|
||||
for (const vendorPrefix of SUPPORTED_VENDOR_PREFIXES) {
|
||||
if (!lowerTrimmed.startsWith(`${vendorPrefix},`)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = trimmed.split(',')
|
||||
if (parts.length >= 2) {
|
||||
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
|
||||
const baseModel = parts.slice(1).join(',').trim()
|
||||
return {
|
||||
vendor: 'ccr',
|
||||
baseModel
|
||||
}
|
||||
if (parts.length < 2) {
|
||||
break
|
||||
}
|
||||
|
||||
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
|
||||
const baseModel = parts.slice(1).join(',').trim()
|
||||
return {
|
||||
vendor: vendorPrefix,
|
||||
baseModel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,10 +188,54 @@ function isOpus45OrNewer(modelName) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个 model 名称是否属于 Anthropic Claude 系列模型。
|
||||
*
|
||||
* 用于 API Key 维度的限额/统计(Claude 周费用)。这里刻意覆盖以下命名:
|
||||
* - 标准 Anthropic 模型:claude-*,包括 claude-3-opus、claude-sonnet-*、claude-haiku-* 等
|
||||
* - Bedrock 模型:{region}.anthropic.claude-... / anthropic.claude-...
|
||||
* - 少数情况下 model 字段可能只包含家族关键词(sonnet/haiku/opus),也视为 Claude 系列
|
||||
*
|
||||
* 注意:会先去掉支持的 vendor 前缀(例如 "ccr,")。
|
||||
*/
|
||||
function isClaudeFamilyModel(modelName) {
|
||||
if (!modelName || typeof modelName !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
const { baseModel } = parseVendorPrefixedModel(modelName)
|
||||
const m = (baseModel || '').trim().toLowerCase()
|
||||
if (!m) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Bedrock 模型格式
|
||||
if (
|
||||
m.includes('.anthropic.claude-') ||
|
||||
m.startsWith('anthropic.claude-') ||
|
||||
m.includes('.claude-')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 标准 Anthropic 模型 ID
|
||||
if (m.startsWith('claude-') || m.includes('claude-')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 兜底:某些下游链路里 model 字段可能不带 "claude-" 前缀,但仍包含家族关键词。
|
||||
if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseVendorPrefixedModel,
|
||||
hasVendorPrefix,
|
||||
getEffectiveModel,
|
||||
getVendorType,
|
||||
isOpus45OrNewer
|
||||
isOpus45OrNewer,
|
||||
isClaudeFamilyModel
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ const OAUTH_CONFIG = {
|
||||
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
|
||||
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
|
||||
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
||||
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback',
|
||||
SCOPES: 'org:create_api_key user:profile user:inference',
|
||||
REDIRECT_URI: 'https://platform.claude.com/oauth/code/callback',
|
||||
SCOPES: 'org:create_api_key user:profile user:inference user:sessions:claude_code',
|
||||
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ function generateState() {
|
||||
|
||||
/**
|
||||
* 生成随机的 code verifier(PKCE)
|
||||
* 符合 RFC 7636 标准:32字节随机数 → base64url编码 → 43字符
|
||||
* @returns {string} base64url 编码的随机字符串
|
||||
*/
|
||||
function generateCodeVerifier() {
|
||||
|
||||
10
src/utils/projectPaths.js
Normal file
10
src/utils/projectPaths.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const path = require('path')
|
||||
|
||||
// 该文件位于 src/utils 下,向上两级即项目根目录。
|
||||
function getProjectRoot() {
|
||||
return path.resolve(__dirname, '..', '..')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProjectRoot
|
||||
}
|
||||
88
src/utils/safeRotatingAppend.js
Normal file
88
src/utils/safeRotatingAppend.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* 安全 JSONL 追加工具(带文件大小限制与自动轮转)
|
||||
* ============================================================================
|
||||
*
|
||||
* 用于所有调试 Dump 模块,避免日志文件无限增长导致 I/O 拥塞。
|
||||
*
|
||||
* 策略:
|
||||
* - 每次写入前检查目标文件大小
|
||||
* - 超过阈值时,将现有文件重命名为 .bak(覆盖旧 .bak)
|
||||
* - 然后写入新文件
|
||||
*/
|
||||
|
||||
const fs = require('fs/promises')
|
||||
const logger = require('./logger')
|
||||
|
||||
// 默认文件大小上限:10MB
|
||||
const DEFAULT_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_FILE_SIZE_ENV = 'DUMP_MAX_FILE_SIZE_BYTES'
|
||||
|
||||
/**
|
||||
* 获取文件大小上限(可通过环境变量覆盖)
|
||||
*/
|
||||
function getMaxFileSize() {
|
||||
const raw = process.env[MAX_FILE_SIZE_ENV]
|
||||
if (raw) {
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return DEFAULT_MAX_FILE_SIZE_BYTES
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件大小,文件不存在时返回 0
|
||||
*/
|
||||
async function getFileSize(filepath) {
|
||||
try {
|
||||
const stat = await fs.stat(filepath)
|
||||
return stat.size
|
||||
} catch (e) {
|
||||
// 文件不存在或无法读取
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全追加写入 JSONL 文件,支持自动轮转
|
||||
*
|
||||
* @param {string} filepath - 目标文件绝对路径
|
||||
* @param {string} line - 要写入的单行(应以 \n 结尾)
|
||||
* @param {Object} options - 可选配置
|
||||
* @param {number} options.maxFileSize - 文件大小上限(字节),默认从环境变量或 10MB
|
||||
*/
|
||||
async function safeRotatingAppend(filepath, line, options = {}) {
|
||||
const maxFileSize = options.maxFileSize || getMaxFileSize()
|
||||
|
||||
const currentSize = await getFileSize(filepath)
|
||||
|
||||
// 如果当前文件已达到或超过阈值,轮转
|
||||
if (currentSize >= maxFileSize) {
|
||||
const backupPath = `${filepath}.bak`
|
||||
try {
|
||||
// 先删除旧备份(如果存在)
|
||||
await fs.unlink(backupPath).catch(() => {})
|
||||
// 重命名当前文件为备份
|
||||
await fs.rename(filepath, backupPath)
|
||||
} catch (renameErr) {
|
||||
// 轮转失败时记录警告日志,继续写入原文件
|
||||
logger.warn('⚠️ Log rotation failed, continuing to write to original file', {
|
||||
filepath,
|
||||
backupPath,
|
||||
error: renameErr?.message || String(renameErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 追加写入
|
||||
await fs.appendFile(filepath, line, { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
safeRotatingAppend,
|
||||
getMaxFileSize,
|
||||
MAX_FILE_SIZE_ENV,
|
||||
DEFAULT_MAX_FILE_SIZE_BYTES
|
||||
}
|
||||
183
src/utils/signatureCache.js
Normal file
183
src/utils/signatureCache.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Signature Cache - 签名缓存模块
|
||||
*
|
||||
* 用于缓存 Antigravity thinking block 的 thoughtSignature。
|
||||
* Claude Code 客户端可能剥离非标准字段,导致多轮对话时签名丢失。
|
||||
* 此模块按 sessionId + thinkingText 存储签名,便于后续请求恢复。
|
||||
*
|
||||
* 参考实现:
|
||||
* - CLIProxyAPI: internal/cache/signature_cache.go
|
||||
* - antigravity-claude-proxy: src/format/signature-cache.js
|
||||
*/
|
||||
|
||||
const crypto = require('crypto')
|
||||
const logger = require('./logger')
|
||||
|
||||
// 配置常量
|
||||
const SIGNATURE_CACHE_TTL_MS = 60 * 60 * 1000 // 1 小时(同 CLIProxyAPI)
|
||||
const MAX_ENTRIES_PER_SESSION = 100 // 每会话最大缓存条目
|
||||
const MIN_SIGNATURE_LENGTH = 50 // 最小有效签名长度
|
||||
const TEXT_HASH_LENGTH = 16 // 文本哈希长度(SHA256 前 16 位)
|
||||
|
||||
// 主缓存:sessionId -> Map<textHash, { signature, timestamp }>
|
||||
const signatureCache = new Map()
|
||||
|
||||
/**
|
||||
* 生成文本内容的稳定哈希值
|
||||
* @param {string} text - 待哈希的文本
|
||||
* @returns {string} 16 字符的十六进制哈希
|
||||
*/
|
||||
function hashText(text) {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return ''
|
||||
}
|
||||
const hash = crypto.createHash('sha256').update(text).digest('hex')
|
||||
return hash.slice(0, TEXT_HASH_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建会话缓存
|
||||
* @param {string} sessionId - 会话 ID
|
||||
* @returns {Map} 会话的签名缓存 Map
|
||||
*/
|
||||
function getOrCreateSessionCache(sessionId) {
|
||||
if (!signatureCache.has(sessionId)) {
|
||||
signatureCache.set(sessionId, new Map())
|
||||
}
|
||||
return signatureCache.get(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查签名是否有效
|
||||
* @param {string} signature - 待检查的签名
|
||||
* @returns {boolean} 签名是否有效
|
||||
*/
|
||||
function isValidSignature(signature) {
|
||||
return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存 thinking 签名
|
||||
* @param {string} sessionId - 会话 ID
|
||||
* @param {string} thinkingText - thinking 内容文本
|
||||
* @param {string} signature - thoughtSignature
|
||||
*/
|
||||
function cacheSignature(sessionId, thinkingText, signature) {
|
||||
if (!sessionId || !thinkingText || !signature) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidSignature(signature)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionCache = getOrCreateSessionCache(sessionId)
|
||||
const textHash = hashText(thinkingText)
|
||||
|
||||
if (!textHash) {
|
||||
return
|
||||
}
|
||||
|
||||
// 淘汰策略:超过限制时删除最老的 1/4 条目
|
||||
if (sessionCache.size >= MAX_ENTRIES_PER_SESSION) {
|
||||
const entries = Array.from(sessionCache.entries())
|
||||
entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
|
||||
const toRemove = Math.max(1, Math.floor(entries.length / 4))
|
||||
for (let i = 0; i < toRemove; i++) {
|
||||
sessionCache.delete(entries[i][0])
|
||||
}
|
||||
logger.debug(
|
||||
`[SignatureCache] Evicted ${toRemove} old entries for session ${sessionId.slice(0, 8)}...`
|
||||
)
|
||||
}
|
||||
|
||||
sessionCache.set(textHash, {
|
||||
signature,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
logger.debug(
|
||||
`[SignatureCache] Cached signature for session ${sessionId.slice(0, 8)}..., hash ${textHash}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的签名
|
||||
* @param {string} sessionId - 会话 ID
|
||||
* @param {string} thinkingText - thinking 内容文本
|
||||
* @returns {string|null} 缓存的签名,未找到或过期则返回 null
|
||||
*/
|
||||
function getCachedSignature(sessionId, thinkingText) {
|
||||
if (!sessionId || !thinkingText) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionCache = signatureCache.get(sessionId)
|
||||
if (!sessionCache) {
|
||||
return null
|
||||
}
|
||||
|
||||
const textHash = hashText(thinkingText)
|
||||
if (!textHash) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entry = sessionCache.get(textHash)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() - entry.timestamp > SIGNATURE_CACHE_TTL_MS) {
|
||||
sessionCache.delete(textHash)
|
||||
logger.debug(`[SignatureCache] Entry expired for hash ${textHash}`)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[SignatureCache] Cache hit for session ${sessionId.slice(0, 8)}..., hash ${textHash}`
|
||||
)
|
||||
return entry.signature
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除会话缓存
|
||||
* @param {string} sessionId - 要清除的会话 ID,为空则清除全部
|
||||
*/
|
||||
function clearSignatureCache(sessionId = null) {
|
||||
if (sessionId) {
|
||||
signatureCache.delete(sessionId)
|
||||
logger.debug(`[SignatureCache] Cleared cache for session ${sessionId.slice(0, 8)}...`)
|
||||
} else {
|
||||
signatureCache.clear()
|
||||
logger.debug('[SignatureCache] Cleared all caches')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息(调试用)
|
||||
* @returns {Object} { sessionCount, totalEntries }
|
||||
*/
|
||||
function getCacheStats() {
|
||||
let totalEntries = 0
|
||||
for (const sessionCache of signatureCache.values()) {
|
||||
totalEntries += sessionCache.size
|
||||
}
|
||||
return {
|
||||
sessionCount: signatureCache.size,
|
||||
totalEntries
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cacheSignature,
|
||||
getCachedSignature,
|
||||
clearSignatureCache,
|
||||
getCacheStats,
|
||||
isValidSignature,
|
||||
// 内部函数导出(用于测试或扩展)
|
||||
hashText,
|
||||
MIN_SIGNATURE_LENGTH,
|
||||
MAX_ENTRIES_PER_SESSION,
|
||||
SIGNATURE_CACHE_TTL_MS
|
||||
}
|
||||
@@ -59,15 +59,20 @@ class ClaudeCodeValidator {
|
||||
typeof customThreshold === 'number' && Number.isFinite(customThreshold)
|
||||
? customThreshold
|
||||
: SYSTEM_PROMPT_THRESHOLD
|
||||
|
||||
|
||||
for (const entry of systemEntries) {
|
||||
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
||||
const { bestScore } = bestSimilarityByTemplates(rawText)
|
||||
const { bestScore, templateId, maskedRaw } = bestSimilarityByTemplates(rawText)
|
||||
if (bestScore < threshold) {
|
||||
logger.error(
|
||||
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}`
|
||||
)
|
||||
logger.warn(`Claude system prompt detail: ${rawText}`)
|
||||
const preview = typeof maskedRaw === 'string' ? maskedRaw.slice(0, 200) : ''
|
||||
logger.warn(
|
||||
`Claude system prompt detail: templateId=${templateId || 'unknown'}, preview=${preview}${
|
||||
maskedRaw && maskedRaw.length > 200 ? '…' : ''
|
||||
}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,8 +125,12 @@ class CodexCliValidator {
|
||||
const part1 = parts1[i] || 0
|
||||
const part2 = parts2[i] || 0
|
||||
|
||||
if (part1 < part2) return -1
|
||||
if (part1 > part2) return 1
|
||||
if (part1 < part2) {
|
||||
return -1
|
||||
}
|
||||
if (part1 > part2) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
|
||||
@@ -53,7 +53,7 @@ class GeminiCliValidator {
|
||||
// 2. 对于 /gemini 路径,检查是否包含 generateContent
|
||||
if (path.includes('generateContent')) {
|
||||
// 包含 generateContent 的路径需要验证 User-Agent
|
||||
const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i
|
||||
const geminiCliPattern = /^GeminiCLI\/v?[\d.]+/i
|
||||
if (!geminiCliPattern.test(userAgent)) {
|
||||
logger.debug(
|
||||
`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`
|
||||
@@ -84,8 +84,12 @@ class GeminiCliValidator {
|
||||
const part1 = parts1[i] || 0
|
||||
const part2 = parts2[i] || 0
|
||||
|
||||
if (part1 < part2) return -1
|
||||
if (part1 > part2) return 1
|
||||
if (part1 < part2) {
|
||||
return -1
|
||||
}
|
||||
if (part1 > part2) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
|
||||
218
tests/accountBalanceService.test.js
Normal file
218
tests/accountBalanceService.test.js
Normal file
@@ -0,0 +1,218 @@
|
||||
// Mock logger,避免测试输出污染控制台
|
||||
jest.mock('../src/utils/logger', () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
}))
|
||||
|
||||
const accountBalanceServiceModule = require('../src/services/accountBalanceService')
|
||||
|
||||
const { AccountBalanceService } = accountBalanceServiceModule
|
||||
|
||||
describe('AccountBalanceService', () => {
|
||||
const originalBalanceScriptEnabled = process.env.BALANCE_SCRIPT_ENABLED
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBalanceScriptEnabled === undefined) {
|
||||
delete process.env.BALANCE_SCRIPT_ENABLED
|
||||
} else {
|
||||
process.env.BALANCE_SCRIPT_ENABLED = originalBalanceScriptEnabled
|
||||
}
|
||||
})
|
||||
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
}
|
||||
|
||||
const buildMockRedis = () => ({
|
||||
getLocalBalance: jest.fn().mockResolvedValue(null),
|
||||
setLocalBalance: jest.fn().mockResolvedValue(undefined),
|
||||
getAccountBalance: jest.fn().mockResolvedValue(null),
|
||||
setAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||||
deleteAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||||
getBalanceScriptConfig: jest.fn().mockResolvedValue(null),
|
||||
getAccountUsageStats: jest.fn().mockResolvedValue({
|
||||
total: { requests: 10 },
|
||||
daily: { requests: 2, cost: 20 },
|
||||
monthly: { requests: 5 }
|
||||
}),
|
||||
getDateInTimezone: (date) => new Date(date.getTime() + 8 * 3600 * 1000)
|
||||
})
|
||||
|
||||
it('should normalize platform aliases', () => {
|
||||
const service = new AccountBalanceService({ redis: buildMockRedis(), logger: mockLogger })
|
||||
expect(service.normalizePlatform('claude-official')).toBe('claude')
|
||||
expect(service.normalizePlatform('azure-openai')).toBe('azure_openai')
|
||||
expect(service.normalizePlatform('gemini-api')).toBe('gemini-api')
|
||||
})
|
||||
|
||||
it('should build local quota/balance from dailyQuota and local dailyCost', async () => {
|
||||
const mockRedis = buildMockRedis()
|
||||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||
|
||||
service._computeMonthlyCost = jest.fn().mockResolvedValue(30)
|
||||
service._computeTotalCost = jest.fn().mockResolvedValue(123.45)
|
||||
|
||||
const account = { id: 'acct-1', name: 'A', dailyQuota: '100', quotaResetTime: '00:00' }
|
||||
const result = await service._getAccountBalanceForAccount(account, 'claude-console', {
|
||||
queryApi: false,
|
||||
useCache: true
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data.source).toBe('local')
|
||||
expect(result.data.balance.amount).toBeCloseTo(80, 6)
|
||||
expect(result.data.quota.percentage).toBeCloseTo(20, 6)
|
||||
expect(result.data.statistics.totalCost).toBeCloseTo(123.45, 6)
|
||||
expect(mockRedis.setLocalBalance).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use cached balance when account has no dailyQuota', async () => {
|
||||
const mockRedis = buildMockRedis()
|
||||
mockRedis.getAccountBalance.mockResolvedValue({
|
||||
status: 'success',
|
||||
balance: 12.34,
|
||||
currency: 'USD',
|
||||
quota: null,
|
||||
errorMessage: '',
|
||||
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||
ttlSeconds: 120
|
||||
})
|
||||
|
||||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||
|
||||
const account = { id: 'acct-2', name: 'B' }
|
||||
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||
queryApi: false,
|
||||
useCache: true
|
||||
})
|
||||
|
||||
expect(result.data.source).toBe('cache')
|
||||
expect(result.data.balance.amount).toBeCloseTo(12.34, 6)
|
||||
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||||
})
|
||||
|
||||
it('should not cache provider errors and fallback to local when queryApi=true', async () => {
|
||||
const mockRedis = buildMockRedis()
|
||||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||
|
||||
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||
|
||||
service.registerProvider('openai', {
|
||||
queryBalance: () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
})
|
||||
|
||||
const account = { id: 'acct-3', name: 'C' }
|
||||
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||
queryApi: true,
|
||||
useCache: false
|
||||
})
|
||||
|
||||
expect(mockRedis.setAccountBalance).not.toHaveBeenCalled()
|
||||
expect(result.data.source).toBe('local')
|
||||
expect(result.data.status).toBe('error')
|
||||
expect(result.data.error).toBe('boom')
|
||||
})
|
||||
|
||||
it('should ignore script config when balance script is disabled', async () => {
|
||||
process.env.BALANCE_SCRIPT_ENABLED = 'false'
|
||||
|
||||
const mockRedis = buildMockRedis()
|
||||
mockRedis.getBalanceScriptConfig.mockResolvedValue({
|
||||
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
|
||||
})
|
||||
|
||||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||
|
||||
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 1, currency: 'USD' }) }
|
||||
service.registerProvider('openai', provider)
|
||||
|
||||
const scriptSpy = jest.spyOn(service, '_getBalanceFromScript')
|
||||
|
||||
const account = { id: 'acct-script-off', name: 'S' }
|
||||
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||
queryApi: true,
|
||||
useCache: false
|
||||
})
|
||||
|
||||
expect(provider.queryBalance).toHaveBeenCalled()
|
||||
expect(scriptSpy).not.toHaveBeenCalled()
|
||||
expect(result.data.source).toBe('api')
|
||||
})
|
||||
|
||||
it('should prefer script when configured and enabled', async () => {
|
||||
process.env.BALANCE_SCRIPT_ENABLED = 'true'
|
||||
|
||||
const mockRedis = buildMockRedis()
|
||||
mockRedis.getBalanceScriptConfig.mockResolvedValue({
|
||||
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
|
||||
})
|
||||
|
||||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||
|
||||
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 2, currency: 'USD' }) }
|
||||
service.registerProvider('openai', provider)
|
||||
|
||||
jest.spyOn(service, '_getBalanceFromScript').mockResolvedValue({
|
||||
status: 'success',
|
||||
balance: 3,
|
||||
currency: 'USD',
|
||||
quota: null,
|
||||
queryMethod: 'script',
|
||||
rawData: { ok: true },
|
||||
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
const account = { id: 'acct-script-on', name: 'T' }
|
||||
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||
queryApi: true,
|
||||
useCache: false
|
||||
})
|
||||
|
||||
expect(provider.queryBalance).not.toHaveBeenCalled()
|
||||
expect(result.data.source).toBe('api')
|
||||
expect(result.data.balance.amount).toBeCloseTo(3, 6)
|
||||
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||||
})
|
||||
|
||||
it('should count low balance once per account in summary', async () => {
|
||||
const mockRedis = buildMockRedis()
|
||||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||
|
||||
service.getSupportedPlatforms = () => ['claude-console']
|
||||
service.getAllAccountsByPlatform = async () => [{ id: 'acct-4', name: 'D' }]
|
||||
service._getAccountBalanceForAccount = async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
accountId: 'acct-4',
|
||||
platform: 'claude-console',
|
||||
balance: { amount: 5, currency: 'USD', formattedAmount: '$5.00' },
|
||||
quota: { percentage: 95 },
|
||||
statistics: { totalCost: 1 },
|
||||
source: 'local',
|
||||
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||
cacheExpiresAt: null,
|
||||
status: 'success',
|
||||
error: null
|
||||
}
|
||||
})
|
||||
|
||||
const summary = await service.getBalanceSummary()
|
||||
expect(summary.lowBalanceCount).toBe(1)
|
||||
expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:append-to-body="true"
|
||||
class="balance-script-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:destroy-on-close="true"
|
||||
:model-value="show"
|
||||
:title="`配置余额脚本 - ${account?.name || ''}`"
|
||||
top="5vh"
|
||||
width="720px"
|
||||
@close="emitClose"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
|
||||
<input v-model="form.apiKey" class="input-text" placeholder="access token / key" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>请求地址(baseUrl)</label
|
||||
>
|
||||
<input v-model="form.baseUrl" class="input-text" placeholder="https://api.example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">Token(可选)</label>
|
||||
<input v-model="form.token" class="input-text" placeholder="Bearer token" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>额外参数 (extra / userId)</label
|
||||
>
|
||||
<input v-model="form.extra" class="input-text" placeholder="用户ID等" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">超时时间(秒)</label>
|
||||
<input v-model.number="form.timeoutSeconds" class="input-text" min="1" type="number" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>自动查询间隔(分钟)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.autoIntervalMinutes"
|
||||
class="input-text"
|
||||
min="0"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">0 表示仅手动刷新</p>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 md:col-span-2">
|
||||
可用变量:{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}apiKey{{ '}'
|
||||
}}{{ '}' }}、{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}accountId{{ '}'
|
||||
}}{{ '}' }}、{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}extra{{ '}'
|
||||
}}{{ '}' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">提取器代码</div>
|
||||
<button
|
||||
class="rounded bg-gray-200 px-2 py-1 text-xs dark:bg-gray-700"
|
||||
@click="applyPreset"
|
||||
>
|
||||
使用示例
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="form.scriptBody"
|
||||
class="min-h-[260px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
extractor 可返回:isValid、invalidMessage、remaining、unit、planName、total、used、extra
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult" class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-gray-800/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold">测试结果</span>
|
||||
<span
|
||||
:class="[
|
||||
'rounded px-2 py-0.5 text-xs',
|
||||
testResult.mapped?.status === 'success'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
|
||||
]"
|
||||
>
|
||||
{{ testResult.mapped?.status || 'unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
|
||||
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
|
||||
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
|
||||
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
|
||||
错误: {{ testResult.mapped.errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<summary class="cursor-pointer">查看 extractor 输出</summary>
|
||||
<pre class="mt-1 whitespace-pre-wrap break-all">{{
|
||||
formatJson(testResult.extracted)
|
||||
}}</pre>
|
||||
</details>
|
||||
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<summary class="cursor-pointer">查看原始响应</summary>
|
||||
<pre class="mt-1 whitespace-pre-wrap break-all">{{
|
||||
formatJson(testResult.response)
|
||||
}}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-button :loading="testing" @click="testScript">测试脚本</el-button>
|
||||
<el-button :loading="saving" type="primary" @click="saveConfig">保存配置</el-button>
|
||||
<el-button @click="emitClose">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
account: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null)
|
||||
|
||||
const presetScript = `({
|
||||
request: {
|
||||
url: "{{baseUrl}}/api/user/self",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {{apiKey}}",
|
||||
"New-Api-User": "{{extra}}"
|
||||
}
|
||||
},
|
||||
extractor: function (response) {
|
||||
if (response && response.success && response.data) {
|
||||
const quota = response.data.quota || 0;
|
||||
const used = response.data.used_quota || 0;
|
||||
return {
|
||||
planName: response.data.group || "默认套餐",
|
||||
remaining: quota / 500000,
|
||||
used: used / 500000,
|
||||
total: (quota + used) / 500000,
|
||||
unit: "USD"
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: false,
|
||||
invalidMessage: (response && response.message) || "查询失败"
|
||||
};
|
||||
}
|
||||
})`
|
||||
|
||||
const form = reactive({
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
token: '',
|
||||
extra: '',
|
||||
timeoutSeconds: 10,
|
||||
autoIntervalMinutes: 0,
|
||||
scriptBody: ''
|
||||
})
|
||||
|
||||
const buildDefaultForm = () => ({
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
token: '',
|
||||
extra: '',
|
||||
timeoutSeconds: 10,
|
||||
autoIntervalMinutes: 0,
|
||||
// 默认给出示例脚本,字段保持清空,避免“上一个账户的配置污染当前账户”
|
||||
scriptBody: presetScript
|
||||
})
|
||||
|
||||
const emitClose = () => emit('close')
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, buildDefaultForm())
|
||||
testResult.value = null
|
||||
saving.value = false
|
||||
testing.value = false
|
||||
}
|
||||
|
||||
const loadConfig = async () => {
|
||||
if (!props.account?.id || !props.account?.platform) return
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`
|
||||
)
|
||||
if (res?.success && res.data) {
|
||||
Object.assign(form, res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载脚本配置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!props.account?.id || !props.account?.platform) return
|
||||
saving.value = true
|
||||
try {
|
||||
await apiClient.put(
|
||||
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`,
|
||||
{ ...form }
|
||||
)
|
||||
showToast('已保存', 'success')
|
||||
emit('saved')
|
||||
} catch (error) {
|
||||
showToast(error.message || '保存失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testScript = async () => {
|
||||
if (!props.account?.id || !props.account?.platform) return
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
`/admin/accounts/${props.account.id}/balance/script/test?platform=${props.account.platform}`,
|
||||
{ ...form }
|
||||
)
|
||||
if (res?.success) {
|
||||
testResult.value = res.data
|
||||
showToast('测试完成', 'success')
|
||||
} else {
|
||||
showToast(res?.error || '测试失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || '测试失败', 'error')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreset = () => {
|
||||
form.scriptBody = presetScript
|
||||
}
|
||||
|
||||
const displayAmount = (val) => {
|
||||
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
|
||||
return Number(val).toFixed(2)
|
||||
}
|
||||
|
||||
const formatJson = (data) => {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2)
|
||||
} catch (error) {
|
||||
return String(data)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val) {
|
||||
resetForm()
|
||||
loadConfig()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.balance-script-dialog) {
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.balance-script-dialog .el-dialog__body) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:deep(.balance-script-dialog .el-dialog__footer) {
|
||||
border-top: 1px solid rgba(229, 231, 235, 0.7);
|
||||
}
|
||||
|
||||
.input-text {
|
||||
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
|
||||
}
|
||||
</style>
|
||||
@@ -477,6 +477,36 @@
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||
:class="[
|
||||
form.platform === 'gemini-antigravity'
|
||||
? 'border-purple-500 bg-purple-50 dark:border-purple-400 dark:bg-purple-900/30'
|
||||
: 'border-gray-300 bg-white hover:border-purple-400 hover:bg-purple-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-purple-500 dark:hover:bg-purple-900/20'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="sr-only"
|
||||
type="radio"
|
||||
value="gemini-antigravity"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-rocket text-sm text-purple-600 dark:text-purple-400"></i>
|
||||
<div>
|
||||
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||
>Antigravity</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">OAuth</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.platform === 'gemini-antigravity'"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-purple-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||
@@ -772,7 +802,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Gemini 项目 ID 字段 -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<div v-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>项目 ID (可选)</label
|
||||
>
|
||||
@@ -822,41 +852,194 @@
|
||||
</div>
|
||||
|
||||
<!-- Bedrock 特定字段 -->
|
||||
<div v-if="form.platform === 'bedrock' && !isEdit" class="space-y-4">
|
||||
<div v-if="form.platform === 'bedrock'" class="space-y-4">
|
||||
<!-- 凭证类型选择器 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>AWS 访问密钥 ID *</label
|
||||
>凭证类型 *</label
|
||||
>
|
||||
<input
|
||||
v-model="form.accessKeyId"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.accessKeyId }"
|
||||
placeholder="请输入 AWS Access Key ID"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
|
||||
{{ errors.accessKeyId }}
|
||||
</p>
|
||||
<div v-if="!isEdit" class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.credentialType"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="access_key"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||
>AWS Access Key(访问密钥)</span
|
||||
>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.credentialType"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="bearer_token"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||
>Bearer Token(长期令牌)</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="flex gap-4">
|
||||
<label class="flex items-center opacity-60">
|
||||
<input
|
||||
v-model="form.credentialType"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
disabled
|
||||
type="radio"
|
||||
value="access_key"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||
>AWS Access Key(访问密钥)</span
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-center opacity-60">
|
||||
<input
|
||||
v-model="form.credentialType"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
disabled
|
||||
type="radio"
|
||||
value="bearer_token"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||
>Bearer Token(长期令牌)</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
|
||||
<div class="text-xs text-blue-700 dark:text-blue-300">
|
||||
<p v-if="form.credentialType === 'access_key'" class="font-medium">
|
||||
使用 AWS Access Key ID 和 Secret Access Key 进行身份验证(支持临时凭证)
|
||||
</p>
|
||||
<p v-else class="font-medium">
|
||||
使用 AWS Bedrock API Keys 生成的 Bearer Token
|
||||
进行身份验证,更简单、权限范围更小
|
||||
</p>
|
||||
<p v-if="isEdit" class="mt-1 text-xs italic">
|
||||
💡 编辑模式下凭证类型不可更改,如需切换类型请重新创建账户
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- AWS Access Key 字段(仅在 access_key 模式下显示)-->
|
||||
<div v-if="form.credentialType === 'access_key'">
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>AWS 访问密钥 ID {{ isEdit ? '' : '*' }}</label
|
||||
>
|
||||
<input
|
||||
v-model="form.accessKeyId"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.accessKeyId }"
|
||||
:placeholder="isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Access Key ID'"
|
||||
:required="!isEdit"
|
||||
type="text"
|
||||
/>
|
||||
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
|
||||
{{ errors.accessKeyId }}
|
||||
</p>
|
||||
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
💡 编辑模式下,留空则保持原有 Access Key ID 不变
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>AWS 秘密访问密钥 {{ isEdit ? '' : '*' }}</label
|
||||
>
|
||||
<input
|
||||
v-model="form.secretAccessKey"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.secretAccessKey }"
|
||||
:placeholder="
|
||||
isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Secret Access Key'
|
||||
"
|
||||
:required="!isEdit"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
|
||||
{{ errors.secretAccessKey }}
|
||||
</p>
|
||||
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
💡 编辑模式下,留空则保持原有 Secret Access Key 不变
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>会话令牌 (可选)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.sessionToken"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:placeholder="
|
||||
isEdit
|
||||
? '留空则保持原有 Session Token 不变'
|
||||
: '如果使用临时凭证,请输入会话令牌'
|
||||
"
|
||||
type="password"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
仅在使用临时 AWS 凭证时需要填写
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bearer Token 字段(仅在 bearer_token 模式下显示)-->
|
||||
<div v-if="form.credentialType === 'bearer_token'">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>AWS 秘密访问密钥 *</label
|
||||
>Bearer Token {{ isEdit ? '' : '*' }}</label
|
||||
>
|
||||
<input
|
||||
v-model="form.secretAccessKey"
|
||||
v-model="form.bearerToken"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.secretAccessKey }"
|
||||
placeholder="请输入 AWS Secret Access Key"
|
||||
required
|
||||
:class="{ 'border-red-500': errors.bearerToken }"
|
||||
:placeholder="
|
||||
isEdit ? '留空则保持原有 Bearer Token 不变' : '请输入 AWS Bearer Token'
|
||||
"
|
||||
:required="!isEdit"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
|
||||
{{ errors.secretAccessKey }}
|
||||
<p v-if="errors.bearerToken" class="mt-1 text-xs text-red-500">
|
||||
{{ errors.bearerToken }}
|
||||
</p>
|
||||
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
💡 编辑模式下,留空则保持原有 Bearer Token 不变
|
||||
</p>
|
||||
<div
|
||||
class="mt-2 rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-700 dark:bg-green-900/30"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas fa-key mt-0.5 text-green-600 dark:text-green-400" />
|
||||
<div class="text-xs text-green-700 dark:text-green-300">
|
||||
<p class="mb-1 font-medium">Bearer Token 说明:</p>
|
||||
<ul class="list-inside list-disc space-y-1 text-xs">
|
||||
<li>输入 AWS Bedrock API Keys 生成的 Bearer Token</li>
|
||||
<li>Bearer Token 仅限 Bedrock 服务访问,权限范围更小</li>
|
||||
<li>相比 Access Key 更简单,无需 Secret Key</li>
|
||||
<li>
|
||||
参考:<a
|
||||
class="text-green-600 underline dark:text-green-400"
|
||||
href="https://aws.amazon.com/cn/blogs/machine-learning/accelerate-ai-development-with-amazon-bedrock-api-keys/"
|
||||
target="_blank"
|
||||
>AWS 官方文档</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AWS 区域(两种凭证类型都需要)-->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>AWS 区域 *</label
|
||||
@@ -872,10 +1055,12 @@
|
||||
<p v-if="errors.region" class="mt-1 text-xs text-red-500">
|
||||
{{ errors.region }}
|
||||
</p>
|
||||
<div class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div
|
||||
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas fa-info-circle mt-0.5 text-blue-600" />
|
||||
<div class="text-xs text-blue-700">
|
||||
<i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
|
||||
<div class="text-xs text-blue-700 dark:text-blue-300">
|
||||
<p class="mb-1 font-medium">常用 AWS 区域参考:</p>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<span>• us-east-1 (美国东部)</span>
|
||||
@@ -885,27 +1070,14 @@
|
||||
<span>• ap-northeast-1 (东京)</span>
|
||||
<span>• eu-central-1 (法兰克福)</span>
|
||||
</div>
|
||||
<p class="mt-2 text-blue-600">💡 请输入完整的区域代码,如 us-east-1</p>
|
||||
<p class="mt-2 text-blue-600 dark:text-blue-400">
|
||||
💡 请输入完整的区域代码,如 us-east-1
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>会话令牌 (可选)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.sessionToken"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="如果使用临时凭证,请输入会话令牌"
|
||||
type="password"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
仅在使用临时 AWS 凭证时需要填写
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>默认主模型 (可选)</label
|
||||
@@ -1824,7 +1996,7 @@
|
||||
Token,建议也一并填写以支持自动刷新。
|
||||
</p>
|
||||
<p
|
||||
v-else-if="form.platform === 'gemini'"
|
||||
v-else-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'"
|
||||
class="mb-2 text-sm text-blue-800 dark:text-blue-300"
|
||||
>
|
||||
请输入有效的 Gemini Access Token。如果您有 Refresh
|
||||
@@ -1861,7 +2033,9 @@
|
||||
文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。
|
||||
</p>
|
||||
<p
|
||||
v-else-if="form.platform === 'gemini'"
|
||||
v-else-if="
|
||||
form.platform === 'gemini' || form.platform === 'gemini-antigravity'
|
||||
"
|
||||
class="text-xs text-blue-800 dark:text-blue-300"
|
||||
>
|
||||
请从已登录 Gemini CLI 的机器上获取
|
||||
@@ -2591,7 +2765,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Gemini 项目 ID 字段 -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<div v-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>项目 ID (可选)</label
|
||||
>
|
||||
@@ -3880,7 +4054,7 @@ const determinePlatformGroup = (platform) => {
|
||||
return 'claude'
|
||||
} else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) {
|
||||
return 'openai'
|
||||
} else if (['gemini', 'gemini-api'].includes(platform)) {
|
||||
} else if (['gemini', 'gemini-antigravity', 'gemini-api'].includes(platform)) {
|
||||
return 'gemini'
|
||||
} else if (platform === 'droid') {
|
||||
return 'droid'
|
||||
@@ -4015,7 +4189,8 @@ const form = ref({
|
||||
platform: props.account?.platform || 'claude',
|
||||
addType: (() => {
|
||||
const platform = props.account?.platform || 'claude'
|
||||
if (platform === 'gemini' || platform === 'openai') return 'oauth'
|
||||
if (platform === 'gemini' || platform === 'gemini-antigravity' || platform === 'openai')
|
||||
return 'oauth'
|
||||
if (platform === 'claude') return 'oauth'
|
||||
return 'manual'
|
||||
})(),
|
||||
@@ -4072,10 +4247,12 @@ const form = ref({
|
||||
// 并发控制字段
|
||||
maxConcurrentTasks: props.account?.maxConcurrentTasks || 0,
|
||||
// Bedrock 特定字段
|
||||
credentialType: props.account?.credentialType || 'access_key', // 'access_key' 或 'bearer_token'
|
||||
accessKeyId: props.account?.accessKeyId || '',
|
||||
secretAccessKey: props.account?.secretAccessKey || '',
|
||||
region: props.account?.region || '',
|
||||
sessionToken: props.account?.sessionToken || '',
|
||||
bearerToken: props.account?.bearerToken || '', // Bearer Token 字段
|
||||
defaultModel: props.account?.defaultModel || '',
|
||||
smallFastModel: props.account?.smallFastModel || '',
|
||||
// Azure OpenAI 特定字段
|
||||
@@ -4238,6 +4415,7 @@ const errors = ref({
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
region: '',
|
||||
bearerToken: '',
|
||||
azureEndpoint: '',
|
||||
deploymentName: ''
|
||||
})
|
||||
@@ -4357,7 +4535,7 @@ const selectPlatformGroup = (group) => {
|
||||
} else if (group === 'openai') {
|
||||
form.value.platform = 'openai'
|
||||
} else if (group === 'gemini') {
|
||||
form.value.platform = 'gemini'
|
||||
form.value.platform = 'gemini' // Default to Gemini CLI, user can select Antigravity
|
||||
} else if (group === 'droid') {
|
||||
form.value.platform = 'droid'
|
||||
}
|
||||
@@ -4394,7 +4572,11 @@ const nextStep = async () => {
|
||||
}
|
||||
|
||||
// 对于Gemini账户,检查项目 ID
|
||||
if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') {
|
||||
if (
|
||||
(form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') &&
|
||||
oauthStep.value === 1 &&
|
||||
form.value.addType === 'oauth'
|
||||
) {
|
||||
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||
// 使用自定义确认弹窗
|
||||
const confirmed = await showConfirm(
|
||||
@@ -4767,9 +4949,14 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
|
||||
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
||||
manuallySet: true // 标记为手动设置
|
||||
}
|
||||
} else if (currentPlatform === 'gemini') {
|
||||
// Gemini使用geminiOauth字段
|
||||
} else if (currentPlatform === 'gemini' || currentPlatform === 'gemini-antigravity') {
|
||||
// Gemini/Antigravity使用geminiOauth字段
|
||||
data.geminiOauth = tokenInfo.tokens || tokenInfo
|
||||
// 根据 platform 设置 oauthProvider
|
||||
data.oauthProvider =
|
||||
currentPlatform === 'gemini-antigravity'
|
||||
? 'antigravity'
|
||||
: tokenInfo.oauthProvider || 'gemini-cli'
|
||||
if (form.value.projectId) {
|
||||
data.projectId = form.value.projectId
|
||||
}
|
||||
@@ -4941,14 +5128,27 @@ const createAccount = async () => {
|
||||
hasError = true
|
||||
}
|
||||
} else if (form.value.platform === 'bedrock') {
|
||||
// Bedrock 验证
|
||||
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
|
||||
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
|
||||
hasError = true
|
||||
}
|
||||
if (!form.value.secretAccessKey || form.value.secretAccessKey.trim() === '') {
|
||||
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
|
||||
hasError = true
|
||||
// Bedrock 验证 - 根据凭证类型进行不同验证
|
||||
if (form.value.credentialType === 'access_key') {
|
||||
// Access Key 模式:创建时必填,编辑时可选(留空则保持原有凭证)
|
||||
if (!isEdit.value) {
|
||||
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
|
||||
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
|
||||
hasError = true
|
||||
}
|
||||
if (!form.value.secretAccessKey || form.value.secretAccessKey.trim() === '') {
|
||||
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
} else if (form.value.credentialType === 'bearer_token') {
|
||||
// Bearer Token 模式:创建时必填,编辑时可选(留空则保持原有凭证)
|
||||
if (!isEdit.value) {
|
||||
if (!form.value.bearerToken || form.value.bearerToken.trim() === '') {
|
||||
errors.value.bearerToken = '请填写 Bearer Token'
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!form.value.region || form.value.region.trim() === '') {
|
||||
errors.value.region = '请选择 AWS 区域'
|
||||
@@ -5191,6 +5391,10 @@ const createAccount = async () => {
|
||||
data.rateLimitDuration = 60 // 默认值60,不从用户输入获取
|
||||
data.dailyQuota = form.value.dailyQuota || 0
|
||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||
} else if (form.value.platform === 'gemini-antigravity') {
|
||||
// Antigravity OAuth - set oauthProvider, submission happens below
|
||||
data.oauthProvider = 'antigravity'
|
||||
data.priority = form.value.priority || 50
|
||||
} else if (form.value.platform === 'gemini-api') {
|
||||
// Gemini API 账户特定数据
|
||||
data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com'
|
||||
@@ -5200,12 +5404,21 @@ const createAccount = async () => {
|
||||
? form.value.supportedModels
|
||||
: []
|
||||
} else if (form.value.platform === 'bedrock') {
|
||||
// Bedrock 账户特定数据 - 构造 awsCredentials 对象
|
||||
data.awsCredentials = {
|
||||
accessKeyId: form.value.accessKeyId,
|
||||
secretAccessKey: form.value.secretAccessKey,
|
||||
sessionToken: form.value.sessionToken || null
|
||||
// Bedrock 账户特定数据
|
||||
data.credentialType = form.value.credentialType || 'access_key'
|
||||
|
||||
// 根据凭证类型构造不同的凭证对象
|
||||
if (form.value.credentialType === 'access_key') {
|
||||
data.awsCredentials = {
|
||||
accessKeyId: form.value.accessKeyId,
|
||||
secretAccessKey: form.value.secretAccessKey,
|
||||
sessionToken: form.value.sessionToken || null
|
||||
}
|
||||
} else if (form.value.credentialType === 'bearer_token') {
|
||||
// Bearer Token 模式:必须传递 Bearer Token
|
||||
data.bearerToken = form.value.bearerToken
|
||||
}
|
||||
|
||||
data.region = form.value.region
|
||||
data.defaultModel = form.value.defaultModel || null
|
||||
data.smallFastModel = form.value.smallFastModel || null
|
||||
@@ -5242,7 +5455,7 @@ const createAccount = async () => {
|
||||
result = await accountsStore.createOpenAIAccount(data)
|
||||
} else if (form.value.platform === 'azure_openai') {
|
||||
result = await accountsStore.createAzureOpenAIAccount(data)
|
||||
} else if (form.value.platform === 'gemini') {
|
||||
} else if (form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') {
|
||||
result = await accountsStore.createGeminiAccount(data)
|
||||
} else if (form.value.platform === 'gemini-api') {
|
||||
result = await accountsStore.createGeminiApiAccount(data)
|
||||
@@ -5533,19 +5746,33 @@ const updateAccount = async () => {
|
||||
|
||||
// Bedrock 特定更新
|
||||
if (props.account.platform === 'bedrock') {
|
||||
// 只有当有凭证变更时才构造 awsCredentials 对象
|
||||
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
|
||||
data.awsCredentials = {}
|
||||
if (form.value.accessKeyId) {
|
||||
data.awsCredentials.accessKeyId = form.value.accessKeyId
|
||||
// 更新凭证类型
|
||||
if (form.value.credentialType) {
|
||||
data.credentialType = form.value.credentialType
|
||||
}
|
||||
|
||||
// 根据凭证类型更新凭证
|
||||
if (form.value.credentialType === 'access_key') {
|
||||
// 只有当有凭证变更时才构造 awsCredentials 对象
|
||||
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
|
||||
data.awsCredentials = {}
|
||||
if (form.value.accessKeyId) {
|
||||
data.awsCredentials.accessKeyId = form.value.accessKeyId
|
||||
}
|
||||
if (form.value.secretAccessKey) {
|
||||
data.awsCredentials.secretAccessKey = form.value.secretAccessKey
|
||||
}
|
||||
if (form.value.sessionToken !== undefined) {
|
||||
data.awsCredentials.sessionToken = form.value.sessionToken || null
|
||||
}
|
||||
}
|
||||
if (form.value.secretAccessKey) {
|
||||
data.awsCredentials.secretAccessKey = form.value.secretAccessKey
|
||||
}
|
||||
if (form.value.sessionToken !== undefined) {
|
||||
data.awsCredentials.sessionToken = form.value.sessionToken || null
|
||||
} else if (form.value.credentialType === 'bearer_token') {
|
||||
// Bearer Token 模式:更新 Bearer Token(编辑时可选,留空则保留原有凭证)
|
||||
if (form.value.bearerToken && form.value.bearerToken.trim()) {
|
||||
data.bearerToken = form.value.bearerToken
|
||||
}
|
||||
}
|
||||
|
||||
if (form.value.region) {
|
||||
data.region = form.value.region
|
||||
}
|
||||
|
||||
@@ -68,6 +68,22 @@
|
||||
{{ platformLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Bedrock 账号类型 -->
|
||||
<div
|
||||
v-if="props.account?.platform === 'bedrock'"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span class="text-gray-500 dark:text-gray-400">账号类型</span>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
credentialTypeBadgeClass
|
||||
]"
|
||||
>
|
||||
<i :class="credentialTypeIcon" />
|
||||
{{ credentialTypeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
|
||||
@@ -209,13 +225,15 @@ const platformLabel = computed(() => {
|
||||
const platform = props.account.platform
|
||||
if (platform === 'claude') return 'Claude OAuth'
|
||||
if (platform === 'claude-console') return 'Claude Console'
|
||||
if (platform === 'bedrock') return 'AWS Bedrock'
|
||||
return platform
|
||||
})
|
||||
|
||||
const platformIcon = computed(() => {
|
||||
if (!props.account) return 'fas fa-question'
|
||||
const platform = props.account.platform
|
||||
if (platform === 'claude' || platform === 'claude-console') return 'fas fa-brain'
|
||||
if (platform === 'claude' || platform === 'claude-console' || platform === 'bedrock')
|
||||
return 'fas fa-brain'
|
||||
return 'fas fa-robot'
|
||||
})
|
||||
|
||||
@@ -228,6 +246,39 @@ const platformBadgeClass = computed(() => {
|
||||
if (platform === 'claude-console') {
|
||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
|
||||
}
|
||||
if (platform === 'bedrock') {
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
})
|
||||
|
||||
// Bedrock 账号类型相关
|
||||
const credentialTypeLabel = computed(() => {
|
||||
if (!props.account || props.account.platform !== 'bedrock') return ''
|
||||
const credentialType = props.account.credentialType
|
||||
if (credentialType === 'access_key') return 'Access Key'
|
||||
if (credentialType === 'bearer_token') return 'Bearer Token'
|
||||
return 'Unknown'
|
||||
})
|
||||
|
||||
const credentialTypeIcon = computed(() => {
|
||||
if (!props.account || props.account.platform !== 'bedrock') return ''
|
||||
const credentialType = props.account.credentialType
|
||||
if (credentialType === 'access_key') return 'fas fa-key'
|
||||
if (credentialType === 'bearer_token') return 'fas fa-ticket'
|
||||
return 'fas fa-question'
|
||||
})
|
||||
|
||||
const credentialTypeBadgeClass = computed(() => {
|
||||
if (!props.account || props.account.platform !== 'bedrock')
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
const credentialType = props.account.credentialType
|
||||
if (credentialType === 'access_key') {
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
}
|
||||
if (credentialType === 'bearer_token') {
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
})
|
||||
|
||||
@@ -346,6 +397,9 @@ function getTestEndpoint() {
|
||||
if (platform === 'claude-console') {
|
||||
return `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`
|
||||
}
|
||||
if (platform === 'bedrock') {
|
||||
return `${API_PREFIX}/admin/bedrock-accounts/${props.account.id}/test`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -469,7 +523,7 @@ function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 监听show变化,重置状态
|
||||
// 监听show变化,重置状态并设置测试模型
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
@@ -478,6 +532,21 @@ watch(
|
||||
responseText.value = ''
|
||||
errorMessage.value = ''
|
||||
testDuration.value = 0
|
||||
|
||||
// 根据平台和账号类型设置测试模型
|
||||
if (props.account?.platform === 'bedrock') {
|
||||
const credentialType = props.account.credentialType
|
||||
if (credentialType === 'bearer_token') {
|
||||
// Bearer Token 模式使用 Sonnet 4.5
|
||||
testModel.value = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
|
||||
} else {
|
||||
// Access Key 模式使用 Haiku(更快更便宜)
|
||||
testModel.value = 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||
}
|
||||
} else {
|
||||
// 其他平台使用默认模型
|
||||
testModel.value = 'claude-sonnet-4-5-20250929'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -364,7 +364,8 @@ const platformLabelMap = {
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
gemini: 'Gemini',
|
||||
'gemini-api': 'Gemini API',
|
||||
droid: 'Droid'
|
||||
droid: 'Droid',
|
||||
bedrock: 'Claude AWS Bedrock'
|
||||
}
|
||||
|
||||
const platformLabel = computed(() => platformLabelMap[props.account?.platform] || '未知平台')
|
||||
|
||||
381
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal file
381
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<template>
|
||||
<div class="min-w-[200px] space-y-1">
|
||||
<div v-if="loading" class="flex items-center gap-2">
|
||||
<i class="fas fa-spinner fa-spin text-gray-400 dark:text-gray-500"></i>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="requestError" class="flex items-center gap-2">
|
||||
<i class="fas fa-exclamation-circle text-red-500"></i>
|
||||
<span class="text-xs text-red-600 dark:text-red-400">{{ requestError }}</span>
|
||||
<button
|
||||
class="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
|
||||
:disabled="refreshing"
|
||||
@click="reload"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="balanceData" class="space-y-1">
|
||||
<div v-if="balanceData.status === 'error' && balanceData.error" class="text-xs text-red-500">
|
||||
{{ balanceData.error }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
class="fas"
|
||||
:class="
|
||||
balanceData.balance
|
||||
? 'fa-wallet text-green-600 dark:text-green-400'
|
||||
: 'fa-chart-line text-gray-500 dark:text-gray-400'
|
||||
"
|
||||
></i>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ primaryText }}
|
||||
</span>
|
||||
<span class="rounded px-1.5 py-0.5 text-xs" :class="sourceClass">
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!hideRefresh"
|
||||
class="text-xs text-gray-500 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:text-blue-400"
|
||||
:disabled="refreshing || !canRefresh"
|
||||
:title="refreshTitle"
|
||||
@click="refresh"
|
||||
>
|
||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 配额(如适用) -->
|
||||
<div v-if="quotaInfo && isAntigravityQuota" class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>剩余</span>
|
||||
<span>{{ formatQuotaNumber(quotaInfo.remaining) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="row in antigravityRows"
|
||||
:key="row.category"
|
||||
class="flex items-center gap-2 rounded-md bg-gray-50 px-2 py-1.5 dark:bg-gray-700/60"
|
||||
>
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :class="row.dotClass"></span>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-xs font-medium text-gray-800 dark:text-gray-100"
|
||||
:title="row.category"
|
||||
>
|
||||
{{ row.category }}
|
||||
</span>
|
||||
|
||||
<div class="flex w-[94px] flex-col gap-0.5">
|
||||
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-600">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="row.barClass"
|
||||
:style="{ width: `${row.remainingPercent ?? 0}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
<span>{{ row.remainingText }}</span>
|
||||
<span v-if="row.resetAt" class="text-gray-400 dark:text-gray-400">{{
|
||||
formatResetTime(row.resetAt)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="quotaInfo" class="space-y-1">
|
||||
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>已用: {{ formatQuotaNumber(quotaInfo.used) }}</span>
|
||||
<span>剩余: {{ formatQuotaNumber(quotaInfo.remaining) }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="quotaBarClass"
|
||||
:style="{ width: `${Math.min(100, quotaInfo.percentage)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{{ quotaInfo.percentage.toFixed(1) }}% 已使用
|
||||
</span>
|
||||
<span v-if="quotaInfo.resetAt" class="text-gray-400 dark:text-gray-500">
|
||||
重置: {{ formatResetTime(quotaInfo.resetAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="balanceData.quota?.unlimited" class="flex items-center gap-2">
|
||||
<i class="fas fa-infinity text-blue-500 dark:text-blue-400"></i>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">无限制</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="balanceData.cacheExpiresAt && balanceData.source === 'cache'"
|
||||
class="text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
缓存至: {{ formatCacheExpiry(balanceData.cacheExpiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-xs text-gray-400 dark:text-gray-500">暂无余额数据</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
accountId: { type: String, required: true },
|
||||
platform: { type: String, required: true },
|
||||
initialBalance: { type: Object, default: null },
|
||||
hideRefresh: { type: Boolean, default: false },
|
||||
autoLoad: { type: Boolean, default: true },
|
||||
queryMode: { type: String, default: 'local' } // local | auto | api
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refreshed', 'error'])
|
||||
|
||||
const balanceData = ref(props.initialBalance)
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const requestError = ref(null)
|
||||
|
||||
const sourceClass = computed(() => {
|
||||
const source = balanceData.value?.source
|
||||
return {
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300': source === 'api',
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300': source === 'cache',
|
||||
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': source === 'local'
|
||||
}
|
||||
})
|
||||
|
||||
const sourceLabel = computed(() => {
|
||||
const source = balanceData.value?.source
|
||||
return { api: 'API', cache: '缓存', local: '本地' }[source] || '未知'
|
||||
})
|
||||
|
||||
const quotaInfo = computed(() => {
|
||||
const quota = balanceData.value?.quota
|
||||
if (!quota || quota.unlimited) return null
|
||||
if (typeof quota.percentage !== 'number' || !Number.isFinite(quota.percentage)) return null
|
||||
return {
|
||||
used: quota.used ?? 0,
|
||||
remaining: quota.remaining ?? 0,
|
||||
percentage: quota.percentage,
|
||||
resetAt: quota.resetAt || null
|
||||
}
|
||||
})
|
||||
|
||||
const isAntigravityQuota = computed(() => {
|
||||
return balanceData.value?.quota?.type === 'antigravity'
|
||||
})
|
||||
|
||||
const antigravityRows = computed(() => {
|
||||
if (!isAntigravityQuota.value) return []
|
||||
|
||||
const buckets = balanceData.value?.quota?.buckets
|
||||
const list = Array.isArray(buckets) ? buckets : []
|
||||
const map = new Map(list.map((b) => [b?.category, b]))
|
||||
|
||||
const order = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
|
||||
const styles = {
|
||||
'Gemini Pro': { dotClass: 'bg-blue-500', barClass: 'bg-blue-500 dark:bg-blue-400' },
|
||||
Claude: { dotClass: 'bg-purple-500', barClass: 'bg-purple-500 dark:bg-purple-400' },
|
||||
'Gemini Flash': { dotClass: 'bg-cyan-500', barClass: 'bg-cyan-500 dark:bg-cyan-400' },
|
||||
'Gemini Image': { dotClass: 'bg-emerald-500', barClass: 'bg-emerald-500 dark:bg-emerald-400' }
|
||||
}
|
||||
|
||||
return order.map((category) => {
|
||||
const raw = map.get(category) || null
|
||||
const remaining = raw?.remaining
|
||||
const remainingPercent = Number.isFinite(Number(remaining))
|
||||
? Math.max(0, Math.min(100, Number(remaining)))
|
||||
: null
|
||||
|
||||
return {
|
||||
category,
|
||||
remainingPercent,
|
||||
remainingText: remainingPercent === null ? '—' : `${Math.round(remainingPercent)}%`,
|
||||
resetAt: raw?.resetAt || null,
|
||||
dotClass: styles[category]?.dotClass || 'bg-gray-400',
|
||||
barClass: styles[category]?.barClass || 'bg-gray-400'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const quotaBarClass = computed(() => {
|
||||
const percentage = quotaInfo.value?.percentage || 0
|
||||
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
|
||||
if (percentage >= 70) return 'bg-yellow-500 dark:bg-yellow-600'
|
||||
return 'bg-green-500 dark:bg-green-600'
|
||||
})
|
||||
|
||||
const canRefresh = computed(() => {
|
||||
// antigravity 配额:允许直接触发 Provider 刷新(无需脚本)
|
||||
if (props.queryMode === 'api' || props.queryMode === 'auto') {
|
||||
return true
|
||||
}
|
||||
|
||||
// 其他平台:仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
|
||||
const data = balanceData.value
|
||||
if (!data) return false
|
||||
if (data.scriptEnabled === false) return false
|
||||
return !!data.scriptConfigured
|
||||
})
|
||||
|
||||
const refreshTitle = computed(() => {
|
||||
if (refreshing.value) return '刷新中...'
|
||||
if (!canRefresh.value) {
|
||||
if (balanceData.value?.scriptEnabled === false) {
|
||||
return '余额脚本功能已禁用'
|
||||
}
|
||||
return '请先配置余额脚本'
|
||||
}
|
||||
if (isAntigravityQuota.value) {
|
||||
return '刷新配额(调用 Antigravity API)'
|
||||
}
|
||||
return '刷新余额(调用脚本配置的余额 API)'
|
||||
})
|
||||
|
||||
const primaryText = computed(() => {
|
||||
if (balanceData.value?.balance?.formattedAmount) {
|
||||
return balanceData.value.balance.formattedAmount
|
||||
}
|
||||
const dailyCost = Number(balanceData.value?.statistics?.dailyCost || 0)
|
||||
return `今日成本 ${formatCurrency(dailyCost)}`
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
if (!props.autoLoad) return
|
||||
if (!props.accountId || !props.platform) return
|
||||
|
||||
loading.value = true
|
||||
requestError.value = null
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/accounts/${props.accountId}/balance`, {
|
||||
params: {
|
||||
platform: props.platform,
|
||||
queryApi: props.queryMode === 'api' ? true : props.queryMode === 'auto' ? 'auto' : false
|
||||
}
|
||||
})
|
||||
if (response?.success) {
|
||||
balanceData.value = response.data
|
||||
} else {
|
||||
requestError.value = response?.error || '加载失败'
|
||||
}
|
||||
} catch (error) {
|
||||
requestError.value = error.message || '网络错误'
|
||||
emit('error', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
if (!props.accountId || !props.platform) return
|
||||
if (refreshing.value) return
|
||||
if (!canRefresh.value) return
|
||||
|
||||
refreshing.value = true
|
||||
requestError.value = null
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/accounts/${props.accountId}/balance/refresh`, {
|
||||
platform: props.platform
|
||||
})
|
||||
if (response?.success) {
|
||||
balanceData.value = response.data
|
||||
emit('refreshed', response.data)
|
||||
} else {
|
||||
requestError.value = response?.error || '刷新失败'
|
||||
}
|
||||
} catch (error) {
|
||||
requestError.value = error.message || '网络错误'
|
||||
emit('error', error)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
await load()
|
||||
}
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num === Infinity) return '∞'
|
||||
const value = Number(num)
|
||||
if (!Number.isFinite(value)) return 'N/A'
|
||||
return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const formatQuotaNumber = (num) => {
|
||||
if (num === Infinity) return '∞'
|
||||
const value = Number(num)
|
||||
if (!Number.isFinite(value)) return 'N/A'
|
||||
if (isAntigravityQuota.value) {
|
||||
return `${Math.round(value)}%`
|
||||
}
|
||||
return formatNumber(value)
|
||||
}
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
const value = Number(amount)
|
||||
if (!Number.isFinite(value)) return '$0.00'
|
||||
if (value >= 1) return `$${value.toFixed(2)}`
|
||||
if (value >= 0.01) return `$${value.toFixed(3)}`
|
||||
return `$${value.toFixed(6)}`
|
||||
}
|
||||
|
||||
const formatResetTime = (isoString) => {
|
||||
const date = new Date(isoString)
|
||||
const now = new Date()
|
||||
const diff = date.getTime() - now.getTime()
|
||||
if (!Number.isFinite(diff)) return '未知'
|
||||
if (diff < 0) return '已过期'
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainMinutes = minutes % 60
|
||||
if (hours >= 24) {
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}天后`
|
||||
}
|
||||
return `${hours}小时${remainMinutes}分钟`
|
||||
}
|
||||
|
||||
const formatCacheExpiry = (isoString) => {
|
||||
const date = new Date(isoString)
|
||||
if (Number.isNaN(date.getTime())) return '未知'
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initialBalance,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
balanceData.value = newVal
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.initialBalance) {
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ refresh, reload })
|
||||
</script>
|
||||
@@ -287,7 +287,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Gemini OAuth流程 -->
|
||||
<div v-else-if="platform === 'gemini'">
|
||||
<div v-else-if="platform === 'gemini' || platform === 'gemini-antigravity'">
|
||||
<div
|
||||
class="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-700 dark:bg-green-900/30"
|
||||
>
|
||||
@@ -303,6 +303,16 @@
|
||||
请按照以下步骤完成 Gemini 账户的授权:
|
||||
</p>
|
||||
|
||||
<!-- 授权来源显示(由平台类型决定) -->
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-green-800 dark:text-green-300">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
授权类型:<span class="font-semibold">{{
|
||||
platform === 'gemini-antigravity' ? 'Antigravity OAuth' : 'Gemini CLI OAuth'
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 步骤1: 生成授权链接 -->
|
||||
<div
|
||||
@@ -818,6 +828,13 @@ const exchanging = ref(false)
|
||||
const authUrl = ref('')
|
||||
const authCode = ref('')
|
||||
const copied = ref(false)
|
||||
// oauthProvider is now derived from platform prop
|
||||
const geminiOauthProvider = computed(() => {
|
||||
if (props.platform === 'gemini-antigravity') {
|
||||
return 'antigravity'
|
||||
}
|
||||
return 'gemini-cli'
|
||||
})
|
||||
const sessionId = ref('') // 保存sessionId用于后续交换
|
||||
const userCode = ref('')
|
||||
const verificationUri = ref('')
|
||||
@@ -921,7 +938,11 @@ watch(authCode, (newValue) => {
|
||||
console.error('Failed to parse URL:', error)
|
||||
showToast('链接格式错误,请检查是否为完整的 URL', 'error')
|
||||
}
|
||||
} else if (props.platform === 'gemini' || props.platform === 'openai') {
|
||||
} else if (
|
||||
props.platform === 'gemini' ||
|
||||
props.platform === 'gemini-antigravity' ||
|
||||
props.platform === 'openai'
|
||||
) {
|
||||
// Gemini 和 OpenAI 平台可能使用不同的回调URL
|
||||
// 尝试从任何URL中提取code参数
|
||||
try {
|
||||
@@ -972,8 +993,11 @@ const generateAuthUrl = async () => {
|
||||
const result = await accountsStore.generateClaudeAuthUrl(proxyConfig)
|
||||
authUrl.value = result.authUrl
|
||||
sessionId.value = result.sessionId
|
||||
} else if (props.platform === 'gemini') {
|
||||
const result = await accountsStore.generateGeminiAuthUrl(proxyConfig)
|
||||
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
|
||||
const result = await accountsStore.generateGeminiAuthUrl({
|
||||
...proxyConfig,
|
||||
oauthProvider: geminiOauthProvider.value
|
||||
})
|
||||
authUrl.value = result.authUrl
|
||||
sessionId.value = result.sessionId
|
||||
} else if (props.platform === 'openai') {
|
||||
@@ -996,6 +1020,8 @@ const generateAuthUrl = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// onGeminiOauthProviderChange removed - oauthProvider is now computed from platform
|
||||
|
||||
// 重新生成授权URL
|
||||
const regenerateAuthUrl = () => {
|
||||
stopCountdown()
|
||||
@@ -1079,11 +1105,12 @@ const exchangeCode = async () => {
|
||||
sessionId: sessionId.value,
|
||||
callbackUrl: authCode.value.trim()
|
||||
}
|
||||
} else if (props.platform === 'gemini') {
|
||||
// Gemini使用code和sessionId
|
||||
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
|
||||
// Gemini/Antigravity使用code和sessionId
|
||||
data = {
|
||||
code: authCode.value.trim(),
|
||||
sessionId: sessionId.value
|
||||
sessionId: sessionId.value,
|
||||
oauthProvider: geminiOauthProvider.value
|
||||
}
|
||||
} else if (props.platform === 'openai') {
|
||||
// OpenAI使用code和sessionId
|
||||
@@ -1111,8 +1138,12 @@ const exchangeCode = async () => {
|
||||
let tokenInfo
|
||||
if (props.platform === 'claude') {
|
||||
tokenInfo = await accountsStore.exchangeClaudeCode(data)
|
||||
} else if (props.platform === 'gemini') {
|
||||
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
|
||||
tokenInfo = await accountsStore.exchangeGeminiCode(data)
|
||||
// 附加 oauthProvider 信息到 tokenInfo
|
||||
if (tokenInfo) {
|
||||
tokenInfo.oauthProvider = geminiOauthProvider.value
|
||||
}
|
||||
} else if (props.platform === 'openai') {
|
||||
tokenInfo = await accountsStore.exchangeOpenAICode(data)
|
||||
} else if (props.platform === 'droid') {
|
||||
|
||||
@@ -232,10 +232,10 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Opus 模型周费用限制 -->
|
||||
<!-- Claude 模型周费用限制 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Opus 模型周费用限制 (美元)
|
||||
Claude 模型周费用限制 (美元)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.weeklyOpusCostLimit"
|
||||
@@ -246,7 +246,7 @@
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户
|
||||
设置 Claude 模型的周费用限制(周一到周日),仅对 Claude 模型请求生效
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -510,7 +510,7 @@ const form = reactive({
|
||||
concurrencyLimit: '',
|
||||
dailyCostLimit: '',
|
||||
totalCostLimit: '',
|
||||
weeklyOpusCostLimit: '', // 新增Opus周费用限制
|
||||
weeklyOpusCostLimit: '', // 新增Claude周费用限制
|
||||
permissions: '', // 空字符串表示不修改
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
|
||||
@@ -386,7 +386,7 @@
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>Opus 模型周费用限制 (美元)</label
|
||||
>Claude 模型周费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
@@ -428,7 +428,8 @@
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制
|
||||
设置 Claude 模型的周费用限制(周一到周日),仅对 Claude 模型请求生效,0
|
||||
或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -579,55 +580,46 @@
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>服务权限</label
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
value="claude"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
value="gemini"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
value="openai"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
value="droid"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Droid</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
控制此 API Key 可以访问哪些服务
|
||||
不选择任何服务表示允许访问全部服务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -662,7 +654,7 @@
|
||||
v-model="form.claudeAccountId"
|
||||
:accounts="localAccounts.claude"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
|
||||
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
placeholder="请选择Claude账号"
|
||||
platform="claude"
|
||||
@@ -676,7 +668,7 @@
|
||||
v-model="form.geminiAccountId"
|
||||
:accounts="localAccounts.gemini"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
|
||||
:disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
placeholder="请选择Gemini账号"
|
||||
platform="gemini"
|
||||
@@ -690,7 +682,7 @@
|
||||
v-model="form.openaiAccountId"
|
||||
:accounts="localAccounts.openai"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||
:disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
|
||||
:groups="localAccounts.openaiGroups"
|
||||
placeholder="请选择OpenAI账号"
|
||||
platform="openai"
|
||||
@@ -704,7 +696,7 @@
|
||||
v-model="form.bedrockAccountId"
|
||||
:accounts="localAccounts.bedrock"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
|
||||
:groups="[]"
|
||||
placeholder="请选择Bedrock账号"
|
||||
platform="bedrock"
|
||||
@@ -718,7 +710,7 @@
|
||||
v-model="form.droidAccountId"
|
||||
:accounts="localAccounts.droid"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
|
||||
:disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
|
||||
:groups="localAccounts.droidGroups"
|
||||
placeholder="请选择Droid账号"
|
||||
platform="droid"
|
||||
@@ -966,7 +958,7 @@ const form = reactive({
|
||||
expirationMode: 'fixed', // 过期模式:fixed(固定) 或 activation(激活)
|
||||
activationDays: 30, // 激活后有效天数
|
||||
activationUnit: 'days', // 激活时间单位:hours 或 days
|
||||
permissions: 'all',
|
||||
permissions: [], // 数组格式,空数组表示全部服务
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
openaiAccountId: '',
|
||||
|
||||
@@ -324,7 +324,7 @@
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>Opus 模型周费用限制 (美元)</label
|
||||
>Claude 模型周费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
@@ -366,7 +366,8 @@
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制
|
||||
设置 Claude 模型的周费用限制(周一到周日),仅对 Claude 模型请求生效,0
|
||||
或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -412,55 +413,46 @@
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>服务权限</label
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
value="claude"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
value="gemini"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
value="openai"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
value="droid"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Droid</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
控制此 API Key 可以访问哪些服务
|
||||
不选择任何服务表示允许访问全部服务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -495,7 +487,7 @@
|
||||
v-model="form.claudeAccountId"
|
||||
:accounts="localAccounts.claude"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
|
||||
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
placeholder="请选择Claude账号"
|
||||
platform="claude"
|
||||
@@ -509,7 +501,7 @@
|
||||
v-model="form.geminiAccountId"
|
||||
:accounts="localAccounts.gemini"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
|
||||
:disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
placeholder="请选择Gemini账号"
|
||||
platform="gemini"
|
||||
@@ -523,7 +515,7 @@
|
||||
v-model="form.openaiAccountId"
|
||||
:accounts="localAccounts.openai"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||
:disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
|
||||
:groups="localAccounts.openaiGroups"
|
||||
placeholder="请选择OpenAI账号"
|
||||
platform="openai"
|
||||
@@ -537,7 +529,7 @@
|
||||
v-model="form.bedrockAccountId"
|
||||
:accounts="localAccounts.bedrock"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
|
||||
:groups="[]"
|
||||
placeholder="请选择Bedrock账号"
|
||||
platform="bedrock"
|
||||
@@ -551,7 +543,7 @@
|
||||
v-model="form.droidAccountId"
|
||||
:accounts="localAccounts.droid"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
|
||||
:disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
|
||||
:groups="localAccounts.droidGroups"
|
||||
placeholder="请选择Droid账号"
|
||||
platform="droid"
|
||||
@@ -800,7 +792,7 @@ const form = reactive({
|
||||
dailyCostLimit: '',
|
||||
totalCostLimit: '',
|
||||
weeklyOpusCostLimit: '',
|
||||
permissions: 'all',
|
||||
permissions: [], // 数组格式,空数组表示全部服务
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
openaiAccountId: '',
|
||||
@@ -1241,7 +1233,38 @@ onMounted(async () => {
|
||||
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
||||
form.totalCostLimit = props.apiKey.totalCostLimit || ''
|
||||
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
||||
form.permissions = props.apiKey.permissions || 'all'
|
||||
// 处理权限数据,兼容旧格式(字符串)和新格式(数组)
|
||||
// 有效的权限值
|
||||
const VALID_PERMS = ['claude', 'gemini', 'openai', 'droid']
|
||||
let perms = props.apiKey.permissions
|
||||
// 如果是字符串,尝试 JSON.parse(Redis 可能返回 "[]" 或 "[\"gemini\"]")
|
||||
if (typeof perms === 'string') {
|
||||
if (perms === 'all' || perms === '') {
|
||||
perms = []
|
||||
} else if (perms.startsWith('[')) {
|
||||
try {
|
||||
perms = JSON.parse(perms)
|
||||
} catch {
|
||||
perms = VALID_PERMS.includes(perms) ? [perms] : []
|
||||
}
|
||||
} else if (perms.includes(',')) {
|
||||
// 兼容逗号分隔格式(如 "claude,openai")
|
||||
perms = perms
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => VALID_PERMS.includes(p))
|
||||
} else if (VALID_PERMS.includes(perms)) {
|
||||
perms = [perms]
|
||||
} else {
|
||||
perms = []
|
||||
}
|
||||
}
|
||||
if (Array.isArray(perms)) {
|
||||
// 过滤掉无效值(如 "[]")
|
||||
form.permissions = perms.filter((p) => VALID_PERMS.includes(p))
|
||||
} else {
|
||||
form.permissions = []
|
||||
}
|
||||
// 处理 Claude 账号(区分 OAuth 和 Console)
|
||||
if (props.apiKey.claudeConsoleAccountId) {
|
||||
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`
|
||||
|
||||
@@ -167,11 +167,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opus 模型周费用限制 -->
|
||||
<!-- Claude 模型周费用限制 -->
|
||||
<div v-if="statsData.limits.weeklyOpusCostLimit > 0">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-400 md:text-base"
|
||||
>Opus 模型周费用限制</span
|
||||
>Claude 模型周费用限制</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 md:text-sm">
|
||||
${{ statsData.limits.weeklyOpusCost.toFixed(4) }} / ${{
|
||||
@@ -383,7 +383,7 @@ const getTotalCostProgressColor = () => {
|
||||
return 'bg-blue-500'
|
||||
}
|
||||
|
||||
// 获取Opus周费用进度
|
||||
// 获取Claude周费用进度
|
||||
const getOpusWeeklyCostProgress = () => {
|
||||
if (
|
||||
!statsData.value.limits.weeklyOpusCostLimit ||
|
||||
@@ -395,7 +395,7 @@ const getOpusWeeklyCostProgress = () => {
|
||||
return Math.min(percentage, 100)
|
||||
}
|
||||
|
||||
// 获取Opus周费用进度条颜色
|
||||
// 获取Claude周费用进度条颜色
|
||||
const getOpusWeeklyCostProgressColor = () => {
|
||||
const progress = getOpusWeeklyCostProgress()
|
||||
if (progress >= 100) return 'bg-red-500'
|
||||
|
||||
@@ -141,6 +141,28 @@
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 刷新余额按钮 -->
|
||||
<div class="relative">
|
||||
<el-tooltip :content="refreshBalanceTooltip" effect="dark" placement="bottom">
|
||||
<button
|
||||
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
|
||||
:disabled="accountsLoading || refreshingBalances || !canRefreshVisibleBalances"
|
||||
@click="refreshVisibleBalances"
|
||||
>
|
||||
<div
|
||||
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||
></div>
|
||||
<i
|
||||
:class="[
|
||||
'fas relative text-blue-500',
|
||||
refreshingBalances ? 'fa-spinner fa-spin' : 'fa-wallet'
|
||||
]"
|
||||
/>
|
||||
<span class="relative">刷新余额</span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 选择/取消选择按钮 -->
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
@@ -263,6 +285,11 @@
|
||||
>
|
||||
今日使用
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[220px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
余额/配额
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[210px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
@@ -765,6 +792,31 @@
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<BalanceDisplay
|
||||
:account-id="account.id"
|
||||
:initial-balance="account.balanceInfo"
|
||||
:platform="account.platform"
|
||||
:query-mode="
|
||||
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
|
||||
? 'auto'
|
||||
: 'local'
|
||||
"
|
||||
@error="(error) => handleBalanceError(account.id, error)"
|
||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||
/>
|
||||
<div class="mt-1 text-xs">
|
||||
<button
|
||||
v-if="
|
||||
!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')
|
||||
"
|
||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||
@click="openBalanceScriptModal(account)"
|
||||
>
|
||||
配置余额脚本
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<div v-if="account.platform === 'claude'" class="space-y-2">
|
||||
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
||||
@@ -1425,6 +1477,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 余额/配额 -->
|
||||
<div class="mb-3">
|
||||
<p class="mb-1 text-xs text-gray-500 dark:text-gray-400">余额/配额</p>
|
||||
<BalanceDisplay
|
||||
:account-id="account.id"
|
||||
:initial-balance="account.balanceInfo"
|
||||
:platform="account.platform"
|
||||
:query-mode="
|
||||
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
|
||||
? 'auto'
|
||||
: 'local'
|
||||
"
|
||||
@error="(error) => handleBalanceError(account.id, error)"
|
||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||
/>
|
||||
<div class="mt-1 text-xs">
|
||||
<button
|
||||
v-if="!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')"
|
||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||
@click="openBalanceScriptModal(account)"
|
||||
>
|
||||
配置余额脚本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态信息 -->
|
||||
<div class="mb-3 space-y-2">
|
||||
<!-- 会话窗口 -->
|
||||
@@ -1906,6 +1984,13 @@
|
||||
@saved="handleScheduledTestSaved"
|
||||
/>
|
||||
|
||||
<AccountBalanceScriptModal
|
||||
:account="selectedAccountForScript"
|
||||
:show="showBalanceScriptModal"
|
||||
@close="closeBalanceScriptModal"
|
||||
@saved="handleBalanceScriptSaved"
|
||||
/>
|
||||
|
||||
<!-- 账户统计弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showAccountStatsModal"
|
||||
@@ -2062,6 +2147,8 @@ import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTes
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||
import BalanceDisplay from '@/components/accounts/BalanceDisplay.vue'
|
||||
import AccountBalanceScriptModal from '@/components/accounts/AccountBalanceScriptModal.vue'
|
||||
|
||||
// 使用确认弹窗
|
||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||
@@ -2069,6 +2156,7 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
|
||||
// 数据状态
|
||||
const accounts = ref([])
|
||||
const accountsLoading = ref(false)
|
||||
const refreshingBalances = ref(false)
|
||||
const accountsSortBy = ref('name')
|
||||
const accountsSortOrder = ref('asc')
|
||||
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
||||
@@ -2115,7 +2203,8 @@ const supportedUsagePlatforms = [
|
||||
'openai-responses',
|
||||
'gemini',
|
||||
'droid',
|
||||
'gemini-api'
|
||||
'gemini-api',
|
||||
'bedrock'
|
||||
]
|
||||
|
||||
// 过期时间编辑弹窗状态
|
||||
@@ -2459,7 +2548,7 @@ const closeAccountUsageModal = () => {
|
||||
}
|
||||
|
||||
// 测试账户连通性相关函数
|
||||
const supportedTestPlatforms = ['claude', 'claude-console']
|
||||
const supportedTestPlatforms = ['claude', 'claude-console', 'bedrock']
|
||||
|
||||
const canTestAccount = (account) => {
|
||||
return !!account && supportedTestPlatforms.includes(account.platform)
|
||||
@@ -2498,6 +2587,42 @@ const handleScheduledTestSaved = () => {
|
||||
showToast('定时测试配置已保存', 'success')
|
||||
}
|
||||
|
||||
// 余额脚本配置
|
||||
const showBalanceScriptModal = ref(false)
|
||||
const selectedAccountForScript = ref(null)
|
||||
|
||||
const openBalanceScriptModal = (account) => {
|
||||
selectedAccountForScript.value = account
|
||||
showBalanceScriptModal.value = true
|
||||
}
|
||||
|
||||
const closeBalanceScriptModal = () => {
|
||||
showBalanceScriptModal.value = false
|
||||
selectedAccountForScript.value = null
|
||||
}
|
||||
|
||||
const handleBalanceScriptSaved = async () => {
|
||||
showToast('余额脚本已保存', 'success')
|
||||
const account = selectedAccountForScript.value
|
||||
closeBalanceScriptModal()
|
||||
|
||||
if (!account?.id || !account?.platform) {
|
||||
return
|
||||
}
|
||||
|
||||
// 重新拉取一次余额信息,用于刷新 scriptConfigured 状态(启用“刷新余额”按钮)
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/accounts/${account.id}/balance`, {
|
||||
params: { platform: account.platform, queryApi: false }
|
||||
})
|
||||
if (res?.success && res.data) {
|
||||
handleBalanceRefreshed(account.id, res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Failed to reload balance after saving script:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算排序后的账户列表
|
||||
const sortedAccounts = computed(() => {
|
||||
let sourceAccounts = accounts.value
|
||||
@@ -2768,6 +2893,104 @@ const paginatedAccounts = computed(() => {
|
||||
return sortedAccounts.value.slice(start, end)
|
||||
})
|
||||
|
||||
const canRefreshVisibleBalances = computed(() => {
|
||||
const targets = paginatedAccounts.value
|
||||
if (!Array.isArray(targets) || targets.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return targets.some((account) => {
|
||||
const info = account?.balanceInfo
|
||||
return info?.scriptEnabled !== false && !!info?.scriptConfigured
|
||||
})
|
||||
})
|
||||
|
||||
const refreshBalanceTooltip = computed(() => {
|
||||
if (accountsLoading.value) return '正在加载账户...'
|
||||
if (refreshingBalances.value) return '刷新中...'
|
||||
if (!canRefreshVisibleBalances.value) return '当前页未配置余额脚本,无法刷新'
|
||||
return '刷新当前页余额(仅对已配置余额脚本的账户生效)'
|
||||
})
|
||||
|
||||
// 余额刷新成功回调
|
||||
const handleBalanceRefreshed = (accountId, balanceInfo) => {
|
||||
accounts.value = accounts.value.map((account) => {
|
||||
if (account.id !== accountId) return account
|
||||
return { ...account, balanceInfo }
|
||||
})
|
||||
}
|
||||
|
||||
// 余额请求错误回调(仅提示,不中断页面)
|
||||
const handleBalanceError = (_accountId, error) => {
|
||||
const message = error?.message || '余额查询失败'
|
||||
showToast(message, 'error')
|
||||
}
|
||||
|
||||
// 批量刷新当前页余额(触发查询)
|
||||
const refreshVisibleBalances = async () => {
|
||||
if (refreshingBalances.value) return
|
||||
|
||||
const targets = paginatedAccounts.value
|
||||
if (!targets || targets.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const eligibleTargets = targets.filter((account) => {
|
||||
const info = account?.balanceInfo
|
||||
return info?.scriptEnabled !== false && !!info?.scriptConfigured
|
||||
})
|
||||
|
||||
if (eligibleTargets.length === 0) {
|
||||
showToast('当前页没有配置余额脚本的账户', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const skippedCount = targets.length - eligibleTargets.length
|
||||
|
||||
refreshingBalances.value = true
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
eligibleTargets.map(async (account) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/accounts/${account.id}/balance/refresh`, {
|
||||
platform: account.platform
|
||||
})
|
||||
return { id: account.id, success: !!response?.success, data: response?.data || null }
|
||||
} catch (error) {
|
||||
return { id: account.id, success: false, error: error?.message || '刷新失败' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const updatedMap = results.reduce((map, item) => {
|
||||
if (item.success && item.data) {
|
||||
map[item.id] = item.data
|
||||
}
|
||||
return map
|
||||
}, {})
|
||||
|
||||
const successCount = results.filter((r) => r.success).length
|
||||
const failCount = results.length - successCount
|
||||
|
||||
const skippedText = skippedCount > 0 ? `,跳过 ${skippedCount} 个未配置脚本` : ''
|
||||
if (Object.keys(updatedMap).length > 0) {
|
||||
accounts.value = accounts.value.map((account) => {
|
||||
const balanceInfo = updatedMap[account.id]
|
||||
if (!balanceInfo) return account
|
||||
return { ...account, balanceInfo }
|
||||
})
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
showToast(`成功刷新 ${successCount} 个账户余额${skippedText}`, 'success')
|
||||
} else {
|
||||
showToast(`刷新完成:${successCount} 成功,${failCount} 失败${skippedText}`, 'warning')
|
||||
}
|
||||
} finally {
|
||||
refreshingBalances.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateSelectAllState = () => {
|
||||
const currentIds = paginatedAccounts.value.map((account) => account.id)
|
||||
const selectedInCurrentPage = currentIds.filter((id) =>
|
||||
@@ -2818,6 +3041,54 @@ const cleanupSelectedAccounts = () => {
|
||||
updateSelectAllState()
|
||||
}
|
||||
|
||||
// 异步加载余额缓存(按平台批量拉取,避免逐行请求)
|
||||
const loadBalanceCacheForAccounts = async () => {
|
||||
const current = accounts.value
|
||||
if (!Array.isArray(current) || current.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const platforms = Array.from(new Set(current.map((acc) => acc.platform).filter(Boolean)))
|
||||
if (platforms.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const responses = await Promise.all(
|
||||
platforms.map(async (platform) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/accounts/balance/platform/${platform}`, {
|
||||
params: { queryApi: false }
|
||||
})
|
||||
return { platform, success: !!res?.success, data: res?.data || [] }
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load balance cache for ${platform}:`, error)
|
||||
return { platform, success: false, data: [] }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const balanceMap = responses.reduce((map, item) => {
|
||||
if (!item.success) return map
|
||||
const list = Array.isArray(item.data) ? item.data : []
|
||||
list.forEach((entry) => {
|
||||
const accountId = entry?.data?.accountId
|
||||
if (accountId) {
|
||||
map[accountId] = entry.data
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, {})
|
||||
|
||||
if (Object.keys(balanceMap).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.value = accounts.value.map((account) => ({
|
||||
...account,
|
||||
balanceInfo: balanceMap[account.id] || account.balanceInfo || null
|
||||
}))
|
||||
}
|
||||
|
||||
// 加载账户列表
|
||||
const loadAccounts = async (forceReload = false) => {
|
||||
accountsLoading.value = true
|
||||
@@ -3010,6 +3281,11 @@ const loadAccounts = async (forceReload = false) => {
|
||||
console.debug('Claude usage loading failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// 异步加载余额缓存(按平台批量)
|
||||
loadBalanceCacheForAccounts().catch((err) => {
|
||||
console.debug('Balance cache loading failed:', err)
|
||||
})
|
||||
} catch (error) {
|
||||
showToast('加载账户失败', 'error')
|
||||
} finally {
|
||||
|
||||
312
web/admin-spa/src/views/BalanceScriptsView.vue
Normal file
312
web/admin-spa/src/views/BalanceScriptsView.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row">
|
||||
<div class="glass-strong flex-1 rounded-2xl p-4 shadow-lg">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">脚本余额配置</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
使用自定义脚本 + 模板变量适配任意余额接口
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
@click="loadConfig"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700"
|
||||
:disabled="saving"
|
||||
@click="saveConfig"
|
||||
>
|
||||
<span v-if="saving">保存中...</span>
|
||||
<span v-else>保存配置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
|
||||
<input v-model="form.apiKey" class="input-text" placeholder="sk-xxxx" type="text" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
请求地址(baseUrl)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.baseUrl"
|
||||
class="input-text"
|
||||
placeholder="https://api.example.com"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>Token(可选)</label
|
||||
>
|
||||
<input v-model="form.token" class="input-text" placeholder="Bearer token" type="text" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>超时时间(秒)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.timeoutSeconds"
|
||||
class="input-text"
|
||||
min="1"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
自动查询间隔(分钟)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.autoIntervalMinutes"
|
||||
class="input-text"
|
||||
min="0"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">模板变量</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
可用变量:{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}apiKey{{ '}'
|
||||
}}{{ '}' }}、{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}accountId{{
|
||||
'}'
|
||||
}}{{ '}' }}、{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}extra{{
|
||||
'}'
|
||||
}}{{ '}' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-strong w-full max-w-xl rounded-2xl p-4 shadow-lg">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">测试脚本</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
填入账号上下文(可选),调试 extractor 输出
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||
:disabled="testing"
|
||||
@click="testScript"
|
||||
>
|
||||
<span v-if="testing">测试中...</span>
|
||||
<span v-else>测试脚本</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">平台</label>
|
||||
<input v-model="testForm.platform" class="input-text" placeholder="例如 claude" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">账号ID</label>
|
||||
<input v-model="testForm.accountId" class="input-text" placeholder="账号标识,可选" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>额外参数 (extra)</label
|
||||
>
|
||||
<input v-model="testForm.extra" class="input-text" placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult" class="mt-4 space-y-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800/60">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-semibold text-gray-800 dark:text-gray-100">测试结果</span>
|
||||
<span
|
||||
:class="[
|
||||
'rounded px-2 py-0.5 text-xs',
|
||||
testResult.mapped?.status === 'success'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
|
||||
]"
|
||||
>
|
||||
{{ testResult.mapped?.status || 'unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
|
||||
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
|
||||
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
|
||||
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
|
||||
错误: {{ testResult.mapped.errorMessage }}
|
||||
</div>
|
||||
<div v-if="testResult.mapped?.quota">
|
||||
配额: {{ JSON.stringify(testResult.mapped.quota) }}
|
||||
</div>
|
||||
</div>
|
||||
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<summary class="cursor-pointer">查看 extractor 输出</summary>
|
||||
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
|
||||
>{{ formatJson(testResult.extracted) }}
|
||||
</pre
|
||||
>
|
||||
</details>
|
||||
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<summary class="cursor-pointer">查看原始响应</summary>
|
||||
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
|
||||
>{{ formatJson(testResult.response) }}
|
||||
</pre
|
||||
>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-strong rounded-2xl p-4 shadow-lg">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">提取器代码</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
返回对象需包含 request、extractor;支持模板变量替换
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
@click="applyPreset"
|
||||
>
|
||||
使用示例模板
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="form.scriptBody"
|
||||
class="min-h-[320px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
extractor
|
||||
返回字段(可选):isValid、invalidMessage、remaining、unit、planName、total、used、extra
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const form = reactive({
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
token: '',
|
||||
timeoutSeconds: 10,
|
||||
autoIntervalMinutes: 0,
|
||||
scriptBody: ''
|
||||
})
|
||||
|
||||
const testForm = reactive({
|
||||
platform: '',
|
||||
accountId: '',
|
||||
extra: ''
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null)
|
||||
|
||||
const presetScript = `({
|
||||
request: {
|
||||
url: "{{baseUrl}}/user/balance",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": "Bearer {{apiKey}}",
|
||||
"User-Agent": "cc-switch/1.0"
|
||||
}
|
||||
},
|
||||
extractor: function(response) {
|
||||
return {
|
||||
isValid: response.is_active || true,
|
||||
remaining: response.balance,
|
||||
unit: "USD",
|
||||
planName: response.plan || "默认套餐"
|
||||
};
|
||||
}
|
||||
})`
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/balance-scripts/default')
|
||||
if (res?.success && res.data) {
|
||||
Object.assign(form, res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载配置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form }
|
||||
await apiClient.put('/admin/balance-scripts/default', payload)
|
||||
showToast('配置已保存', 'success')
|
||||
} catch (error) {
|
||||
showToast(error.message || '保存失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testScript = async () => {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
...testForm,
|
||||
scriptBody: form.scriptBody
|
||||
}
|
||||
const res = await apiClient.post('/admin/balance-scripts/default/test', payload)
|
||||
if (res?.success) {
|
||||
testResult.value = res.data
|
||||
showToast('测试完成', 'success')
|
||||
} else {
|
||||
showToast(res?.error || '测试失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || '测试失败', 'error')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreset = () => {
|
||||
form.scriptBody = presetScript
|
||||
}
|
||||
|
||||
const displayAmount = (val) => {
|
||||
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
|
||||
return Number(val).toFixed(2)
|
||||
}
|
||||
|
||||
const formatJson = (data) => {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2)
|
||||
} catch (error) {
|
||||
return String(data)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyPreset()
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-text {
|
||||
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
|
||||
}
|
||||
</style>
|
||||
@@ -196,6 +196,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账户余额/配额汇总 -->
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6">
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
账户余额/配额
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
|
||||
{{ formatCurrencyUsd(balanceSummary.totalBalance || 0) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
低余额: {{ balanceSummary.lowBalanceCount || 0 }} | 总成本:
|
||||
{{ formatCurrencyUsd(balanceSummary.totalCost || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-green-600">
|
||||
<i class="fas fa-wallet" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
更新时间: {{ formatLastUpdate(balanceSummaryUpdatedAt) }}
|
||||
</p>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500"
|
||||
:disabled="loadingBalanceSummary"
|
||||
@click="loadBalanceSummary"
|
||||
>
|
||||
<i :class="['fas', loadingBalanceSummary ? 'fa-spinner fa-spin' : 'fa-sync-alt']" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">低余额账户</h3>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ lowBalanceAccounts.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingBalanceSummary"
|
||||
class="py-6 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
正在加载...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="lowBalanceAccounts.length === 0"
|
||||
class="py-6 text-center text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
全部正常
|
||||
</div>
|
||||
<div v-else class="max-h-64 space-y-2 overflow-y-auto">
|
||||
<div
|
||||
v-for="account in lowBalanceAccounts"
|
||||
:key="account.accountId"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-900/60 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ account.name || account.accountId }}
|
||||
</div>
|
||||
<span
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ getBalancePlatformLabel(account.platform) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span v-if="account.balance">余额: {{ account.balance.formattedAmount }}</span>
|
||||
<span v-else
|
||||
>今日成本: {{ formatCurrencyUsd(account.statistics?.dailyCost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="account.quota && typeof account.quota.percentage === 'number'" class="mt-2">
|
||||
<div
|
||||
class="mb-1 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<span>配额使用</span>
|
||||
<span class="text-red-600 dark:text-red-400">
|
||||
{{ account.quota.percentage.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-2 rounded-full bg-red-500"
|
||||
:style="{ width: `${Math.min(100, account.quota.percentage)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token统计和性能指标 -->
|
||||
<div
|
||||
class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6 lg:grid-cols-4"
|
||||
@@ -681,6 +780,8 @@ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import Chart from 'chart.js/auto'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
@@ -732,6 +833,97 @@ const accountGroupOptions = [
|
||||
|
||||
const accountTrendUpdating = ref(false)
|
||||
|
||||
// 余额/配额汇总
|
||||
const balanceSummary = ref({
|
||||
totalBalance: 0,
|
||||
totalCost: 0,
|
||||
lowBalanceCount: 0,
|
||||
platforms: {}
|
||||
})
|
||||
const loadingBalanceSummary = ref(false)
|
||||
const balanceSummaryUpdatedAt = ref(null)
|
||||
|
||||
const getBalancePlatformLabel = (platform) => {
|
||||
const map = {
|
||||
claude: 'Claude',
|
||||
'claude-console': 'Claude Console',
|
||||
gemini: 'Gemini',
|
||||
'gemini-api': 'Gemini API',
|
||||
openai: 'OpenAI',
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
azure_openai: 'Azure OpenAI',
|
||||
bedrock: 'Bedrock',
|
||||
droid: 'Droid',
|
||||
ccr: 'CCR'
|
||||
}
|
||||
return map[platform] || platform
|
||||
}
|
||||
|
||||
const lowBalanceAccounts = computed(() => {
|
||||
const result = []
|
||||
const platforms = balanceSummary.value?.platforms || {}
|
||||
|
||||
Object.entries(platforms).forEach(([platform, data]) => {
|
||||
const list = Array.isArray(data?.accounts) ? data.accounts : []
|
||||
list.forEach((entry) => {
|
||||
const accountData = entry?.data
|
||||
if (!accountData) return
|
||||
|
||||
const amount = accountData.balance?.amount
|
||||
const percentage = accountData.quota?.percentage
|
||||
|
||||
const isLowBalance = typeof amount === 'number' && amount < 10
|
||||
const isHighUsage = typeof percentage === 'number' && percentage > 90
|
||||
|
||||
if (isLowBalance || isHighUsage) {
|
||||
result.push({
|
||||
...accountData,
|
||||
name: entry?.name || accountData.accountId,
|
||||
platform: accountData.platform || platform
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const formatCurrencyUsd = (amount) => {
|
||||
const value = Number(amount)
|
||||
if (!Number.isFinite(value)) return '$0.00'
|
||||
if (value >= 1) return `$${value.toFixed(2)}`
|
||||
if (value >= 0.01) return `$${value.toFixed(3)}`
|
||||
return `$${value.toFixed(6)}`
|
||||
}
|
||||
|
||||
const formatLastUpdate = (isoString) => {
|
||||
if (!isoString) return '未知'
|
||||
const date = new Date(isoString)
|
||||
if (Number.isNaN(date.getTime())) return '未知'
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const loadBalanceSummary = async () => {
|
||||
loadingBalanceSummary.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/admin/accounts/balance/summary')
|
||||
if (response?.success) {
|
||||
balanceSummary.value = response.data || {
|
||||
totalBalance: 0,
|
||||
totalCost: 0,
|
||||
lowBalanceCount: 0,
|
||||
platforms: {}
|
||||
}
|
||||
balanceSummaryUpdatedAt.value = new Date().toISOString()
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('加载余额汇总失败:', error)
|
||||
showToast('加载余额汇总失败', 'error')
|
||||
} finally {
|
||||
loadingBalanceSummary.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 自动刷新相关
|
||||
const autoRefreshEnabled = ref(false)
|
||||
const autoRefreshInterval = ref(30) // 秒
|
||||
@@ -1488,7 +1680,7 @@ async function refreshAllData() {
|
||||
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
await Promise.all([loadDashboardData(), refreshChartsData()])
|
||||
await Promise.all([loadDashboardData(), refreshChartsData(), loadBalanceSummary()])
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user