Compare commits

..

34 Commits

Author SHA1 Message Date
github-actions[bot]
0713f746ee chore: sync VERSION file with release v1.1.248 [skip ci] 2025-12-26 08:17:23 +00:00
shaw
69a632d054 chore: trigger release [force release] 2025-12-26 16:17:03 +08:00
52227
9960f237b8 feat: 实现 Antigravity OAuth 账户支持与路径分流 2025-12-25 14:33:24 +08:00
Wesley Liddick
53cda0fd18 Merge pull request #806 from XiaoXice/main [skip ci]
fix: 全时间api-token统计因为日token记录过期导致不准的问题
2025-12-16 19:34:35 -05:00
Wesley Liddick
151cb7536c Merge pull request #808 from SilentFlower/fix/openai-scheduler-priority [skip ci]
fix(scheduler): 恢复OpenAI 账号选择支持 priority + lastUsedAt
2025-12-16 19:33:18 -05:00
huajiwuyan
052e236a93 fix(scheduler): 恢复OpenAI 账号选择支持 priority + lastUsedAt 2025-12-15 23:17:44 +08:00
XiaoXice
c79ea19aa1 fix: 全时间api-token统计因为日token记录过期导致不准的问题 2025-12-15 15:14:09 +08:00
github-actions[bot]
79f2cebdb8 chore: sync VERSION file with release v1.1.235 [skip ci] 2025-12-15 01:48:14 +00:00
Wesley Liddick
bd7b8884ab Merge pull request #801 from miraserver/fix/cost-calculation-and-ui-display
fix: correct API key cost calculation and UI display issues
2025-12-14 20:48:00 -05:00
github-actions[bot]
38e0adb499 chore: sync VERSION file with release v1.1.234 [skip ci] 2025-12-15 01:44:56 +00:00
shaw
7698f5ce11 chore: 增加opus4.5快捷映射按钮 2025-12-15 09:44:36 +08:00
shaw
ce13e5ddb1 fix: console账号转发使用白名单透传header 2025-12-15 09:38:51 +08:00
John Doe
baafebbf7b fix: correct API key cost calculation and UI display issues
- Fix admin panel cost display for "all time" period using permanent Redis key
- Fix user statistics total cost limit to show complete history
- Fix restricted models list overflow with scrollable container

Backend changes:
- src/routes/admin/apiKeys.js: Use allTimeCost for timeRange='all' instead of scanning TTL keys
- src/routes/apiStats.js: Prioritize permanent usage:cost:total key over monthly keys

Frontend changes:
- web/admin-spa/src/components/apistats/LimitConfig.vue: Add overflow-visible and scrolling to model list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 18:11:02 +03:00
github-actions[bot]
87426133a2 chore: sync VERSION file with release v1.1.233 [skip ci] 2025-12-12 06:58:37 +00:00
Wesley Liddick
60f5cbe780 Merge pull request #800 from DaydreamCoding/feature/concurrency-queue
feat: enhance concurrency queue with health check and admin endpoints
2025-12-12 01:58:24 -05:00
Wesley Liddick
86d8ed52d7 Merge pull request #799 from kikii16/main [skip ci]
尝试自定义请求体maxSize大小
2025-12-12 01:58:12 -05:00
DaydreamCoding
07633ddbf8 feat: enhance concurrency queue with health check and admin endpoints
- Add queue health check for fast-fail when overloaded (P90 > threshold)
  - Implement socket identity verification with UUID token
  - Add wait time statistics (P50/P90/P99) and queue stats tracking
  - Add admin endpoints for queue stats and cleanup
  - Add CLEAR_CONCURRENCY_QUEUES_ON_STARTUP config option
  - Update documentation with troubleshooting and proxy config guide
2025-12-12 14:32:09 +08:00
kikii16
dd90c426e4 Update docker-compose.yml 2025-12-12 11:38:31 +08:00
kikii16
059357f834 Update .env.example 2025-12-12 11:36:01 +08:00
kikii16
ceee3a9295 Update auth.js 2025-12-12 11:34:46 +08:00
Wesley Liddick
403f609f69 Merge pull request #797 from thejoven/patch-1 [skip ci]
修改提醒内容:新版 gemini-cli 的 Access Token 位置和文件名已变更
2025-12-11 21:10:07 -05:00
thejoven
304c8dda4e Update AccountForm.vue
新版 gemini-cli 的 Access Token 位置和文件名已变更
2025-12-12 00:43:11 +08:00
github-actions[bot]
c4d923c46f chore: sync VERSION file with release v1.1.232 [skip ci] 2025-12-11 02:46:31 +00:00
Wesley Liddick
fa9f9146a2 Merge pull request #793 from qq790716890/main
fix:修复codex统计token问题
2025-12-10 21:46:15 -05:00
Wesley Liddick
dc9409a5a6 Merge pull request #788 from atoz03/main [skip ci]
fix: 账户列表默认显示限额/限流账号并加固加载健壮性
2025-12-10 21:44:55 -05:00
LZY
51aa8dc381 fix:修复codex统计token问题 2025-12-10 22:56:25 +08:00
github-actions[bot]
5061f4d9fd chore: sync VERSION file with release v1.1.231 [skip ci] 2025-12-10 12:11:39 +00:00
Wesley Liddick
4337af06d4 Merge pull request #791 from DaydreamCoding/feature/log-opt
fix: improve logging for client disconnections in relay services
2025-12-10 07:11:24 -05:00
Wesley Liddick
d226d57325 Merge pull request #790 from DaydreamCoding/patch-4 [skip ci]
fix(security): add authenticateAdmin middleware to concurrency routes
2025-12-10 07:11:07 -05:00
Wesley Liddick
9f92c58640 Merge pull request #789 from DaydreamCoding/feature/user-message-queue-optimize [skip ci]
feat(queue): 优化用户消息队列锁释放时机
2025-12-10 07:10:38 -05:00
QTom
8901994644 fix: improve logging for client disconnections in relay services
当客户端主动断开连接时,改为使用 INFO 级别记录而不是 ERROR 级别,
因为这是正常情况而非错误。

- ccrRelayService: 区分客户端断开与实际错误
- claudeConsoleRelayService: 区分客户端断开与实际错误
- claudeRelayService: 区分客户端断开与实际错误
- droidRelayService: 区分客户端断开与实际错误
2025-12-10 14:18:44 +08:00
QTom
e3ca555df7 fix(security): add authenticateAdmin middleware to concurrency routes
fix(security): add authenticateAdmin middleware to concurrency routes

All concurrency management endpoints were missing authentication,
allowing unauthenticated access to view and clear concurrency data.
2025-12-10 13:59:25 +08:00
QTom
3b9c96dff8 feat(queue): 优化用户消息队列锁释放时机
将队列锁释放时机从"请求完成后"提前到"请求发送后",因为 Claude API
限流(RPM)基于请求发送时刻计算,无需等待响应完成。

主要变更:
- 移除锁续租机制(startLockRenewal、refreshUserMessageLock)
- 所有 relay 服务在请求发送成功后立即释放锁
- 流式请求通过 onResponseStart 回调在收到响应头时释放
- 调整默认配置:timeoutMs 60s→5s,lockTtlMs 120s→5s
- 新增 USER_MESSAGE_QUEUE_LOCK_TTL_MS 环境变量支持
2025-12-10 01:26:00 +08:00
atoz03
fc25840f95 fix: 账户列表默认显示限额/限流账号并加固加载健壮性
- 将账户页状态筛选默认值从 normal 改为 all,额度满/限流/异常账号默认可见
  - appendAccounts 使用 Array.isArray 兜底接口响应,避免空/异常数据导致“加载账户失败”
  - 便于在额度耗尽场景查看并处理账号
2025-12-09 18:49:57 +08:00
56 changed files with 8022 additions and 794 deletions

View File

@@ -33,6 +33,41 @@ 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 CodeAnthropic Messages API路由分流无需额外环境变量
# - /api -> Claude 账号池(默认)
# - /antigravity/api -> Antigravity OAuth
# - /gemini-cli/api -> Gemini CLI OAuth
# 可选Claude Code 调试 Dump会在项目根目录写入 jsonl 文件,便于排查 tools/schema/回包问题
# - anthropic-requests-dump.jsonl
# - anthropic-responses-dump.jsonl
# - anthropic-tools-dump.jsonl
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
#
# 可选Antigravity 上游请求 Dump会在项目根目录写入 jsonl 文件,便于核对最终发往上游的 payload含 tools/schema 清洗后的结果)
# - antigravity-upstream-requests-dump.jsonl
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
# 🚫 529错误处理配置
# 启用529错误处理0表示禁用>0表示过载状态持续时间分钟
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
@@ -61,6 +96,9 @@ PROXY_USE_IPV4=true
# ⏱️ 请求超时配置
REQUEST_TIMEOUT=600000 # 请求超时设置毫秒默认10分钟
# 🔧 请求体大小配置
REQUEST_MAX_SIZE_MB=60
# 📈 使用限制
DEFAULT_TOKEN_LIMIT=1000000
@@ -75,6 +113,8 @@ TOKEN_USAGE_RETENTION=2592000000
HEALTH_CHECK_INTERVAL=60000
TIMEZONE_OFFSET=8 # UTC偏移小时数默认+8中国时区
METRICS_WINDOW=5 # 实时指标统计窗口分钟可选1-60默认5分钟
# 启动时清理残留的并发排队计数器默认true多实例部署时建议设为false
CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=true
# 🎨 Web 界面配置
WEB_TITLE=Claude Relay Service

View File

@@ -22,6 +22,7 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
- **权限控制**: API Key支持权限配置all/claude/gemini/openai等控制可访问的服务类型
- **客户端限制**: 基于User-Agent的客户端识别和限制支持ClaudeCode、Gemini-CLI等预定义客户端
- **模型黑名单**: 支持API Key级别的模型访问限制
- **并发请求排队**: 当API Key并发数超限时请求进入队列等待而非立即返回429支持配置最大排队数、超时时间适用于Claude Code Agent并行工具调用场景
### 主要服务组件
@@ -186,15 +187,17 @@ npm run service:stop # 停止服务
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间分钟0表示禁用
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL小时默认1
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值分钟默认0
- `USER_MESSAGE_QUEUE_ENABLED`: 启用用户消息串行队列(默认true
- `USER_MESSAGE_QUEUE_ENABLED`: 启用用户消息串行队列(默认false
- `USER_MESSAGE_QUEUE_DELAY_MS`: 用户消息请求间隔毫秒默认200
- `USER_MESSAGE_QUEUE_TIMEOUT_MS`: 队列等待超时(毫秒,默认30000
- `USER_MESSAGE_QUEUE_TIMEOUT_MS`: 队列等待超时(毫秒,默认5000,锁持有时间短无需长等待
- `USER_MESSAGE_QUEUE_LOCK_TTL_MS`: 锁TTL毫秒默认5000请求发送后立即释放无需长TTL
- `METRICS_WINDOW`: 实时指标统计窗口分钟1-60默认5
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量默认1
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys默认false
- `DEBUG_HTTP_TRAFFIC`: 启用HTTP请求/响应调试日志默认false仅开发环境
- `PROXY_USE_IPV4`: 代理使用IPv4默认true
- `REQUEST_TIMEOUT`: 请求超时时间毫秒默认600000即10分钟
- `CLEAR_CONCURRENCY_QUEUES_ON_STARTUP`: 启动时清理残留的并发排队计数器默认true多实例部署时建议设为false
#### AWS Bedrock配置可选
- `CLAUDE_CODE_USE_BEDROCK`: 启用Bedrock设置为1启用
@@ -341,7 +344,35 @@ npm run setup # 自动生成密钥并创建管理员账户
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据检查pricingService是否正确加载模型价格
13. **缓存命中率低**: 查看缓存监控统计调整LRU缓存大小配置
14. **用户消息队列超时**: 检查 `USER_MESSAGE_QUEUE_TIMEOUT_MS` 配置是否合理,查看日志中的 `queue_timeout` 错误,可通过 Web 界面或 `USER_MESSAGE_QUEUE_ENABLED=false` 禁用此功能
14. **用户消息队列超时**: 优化后锁持有时间已从分钟级降到毫秒级(请求发送后立即释放),默认 `USER_MESSAGE_QUEUE_TIMEOUT_MS=5000` 已足够。如仍有超时,检查网络延迟或禁用此功能(`USER_MESSAGE_QUEUE_ENABLED=false`
15. **并发请求排队问题**:
- 排队超时:检查 `concurrentRequestQueueTimeoutMs` 配置是否合理默认10秒
- 排队数过多:调整 `concurrentRequestQueueMaxSize` 和 `concurrentRequestQueueMaxSizeMultiplier`
- 查看排队统计:访问 `/admin/concurrency-queue/stats` 接口查看 entered/success/timeout/cancelled/socket_changed/rejected_overload 统计
- 排队计数泄漏:系统重启时自动清理,或访问 `/admin/concurrency-queue` DELETE 接口手动清理
- Socket 身份验证失败:查看 `socket_changed` 统计,如果频繁发生,检查代理配置或客户端连接稳定性
- 健康检查拒绝:查看 `rejected_overload` 统计,表示队列过载时的快速失败次数
### 代理配置要求(并发请求排队)
使用并发请求排队功能时,需要正确配置代理(如 Nginx的超时参数
- **推荐配置**: `proxy_read_timeout >= max(2 × concurrentRequestQueueTimeoutMs, 60s)`
- 当前默认排队超时 10 秒Nginx 默认 `proxy_read_timeout = 60s` 已满足要求
- 如果调整排队超时到 60 秒,推荐代理超时 ≥ 120 秒
- **Nginx 配置示例**:
```nginx
location /api/ {
proxy_read_timeout 120s; # 排队超时 60s 时推荐 120s
proxy_connect_timeout 10s;
# ...其他配置
}
```
- **企业防火墙环境**:
- 某些企业防火墙可能静默关闭长时间无数据的连接20-40 秒)
- 如遇此问题,联系网络管理员调整空闲连接超时策略
- 或降低 `concurrentRequestQueueTimeoutMs` 配置
- **后续升级说明**: 如有需要,后续版本可能提供可选的轻量级心跳机制
### 调试工具
@@ -454,6 +485,15 @@ npm run setup # 自动生成密钥并创建管理员账户
- **缓存优化**: 多层LRU缓存解密缓存、账户缓存全局缓存监控和统计
- **成本追踪**: 实时token使用统计input/output/cache_create/cache_read和成本计算基于pricingService
- **并发控制**: Redis Sorted Set实现的并发计数支持自动过期清理
- **并发请求排队**: 当API Key并发超限时请求进入队列等待而非直接返回429
- **工作原理**: 采用「先占后检查」模式,每次轮询尝试占位,超限则释放继续等待
- **指数退避**: 初始200ms指数增长至最大2秒带±20%抖动防惊群效应
- **智能清理**: 排队计数有TTL保护超时+30秒进程崩溃也能自动清理
- **Socket身份验证**: 使用UUID token + socket对象引用双重验证避免HTTP Keep-Alive连接复用导致的身份混淆
- **健康检查**: P90等待时间超过阈值时快速失败返回429避免新请求在过载时继续排队
- **配置参数**: `concurrentRequestQueueEnabled`默认false、`concurrentRequestQueueMaxSize`默认3、`concurrentRequestQueueMaxSizeMultiplier`默认0、`concurrentRequestQueueTimeoutMs`默认10秒、`concurrentRequestQueueMaxRedisFailCount`默认5、`concurrentRequestQueueHealthCheckEnabled`默认true、`concurrentRequestQueueHealthThreshold`默认0.8
- **最大排队数**: max(固定值, 并发限制×倍数),例如并发限制=10、倍数=2时最大排队数=20
- **适用场景**: Claude Code Agent并行工具调用、批量请求处理
- **客户端识别**: 基于User-Agent的客户端限制支持预定义客户端ClaudeCode、Gemini-CLI等
- **错误处理**: 529错误自动标记账户过载状态配置时长内自动排除该账户
@@ -513,6 +553,11 @@ npm run setup # 自动生成密钥并创建管理员账户
- `overload:{accountId}` - 账户过载状态529错误
- **并发控制**:
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
- **并发请求排队**:
- `concurrency:queue:{apiKeyId}` - API Key级别的排队计数器TTL由 `concurrentRequestQueueTimeoutMs` + 30秒缓冲决定
- `concurrency:queue:stats:{apiKeyId}` - 排队统计entered/success/timeout/cancelled
- `concurrency:queue:wait_times:{apiKeyId}` - 按API Key的等待时间记录用于P50/P90/P99计算
- `concurrency:queue:wait_times:global` - 全局等待时间记录
- **Webhook配置**:
- `webhook_config:{id}` - Webhook配置
- **用户消息队列**:

View File

@@ -389,13 +389,31 @@ docker-compose.yml 已包含:
**Claude Code 设置环境变量:**
默认使用标准 Claude 账号池:
默认使用标准 Claude 账号池Claude/Console/Bedrock/CCR
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
```
如果希望 Claude Code 通过 Anthropic 协议直接使用 Gemini OAuth 账号池(路径分流,不需要在模型名里加前缀):
Antigravity OAuth支持 `claude-opus-4-5` 等 Antigravity 模型):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥permissions 需要是 all 或 gemini"
export ANTHROPIC_MODEL="claude-opus-4-5"
```
Gemini CLI OAuth使用 Gemini 模型):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥permissions 需要是 all 或 gemini"
export ANTHROPIC_MODEL="gemini-2.5-pro"
```
**VSCode Claude 插件配置:**
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:

View File

@@ -238,13 +238,31 @@ Now you can replace the official API with your own service:
**Claude Code Set Environment Variables:**
Default uses standard Claude account pool:
Default uses standard Claude account pool (Claude/Console/Bedrock/CCR):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain
export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
```
If you want Claude Code to use Gemini OAuth accounts via the Anthropic protocol (path-based routing, no vendor prefix in `model`):
Antigravity OAuth (supports `claude-opus-4-5` and other Antigravity models):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
export ANTHROPIC_MODEL="claude-opus-4-5"
```
Gemini CLI OAuth (Gemini models):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
export ANTHROPIC_MODEL="gemini-2.5-pro"
```
**VSCode Claude Plugin Configuration:**
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
@@ -604,4 +622,4 @@ This project uses the [MIT License](LICENSE).
**🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions**
</div>
</div>

View File

@@ -1 +1 @@
1.1.230
1.1.248

View File

@@ -206,11 +206,12 @@ const config = {
},
// 📬 用户消息队列配置
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
userMessageQueue: {
enabled: process.env.USER_MESSAGE_QUEUE_ENABLED === 'true', // 默认关闭
delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 100, // 请求间隔(毫秒)
timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 60000, // 队列等待超时(毫秒)
lockTtlMs: 120000 // 锁租约TTL毫秒会在请求期间自动续租以防死锁
delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 200, // 请求间隔(毫秒)
timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 5000, // 队列等待超时(毫秒),锁持有时间短,无需长等待
lockTtlMs: parseInt(process.env.USER_MESSAGE_QUEUE_LOCK_TTL_MS) || 5000 // 锁TTL毫秒5秒足以覆盖请求发送
}
}

View File

@@ -21,6 +21,9 @@ services:
- PORT=3000
- HOST=0.0.0.0
# 🔧 请求体大小配置
- REQUEST_MAX_SIZE_MB=60
# 🔐 安全配置(必填)
- JWT_SECRET=${JWT_SECRET} # 必填至少32字符的随机字符串
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填32字符的加密密钥

8
package-lock.json generated
View File

@@ -891,7 +891,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",
@@ -3000,7 +2999,6 @@
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3082,7 +3080,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3538,7 +3535,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -4426,7 +4422,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",
@@ -4483,7 +4478,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7582,7 +7576,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9101,7 +9094,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",

View File

@@ -264,6 +264,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 和页面重定向)
@@ -584,6 +603,20 @@ class Application {
// 使用 Lua 脚本批量清理所有过期项
for (const key of keys) {
// 跳过非 Sorted Set 类型的键(这些键有各自的清理逻辑)
// - concurrency:queue:stats:* 是 Hash 类型
// - concurrency:queue:wait_times:* 是 List 类型
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
if (
key.startsWith('concurrency:queue:stats:') ||
key.startsWith('concurrency:queue:wait_times:') ||
(key.startsWith('concurrency:queue:') &&
!key.includes(':stats:') &&
!key.includes(':wait_times:'))
) {
continue
}
try {
const cleaned = await redis.client.eval(
`
@@ -633,6 +666,20 @@ class Application {
// 然后启动定时清理任务
userMessageQueueService.startCleanupTask()
})
// 🚦 清理服务重启后残留的并发排队计数器
// 多实例部署时建议关闭此开关,避免新实例启动时清空其他实例的队列计数
// 可通过 DELETE /admin/concurrency/queue 接口手动清理
const clearQueuesOnStartup = process.env.CLEAR_CONCURRENCY_QUEUES_ON_STARTUP !== 'false'
if (clearQueuesOnStartup) {
redis.clearAllConcurrencyQueues().catch((error) => {
logger.error('❌ Error clearing concurrency queues on startup:', error)
})
} else {
logger.info(
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)'
)
}
}
setupGracefulShutdown() {
@@ -669,10 +716,9 @@ class Application {
logger.error('❌ Error stopping rate limit cleanup service:', error)
}
// 停止用户消息队列清理服务和续租定时器
// 停止用户消息队列清理服务
try {
const userMessageQueueService = require('./services/userMessageQueueService')
userMessageQueueService.stopAllRenewalTimers()
userMessageQueueService.stopCleanupTask()
logger.info('📬 User message queue service stopped')
} catch (error) {

View File

@@ -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')
@@ -508,20 +509,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 +772,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({
@@ -927,7 +953,8 @@ function handleSimpleEndpoint(apiMethod) {
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig
proxyConfig,
account.oauthProvider
)
// 直接转发请求体,不做特殊处理
@@ -1006,7 +1033,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 +1136,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
@@ -1256,7 +1293,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 +1404,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 +1433,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 +1461,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 +1639,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 +1668,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 +1695,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 +2062,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 +2116,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 +2366,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 +2417,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 响应头

View File

@@ -8,6 +8,102 @@ const redis = require('../models/redis')
const ClientValidator = require('../validators/clientValidator')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const { calculateWaitTimeStats } = require('../utils/statsHelper')
// 工具函数
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* 检查排队是否过载,决定是否应该快速失败
* 详见 design.md Decision 7: 排队健康检查与快速失败
*
* @param {string} apiKeyId - API Key ID
* @param {number} timeoutMs - 排队超时时间(毫秒)
* @param {Object} queueConfig - 队列配置
* @param {number} maxQueueSize - 最大排队数
* @returns {Promise<Object>} { reject: boolean, reason?: string, estimatedWaitMs?: number, timeoutMs?: number }
*/
async function shouldRejectDueToOverload(apiKeyId, timeoutMs, queueConfig, maxQueueSize) {
try {
// 如果健康检查被禁用,直接返回不拒绝
if (!queueConfig.concurrentRequestQueueHealthCheckEnabled) {
return { reject: false, reason: 'health_check_disabled' }
}
// 🔑 先检查当前队列长度
const currentQueueCount = await redis.getConcurrencyQueueCount(apiKeyId).catch(() => 0)
// 队列为空,说明系统已恢复,跳过健康检查
if (currentQueueCount === 0) {
return { reject: false, reason: 'queue_empty', currentQueueCount: 0 }
}
// 🔑 关键改进:只有当队列接近满载时才进行健康检查
// 队列长度 <= maxQueueSize * 0.5 时,认为系统有足够余量,跳过健康检查
// 这避免了在队列较短时过于保守地拒绝请求
// 使用 ceil 确保小队列(如 maxQueueSize=3时阈值为 2即队列 <=1 时跳过
const queueLoadThreshold = Math.ceil(maxQueueSize * 0.5)
if (currentQueueCount <= queueLoadThreshold) {
return {
reject: false,
reason: 'queue_not_loaded',
currentQueueCount,
queueLoadThreshold,
maxQueueSize
}
}
// 获取该 API Key 的等待时间样本
const waitTimes = await redis.getQueueWaitTimes(apiKeyId)
const stats = calculateWaitTimeStats(waitTimes)
// 样本不足(< 10跳过健康检查避免冷启动误判
if (!stats || stats.sampleCount < 10) {
return { reject: false, reason: 'insufficient_samples', sampleCount: stats?.sampleCount || 0 }
}
// P90 不可靠时也跳过(虽然 sampleCount >= 10 时 p90Unreliable 应该是 false
if (stats.p90Unreliable) {
return { reject: false, reason: 'p90_unreliable', sampleCount: stats.sampleCount }
}
// 计算健康阈值P90 >= 超时时间 × 阈值 时拒绝
const threshold = queueConfig.concurrentRequestQueueHealthThreshold || 0.8
const maxAllowedP90 = timeoutMs * threshold
if (stats.p90 >= maxAllowedP90) {
return {
reject: true,
reason: 'queue_overloaded',
estimatedWaitMs: stats.p90,
timeoutMs,
threshold,
sampleCount: stats.sampleCount,
currentQueueCount,
maxQueueSize
}
}
return { reject: false, p90: stats.p90, sampleCount: stats.sampleCount, currentQueueCount }
} catch (error) {
// 健康检查出错时不阻塞请求,记录警告并继续
logger.warn(`Health check failed for ${apiKeyId}:`, error.message)
return { reject: false, reason: 'health_check_error', error: error.message }
}
}
// 排队轮询配置常量(可通过配置文件覆盖)
// 性能权衡:初始间隔越短响应越快,但 Redis QPS 越高
// 当前配置100 个等待者时约 250-300 QPS指数退避后
const QUEUE_POLLING_CONFIG = {
pollIntervalMs: 200, // 初始轮询间隔(毫秒)- 平衡响应速度和 Redis 压力
maxPollIntervalMs: 2000, // 最大轮询间隔(毫秒)- 长时间等待时降低 Redis 压力
backoffFactor: 1.5, // 指数退避系数
jitterRatio: 0.2, // 抖动比例±20%- 防止惊群效应
maxRedisFailCount: 5 // 连续 Redis 失败阈值(从 3 提高到 5提高网络抖动容忍度
}
const FALLBACK_CONCURRENCY_CONFIG = {
leaseSeconds: 300,
@@ -128,9 +224,223 @@ function isTokenCountRequest(req) {
return false
}
/**
* 等待并发槽位(排队机制核心)
*
* 采用「先占后检查」模式避免竞态条件:
* - 每次轮询时尝试 incrConcurrency 占位
* - 如果超限则 decrConcurrency 释放并继续等待
* - 成功获取槽位后返回,调用方无需再次 incrConcurrency
*
* ⚠️ 重要清理责任说明:
* - 排队计数:此函数的 finally 块负责调用 decrConcurrencyQueue 清理
* - 并发槽位:当返回 acquired=true 时,槽位已被占用(通过 incrConcurrency
* 调用方必须在请求结束时调用 decrConcurrency 释放槽位
* (已在 authenticateApiKey 的 finally 块中处理)
*
* @param {Object} req - Express 请求对象
* @param {Object} res - Express 响应对象
* @param {string} apiKeyId - API Key ID
* @param {Object} queueOptions - 配置参数
* @returns {Promise<Object>} { acquired: boolean, reason?: string, waitTimeMs: number }
*/
async function waitForConcurrencySlot(req, res, apiKeyId, queueOptions) {
const {
concurrencyLimit,
requestId,
leaseSeconds,
timeoutMs,
pollIntervalMs,
maxPollIntervalMs,
backoffFactor,
jitterRatio,
maxRedisFailCount: configMaxRedisFailCount
} = queueOptions
let clientDisconnected = false
// 追踪轮询过程中是否临时占用了槽位(用于异常时清理)
// 工作流程:
// 1. incrConcurrency 成功且 count <= limit 时,设置 internalSlotAcquired = true
// 2. 统计记录完成后,设置 internalSlotAcquired = false 并返回(所有权转移给调用方)
// 3. 如果在步骤 1-2 之间发生异常finally 块会检测到 internalSlotAcquired = true 并释放槽位
let internalSlotAcquired = false
// 监听客户端断开事件
// ⚠️ 重要:必须监听 socket 的事件,而不是 req 的事件!
// 原因:对于 POST 请求,当 body-parser 读取完请求体后reqIncomingMessage 可读流)
// 的 'close' 事件会立即触发,但这不代表客户端断开连接!客户端仍在等待响应。
// socket 的 'close' 事件才是真正的连接关闭信号。
const { socket } = req
const onSocketClose = () => {
clientDisconnected = true
logger.debug(
`🔌 [Queue] Socket closed during queue wait for API key ${apiKeyId}, requestId: ${requestId}`
)
}
if (socket) {
socket.once('close', onSocketClose)
}
// 检查 socket 是否在监听器注册前已被销毁(边界情况)
if (socket?.destroyed) {
clientDisconnected = true
}
const startTime = Date.now()
let pollInterval = pollIntervalMs
let redisFailCount = 0
// 优先使用配置中的值,否则使用默认值
const maxRedisFailCount = configMaxRedisFailCount || QUEUE_POLLING_CONFIG.maxRedisFailCount
try {
while (Date.now() - startTime < timeoutMs) {
// 检测客户端是否断开(双重检查:事件标记 + socket 状态)
// socket.destroyed 是同步检查,确保即使事件处理有延迟也能及时检测
if (clientDisconnected || socket?.destroyed) {
redis
.incrConcurrencyQueueStats(apiKeyId, 'cancelled')
.catch((e) => logger.warn('Failed to record cancelled stat:', e))
return {
acquired: false,
reason: 'client_disconnected',
waitTimeMs: Date.now() - startTime
}
}
// 尝试获取槽位(先占后检查)
try {
const count = await redis.incrConcurrency(apiKeyId, requestId, leaseSeconds)
redisFailCount = 0 // 重置失败计数
if (count <= concurrencyLimit) {
// 成功获取槽位!
const waitTimeMs = Date.now() - startTime
// 槽位所有权转移说明:
// 1. 此时槽位已通过 incrConcurrency 获取
// 2. 先标记 internalSlotAcquired = true确保异常时 finally 块能清理
// 3. 统计操作完成后,清除标记并返回,所有权转移给调用方
// 4. 调用方authenticateApiKey负责在请求结束时释放槽位
// 标记槽位已获取(用于异常时 finally 块清理)
internalSlotAcquired = true
// 记录统计非阻塞fire-and-forget 模式)
// ⚠️ 设计说明:
// - 故意不 await 这些 Promise因为统计记录不应阻塞请求处理
// - 每个 Promise 都有独立的 .catch(),确保单个失败不影响其他
// - 外层 .catch() 是防御性措施,处理 Promise.all 本身的异常
// - 即使统计记录在函数返回后才完成/失败,也是安全的(仅日志记录)
// - 统计数据丢失可接受,不影响核心业务逻辑
Promise.all([
redis
.recordQueueWaitTime(apiKeyId, waitTimeMs)
.catch((e) => logger.warn('Failed to record queue wait time:', e)),
redis
.recordGlobalQueueWaitTime(waitTimeMs)
.catch((e) => logger.warn('Failed to record global wait time:', e)),
redis
.incrConcurrencyQueueStats(apiKeyId, 'success')
.catch((e) => logger.warn('Failed to increment success stats:', e))
]).catch((e) => logger.warn('Failed to record queue stats batch:', e))
// 成功返回前清除标记(所有权转移给调用方,由其负责释放)
internalSlotAcquired = false
return { acquired: true, waitTimeMs }
}
// 超限,释放槽位继续等待
try {
await redis.decrConcurrency(apiKeyId, requestId)
} catch (decrError) {
// 释放失败时记录警告但继续轮询
// 下次 incrConcurrency 会自然覆盖同一 requestId 的条目
logger.warn(
`Failed to release slot during polling for ${apiKeyId}, will retry:`,
decrError
)
}
} catch (redisError) {
redisFailCount++
logger.error(
`Redis error in queue polling (${redisFailCount}/${maxRedisFailCount}):`,
redisError
)
if (redisFailCount >= maxRedisFailCount) {
// 连续 Redis 失败,放弃排队
return {
acquired: false,
reason: 'redis_error',
waitTimeMs: Date.now() - startTime
}
}
}
// 指数退避等待
await sleep(pollInterval)
// 计算下一次轮询间隔(指数退避 + 抖动)
// 1. 先应用指数退避
let nextInterval = pollInterval * backoffFactor
// 2. 添加抖动防止惊群效应±jitterRatio 范围内的随机偏移)
// 抖动范围:[-jitterRatio, +jitterRatio],例如 jitterRatio=0.2 时为 ±20%
// 这是预期行为:负抖动可使间隔略微缩短,正抖动可使间隔略微延长
// 目的是分散多个等待者的轮询时间点,避免同时请求 Redis
const jitter = nextInterval * jitterRatio * (Math.random() * 2 - 1)
nextInterval = nextInterval + jitter
// 3. 确保在合理范围内:最小 1ms最大 maxPollIntervalMs
// Math.max(1, ...) 保证即使负抖动也不会产生 ≤0 的间隔
pollInterval = Math.max(1, Math.min(nextInterval, maxPollIntervalMs))
}
// 超时
redis
.incrConcurrencyQueueStats(apiKeyId, 'timeout')
.catch((e) => logger.warn('Failed to record timeout stat:', e))
return { acquired: false, reason: 'timeout', waitTimeMs: Date.now() - startTime }
} finally {
// 确保清理:
// 1. 减少排队计数(排队计数在调用方已增加,这里负责减少)
try {
await redis.decrConcurrencyQueue(apiKeyId)
} catch (cleanupError) {
// 清理失败记录错误(可能导致计数泄漏,但有 TTL 保护)
logger.error(
`Failed to decrement queue count in finally block for ${apiKeyId}:`,
cleanupError
)
}
// 2. 如果内部获取了槽位但未正常返回(异常路径),释放槽位
if (internalSlotAcquired) {
try {
await redis.decrConcurrency(apiKeyId, requestId)
logger.warn(
`⚠️ Released orphaned concurrency slot in finally block for ${apiKeyId}, requestId: ${requestId}`
)
} catch (slotCleanupError) {
logger.error(
`Failed to release orphaned concurrency slot for ${apiKeyId}:`,
slotCleanupError
)
}
}
// 清理 socket 事件监听器
if (socket) {
socket.removeListener('close', onSocketClose)
}
}
}
// 🔑 API Key验证中间件优化版
const authenticateApiKey = async (req, res, next) => {
const startTime = Date.now()
let authErrored = false
let concurrencyCleanup = null
let hasConcurrencySlot = false
try {
// 安全提取API Key支持多种格式包括Gemini CLI支持
@@ -265,39 +575,346 @@ const authenticateApiKey = async (req, res, next) => {
}
const requestId = uuidv4()
// ⚠️ 优化后的 Connection: close 设置策略
// 问题背景HTTP Keep-Alive 使多个请求共用同一个 TCP 连接
// 当第一个请求正在处理,第二个请求进入排队时,它们共用同一个 socket
// 如果客户端超时关闭连接,两个请求都会受影响
// 优化方案:只有在请求实际进入排队时才设置 Connection: close
// 未排队的请求保持 Keep-Alive避免不必要的 TCP 握手开销
// 详见 design.md Decision 2: Connection: close 设置时机
// 注意Connection: close 将在下方代码实际进入排队时设置(第 637 行左右)
// ============================================================
// 🔒 并发槽位状态管理说明
// ============================================================
// 此函数中有两个关键状态变量:
// - hasConcurrencySlot: 当前是否持有并发槽位
// - concurrencyCleanup: 错误时调用的清理函数
//
// 状态转换流程:
// 1. incrConcurrency 成功 → hasConcurrencySlot=true, 设置临时清理函数
// 2. 若超限 → 释放槽位hasConcurrencySlot=false, concurrencyCleanup=null
// 3. 若排队成功 → hasConcurrencySlot=true, 升级为完整清理函数(含 interval 清理)
// 4. 请求结束res.close/req.close→ 调用 decrementConcurrency 释放
// 5. 认证错误 → finally 块调用 concurrencyCleanup 释放
//
// 为什么需要两种清理函数?
// - 临时清理:在排队/认证过程中出错时使用,只释放槽位
// - 完整清理:请求正常开始后使用,还需清理 leaseRenewInterval
// ============================================================
const setTemporaryConcurrencyCleanup = () => {
concurrencyCleanup = async () => {
if (!hasConcurrencySlot) {
return
}
hasConcurrencySlot = false
try {
await redis.decrConcurrency(validation.keyData.id, requestId)
} catch (cleanupError) {
logger.error(
`Failed to decrement concurrency after auth error for key ${validation.keyData.id}:`,
cleanupError
)
}
}
}
const currentConcurrency = await redis.incrConcurrency(
validation.keyData.id,
requestId,
leaseSeconds
)
hasConcurrencySlot = true
setTemporaryConcurrencyCleanup()
logger.api(
`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}`
)
if (currentConcurrency > concurrencyLimit) {
// 如果超过限制,立即减少计数(添加 try-catch 防止异常导致并发泄漏)
// 1. 先释放刚占用的槽位
try {
const newCount = await redis.decrConcurrency(validation.keyData.id, requestId)
logger.api(
`📉 Decremented concurrency (429 rejected) for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}`
)
await redis.decrConcurrency(validation.keyData.id, requestId)
} catch (error) {
logger.error(
`Failed to decrement concurrency after limit exceeded for key ${validation.keyData.id}:`,
error
)
}
logger.security(
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`
hasConcurrencySlot = false
concurrencyCleanup = null
// 2. 获取排队配置
const queueConfig = await claudeRelayConfigService.getConfig()
// 3. 排队功能未启用,直接返回 429保持现有行为
if (!queueConfig.concurrentRequestQueueEnabled) {
logger.security(
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`
)
// 建议客户端在短暂延迟后重试(并发场景下通常很快会有槽位释放)
res.set('Retry-After', '1')
return res.status(429).json({
error: 'Concurrency limit exceeded',
message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`,
currentConcurrency: currentConcurrency - 1,
concurrencyLimit
})
}
// 4. 计算最大排队数
const maxQueueSize = Math.max(
concurrencyLimit * queueConfig.concurrentRequestQueueMaxSizeMultiplier,
queueConfig.concurrentRequestQueueMaxSize
)
return res.status(429).json({
error: 'Concurrency limit exceeded',
message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`,
currentConcurrency: currentConcurrency - 1,
concurrencyLimit
})
// 4.5 排队健康检查:过载时快速失败
// 详见 design.md Decision 7: 排队健康检查与快速失败
const overloadCheck = await shouldRejectDueToOverload(
validation.keyData.id,
queueConfig.concurrentRequestQueueTimeoutMs,
queueConfig,
maxQueueSize
)
if (overloadCheck.reject) {
// 使用健康检查返回的当前排队数,避免重复调用 Redis
const currentQueueCount = overloadCheck.currentQueueCount || 0
logger.api(
`🚨 Queue overloaded for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`P90=${overloadCheck.estimatedWaitMs}ms, timeout=${overloadCheck.timeoutMs}ms, ` +
`threshold=${overloadCheck.threshold}, samples=${overloadCheck.sampleCount}, ` +
`concurrency=${concurrencyLimit}, queue=${currentQueueCount}/${maxQueueSize}`
)
// 记录被拒绝的过载统计
redis
.incrConcurrencyQueueStats(validation.keyData.id, 'rejected_overload')
.catch((e) => logger.warn('Failed to record rejected_overload stat:', e))
// 返回 429 + Retry-After让客户端稍后重试
const retryAfterSeconds = 30
res.set('Retry-After', String(retryAfterSeconds))
return res.status(429).json({
error: 'Queue overloaded',
message: `Queue is overloaded. Estimated wait time (${overloadCheck.estimatedWaitMs}ms) exceeds threshold. Limit: ${concurrencyLimit} concurrent requests, queue: ${currentQueueCount}/${maxQueueSize}. Please retry later.`,
currentConcurrency: concurrencyLimit,
concurrencyLimit,
queueCount: currentQueueCount,
maxQueueSize,
estimatedWaitMs: overloadCheck.estimatedWaitMs,
timeoutMs: overloadCheck.timeoutMs,
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
retryAfterSeconds
})
}
// 5. 尝试进入排队(原子操作:先增加再检查,避免竞态条件)
let queueIncremented = false
try {
const newQueueCount = await redis.incrConcurrencyQueue(
validation.keyData.id,
queueConfig.concurrentRequestQueueTimeoutMs
)
queueIncremented = true
if (newQueueCount > maxQueueSize) {
// 超过最大排队数,立即释放并返回 429
await redis.decrConcurrencyQueue(validation.keyData.id)
queueIncremented = false
logger.api(
`🚦 Concurrency queue full for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`queue: ${newQueueCount - 1}, maxQueue: ${maxQueueSize}`
)
// 队列已满,建议客户端在排队超时时间后重试
const retryAfterSeconds = Math.ceil(queueConfig.concurrentRequestQueueTimeoutMs / 1000)
res.set('Retry-After', String(retryAfterSeconds))
return res.status(429).json({
error: 'Concurrency queue full',
message: `Too many requests waiting in queue. Limit: ${concurrencyLimit} concurrent requests, queue: ${newQueueCount - 1}/${maxQueueSize}, timeout: ${retryAfterSeconds}s`,
currentConcurrency: concurrencyLimit,
concurrencyLimit,
queueCount: newQueueCount - 1,
maxQueueSize,
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
retryAfterSeconds
})
}
// 6. 已成功进入排队,记录统计并开始等待槽位
logger.api(
`⏳ Request entering queue for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`queue position: ${newQueueCount}`
)
redis
.incrConcurrencyQueueStats(validation.keyData.id, 'entered')
.catch((e) => logger.warn('Failed to record entered stat:', e))
// ⚠️ 仅在请求实际进入排队时设置 Connection: close
// 详见 design.md Decision 2: Connection: close 设置时机
// 未排队的请求保持 Keep-Alive避免不必要的 TCP 握手开销
if (!res.headersSent) {
res.setHeader('Connection', 'close')
logger.api(
`🔌 [Queue] Set Connection: close for queued request, key: ${validation.keyData.id}`
)
}
// ⚠️ 记录排队开始时的 socket 标识,用于排队完成后验证
// 问题背景HTTP Keep-Alive 连接复用时,长时间排队可能导致 socket 被其他请求使用
// 验证方法:使用 UUID token + socket 对象引用双重验证
// 详见 design.md Decision 1: Socket 身份验证机制
req._crService = req._crService || {}
req._crService.queueToken = uuidv4()
req._crService.originalSocket = req.socket
req._crService.startTime = Date.now()
const savedToken = req._crService.queueToken
const savedSocket = req._crService.originalSocket
// ⚠️ 重要:在调用前将 queueIncremented 设为 false
// 因为 waitForConcurrencySlot 的 finally 块会负责清理排队计数
// 如果在调用后设置,当 waitForConcurrencySlot 抛出异常时
// 外层 catch 块会重复减少计数finally 已经减过一次)
queueIncremented = false
const slot = await waitForConcurrencySlot(req, res, validation.keyData.id, {
concurrencyLimit,
requestId,
leaseSeconds,
timeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
pollIntervalMs: QUEUE_POLLING_CONFIG.pollIntervalMs,
maxPollIntervalMs: QUEUE_POLLING_CONFIG.maxPollIntervalMs,
backoffFactor: QUEUE_POLLING_CONFIG.backoffFactor,
jitterRatio: QUEUE_POLLING_CONFIG.jitterRatio,
maxRedisFailCount: queueConfig.concurrentRequestQueueMaxRedisFailCount
})
// 7. 处理排队结果
if (!slot.acquired) {
if (slot.reason === 'client_disconnected') {
// 客户端已断开,不返回响应(连接已关闭)
logger.api(
`🔌 Client disconnected while queuing for key: ${validation.keyData.id} (${validation.keyData.name})`
)
return
}
if (slot.reason === 'redis_error') {
// Redis 连续失败,返回 503
logger.error(
`❌ Redis error during queue wait for key: ${validation.keyData.id} (${validation.keyData.name})`
)
return res.status(503).json({
error: 'Service temporarily unavailable',
message: 'Failed to acquire concurrency slot due to internal error'
})
}
// 排队超时(使用 api 级别,与其他排队日志保持一致)
logger.api(
`⏰ Queue timeout for key: ${validation.keyData.id} (${validation.keyData.name}), waited: ${slot.waitTimeMs}ms`
)
// 已等待超时,建议客户端稍后重试
// ⚠️ Retry-After 策略优化:
// - 请求已经等了完整的 timeout 时间,说明系统负载较高
// - 过早重试(如固定 5 秒)会加剧拥塞,导致更多超时
// - 合理策略:使用 timeout 时间的一半作为重试间隔
// - 最小值 5 秒,最大值 30 秒,避免极端情况
const timeoutSeconds = Math.ceil(queueConfig.concurrentRequestQueueTimeoutMs / 1000)
const retryAfterSeconds = Math.max(5, Math.min(30, Math.ceil(timeoutSeconds / 2)))
res.set('Retry-After', String(retryAfterSeconds))
return res.status(429).json({
error: 'Queue timeout',
message: `Request timed out waiting for concurrency slot. Limit: ${concurrencyLimit} concurrent requests, maxQueue: ${maxQueueSize}, Queue timeout: ${timeoutSeconds}s, waited: ${slot.waitTimeMs}ms`,
currentConcurrency: concurrencyLimit,
concurrencyLimit,
maxQueueSize,
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
waitTimeMs: slot.waitTimeMs,
retryAfterSeconds
})
}
// 8. 排队成功slot.acquired 表示已在 waitForConcurrencySlot 中获取到槽位
logger.api(
`✅ Queue wait completed for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`waited: ${slot.waitTimeMs}ms`
)
hasConcurrencySlot = true
setTemporaryConcurrencyCleanup()
// 9. ⚠️ 关键检查:排队等待结束后,验证客户端是否还在等待响应
// 长时间排队后,客户端可能在应用层已放弃(如 Claude Code 的超时机制),
// 但 TCP 连接仍然存活。此时继续处理请求是浪费资源。
// 注意如果发送了心跳headersSent 会是 true但这是正常的
const postQueueSocket = req.socket
// 只检查连接是否真正断开destroyed/writableEnded/socketDestroyed
// headersSent 在心跳场景下是正常的,不应该作为放弃的依据
if (res.destroyed || res.writableEnded || postQueueSocket?.destroyed) {
logger.warn(
`⚠️ Client no longer waiting after queue for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`waited: ${slot.waitTimeMs}ms | destroyed: ${res.destroyed}, ` +
`writableEnded: ${res.writableEnded}, socketDestroyed: ${postQueueSocket?.destroyed}`
)
// 释放刚获取的槽位
hasConcurrencySlot = false
await redis
.decrConcurrency(validation.keyData.id, requestId)
.catch((e) => logger.error('Failed to release slot after client abandoned:', e))
// 不返回响应(客户端已不在等待)
return
}
// 10. ⚠️ 关键检查:验证 socket 身份是否改变
// HTTP Keep-Alive 连接复用可能导致排队期间 socket 被其他请求使用
// 验证方法UUID token + socket 对象引用双重验证
// 详见 design.md Decision 1: Socket 身份验证机制
const queueData = req._crService
const socketIdentityChanged =
!queueData ||
queueData.queueToken !== savedToken ||
queueData.originalSocket !== savedSocket
if (socketIdentityChanged) {
logger.error(
`❌ [Queue] Socket identity changed during queue wait! ` +
`key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`waited: ${slot.waitTimeMs}ms | ` +
`tokenMatch: ${queueData?.queueToken === savedToken}, ` +
`socketMatch: ${queueData?.originalSocket === savedSocket}`
)
// 释放刚获取的槽位
hasConcurrencySlot = false
await redis
.decrConcurrency(validation.keyData.id, requestId)
.catch((e) => logger.error('Failed to release slot after socket identity change:', e))
// 记录 socket_changed 统计
redis
.incrConcurrencyQueueStats(validation.keyData.id, 'socket_changed')
.catch((e) => logger.warn('Failed to record socket_changed stat:', e))
// 不返回响应socket 已被其他请求使用)
return
}
} catch (queueError) {
// 异常时清理资源,防止泄漏
// 1. 清理排队计数(如果还没被 waitForConcurrencySlot 的 finally 清理)
if (queueIncremented) {
await redis
.decrConcurrencyQueue(validation.keyData.id)
.catch((e) => logger.error('Failed to cleanup queue count after error:', e))
}
// 2. 防御性清理:如果 waitForConcurrencySlot 内部获取了槽位但在返回前异常
// 虽然这种情况极少发生(统计记录的异常会被内部捕获),但为了安全起见
// 尝试释放可能已获取的槽位。decrConcurrency 使用 ZREM即使成员不存在也安全
if (hasConcurrencySlot) {
hasConcurrencySlot = false
await redis
.decrConcurrency(validation.keyData.id, requestId)
.catch((e) =>
logger.error('Failed to cleanup concurrency slot after queue error:', e)
)
}
throw queueError
}
}
const renewIntervalMs =
@@ -358,6 +975,7 @@ const authenticateApiKey = async (req, res, next) => {
const decrementConcurrency = async () => {
if (!concurrencyDecremented) {
concurrencyDecremented = true
hasConcurrencySlot = false
if (leaseRenewInterval) {
clearInterval(leaseRenewInterval)
leaseRenewInterval = null
@@ -372,6 +990,11 @@ const authenticateApiKey = async (req, res, next) => {
}
}
}
// 升级为完整清理函数(包含 leaseRenewInterval 清理逻辑)
// 此时请求已通过认证,后续由 res.close/req.close 事件触发清理
if (hasConcurrencySlot) {
concurrencyCleanup = decrementConcurrency
}
// 监听最可靠的事件(避免重复监听)
// res.on('close') 是最可靠的,会在连接关闭时触发
@@ -697,6 +1320,7 @@ const authenticateApiKey = async (req, res, next) => {
return next()
} catch (error) {
authErrored = true
const authDuration = Date.now() - startTime
logger.error(`❌ Authentication middleware error (${authDuration}ms):`, {
error: error.message,
@@ -710,6 +1334,14 @@ const authenticateApiKey = async (req, res, next) => {
error: 'Authentication error',
message: 'Internal server error during authentication'
})
} finally {
if (authErrored && typeof concurrencyCleanup === 'function') {
try {
await concurrencyCleanup()
} catch (cleanupError) {
logger.error('Failed to cleanup concurrency after auth error:', cleanupError)
}
}
}
}
@@ -1388,7 +2020,8 @@ const globalRateLimit = async (req, res, next) =>
// 📊 请求大小限制中间件
const requestSizeLimit = (req, res, next) => {
const maxSize = 60 * 1024 * 1024 // 60MB
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '60', 10)
const maxSize = MAX_SIZE_MB * 1024 * 1024
const contentLength = parseInt(req.headers['content-length'] || '0')
if (contentLength > maxSize) {

View File

@@ -50,6 +50,18 @@ function getWeekStringInTimezone(date = new Date()) {
return `${year}-W${String(weekNumber).padStart(2, '0')}`
}
// 并发队列相关常量
const QUEUE_STATS_TTL_SECONDS = 86400 * 7 // 统计计数保留 7 天
const WAIT_TIME_TTL_SECONDS = 86400 // 等待时间样本保留 1 天(滚动窗口,无需长期保留)
// 等待时间样本数配置(提高统计置信度)
// - 每 API Key 从 100 提高到 500提供更稳定的 P99 估计
// - 全局从 500 提高到 2000支持更高精度的 P99.9 分析
// - 内存开销约 12-20KBRedis quicklist 每元素 1-10 字节),可接受
// 详见 design.md Decision 5: 等待时间统计样本数
const WAIT_TIME_SAMPLES_PER_KEY = 500 // 每个 API Key 保留的等待时间样本数
const WAIT_TIME_SAMPLES_GLOBAL = 2000 // 全局保留的等待时间样本数
const QUEUE_TTL_BUFFER_SECONDS = 30 // 排队计数器TTL缓冲时间
class RedisClient {
constructor() {
this.client = null
@@ -2626,38 +2638,6 @@ redisClient.acquireUserMessageLock = async function (accountId, requestId, lockT
}
}
/**
* 续租用户消息队列锁(仅锁持有者可续租)
* @param {string} accountId - 账户ID
* @param {string} requestId - 请求ID
* @param {number} lockTtlMs - 锁 TTL毫秒
* @returns {Promise<boolean>} 是否续租成功(只有锁持有者才能续租)
*/
redisClient.refreshUserMessageLock = async function (accountId, requestId, lockTtlMs) {
const lockKey = `user_msg_queue_lock:${accountId}`
const script = `
local lockKey = KEYS[1]
local requestId = ARGV[1]
local lockTtl = tonumber(ARGV[2])
local currentLock = redis.call('GET', lockKey)
if currentLock == requestId then
redis.call('PEXPIRE', lockKey, lockTtl)
return 1
end
return 0
`
try {
const result = await this.client.eval(script, 1, lockKey, requestId, lockTtlMs)
return result === 1
} catch (error) {
logger.error(`Failed to refresh user message lock for account ${accountId}:`, error)
return false
}
}
/**
* 释放用户消息队列锁并记录完成时间
* @param {string} accountId - 账户ID
@@ -2801,4 +2781,380 @@ redisClient.scanUserMessageQueueLocks = async function () {
}
}
// ============================================
// 🚦 API Key 并发请求排队方法
// ============================================
/**
* 增加排队计数(使用 Lua 脚本确保原子性)
* @param {string} apiKeyId - API Key ID
* @param {number} [timeoutMs=60000] - 排队超时时间(毫秒),用于计算 TTL
* @returns {Promise<number>} 增加后的排队数量
*/
redisClient.incrConcurrencyQueue = async function (apiKeyId, timeoutMs = 60000) {
const key = `concurrency:queue:${apiKeyId}`
try {
// 使用 Lua 脚本确保 INCR 和 EXPIRE 原子执行,防止进程崩溃导致计数器泄漏
// TTL = 超时时间 + 缓冲时间(确保键不会在请求还在等待时过期)
const ttlSeconds = Math.ceil(timeoutMs / 1000) + QUEUE_TTL_BUFFER_SECONDS
const script = `
local count = redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
return count
`
const count = await this.client.eval(script, 1, key, String(ttlSeconds))
logger.database(
`🚦 Incremented queue count for key ${apiKeyId}: ${count} (TTL: ${ttlSeconds}s)`
)
return parseInt(count)
} catch (error) {
logger.error(`Failed to increment concurrency queue for ${apiKeyId}:`, error)
throw error
}
}
/**
* 减少排队计数(使用 Lua 脚本确保原子性)
* @param {string} apiKeyId - API Key ID
* @returns {Promise<number>} 减少后的排队数量
*/
redisClient.decrConcurrencyQueue = async function (apiKeyId) {
const key = `concurrency:queue:${apiKeyId}`
try {
// 使用 Lua 脚本确保 DECR 和 DEL 原子执行,防止进程崩溃导致计数器残留
const script = `
local count = redis.call('DECR', KEYS[1])
if count <= 0 then
redis.call('DEL', KEYS[1])
return 0
end
return count
`
const count = await this.client.eval(script, 1, key)
const result = parseInt(count)
if (result === 0) {
logger.database(`🚦 Queue count for key ${apiKeyId} is 0, removed key`)
} else {
logger.database(`🚦 Decremented queue count for key ${apiKeyId}: ${result}`)
}
return result
} catch (error) {
logger.error(`Failed to decrement concurrency queue for ${apiKeyId}:`, error)
throw error
}
}
/**
* 获取排队计数
* @param {string} apiKeyId - API Key ID
* @returns {Promise<number>} 当前排队数量
*/
redisClient.getConcurrencyQueueCount = async function (apiKeyId) {
const key = `concurrency:queue:${apiKeyId}`
try {
const count = await this.client.get(key)
return parseInt(count || 0)
} catch (error) {
logger.error(`Failed to get concurrency queue count for ${apiKeyId}:`, error)
return 0
}
}
/**
* 清空排队计数
* @param {string} apiKeyId - API Key ID
* @returns {Promise<boolean>} 是否成功清空
*/
redisClient.clearConcurrencyQueue = async function (apiKeyId) {
const key = `concurrency:queue:${apiKeyId}`
try {
await this.client.del(key)
logger.database(`🚦 Cleared queue count for key ${apiKeyId}`)
return true
} catch (error) {
logger.error(`Failed to clear concurrency queue for ${apiKeyId}:`, error)
return false
}
}
/**
* 扫描所有排队计数器
* @returns {Promise<string[]>} API Key ID 列表
*/
redisClient.scanConcurrencyQueueKeys = async function () {
const apiKeyIds = []
let cursor = '0'
let iterations = 0
const MAX_ITERATIONS = 1000
try {
do {
const [newCursor, keys] = await this.client.scan(
cursor,
'MATCH',
'concurrency:queue:*',
'COUNT',
100
)
cursor = newCursor
iterations++
for (const key of keys) {
// 排除统计和等待时间相关的键
if (
key.startsWith('concurrency:queue:stats:') ||
key.startsWith('concurrency:queue:wait_times:')
) {
continue
}
const apiKeyId = key.replace('concurrency:queue:', '')
apiKeyIds.push(apiKeyId)
}
if (iterations >= MAX_ITERATIONS) {
logger.warn(
`🚦 Concurrency queue: SCAN reached max iterations (${MAX_ITERATIONS}), stopping early`,
{ foundQueues: apiKeyIds.length }
)
break
}
} while (cursor !== '0')
return apiKeyIds
} catch (error) {
logger.error('Failed to scan concurrency queue keys:', error)
return []
}
}
/**
* 清理所有排队计数器(用于服务重启)
* @returns {Promise<number>} 清理的计数器数量
*/
redisClient.clearAllConcurrencyQueues = async function () {
let cleared = 0
let cursor = '0'
let iterations = 0
const MAX_ITERATIONS = 1000
try {
do {
const [newCursor, keys] = await this.client.scan(
cursor,
'MATCH',
'concurrency:queue:*',
'COUNT',
100
)
cursor = newCursor
iterations++
// 只删除排队计数器,保留统计数据
const queueKeys = keys.filter(
(key) =>
!key.startsWith('concurrency:queue:stats:') &&
!key.startsWith('concurrency:queue:wait_times:')
)
if (queueKeys.length > 0) {
await this.client.del(...queueKeys)
cleared += queueKeys.length
}
if (iterations >= MAX_ITERATIONS) {
break
}
} while (cursor !== '0')
if (cleared > 0) {
logger.info(`🚦 Cleared ${cleared} concurrency queue counter(s) on startup`)
}
return cleared
} catch (error) {
logger.error('Failed to clear all concurrency queues:', error)
return 0
}
}
/**
* 增加排队统计计数(使用 Lua 脚本确保原子性)
* @param {string} apiKeyId - API Key ID
* @param {string} field - 统计字段 (entered/success/timeout/cancelled)
* @returns {Promise<number>} 增加后的计数
*/
redisClient.incrConcurrencyQueueStats = async function (apiKeyId, field) {
const key = `concurrency:queue:stats:${apiKeyId}`
try {
// 使用 Lua 脚本确保 HINCRBY 和 EXPIRE 原子执行
// 防止在两者之间崩溃导致统计键没有 TTL内存泄漏
const script = `
local count = redis.call('HINCRBY', KEYS[1], ARGV[1], 1)
redis.call('EXPIRE', KEYS[1], ARGV[2])
return count
`
const count = await this.client.eval(script, 1, key, field, String(QUEUE_STATS_TTL_SECONDS))
return parseInt(count)
} catch (error) {
logger.error(`Failed to increment queue stats ${field} for ${apiKeyId}:`, error)
return 0
}
}
/**
* 获取排队统计
* @param {string} apiKeyId - API Key ID
* @returns {Promise<Object>} 统计数据
*/
redisClient.getConcurrencyQueueStats = async function (apiKeyId) {
const key = `concurrency:queue:stats:${apiKeyId}`
try {
const stats = await this.client.hgetall(key)
return {
entered: parseInt(stats?.entered || 0),
success: parseInt(stats?.success || 0),
timeout: parseInt(stats?.timeout || 0),
cancelled: parseInt(stats?.cancelled || 0),
socket_changed: parseInt(stats?.socket_changed || 0),
rejected_overload: parseInt(stats?.rejected_overload || 0)
}
} catch (error) {
logger.error(`Failed to get queue stats for ${apiKeyId}:`, error)
return {
entered: 0,
success: 0,
timeout: 0,
cancelled: 0,
socket_changed: 0,
rejected_overload: 0
}
}
}
/**
* 记录排队等待时间(按 API Key 分开存储)
* @param {string} apiKeyId - API Key ID
* @param {number} waitTimeMs - 等待时间(毫秒)
* @returns {Promise<void>}
*/
redisClient.recordQueueWaitTime = async function (apiKeyId, waitTimeMs) {
const key = `concurrency:queue:wait_times:${apiKeyId}`
try {
// 使用 Lua 脚本确保原子性,同时设置 TTL 防止内存泄漏
const script = `
redis.call('LPUSH', KEYS[1], ARGV[1])
redis.call('LTRIM', KEYS[1], 0, ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1
`
await this.client.eval(
script,
1,
key,
waitTimeMs,
WAIT_TIME_SAMPLES_PER_KEY - 1,
WAIT_TIME_TTL_SECONDS
)
} catch (error) {
logger.error(`Failed to record queue wait time for ${apiKeyId}:`, error)
}
}
/**
* 记录全局排队等待时间
* @param {number} waitTimeMs - 等待时间(毫秒)
* @returns {Promise<void>}
*/
redisClient.recordGlobalQueueWaitTime = async function (waitTimeMs) {
const key = 'concurrency:queue:wait_times:global'
try {
// 使用 Lua 脚本确保原子性,同时设置 TTL 防止内存泄漏
const script = `
redis.call('LPUSH', KEYS[1], ARGV[1])
redis.call('LTRIM', KEYS[1], 0, ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1
`
await this.client.eval(
script,
1,
key,
waitTimeMs,
WAIT_TIME_SAMPLES_GLOBAL - 1,
WAIT_TIME_TTL_SECONDS
)
} catch (error) {
logger.error('Failed to record global queue wait time:', error)
}
}
/**
* 获取全局等待时间列表
* @returns {Promise<number[]>} 等待时间列表
*/
redisClient.getGlobalQueueWaitTimes = async function () {
const key = 'concurrency:queue:wait_times:global'
try {
const samples = await this.client.lrange(key, 0, -1)
return samples.map(Number)
} catch (error) {
logger.error('Failed to get global queue wait times:', error)
return []
}
}
/**
* 获取指定 API Key 的等待时间列表
* @param {string} apiKeyId - API Key ID
* @returns {Promise<number[]>} 等待时间列表
*/
redisClient.getQueueWaitTimes = async function (apiKeyId) {
const key = `concurrency:queue:wait_times:${apiKeyId}`
try {
const samples = await this.client.lrange(key, 0, -1)
return samples.map(Number)
} catch (error) {
logger.error(`Failed to get queue wait times for ${apiKeyId}:`, error)
return []
}
}
/**
* 扫描所有排队统计键
* @returns {Promise<string[]>} API Key ID 列表
*/
redisClient.scanConcurrencyQueueStatsKeys = async function () {
const apiKeyIds = []
let cursor = '0'
let iterations = 0
const MAX_ITERATIONS = 1000
try {
do {
const [newCursor, keys] = await this.client.scan(
cursor,
'MATCH',
'concurrency:queue:stats:*',
'COUNT',
100
)
cursor = newCursor
iterations++
for (const key of keys) {
const apiKeyId = key.replace('concurrency:queue:stats:', '')
apiKeyIds.push(apiKeyId)
}
if (iterations >= MAX_ITERATIONS) {
break
}
} while (cursor !== '0')
return apiKeyIds
} catch (error) {
logger.error('Failed to scan concurrency queue stats keys:', error)
return []
}
}
module.exports = redisClient

View File

@@ -945,6 +945,30 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
}
// 🔧 FIX: 对于 "全部时间" 时间范围,直接使用 allTimeCost
// 因为 usage:*:model:daily:* 键有 30 天 TTL旧数据已经过期
if (timeRange === 'all' && allTimeCost > 0) {
logger.debug(`📊 使用 allTimeCost 计算 timeRange='all': ${allTimeCost}`)
return {
requests: 0, // 旧数据详情不可用
tokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
cost: allTimeCost,
formattedCost: CostCalculator.formatCost(allTimeCost),
// 实时限制数据(始终返回,不受时间范围影响)
dailyCost,
currentWindowCost,
windowRemainingSeconds,
windowStartTime,
windowEndTime,
allTimeCost
}
}
// 只在启用了窗口限制时查询窗口数据
if (rateLimitWindow > 0) {
const costCountKey = `rate_limit:cost:${keyId}`
@@ -1006,12 +1030,10 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
const modelStatsMap = new Map()
let totalRequests = 0
// 用于去重:统计数据,避免与数据重复
// 用于去重:统计数据,避免与数据重复
const dailyKeyPattern = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
const monthlyKeyPattern = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
// 检查是否有日数据
const hasDailyData = uniqueKeys.some((key) => dailyKeyPattern.test(key))
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
for (let i = 0; i < results.length; i++) {
const [err, data] = results[i]
@@ -1038,8 +1060,12 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
continue
}
// 如果有日数据,则跳过月数据以避免重复
if (hasDailyData && isMonthly) {
// 跳过当前月的月数据
if (isMonthly && key.includes(`:${currentMonth}`)) {
continue
}
// 跳过非当前月的日数据
if (!isMonthly && !key.includes(`:${currentMonth}-`)) {
continue
}

View File

@@ -277,7 +277,7 @@ router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req,
logger.info('🍪 Starting Cookie-based OAuth authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
hasProxy: !!proxy
})
@@ -326,7 +326,7 @@ router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
hasProxy: !!proxy
})

View File

@@ -43,7 +43,11 @@ router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
sessionBindingTtlDays,
userMessageQueueEnabled,
userMessageQueueDelayMs,
userMessageQueueTimeoutMs
userMessageQueueTimeoutMs,
concurrentRequestQueueEnabled,
concurrentRequestQueueMaxSize,
concurrentRequestQueueMaxSizeMultiplier,
concurrentRequestQueueTimeoutMs
} = req.body
// 验证输入
@@ -110,6 +114,54 @@ router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
}
}
// 验证并发请求排队配置
if (
concurrentRequestQueueEnabled !== undefined &&
typeof concurrentRequestQueueEnabled !== 'boolean'
) {
return res.status(400).json({ error: 'concurrentRequestQueueEnabled must be a boolean' })
}
if (concurrentRequestQueueMaxSize !== undefined) {
if (
typeof concurrentRequestQueueMaxSize !== 'number' ||
!Number.isInteger(concurrentRequestQueueMaxSize) ||
concurrentRequestQueueMaxSize < 1 ||
concurrentRequestQueueMaxSize > 100
) {
return res
.status(400)
.json({ error: 'concurrentRequestQueueMaxSize must be an integer between 1 and 100' })
}
}
if (concurrentRequestQueueMaxSizeMultiplier !== undefined) {
// 使用 Number.isFinite() 同时排除 NaN、Infinity、-Infinity 和非数字类型
if (
!Number.isFinite(concurrentRequestQueueMaxSizeMultiplier) ||
concurrentRequestQueueMaxSizeMultiplier < 0 ||
concurrentRequestQueueMaxSizeMultiplier > 10
) {
return res.status(400).json({
error: 'concurrentRequestQueueMaxSizeMultiplier must be a finite number between 0 and 10'
})
}
}
if (concurrentRequestQueueTimeoutMs !== undefined) {
if (
typeof concurrentRequestQueueTimeoutMs !== 'number' ||
!Number.isInteger(concurrentRequestQueueTimeoutMs) ||
concurrentRequestQueueTimeoutMs < 5000 ||
concurrentRequestQueueTimeoutMs > 300000
) {
return res.status(400).json({
error:
'concurrentRequestQueueTimeoutMs must be an integer between 5000 and 300000 (5 seconds to 5 minutes)'
})
}
}
const updateData = {}
if (claudeCodeOnlyEnabled !== undefined) {
updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled
@@ -132,6 +184,18 @@ router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
if (userMessageQueueTimeoutMs !== undefined) {
updateData.userMessageQueueTimeoutMs = userMessageQueueTimeoutMs
}
if (concurrentRequestQueueEnabled !== undefined) {
updateData.concurrentRequestQueueEnabled = concurrentRequestQueueEnabled
}
if (concurrentRequestQueueMaxSize !== undefined) {
updateData.concurrentRequestQueueMaxSize = concurrentRequestQueueMaxSize
}
if (concurrentRequestQueueMaxSizeMultiplier !== undefined) {
updateData.concurrentRequestQueueMaxSizeMultiplier = concurrentRequestQueueMaxSizeMultiplier
}
if (concurrentRequestQueueTimeoutMs !== undefined) {
updateData.concurrentRequestQueueTimeoutMs = concurrentRequestQueueTimeoutMs
}
const updatedConfig = await claudeRelayConfigService.updateConfig(
updateData,

View File

@@ -7,26 +7,40 @@ const express = require('express')
const router = express.Router()
const redis = require('../../models/redis')
const logger = require('../../utils/logger')
const { authenticateAdmin } = require('../../middleware/auth')
const { calculateWaitTimeStats } = require('../../utils/statsHelper')
/**
* GET /admin/concurrency
* 获取所有并发状态
*/
router.get('/concurrency', async (req, res) => {
router.get('/concurrency', authenticateAdmin, async (req, res) => {
try {
const status = await redis.getAllConcurrencyStatus()
// 为每个 API Key 获取排队计数
const statusWithQueue = await Promise.all(
status.map(async (s) => {
const queueCount = await redis.getConcurrencyQueueCount(s.apiKeyId)
return {
...s,
queueCount
}
})
)
// 计算汇总统计
const summary = {
totalKeys: status.length,
totalActiveRequests: status.reduce((sum, s) => sum + s.activeCount, 0),
totalExpiredRequests: status.reduce((sum, s) => sum + s.expiredCount, 0)
totalKeys: statusWithQueue.length,
totalActiveRequests: statusWithQueue.reduce((sum, s) => sum + s.activeCount, 0),
totalExpiredRequests: statusWithQueue.reduce((sum, s) => sum + s.expiredCount, 0),
totalQueuedRequests: statusWithQueue.reduce((sum, s) => sum + s.queueCount, 0)
}
res.json({
success: true,
summary,
concurrencyStatus: status
concurrencyStatus: statusWithQueue
})
} catch (error) {
logger.error('❌ Failed to get concurrency status:', error)
@@ -39,17 +53,171 @@ router.get('/concurrency', async (req, res) => {
})
/**
* GET /admin/concurrency/:apiKeyId
* 获取特定 API Key 的并发状态详情
* GET /admin/concurrency-queue/stats
* 获取排队统计信息
*/
router.get('/concurrency/:apiKeyId', async (req, res) => {
router.get('/concurrency-queue/stats', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.params
const status = await redis.getConcurrencyStatus(apiKeyId)
// 获取所有有统计数据的 API Key
const statsKeys = await redis.scanConcurrencyQueueStatsKeys()
const queueKeys = await redis.scanConcurrencyQueueKeys()
// 合并所有相关的 API Key
const allApiKeyIds = [...new Set([...statsKeys, ...queueKeys])]
// 获取各 API Key 的详细统计
const perKeyStats = await Promise.all(
allApiKeyIds.map(async (apiKeyId) => {
const [queueCount, stats, waitTimes] = await Promise.all([
redis.getConcurrencyQueueCount(apiKeyId),
redis.getConcurrencyQueueStats(apiKeyId),
redis.getQueueWaitTimes(apiKeyId)
])
return {
apiKeyId,
currentQueueCount: queueCount,
stats,
waitTimeStats: calculateWaitTimeStats(waitTimes)
}
})
)
// 获取全局等待时间统计
const globalWaitTimes = await redis.getGlobalQueueWaitTimes()
const globalWaitTimeStats = calculateWaitTimeStats(globalWaitTimes)
// 计算全局汇总
const globalStats = {
totalEntered: perKeyStats.reduce((sum, s) => sum + s.stats.entered, 0),
totalSuccess: perKeyStats.reduce((sum, s) => sum + s.stats.success, 0),
totalTimeout: perKeyStats.reduce((sum, s) => sum + s.stats.timeout, 0),
totalCancelled: perKeyStats.reduce((sum, s) => sum + s.stats.cancelled, 0),
totalSocketChanged: perKeyStats.reduce((sum, s) => sum + (s.stats.socket_changed || 0), 0),
totalRejectedOverload: perKeyStats.reduce(
(sum, s) => sum + (s.stats.rejected_overload || 0),
0
),
currentTotalQueued: perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0),
// 队列资源利用率指标
peakQueueSize:
perKeyStats.length > 0 ? Math.max(...perKeyStats.map((s) => s.currentQueueCount)) : 0,
avgQueueSize:
perKeyStats.length > 0
? Math.round(
perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0) / perKeyStats.length
)
: 0,
activeApiKeys: perKeyStats.filter((s) => s.currentQueueCount > 0).length
}
// 计算成功率
if (globalStats.totalEntered > 0) {
globalStats.successRate = Math.round(
(globalStats.totalSuccess / globalStats.totalEntered) * 100
)
globalStats.timeoutRate = Math.round(
(globalStats.totalTimeout / globalStats.totalEntered) * 100
)
globalStats.cancelledRate = Math.round(
(globalStats.totalCancelled / globalStats.totalEntered) * 100
)
}
// 从全局等待时间统计中提取关键指标
if (globalWaitTimeStats) {
globalStats.avgWaitTimeMs = globalWaitTimeStats.avg
globalStats.p50WaitTimeMs = globalWaitTimeStats.p50
globalStats.p90WaitTimeMs = globalWaitTimeStats.p90
globalStats.p99WaitTimeMs = globalWaitTimeStats.p99
// 多实例采样策略标记(详见 design.md Decision 9
// 全局 P90 仅用于可视化和监控,不用于系统决策
// 健康检查使用 API Key 级别的 P90每 Key 独立采样)
globalWaitTimeStats.globalP90ForVisualizationOnly = true
}
res.json({
success: true,
concurrencyStatus: status
globalStats,
globalWaitTimeStats,
perKeyStats
})
} catch (error) {
logger.error('❌ Failed to get queue stats:', error)
res.status(500).json({
success: false,
error: 'Failed to get queue stats',
message: error.message
})
}
})
/**
* DELETE /admin/concurrency-queue/:apiKeyId
* 清理特定 API Key 的排队计数
*/
router.delete('/concurrency-queue/:apiKeyId', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.params
await redis.clearConcurrencyQueue(apiKeyId)
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared queue for key ${apiKeyId}`)
res.json({
success: true,
message: `Successfully cleared queue for API key ${apiKeyId}`
})
} catch (error) {
logger.error(`❌ Failed to clear queue for ${req.params.apiKeyId}:`, error)
res.status(500).json({
success: false,
error: 'Failed to clear queue',
message: error.message
})
}
})
/**
* DELETE /admin/concurrency-queue
* 清理所有排队计数
*/
router.delete('/concurrency-queue', authenticateAdmin, async (req, res) => {
try {
const cleared = await redis.clearAllConcurrencyQueues()
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared ALL queues`)
res.json({
success: true,
message: 'Successfully cleared all queues',
cleared
})
} catch (error) {
logger.error('❌ Failed to clear all queues:', error)
res.status(500).json({
success: false,
error: 'Failed to clear all queues',
message: error.message
})
}
})
/**
* GET /admin/concurrency/:apiKeyId
* 获取特定 API Key 的并发状态详情
*/
router.get('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.params
const status = await redis.getConcurrencyStatus(apiKeyId)
const queueCount = await redis.getConcurrencyQueueCount(apiKeyId)
res.json({
success: true,
concurrencyStatus: {
...status,
queueCount
}
})
} catch (error) {
logger.error(`❌ Failed to get concurrency status for ${req.params.apiKeyId}:`, error)
@@ -65,7 +233,7 @@ router.get('/concurrency/:apiKeyId', async (req, res) => {
* DELETE /admin/concurrency/:apiKeyId
* 强制清理特定 API Key 的并发计数
*/
router.delete('/concurrency/:apiKeyId', async (req, res) => {
router.delete('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.params
const result = await redis.forceClearConcurrency(apiKeyId)
@@ -93,7 +261,7 @@ router.delete('/concurrency/:apiKeyId', async (req, res) => {
* DELETE /admin/concurrency
* 强制清理所有并发计数
*/
router.delete('/concurrency', async (req, res) => {
router.delete('/concurrency', authenticateAdmin, async (req, res) => {
try {
const result = await redis.forceClearAllConcurrency()
@@ -118,7 +286,7 @@ router.delete('/concurrency', async (req, res) => {
* POST /admin/concurrency/cleanup
* 清理过期的并发条目(不影响活跃请求)
*/
router.post('/concurrency/cleanup', async (req, res) => {
router.post('/concurrency/cleanup', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.body
const result = await redis.cleanupExpiredConcurrency(apiKeyId || null)

View File

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

View File

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

View File

@@ -13,6 +13,11 @@ const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
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 = '') {
@@ -110,20 +115,6 @@ async function handleMessagesRequest(req, res) {
try {
const startTime = Date.now()
// Claude 服务权限校验,阻止未授权的 Key
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
}
})
}
// 🔄 并发满额重试标志最多重试一次使用req对象存储状态
if (req._concurrencyRetryAttempted === undefined) {
req._concurrencyRetryAttempted = false
@@ -168,6 +159,50 @@ async function handleMessagesRequest(req, res) {
}
}
const forcedVendor = req._anthropicVendor || null
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 permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Gemini 服务'
}
})
}
const baseModel = (req.body.model || '').trim()
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
}
// Claude 服务权限校验,阻止未授权的 Key默认路径保持不变
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
}
})
}
// 检查是否为流式请求
const isStream = req.body.stream === true
@@ -190,12 +225,42 @@ async function handleMessagesRequest(req, res) {
)
if (isStream) {
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn(
`⚠️ Client disconnected before stream response could start for key: ${req.apiKey?.name || 'unknown'}`
)
return undefined
}
// 流式响应 - 只使用官方真实usage数据
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲
// ⚠️ 检查 headers 是否已发送(可能在排队心跳时已设置)
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
// 当并发队列功能启用时auth.js 会设置 Connection: close 来禁用 Keep-Alive
// 这里只在没有设置过 Connection 头时才设置 keep-alive
const existingConnection = res.getHeader('Connection')
if (!existingConnection) {
res.setHeader('Connection', 'keep-alive')
} else {
logger.api(
`🔌 [STREAM] Preserving existing Connection header: ${existingConnection} for key: ${req.apiKey?.name || 'unknown'}`
)
}
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲
} else {
logger.debug(
`📤 [STREAM] Headers already sent, skipping setHeader for key: ${req.apiKey?.name || 'unknown'}`
)
}
// 禁用 Nagle 算法,确保数据立即发送
if (res.socket && typeof res.socket.setNoDelay === 'function') {
@@ -657,12 +722,61 @@ async function handleMessagesRequest(req, res) {
}
}, 1000) // 1秒后检查
} else {
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn(
`⚠️ Client disconnected before non-stream request could start for key: ${req.apiKey?.name || 'unknown'}`
)
return undefined
}
// 非流式响应 - 只使用官方真实usage数据
logger.info('📄 Starting non-streaming request', {
apiKeyId: req.apiKey.id,
apiKeyName: req.apiKey.name
})
// 📊 监听 socket 事件以追踪连接状态变化
const nonStreamSocket = res.socket
let _clientClosedConnection = false
let _socketCloseTime = null
if (nonStreamSocket) {
const onSocketEnd = () => {
_clientClosedConnection = true
_socketCloseTime = Date.now()
logger.warn(
`⚠️ [NON-STREAM] Socket 'end' event - client sent FIN | key: ${req.apiKey?.name}, ` +
`requestId: ${req.requestId}, elapsed: ${Date.now() - startTime}ms`
)
}
const onSocketClose = () => {
_clientClosedConnection = true
logger.warn(
`⚠️ [NON-STREAM] Socket 'close' event | key: ${req.apiKey?.name}, ` +
`requestId: ${req.requestId}, elapsed: ${Date.now() - startTime}ms, ` +
`hadError: ${nonStreamSocket.destroyed}`
)
}
const onSocketError = (err) => {
logger.error(
`❌ [NON-STREAM] Socket error | key: ${req.apiKey?.name}, ` +
`requestId: ${req.requestId}, error: ${err.message}`
)
}
nonStreamSocket.once('end', onSocketEnd)
nonStreamSocket.once('close', onSocketClose)
nonStreamSocket.once('error', onSocketError)
// 清理监听器(在响应结束后)
res.once('finish', () => {
nonStreamSocket.removeListener('end', onSocketEnd)
nonStreamSocket.removeListener('close', onSocketClose)
nonStreamSocket.removeListener('error', onSocketError)
})
}
// 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(req.body)
@@ -867,6 +981,15 @@ async function handleMessagesRequest(req, res) {
bodyLength: response.body ? response.body.length : 0
})
// 🔍 检查客户端连接是否仍然有效
// 在长时间请求过程中,客户端可能已经断开连接(超时、用户取消等)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn(
`⚠️ Client disconnected before non-stream response could be sent for key: ${req.apiKey?.name || 'unknown'}`
)
return undefined
}
res.status(response.statusCode)
// 设置响应头,避免 Content-Length 和 Transfer-Encoding 冲突
@@ -897,8 +1020,8 @@ async function handleMessagesRequest(req, res) {
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 { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
const model = usageBaseModel || rawModel
// 记录真实的token使用量包含模型信息和所有4种token以及账户ID
const { accountId: responseAccountId } = response
@@ -932,10 +1055,12 @@ async function handleMessagesRequest(req, res) {
logger.warn('⚠️ No usage data found in Claude API JSON response')
}
// 使用 Express 内建的 res.json() 发送响应(简单可靠)
res.json(jsonData)
} catch (parseError) {
logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message)
logger.info('📄 Raw response body:', response.body)
// 使用 Express 内建的 res.send() 发送响应(简单可靠)
res.send(response.body)
}
@@ -1072,6 +1197,66 @@ 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') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && 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 获取所有支持的模型
@@ -1208,6 +1393,22 @@ 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) => {
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: 'This API key does not have permission to access Gemini'
}
})
}
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
}
// 检查权限
if (
req.apiKey.permissions &&

View File

@@ -206,74 +206,85 @@ router.post('/api/user-stats', async (req, res) => {
// 获取验证结果中的完整keyData包含isActive状态和cost信息
const fullKeyData = keyData
// 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算)
// 🔧 FIX: 使用 allTimeCost 而不是扫描月度键
// 计算总费用 - 优先使用持久化的总费用计数器
let totalCost = 0
let formattedCost = '$0.000000'
try {
const client = redis.getClientSafe()
// 获取所有月度模型统计与model-stats接口相同的逻辑
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
const modelUsageMap = new Map()
// 读取累积的总费用(没有 TTL 的持久键
const totalCostKey = `usage:cost:total:${keyId}`
const allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
for (const key of allModelKeys) {
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
if (!modelMatch) {
continue
}
if (allTimeCost > 0) {
totalCost = allTimeCost
formattedCost = CostCalculator.formatCost(allTimeCost)
logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`)
} else {
// Fallback: 如果 allTimeCost 为空(旧键),尝试月度键
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
const modelUsageMap = new Map()
const model = modelMatch[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) {
modelUsageMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
})
for (const key of allModelKeys) {
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
if (!modelMatch) {
continue
}
const modelUsage = modelUsageMap.get(model)
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
}
}
const model = modelMatch[1]
const data = await client.hgetall(key)
// 按模型计算费用并汇总
for (const [model, usage] of modelUsageMap) {
const usageData = {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_creation_input_tokens: usage.cacheCreateTokens,
cache_read_input_tokens: usage.cacheReadTokens
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) {
modelUsageMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
})
}
const modelUsage = modelUsageMap.get(model)
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
}
}
const costResult = CostCalculator.calculateCost(usageData, model)
totalCost += costResult.costs.total
}
// 按模型计算费用并汇总
for (const [model, usage] of modelUsageMap) {
const usageData = {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_creation_input_tokens: usage.cacheCreateTokens,
cache_read_input_tokens: usage.cacheReadTokens
}
// 如果没有模型级别的详细数据,回退到总体数据计算
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total
const costUsage = {
input_tokens: usage.inputTokens || 0,
output_tokens: usage.outputTokens || 0,
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
cache_read_input_tokens: usage.cacheReadTokens || 0
const costResult = CostCalculator.calculateCost(usageData, model)
totalCost += costResult.costs.total
}
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
totalCost = costResult.costs.total
}
// 如果没有模型级别的详细数据,回退到总体数据计算
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total
const costUsage = {
input_tokens: usage.inputTokens || 0,
output_tokens: usage.outputTokens || 0,
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
cache_read_input_tokens: usage.cacheReadTokens || 0
}
formattedCost = CostCalculator.formatCost(totalCost)
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
totalCost = costResult.costs.total
}
formattedCost = CostCalculator.formatCost(totalCost)
}
} catch (error) {
logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error)
logger.warn(`Failed to calculate cost for key ${keyId}:`, error)
// 回退到简单计算
if (fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total

View File

@@ -19,6 +19,16 @@ 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'
@@ -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')
@@ -559,20 +592,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)
@@ -604,7 +658,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) {
@@ -665,8 +727,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 +754,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))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,559 @@
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'
}
}
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 模型移除 maxOutputTokensAntigravity 环境不稳定)
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) => {
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
const msg = typeof data === 'string' ? data : JSON.stringify(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
}

View 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
}

View File

@@ -73,7 +73,6 @@ class BedrockRelayService {
const accountId = bedrockAccount?.id
let queueLockAcquired = false
let queueRequestId = null
let queueLockRenewalStopper = null
try {
// 📬 用户消息队列处理
@@ -127,9 +126,8 @@ class BedrockRelayService {
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
accountId,
queueRequestId
logger.debug(
`📬 User message queue lock acquired for Bedrock account ${accountId}, requestId: ${queueRequestId}`
)
}
}
@@ -154,6 +152,23 @@ class BedrockRelayService {
const response = await client.send(command)
const duration = Date.now() - startTime
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for Bedrock account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for Bedrock account ${accountId}:`,
releaseError.message
)
}
}
// 解析响应
const responseBody = JSON.parse(new TextDecoder().decode(response.body))
const claudeResponse = this._convertFromBedrockFormat(responseBody)
@@ -171,13 +186,13 @@ class BedrockRelayService {
logger.error('❌ Bedrock非流式请求失败:', error)
throw this._handleBedrockError(error)
} finally {
// 📬 释放用户消息队列锁
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
if (queueLockRenewalStopper) {
queueLockRenewalStopper()
}
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for Bedrock account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for Bedrock account ${accountId}:`,
@@ -193,7 +208,6 @@ class BedrockRelayService {
const accountId = bedrockAccount?.id
let queueLockAcquired = false
let queueRequestId = null
let queueLockRenewalStopper = null
try {
// 📬 用户消息队列处理
@@ -229,10 +243,11 @@ class BedrockRelayService {
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
if (!res.headersSent) {
const existingConnection = res.getHeader ? res.getHeader('Connection') : null
res.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: existingConnection || 'keep-alive',
'x-user-message-queue-error': errorType
})
}
@@ -252,9 +267,8 @@ class BedrockRelayService {
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
accountId,
queueRequestId
logger.debug(
`📬 User message queue lock acquired for Bedrock account ${accountId} (stream), requestId: ${queueRequestId}`
)
}
}
@@ -278,11 +292,35 @@ class BedrockRelayService {
const startTime = Date.now()
const response = await client.send(command)
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for Bedrock stream account ${accountId}:`,
releaseError.message
)
}
}
// 设置SSE响应头
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
const existingConnection = res.getHeader ? res.getHeader('Connection') : null
if (existingConnection) {
logger.debug(
`🔌 [Bedrock Stream] Preserving existing Connection header: ${existingConnection}`
)
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: existingConnection || 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
})
@@ -339,13 +377,13 @@ class BedrockRelayService {
throw this._handleBedrockError(error)
} finally {
// 📬 释放用户消息队列锁
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
if (queueLockRenewalStopper) {
queueLockRenewalStopper()
}
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for Bedrock stream account ${accountId}:`,

View File

@@ -4,6 +4,7 @@ const logger = require('../utils/logger')
const config = require('../../config/config')
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
const userMessageQueueService = require('./userMessageQueueService')
const { isStreamWritable } = require('../utils/streamHelper')
class CcrRelayService {
constructor() {
@@ -24,7 +25,6 @@ class CcrRelayService {
let account = null
let queueLockAcquired = false
let queueRequestId = null
let queueLockRenewalStopper = null
try {
// 📬 用户消息队列处理
@@ -78,9 +78,8 @@ class CcrRelayService {
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
accountId,
queueRequestId
logger.debug(
`📬 User message queue lock acquired for CCR account ${accountId}, requestId: ${queueRequestId}`
)
}
}
@@ -224,6 +223,23 @@ class CcrRelayService {
)
const response = await axios(requestConfig)
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for CCR account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for CCR account ${accountId}:`,
releaseError.message
)
}
}
// 移除监听器(请求成功完成)
if (clientRequest) {
clientRequest.removeListener('close', handleClientDisconnect)
@@ -296,13 +312,13 @@ class CcrRelayService {
throw error
} finally {
// 📬 释放用户消息队列锁
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
if (queueLockRenewalStopper) {
queueLockRenewalStopper()
}
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for CCR account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for CCR account ${accountId}:`,
@@ -327,7 +343,6 @@ class CcrRelayService {
let account = null
let queueLockAcquired = false
let queueRequestId = null
let queueLockRenewalStopper = null
try {
// 📬 用户消息队列处理
@@ -365,10 +380,13 @@ class CcrRelayService {
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: existingConnection || 'keep-alive',
'x-user-message-queue-error': errorType
})
}
@@ -388,9 +406,8 @@ class CcrRelayService {
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
accountId,
queueRequestId
logger.debug(
`📬 User message queue lock acquired for CCR account ${accountId} (stream), requestId: ${queueRequestId}`
)
}
}
@@ -442,22 +459,46 @@ class CcrRelayService {
accountId,
usageCallback,
streamTransformer,
options
options,
// 📬 回调:在收到响应头时释放队列锁
async () => {
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for CCR stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for CCR stream account ${accountId}:`,
releaseError.message
)
}
}
}
)
// 更新最后使用时间
await this._updateLastUsedTime(accountId)
} catch (error) {
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info(
`🔌 CCR stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
)
} else {
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
}
throw error
} finally {
// 📬 释放用户消息队列锁
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
if (queueLockRenewalStopper) {
queueLockRenewalStopper()
}
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for CCR stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for CCR stream account ${accountId}:`,
@@ -478,7 +519,8 @@ class CcrRelayService {
accountId,
usageCallback,
streamTransformer = null,
requestOptions = {}
requestOptions = {},
onResponseHeaderReceived = null
) {
return new Promise((resolve, reject) => {
let aborted = false
@@ -541,8 +583,11 @@ class CcrRelayService {
// 发送请求
const request = axios(requestConfig)
// 注意:使用 .then(async ...) 模式处理响应
// - 内部的 releaseQueueLock 有独立的 try-catch不会导致未捕获异常
// - queueLockAcquired = false 的赋值会在 finally 执行前完成JS 单线程保证)
request
.then((response) => {
.then(async (response) => {
logger.debug(`🌊 CCR stream response status: ${response.status}`)
// 错误响应处理
@@ -565,10 +610,13 @@ class CcrRelayService {
// 设置错误响应的状态码和响应头
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
const errorHeaders = {
'Content-Type': response.headers['content-type'] || 'application/json',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
Connection: existingConnection || 'keep-alive'
}
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
delete errorHeaders['Transfer-Encoding']
@@ -578,13 +626,13 @@ class CcrRelayService {
// 直接透传错误数据,不进行包装
response.data.on('data', (chunk) => {
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(chunk)
}
})
response.data.on('end', () => {
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.end()
}
resolve() // 不抛出异常,正常完成流处理
@@ -592,6 +640,19 @@ class CcrRelayService {
return
}
// 📬 收到成功响应头HTTP 200调用回调释放队列锁
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') {
try {
await onResponseHeaderReceived()
} catch (callbackError) {
logger.error(
`❌ Failed to execute onResponseHeaderReceived callback for CCR stream account ${accountId}:`,
callbackError.message
)
}
}
// 成功响应,检查并移除错误状态
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
if (isRateLimited) {
@@ -605,11 +666,20 @@ class CcrRelayService {
})
// 设置响应头
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
if (existingConnection) {
logger.debug(
`🔌 [CCR Stream] Preserving existing Connection header: ${existingConnection}`
)
}
const headers = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: existingConnection || 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
}
@@ -648,12 +718,17 @@ class CcrRelayService {
}
// 写入到响应流
if (outputLine && !responseStream.destroyed) {
if (outputLine && isStreamWritable(responseStream)) {
responseStream.write(`${outputLine}\n`)
} else if (outputLine) {
// 客户端连接已断开,记录警告
logger.warn(
`⚠️ [CCR] Client disconnected during stream, skipping data for account: ${accountId}`
)
}
} else {
// 空行也需要传递
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write('\n')
}
}
@@ -664,10 +739,6 @@ class CcrRelayService {
})
response.data.on('end', () => {
if (!responseStream.destroyed) {
responseStream.end()
}
// 如果收集到使用统计数据,调用回调
if (usageCallback && Object.keys(collectedUsage).length > 0) {
try {
@@ -679,12 +750,26 @@ class CcrRelayService {
}
}
resolve()
if (isStreamWritable(responseStream)) {
// 等待数据完全 flush 到客户端后再 resolve
responseStream.end(() => {
logger.debug(
`🌊 CCR stream response completed and flushed | bytesWritten: ${responseStream.bytesWritten || 'unknown'}`
)
resolve()
})
} else {
// 连接已断开,记录警告
logger.warn(
`⚠️ [CCR] Client disconnected before stream end, data may not have been received | account: ${accountId}`
)
resolve()
}
})
response.data.on('error', (err) => {
logger.error('❌ Stream data error:', err)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.end()
}
reject(err)
@@ -716,7 +801,7 @@ class CcrRelayService {
}
}
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
responseStream.end()
}

View File

@@ -10,6 +10,8 @@ const {
isAccountDisabledError
} = require('../utils/errorSanitizer')
const userMessageQueueService = require('./userMessageQueueService')
const { isStreamWritable } = require('../utils/streamHelper')
const { filterForClaude } = require('../utils/headerFilter')
class ClaudeConsoleRelayService {
constructor() {
@@ -32,7 +34,6 @@ class ClaudeConsoleRelayService {
let concurrencyAcquired = false
let queueLockAcquired = false
let queueRequestId = null
let queueLockRenewalStopper = null
try {
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
@@ -87,10 +88,6 @@ class ClaudeConsoleRelayService {
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
accountId,
queueRequestId
)
logger.debug(
`📬 User message queue lock acquired for console account ${accountId}, requestId: ${queueRequestId}`
)
@@ -269,6 +266,23 @@ class ClaudeConsoleRelayService {
)
const response = await axios(requestConfig)
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for console account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for console account ${accountId}:`,
releaseError.message
)
}
}
// 移除监听器(请求成功完成)
if (clientRequest) {
clientRequest.removeListener('close', handleClientDisconnect)
@@ -433,13 +447,13 @@ class ClaudeConsoleRelayService {
}
}
// 📬 释放用户消息队列锁
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
if (queueLockRenewalStopper) {
queueLockRenewalStopper()
}
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for console account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for account ${accountId}:`,
@@ -467,7 +481,6 @@ class ClaudeConsoleRelayService {
let leaseRefreshInterval = null // 租约刷新定时器
let queueLockAcquired = false
let queueRequestId = null
let queueLockRenewalStopper = null
try {
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
@@ -506,10 +519,13 @@ class ClaudeConsoleRelayService {
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: existingConnection || 'keep-alive',
'x-user-message-queue-error': errorType
})
}
@@ -522,10 +538,6 @@ class ClaudeConsoleRelayService {
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
accountId,
queueRequestId
)
logger.debug(
`📬 User message queue lock acquired for console account ${accountId} (stream), requestId: ${queueRequestId}`
)
@@ -629,16 +641,40 @@ class ClaudeConsoleRelayService {
accountId,
usageCallback,
streamTransformer,
options
options,
// 📬 回调:在收到响应头时释放队列锁
async () => {
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for console stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for console stream account ${accountId}:`,
releaseError.message
)
}
}
}
)
// 更新最后使用时间
await this._updateLastUsedTime(accountId)
} catch (error) {
logger.error(
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
error
)
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info(
`🔌 Claude Console stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
)
} else {
logger.error(
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
error
)
}
throw error
} finally {
// 🛑 清理租约刷新定时器
@@ -664,13 +700,13 @@ class ClaudeConsoleRelayService {
}
}
// 📬 释放用户消息队列锁
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
if (queueLockRenewalStopper) {
queueLockRenewalStopper()
}
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for console stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for stream account ${accountId}:`,
@@ -691,7 +727,8 @@ class ClaudeConsoleRelayService {
accountId,
usageCallback,
streamTransformer = null,
requestOptions = {}
requestOptions = {},
onResponseHeaderReceived = null
) {
return new Promise((resolve, reject) => {
let aborted = false
@@ -754,8 +791,11 @@ class ClaudeConsoleRelayService {
// 发送请求
const request = axios(requestConfig)
// 注意:使用 .then(async ...) 模式处理响应
// - 内部的 releaseQueueLock 有独立的 try-catch不会导致未捕获异常
// - queueLockAcquired = false 的赋值会在 finally 执行前完成JS 单线程保证)
request
.then((response) => {
.then(async (response) => {
logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`)
// 错误响应处理
@@ -843,7 +883,7 @@ class ClaudeConsoleRelayService {
`🧹 [Stream] [SANITIZED] Error response to client: ${JSON.stringify(sanitizedError)}`
)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(JSON.stringify(sanitizedError))
responseStream.end()
}
@@ -851,7 +891,7 @@ class ClaudeConsoleRelayService {
const sanitizedText = sanitizeErrorMessage(errorDataForCheck)
logger.error(`🧹 [Stream] [SANITIZED] Error response to client: ${sanitizedText}`)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(sanitizedText)
responseStream.end()
}
@@ -862,6 +902,19 @@ class ClaudeConsoleRelayService {
return
}
// 📬 收到成功响应头HTTP 200调用回调释放队列锁
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') {
try {
await onResponseHeaderReceived()
} catch (callbackError) {
logger.error(
`❌ Failed to execute onResponseHeaderReceived callback for console stream account ${accountId}:`,
callbackError.message
)
}
}
// 成功响应,检查并移除错误状态
claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
if (isRateLimited) {
@@ -875,11 +928,22 @@ class ClaudeConsoleRelayService {
})
// 设置响应头
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
// 当并发队列功能启用时auth.js 会设置 Connection: close 来禁用 Keep-Alive
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
const connectionHeader = existingConnection || 'keep-alive'
if (existingConnection) {
logger.debug(
`🔌 [Console Stream] Preserving existing Connection header: ${existingConnection}`
)
}
responseStream.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: connectionHeader,
'X-Accel-Buffering': 'no'
})
}
@@ -905,20 +969,33 @@ class ClaudeConsoleRelayService {
buffer = lines.pop() || ''
// 转发数据并解析usage
if (lines.length > 0 && !responseStream.destroyed) {
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
if (lines.length > 0) {
// 检查流是否可写(客户端连接是否有效)
if (isStreamWritable(responseStream)) {
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
// 应用流转换器如果有
if (streamTransformer) {
const transformed = streamTransformer(linesToForward)
if (transformed) {
responseStream.write(transformed)
// 应用流转换器如果有
let dataToWrite = linesToForward
if (streamTransformer) {
const transformed = streamTransformer(linesToForward)
if (transformed) {
dataToWrite = transformed
} else {
dataToWrite = null
}
}
if (dataToWrite) {
responseStream.write(dataToWrite)
}
} else {
responseStream.write(linesToForward)
// 客户端连接已断开记录警告但仍继续解析usage
logger.warn(
`⚠️ [Console] Client disconnected during stream, skipping ${lines.length} lines for account: ${account?.name || accountId}`
)
}
// 解析SSE数据寻找usage信息
// 解析SSE数据寻找usage信息(无论连接状态如何)
for (const line of lines) {
if (line.startsWith('data:')) {
const jsonStr = line.slice(5).trimStart()
@@ -1026,7 +1103,7 @@ class ClaudeConsoleRelayService {
`❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`,
error
)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
@@ -1049,7 +1126,7 @@ class ClaudeConsoleRelayService {
response.data.on('end', () => {
try {
// 处理缓冲区中剩余的数据
if (buffer.trim() && !responseStream.destroyed) {
if (buffer.trim() && isStreamWritable(responseStream)) {
if (streamTransformer) {
const transformed = streamTransformer(buffer)
if (transformed) {
@@ -1098,12 +1175,33 @@ class ClaudeConsoleRelayService {
}
// 确保流正确结束
if (!responseStream.destroyed) {
responseStream.end()
}
if (isStreamWritable(responseStream)) {
// 📊 诊断日志:流结束前状态
logger.info(
`📤 [STREAM] Ending response | destroyed: ${responseStream.destroyed}, ` +
`socketDestroyed: ${responseStream.socket?.destroyed}, ` +
`socketBytesWritten: ${responseStream.socket?.bytesWritten || 0}`
)
logger.debug('🌊 Claude Console Claude stream response completed')
resolve()
// 禁用 Nagle 算法确保数据立即发送
if (responseStream.socket && !responseStream.socket.destroyed) {
responseStream.socket.setNoDelay(true)
}
// 等待数据完全 flush 到客户端后再 resolve
responseStream.end(() => {
logger.info(
`✅ [STREAM] Response ended and flushed | socketBytesWritten: ${responseStream.socket?.bytesWritten || 'unknown'}`
)
resolve()
})
} else {
// 连接已断开,记录警告
logger.warn(
`⚠️ [Console] Client disconnected before stream end, data may not have been received | account: ${account?.name || accountId}`
)
resolve()
}
} catch (error) {
logger.error('❌ Error processing stream end:', error)
reject(error)
@@ -1115,7 +1213,7 @@ class ClaudeConsoleRelayService {
`❌ Claude Console stream error (Account: ${account?.name || accountId}):`,
error
)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
@@ -1163,14 +1261,17 @@ class ClaudeConsoleRelayService {
// 发送错误响应
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(error.response?.status || 500, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
Connection: existingConnection || 'keep-alive'
})
}
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
@@ -1202,30 +1303,9 @@ class ClaudeConsoleRelayService {
// 🔧 过滤客户端请求头
_filterClientHeaders(clientHeaders) {
const sensitiveHeaders = [
'content-type',
'user-agent',
'authorization',
'x-api-key',
'host',
'content-length',
'connection',
'proxy-authorization',
'content-encoding',
'transfer-encoding',
'anthropic-version'
]
const filteredHeaders = {}
Object.keys(clientHeaders || {}).forEach((key) => {
const lowerKey = key.toLowerCase()
if (!sensitiveHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
})
return filteredHeaders
// 使用统一的 headerFilter 工具类(白名单模式)
// 与 claudeRelayService 保持一致,避免透传 CDN headers 触发上游 API 安全检查
return filterForClaude(clientHeaders)
}
// 🕐 更新最后使用时间
@@ -1340,7 +1420,7 @@ class ClaudeConsoleRelayService {
'Cache-Control': 'no-cache'
})
}
if (!responseStream.destroyed && !responseStream.writableEnded) {
if (isStreamWritable(responseStream)) {
responseStream.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
)

View File

@@ -17,8 +17,18 @@ const DEFAULT_CONFIG = {
sessionBindingTtlDays: 30, // 会话绑定 TTL默认30天
// 用户消息队列配置
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
userMessageQueueDelayMs: 100, // 请求间隔(毫秒)
userMessageQueueTimeoutMs: 60000, // 队列超时(毫秒)
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
userMessageQueueTimeoutMs: 5000, // 队列等待超时(毫秒),优化后锁持有时间短无需长等待
userMessageQueueLockTtlMs: 5000, // 锁TTL毫秒请求发送后立即释放无需长TTL
// 并发请求排队配置
concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭)
concurrentRequestQueueMaxSize: 3, // 固定最小排队数默认3
concurrentRequestQueueMaxSizeMultiplier: 0, // 并发数的倍数默认0仅使用固定值
concurrentRequestQueueTimeoutMs: 10000, // 排队超时毫秒默认10秒
concurrentRequestQueueMaxRedisFailCount: 5, // 连续 Redis 失败阈值默认5次
// 排队健康检查配置
concurrentRequestQueueHealthCheckEnabled: true, // 是否启用排队健康检查(默认开启)
concurrentRequestQueueHealthThreshold: 0.8, // 健康检查阈值P90 >= 超时 × 阈值时拒绝新请求)
updatedAt: null,
updatedBy: null
}
@@ -104,7 +114,8 @@ class ClaudeRelayConfigService {
logger.info(`✅ Claude relay config updated by ${updatedBy}:`, {
claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled,
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled,
concurrentRequestQueueEnabled: updatedConfig.concurrentRequestQueueEnabled
})
return updatedConfig
@@ -320,11 +331,11 @@ class ClaudeRelayConfigService {
/**
* 验证新会话请求
* @param {Object} requestBody - 请求体
* @param {Object} _requestBody - 请求体(预留参数,当前未使用)
* @param {string} originalSessionId - 原始会话ID
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
*/
async validateNewSession(requestBody, originalSessionId) {
async validateNewSession(_requestBody, originalSessionId) {
const cfg = await this.getConfig()
if (!cfg.globalSessionBindingEnabled) {

View File

@@ -16,6 +16,7 @@ const { formatDateWithTimezone } = require('../utils/dateHelper')
const requestIdentityService = require('./requestIdentityService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
const userMessageQueueService = require('./userMessageQueueService')
const { isStreamWritable } = require('../utils/streamHelper')
class ClaudeRelayService {
constructor() {
@@ -151,7 +152,6 @@ class ClaudeRelayService {
let upstreamRequest = null
let queueLockAcquired = false
let queueRequestId = null
let queueLockRenewalStopper = null
let selectedAccountId = null
try {
@@ -255,10 +255,6 @@ class ClaudeRelayService {
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
accountId,
queueRequestId
)
logger.debug(
`📬 User message queue lock acquired for account ${accountId}, requestId: ${queueRequestId}`
)
@@ -339,6 +335,23 @@ class ClaudeRelayService {
options
)
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && selectedAccountId) {
try {
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for account ${selectedAccountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for account ${selectedAccountId}:`,
releaseError.message
)
}
}
response.accountId = accountId
response.accountType = accountType
@@ -608,13 +621,13 @@ class ClaudeRelayService {
)
throw error
} finally {
// 📬 释放用户消息队列锁
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && selectedAccountId) {
try {
if (queueLockRenewalStopper) {
queueLockRenewalStopper()
}
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for account ${selectedAccountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for account ${selectedAccountId}:`,
@@ -1045,6 +1058,8 @@ 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']
@@ -1245,7 +1260,6 @@ class ClaudeRelayService {
) {
let queueLockAcquired = false
let queueRequestId = null
let queueLockRenewalStopper = null
let selectedAccountId = null
try {
@@ -1327,10 +1341,13 @@ class ClaudeRelayService {
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: existingConnection || 'keep-alive',
'x-user-message-queue-error': errorType
})
}
@@ -1350,10 +1367,6 @@ class ClaudeRelayService {
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
accountId,
queueRequestId
)
logger.debug(
`📬 User message queue lock acquired for account ${accountId} (stream), requestId: ${queueRequestId}`
)
@@ -1425,19 +1438,41 @@ class ClaudeRelayService {
sessionHash,
streamTransformer,
options,
isDedicatedOfficialAccount
isDedicatedOfficialAccount,
// 📬 新增回调:在收到响应头时释放队列锁
async () => {
if (queueLockAcquired && queueRequestId && selectedAccountId) {
try {
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for stream account ${selectedAccountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for stream account ${selectedAccountId}:`,
releaseError.message
)
}
}
}
)
} catch (error) {
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info(`🔌 Claude stream relay ended: Client disconnected`)
} else {
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
}
throw error
} finally {
// 📬 释放用户消息队列锁
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
if (queueLockAcquired && queueRequestId && selectedAccountId) {
try {
if (queueLockRenewalStopper) {
queueLockRenewalStopper()
}
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for stream account ${selectedAccountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for stream account ${selectedAccountId}:`,
@@ -1461,7 +1496,8 @@ class ClaudeRelayService {
sessionHash,
streamTransformer = null,
requestOptions = {},
isDedicatedOfficialAccount = false
isDedicatedOfficialAccount = false,
onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁
) {
// 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId)
@@ -1669,7 +1705,7 @@ class ClaudeRelayService {
}
})()
}
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 解析 Claude API 返回的错误详情
let errorMessage = `Claude API error: ${res.statusCode}`
try {
@@ -1707,6 +1743,16 @@ class ClaudeRelayService {
return
}
// 📬 收到成功响应头HTTP 200立即调用回调释放队列锁
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
if (onResponseStart && typeof onResponseStart === 'function') {
try {
await onResponseStart()
} catch (callbackError) {
logger.error('❌ Error in onResponseStart callback:', callbackError.message)
}
}
let buffer = ''
const allUsageData = [] // 收集所有的usage事件
let currentUsageData = {} // 当前正在收集的usage数据
@@ -1724,16 +1770,23 @@ class ClaudeRelayService {
buffer = lines.pop() || '' // 保留最后的不完整行
// 转发已处理的完整行到客户端
if (lines.length > 0 && !responseStream.destroyed) {
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
// 如果有流转换器,应用转换
if (streamTransformer) {
const transformed = streamTransformer(linesToForward)
if (transformed) {
responseStream.write(transformed)
if (lines.length > 0) {
if (isStreamWritable(responseStream)) {
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
// 如果有流转换器,应用转换
if (streamTransformer) {
const transformed = streamTransformer(linesToForward)
if (transformed) {
responseStream.write(transformed)
}
} else {
responseStream.write(linesToForward)
}
} else {
responseStream.write(linesToForward)
// 客户端连接已断开记录警告但仍继续解析usage
logger.warn(
`⚠️ [Official] Client disconnected during stream, skipping ${lines.length} lines for account: ${accountId}`
)
}
}
@@ -1838,7 +1891,7 @@ class ClaudeRelayService {
} catch (error) {
logger.error('❌ Error processing stream data:', error)
// 发送错误但不破坏流,让它自然结束
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write('event: error\n')
responseStream.write(
`data: ${JSON.stringify({
@@ -1854,7 +1907,7 @@ class ClaudeRelayService {
res.on('end', async () => {
try {
// 处理缓冲区中剩余的数据
if (buffer.trim() && !responseStream.destroyed) {
if (buffer.trim() && isStreamWritable(responseStream)) {
if (streamTransformer) {
const transformed = streamTransformer(buffer)
if (transformed) {
@@ -1866,8 +1919,16 @@ class ClaudeRelayService {
}
// 确保流正确结束
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.end()
logger.debug(
`🌊 Stream end called | bytesWritten: ${responseStream.bytesWritten || 'unknown'}`
)
} else {
// 连接已断开,记录警告
logger.warn(
`⚠️ [Official] Client disconnected before stream end, data may not have been received | account: ${account?.name || accountId}`
)
}
} catch (error) {
logger.error('❌ Error processing stream end:', error)
@@ -2065,14 +2126,17 @@ class ClaudeRelayService {
}
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
Connection: existingConnection || 'keep-alive'
})
}
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 发送 SSE 错误事件
responseStream.write('event: error\n')
responseStream.write(
@@ -2092,13 +2156,16 @@ class ClaudeRelayService {
logger.error(`❌ Claude stream request timeout | Account: ${account?.name || accountId}`)
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(504, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
Connection: existingConnection || 'keep-alive'
})
}
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 发送 SSE 错误事件
responseStream.write('event: error\n')
responseStream.write(
@@ -2413,10 +2480,13 @@ class ClaudeRelayService {
// 设置响应头
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: existingConnection || 'keep-alive',
'X-Accel-Buffering': 'no'
})
}
@@ -2444,7 +2514,7 @@ class ClaudeRelayService {
} catch (error) {
logger.error(`❌ Test account connection failed:`, error)
// 发送错误事件给前端
if (!responseStream.destroyed && !responseStream.writableEnded) {
if (isStreamWritable(responseStream)) {
try {
const errorMsg = error.message || '测试失败'
responseStream.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)

View File

@@ -336,7 +336,12 @@ class DroidRelayService {
)
}
} catch (error) {
logger.error(`❌ Droid relay error: ${error.message}`, error)
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info(`🔌 Droid relay ended: Client disconnected`)
} else {
logger.error(`❌ Droid relay error: ${error.message}`, error)
}
const status = error?.response?.status
if (status >= 400 && status < 500) {

View File

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

View File

@@ -426,9 +426,9 @@ class OpenAIResponsesRelayService {
const lines = data.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
if (line.startsWith('data:')) {
try {
const jsonStr = line.slice(6)
const jsonStr = line.slice(5).trim()
if (jsonStr === '[DONE]') {
continue
}

View File

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

View File

@@ -9,6 +9,26 @@ class UnifiedOpenAIScheduler {
this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:'
}
// 🔢 按优先级和最后使用时间排序账户(与 Claude/Gemini 调度保持一致)
_sortAccountsByPriority(accounts) {
return accounts.sort((a, b) => {
const aPriority = Number.parseInt(a.priority, 10)
const bPriority = Number.parseInt(b.priority, 10)
const normalizedAPriority = Number.isFinite(aPriority) ? aPriority : 50
const normalizedBPriority = Number.isFinite(bPriority) ? bPriority : 50
// 首先按优先级排序(数字越小优先级越高)
if (normalizedAPriority !== normalizedBPriority) {
return normalizedAPriority - normalizedBPriority
}
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
return aLastUsed - bLastUsed
})
}
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
_isSchedulable(schedulable) {
// 如果是 undefined 或 null默认为可调度
@@ -244,13 +264,7 @@ class UnifiedOpenAIScheduler {
`🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}`
)
// 更新账户的最后使用时间
if (accountType === 'openai') {
await openaiAccountService.recordUsage(boundAccount.id, 0)
} else {
await openaiResponsesAccountService.updateAccount(boundAccount.id, {
lastUsedAt: new Date().toISOString()
})
}
await this.updateAccountLastUsed(boundAccount.id, accountType)
return {
accountId: boundAccount.id,
accountType
@@ -292,7 +306,7 @@ class UnifiedOpenAIScheduler {
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
// 更新账户的最后使用时间
await openaiAccountService.recordUsage(mappedAccount.accountId, 0)
await this.updateAccountLastUsed(mappedAccount.accountId, mappedAccount.accountType)
return mappedAccount
} else {
logger.warn(
@@ -321,12 +335,8 @@ class UnifiedOpenAIScheduler {
}
}
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
const sortedAccounts = availableAccounts.sort((a, b) => {
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
return aLastUsed - bLastUsed // 最久未使用的优先
})
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 选择第一个账户
const selectedAccount = sortedAccounts[0]
@@ -344,11 +354,11 @@ class UnifiedOpenAIScheduler {
}
logger.info(
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for API key ${apiKeyData.name}`
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}, priority: ${selectedAccount.priority || 50}) for API key ${apiKeyData.name}`
)
// 更新账户的最后使用时间
await openaiAccountService.recordUsage(selectedAccount.accountId, 0)
await this.updateAccountLastUsed(selectedAccount.accountId, selectedAccount.accountType)
return {
accountId: selectedAccount.accountId,
@@ -494,21 +504,6 @@ class UnifiedOpenAIScheduler {
return availableAccounts
}
// 🔢 按优先级和最后使用时间排序账户(已废弃,改为与 Claude 保持一致,只按最后使用时间排序)
// _sortAccountsByPriority(accounts) {
// return accounts.sort((a, b) => {
// // 首先按优先级排序(数字越小优先级越高)
// if (a.priority !== b.priority) {
// return a.priority - b.priority
// }
// // 优先级相同时,按最后使用时间排序(最久未使用的优先)
// const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
// const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
// return aLastUsed - bLastUsed
// })
// }
// 🔍 检查账户是否可用
async _isAccountAvailable(accountId, accountType) {
try {
@@ -817,7 +812,7 @@ class UnifiedOpenAIScheduler {
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
)
// 更新账户的最后使用时间
await openaiAccountService.recordUsage(mappedAccount.accountId, 0)
await this.updateAccountLastUsed(mappedAccount.accountId, mappedAccount.accountType)
return mappedAccount
}
}
@@ -909,12 +904,8 @@ class UnifiedOpenAIScheduler {
throw error
}
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
const sortedAccounts = availableAccounts.sort((a, b) => {
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
return aLastUsed - bLastUsed // 最久未使用的优先
})
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 选择第一个账户
const selectedAccount = sortedAccounts[0]
@@ -932,11 +923,11 @@ class UnifiedOpenAIScheduler {
}
logger.info(
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId})`
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}, priority: ${selectedAccount.priority || 50})`
)
// 更新账户的最后使用时间
await openaiAccountService.recordUsage(selectedAccount.accountId, 0)
await this.updateAccountLastUsed(selectedAccount.accountId, selectedAccount.accountType)
return {
accountId: selectedAccount.accountId,
@@ -958,9 +949,12 @@ class UnifiedOpenAIScheduler {
async updateAccountLastUsed(accountId, accountType) {
try {
if (accountType === 'openai') {
await openaiAccountService.updateAccount(accountId, {
lastUsedAt: new Date().toISOString()
})
await openaiAccountService.recordUsage(accountId, 0)
return
}
if (accountType === 'openai-responses') {
await openaiResponsesAccountService.recordUsage(accountId, 0)
}
} catch (error) {
logger.warn(`⚠️ Failed to update last used time for account ${accountId}:`, error)

View File

@@ -14,9 +14,6 @@ const logger = require('../utils/logger')
// 清理任务间隔
const CLEANUP_INTERVAL_MS = 60000 // 1分钟
// 锁续租最大持续时间(从配置读取,与 REQUEST_TIMEOUT 保持一致)
const MAX_RENEWAL_DURATION_MS = config.requestTimeout || 10 * 60 * 1000
// 轮询等待配置
const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔
const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔
@@ -25,8 +22,6 @@ const POLL_BACKOFF_FACTOR = 1.5 // 退避因子
class UserMessageQueueService {
constructor() {
this.cleanupTimer = null
// 跟踪活跃的续租定时器,用于服务关闭时清理
this.activeRenewalTimers = new Map()
}
/**
@@ -74,12 +69,13 @@ class UserMessageQueueService {
*/
async getConfig() {
// 默认配置(防止 config.userMessageQueue 未定义)
// 注意:优化后的默认值 - 锁持有时间从分钟级降到毫秒级,无需长等待
const queueConfig = config.userMessageQueue || {}
const defaults = {
enabled: queueConfig.enabled ?? false,
delayMs: queueConfig.delayMs ?? 100,
timeoutMs: queueConfig.timeoutMs ?? 60000,
lockTtlMs: queueConfig.lockTtlMs ?? 120000
delayMs: queueConfig.delayMs ?? 200,
timeoutMs: queueConfig.timeoutMs ?? 5000, // 从 60000 降到 5000因为锁持有时间短
lockTtlMs: queueConfig.lockTtlMs ?? 5000 // 从 120000 降到 50005秒足以覆盖请求发送
}
// 尝试从 claudeRelayConfigService 获取 Web 界面配置
@@ -100,7 +96,10 @@ class UserMessageQueueService {
webConfig.userMessageQueueTimeoutMs !== undefined
? webConfig.userMessageQueueTimeoutMs
: defaults.timeoutMs,
lockTtlMs: defaults.lockTtlMs
lockTtlMs:
webConfig.userMessageQueueLockTtlMs !== undefined
? webConfig.userMessageQueueLockTtlMs
: defaults.lockTtlMs
}
} catch {
// 回退到环境变量配置
@@ -232,83 +231,6 @@ class UserMessageQueueService {
return released
}
/**
* 启动锁续租防止长连接超过TTL导致锁丢失
* @param {string} accountId - 账户ID
* @param {string} requestId - 请求ID
* @returns {Promise<Function>} 停止续租的函数
*/
async startLockRenewal(accountId, requestId) {
const cfg = await this.getConfig()
if (!cfg.enabled || !accountId || !requestId) {
return () => {}
}
const intervalMs = Math.max(10000, Math.floor(cfg.lockTtlMs / 2)) // 约一半TTL刷新一次
const maxRenewals = Math.ceil(MAX_RENEWAL_DURATION_MS / intervalMs) // 最大续租次数
const startTime = Date.now()
const timerKey = `${accountId}:${requestId}`
let stopped = false
let renewalCount = 0
const stopRenewal = () => {
if (!stopped) {
clearInterval(timer)
stopped = true
this.activeRenewalTimers.delete(timerKey)
}
}
const timer = setInterval(async () => {
if (stopped) {
return
}
renewalCount++
// 检查是否超过最大续租次数或最大持续时间
if (renewalCount > maxRenewals || Date.now() - startTime > MAX_RENEWAL_DURATION_MS) {
logger.warn(`📬 User message queue: max renewal duration exceeded, stopping renewal`, {
accountId,
requestId,
renewalCount,
durationMs: Date.now() - startTime
})
stopRenewal()
return
}
try {
const refreshed = await redis.refreshUserMessageLock(accountId, requestId, cfg.lockTtlMs)
if (!refreshed) {
// 锁可能已被释放或超时,停止续租
logger.warn(
`📬 User message queue: failed to refresh lock (possibly lost), stop renewal`,
{
accountId,
requestId,
renewalCount
}
)
stopRenewal()
}
} catch (error) {
logger.error('📬 User message queue: lock renewal error:', error)
}
}, intervalMs)
// 避免阻止进程退出
if (typeof timer.unref === 'function') {
timer.unref()
}
// 跟踪活跃的定时器
this.activeRenewalTimers.set(timerKey, { timer, stopRenewal, accountId, requestId, startTime })
return stopRenewal
}
/**
* 获取队列统计信息
* @param {string} accountId - 账户ID
@@ -385,32 +307,6 @@ class UserMessageQueueService {
}
}
/**
* 停止所有活跃的锁续租定时器(服务关闭时调用)
*/
stopAllRenewalTimers() {
const count = this.activeRenewalTimers.size
if (count > 0) {
for (const [key, { stopRenewal }] of this.activeRenewalTimers) {
try {
stopRenewal()
} catch (error) {
logger.error(`📬 User message queue: failed to stop renewal timer ${key}:`, error)
}
}
this.activeRenewalTimers.clear()
logger.info(`📬 User message queue: stopped ${count} active renewal timer(s)`)
}
}
/**
* 获取活跃续租定时器数量(用于监控)
* @returns {number}
*/
getActiveRenewalCount() {
return this.activeRenewalTimers.size
}
/**
* 清理孤儿锁
* 检测异常情况锁存在但没有设置过期时间lockTtlRaw === -1

View File

@@ -0,0 +1,126 @@
const fs = require('fs/promises')
const path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
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 fs.appendFile(filename, line, { encoding: 'utf8' })
} 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
}

View File

@@ -0,0 +1,125 @@
const fs = require('fs/promises')
const path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
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 fs.appendFile(filename, line, { encoding: 'utf8' })
} 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
}

View 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
}

View File

@@ -0,0 +1,121 @@
const fs = require('fs/promises')
const path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
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 fs.appendFile(filename, line, { encoding: 'utf8' })
} 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
}

View File

@@ -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)
}
/**

View 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: {} }
}
// $refGemini/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
}
}
// typeflatten 数组 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(', '))
}
// additionalPropertiesGemini/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
}

View File

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

10
src/utils/projectPaths.js Normal file
View File

@@ -0,0 +1,10 @@
const path = require('path')
// 该文件位于 src/utils 下,向上两级即项目根目录。
function getProjectRoot() {
return path.resolve(__dirname, '..', '..')
}
module.exports = {
getProjectRoot
}

105
src/utils/statsHelper.js Normal file
View File

@@ -0,0 +1,105 @@
/**
* 统计计算工具函数
* 提供百分位数计算、等待时间统计等通用统计功能
*/
/**
* 计算百分位数(使用 nearest-rank 方法)
* @param {number[]} sortedArray - 已排序的数组(升序)
* @param {number} percentile - 百分位数 (0-100)
* @returns {number} 百分位值
*
* 边界情况说明:
* - percentile=0: 返回最小值 (index=0)
* - percentile=100: 返回最大值 (index=len-1)
* - percentile=50 且 len=2: 返回第一个元素nearest-rank 向下取)
*
* 算法说明nearest-rank 方法):
* - index = ceil(percentile / 100 * len) - 1
* - 示例len=100, P50 → ceil(50) - 1 = 49第50个元素0-indexed
* - 示例len=100, P99 → ceil(99) - 1 = 98第99个元素
*/
function getPercentile(sortedArray, percentile) {
const len = sortedArray.length
if (len === 0) {
return 0
}
if (len === 1) {
return sortedArray[0]
}
// 边界处理percentile <= 0 返回最小值
if (percentile <= 0) {
return sortedArray[0]
}
// 边界处理percentile >= 100 返回最大值
if (percentile >= 100) {
return sortedArray[len - 1]
}
const index = Math.ceil((percentile / 100) * len) - 1
return sortedArray[index]
}
/**
* 计算等待时间分布统计
* @param {number[]} waitTimes - 等待时间数组(无需预先排序)
* @returns {Object|null} 统计对象,空数组返回 null
*
* 返回对象包含:
* - sampleCount: 样本数量(始终包含,便于调用方判断可靠性)
* - count: 样本数量(向后兼容)
* - min: 最小值
* - max: 最大值
* - avg: 平均值(四舍五入)
* - p50: 50百分位数中位数
* - p90: 90百分位数
* - p99: 99百分位数
* - sampleSizeWarning: 样本量不足时的警告信息(样本 < 10
* - p90Unreliable: P90 统计不可靠标记(样本 < 10
* - p99Unreliable: P99 统计不可靠标记(样本 < 100
*
* 可靠性标记说明(详见 design.md Decision 6
* - 样本 < 10: P90 和 P99 都不可靠
* - 样本 < 100: P99 不可靠P90 需要 10 个样本P99 需要 100 个样本)
* - 即使标记为不可靠,仍返回计算值供参考
*/
function calculateWaitTimeStats(waitTimes) {
if (!waitTimes || waitTimes.length === 0) {
return null
}
const sorted = [...waitTimes].sort((a, b) => a - b)
const sum = sorted.reduce((a, b) => a + b, 0)
const len = sorted.length
const stats = {
sampleCount: len, // 新增:始终包含样本数
count: len, // 向后兼容
min: sorted[0],
max: sorted[len - 1],
avg: Math.round(sum / len),
p50: getPercentile(sorted, 50),
p90: getPercentile(sorted, 90),
p99: getPercentile(sorted, 99)
}
// 渐进式可靠性标记(详见 design.md Decision 6
// 样本 < 10: P90 不可靠P90 至少需要 ceil(100/10) = 10 个样本)
if (len < 10) {
stats.sampleSizeWarning = 'Results may be inaccurate due to small sample size'
stats.p90Unreliable = true
}
// 样本 < 100: P99 不可靠P99 至少需要 ceil(100/1) = 100 个样本)
if (len < 100) {
stats.p99Unreliable = true
}
return stats
}
module.exports = {
getPercentile,
calculateWaitTimeStats
}

36
src/utils/streamHelper.js Normal file
View File

@@ -0,0 +1,36 @@
/**
* Stream Helper Utilities
* 流处理辅助工具函数
*/
/**
* 检查响应流是否仍然可写(客户端连接是否有效)
* @param {import('http').ServerResponse} stream - HTTP响应流
* @returns {boolean} 如果流可写返回true否则返回false
*/
function isStreamWritable(stream) {
if (!stream) {
return false
}
// 检查流是否已销毁
if (stream.destroyed) {
return false
}
// 检查底层socket是否已销毁
if (stream.socket?.destroyed) {
return false
}
// 检查流是否已结束写入
if (stream.writableEnded) {
return false
}
return true
}
module.exports = {
isStreamWritable
}

View File

@@ -62,12 +62,17 @@ class ClaudeCodeValidator {
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
}
}

View File

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

View File

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

View File

@@ -0,0 +1,860 @@
/**
* 并发请求排队功能集成测试
*
* 测试分为三个层次:
* 1. Mock 测试 - 测试核心逻辑,不需要真实 Redis
* 2. Redis 方法测试 - 测试 Redis 操作的原子性和正确性
* 3. 端到端场景测试 - 测试完整的排队流程
*
* 运行方式:
* - npm test -- concurrencyQueue.integration # 运行所有测试Mock 部分)
* - REDIS_TEST=1 npm test -- concurrencyQueue.integration # 包含真实 Redis 测试
*/
// Mock logger to avoid console output during tests
jest.mock('../src/utils/logger', () => ({
api: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
database: jest.fn(),
security: jest.fn()
}))
const redis = require('../src/models/redis')
const claudeRelayConfigService = require('../src/services/claudeRelayConfigService')
// Helper: sleep function
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// Helper: 创建模拟的 req/res 对象
function createMockReqRes() {
const listeners = {}
const req = {
destroyed: false,
once: jest.fn((event, handler) => {
listeners[`req:${event}`] = handler
}),
removeListener: jest.fn((event) => {
delete listeners[`req:${event}`]
}),
// 触发事件的辅助方法
emit: (event) => {
const handler = listeners[`req:${event}`]
if (handler) {
handler()
}
}
}
const res = {
once: jest.fn((event, handler) => {
listeners[`res:${event}`] = handler
}),
removeListener: jest.fn((event) => {
delete listeners[`res:${event}`]
}),
emit: (event) => {
const handler = listeners[`res:${event}`]
if (handler) {
handler()
}
}
}
return { req, res, listeners }
}
// ============================================
// 第一部分Mock 测试 - waitForConcurrencySlot 核心逻辑
// ============================================
describe('ConcurrencyQueue Integration Tests', () => {
describe('Part 1: waitForConcurrencySlot Logic (Mocked)', () => {
// 导入 auth 模块中的 waitForConcurrencySlot
// 由于它是内部函数,我们需要通过测试其行为来验证
// 这里我们模拟整个流程
let mockRedis
beforeEach(() => {
jest.clearAllMocks()
// 创建 Redis mock
mockRedis = {
concurrencyCount: {},
queueCount: {},
stats: {},
waitTimes: {},
globalWaitTimes: []
}
// Mock Redis 并发方法
jest.spyOn(redis, 'incrConcurrency').mockImplementation(async (keyId, requestId, _lease) => {
if (!mockRedis.concurrencyCount[keyId]) {
mockRedis.concurrencyCount[keyId] = new Set()
}
mockRedis.concurrencyCount[keyId].add(requestId)
return mockRedis.concurrencyCount[keyId].size
})
jest.spyOn(redis, 'decrConcurrency').mockImplementation(async (keyId, requestId) => {
if (mockRedis.concurrencyCount[keyId]) {
mockRedis.concurrencyCount[keyId].delete(requestId)
return mockRedis.concurrencyCount[keyId].size
}
return 0
})
// Mock 排队计数方法
jest.spyOn(redis, 'incrConcurrencyQueue').mockImplementation(async (keyId) => {
mockRedis.queueCount[keyId] = (mockRedis.queueCount[keyId] || 0) + 1
return mockRedis.queueCount[keyId]
})
jest.spyOn(redis, 'decrConcurrencyQueue').mockImplementation(async (keyId) => {
mockRedis.queueCount[keyId] = Math.max(0, (mockRedis.queueCount[keyId] || 0) - 1)
return mockRedis.queueCount[keyId]
})
jest
.spyOn(redis, 'getConcurrencyQueueCount')
.mockImplementation(async (keyId) => mockRedis.queueCount[keyId] || 0)
// Mock 统计方法
jest.spyOn(redis, 'incrConcurrencyQueueStats').mockImplementation(async (keyId, field) => {
if (!mockRedis.stats[keyId]) {
mockRedis.stats[keyId] = {}
}
mockRedis.stats[keyId][field] = (mockRedis.stats[keyId][field] || 0) + 1
return mockRedis.stats[keyId][field]
})
jest.spyOn(redis, 'recordQueueWaitTime').mockResolvedValue(undefined)
jest.spyOn(redis, 'recordGlobalQueueWaitTime').mockResolvedValue(undefined)
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('Slot Acquisition Flow', () => {
it('should acquire slot immediately when under concurrency limit', async () => {
// 模拟 waitForConcurrencySlot 的行为
const keyId = 'test-key-1'
const requestId = 'req-1'
const concurrencyLimit = 5
// 直接测试 incrConcurrency 的行为
const count = await redis.incrConcurrency(keyId, requestId, 300)
expect(count).toBe(1)
expect(count).toBeLessThanOrEqual(concurrencyLimit)
})
it('should track multiple concurrent requests correctly', async () => {
const keyId = 'test-key-2'
const concurrencyLimit = 3
// 模拟多个并发请求
const results = []
for (let i = 1; i <= 5; i++) {
const count = await redis.incrConcurrency(keyId, `req-${i}`, 300)
results.push({ requestId: `req-${i}`, count, exceeds: count > concurrencyLimit })
}
// 前3个应该在限制内
expect(results[0].exceeds).toBe(false)
expect(results[1].exceeds).toBe(false)
expect(results[2].exceeds).toBe(false)
// 后2个超过限制
expect(results[3].exceeds).toBe(true)
expect(results[4].exceeds).toBe(true)
})
it('should release slot and allow next request', async () => {
const keyId = 'test-key-3'
const concurrencyLimit = 1
// 第一个请求获取槽位
const count1 = await redis.incrConcurrency(keyId, 'req-1', 300)
expect(count1).toBe(1)
// 第二个请求超限
const count2 = await redis.incrConcurrency(keyId, 'req-2', 300)
expect(count2).toBe(2)
expect(count2).toBeGreaterThan(concurrencyLimit)
// 释放第二个请求(因为超限)
await redis.decrConcurrency(keyId, 'req-2')
// 释放第一个请求
await redis.decrConcurrency(keyId, 'req-1')
// 现在第三个请求应该能获取
const count3 = await redis.incrConcurrency(keyId, 'req-3', 300)
expect(count3).toBe(1)
})
})
describe('Queue Count Management', () => {
it('should increment and decrement queue count atomically', async () => {
const keyId = 'test-key-4'
// 增加排队计数
const count1 = await redis.incrConcurrencyQueue(keyId, 60000)
expect(count1).toBe(1)
const count2 = await redis.incrConcurrencyQueue(keyId, 60000)
expect(count2).toBe(2)
// 减少排队计数
const count3 = await redis.decrConcurrencyQueue(keyId)
expect(count3).toBe(1)
const count4 = await redis.decrConcurrencyQueue(keyId)
expect(count4).toBe(0)
})
it('should not go below zero on decrement', async () => {
const keyId = 'test-key-5'
// 直接减少(没有先增加)
const count = await redis.decrConcurrencyQueue(keyId)
expect(count).toBe(0)
})
it('should handle concurrent queue operations', async () => {
const keyId = 'test-key-6'
// 并发增加
const increments = await Promise.all([
redis.incrConcurrencyQueue(keyId, 60000),
redis.incrConcurrencyQueue(keyId, 60000),
redis.incrConcurrencyQueue(keyId, 60000)
])
// 所有增量应该是连续的
const sortedIncrements = [...increments].sort((a, b) => a - b)
expect(sortedIncrements).toEqual([1, 2, 3])
})
})
describe('Statistics Tracking', () => {
it('should track entered/success/timeout/cancelled stats', async () => {
const keyId = 'test-key-7'
await redis.incrConcurrencyQueueStats(keyId, 'entered')
await redis.incrConcurrencyQueueStats(keyId, 'entered')
await redis.incrConcurrencyQueueStats(keyId, 'success')
await redis.incrConcurrencyQueueStats(keyId, 'timeout')
await redis.incrConcurrencyQueueStats(keyId, 'cancelled')
expect(mockRedis.stats[keyId]).toEqual({
entered: 2,
success: 1,
timeout: 1,
cancelled: 1
})
})
})
describe('Client Disconnection Handling', () => {
it('should detect client disconnection via close event', async () => {
const { req } = createMockReqRes()
let clientDisconnected = false
// 设置监听器
req.once('close', () => {
clientDisconnected = true
})
// 模拟客户端断开
req.emit('close')
expect(clientDisconnected).toBe(true)
})
it('should detect pre-destroyed request', () => {
const { req } = createMockReqRes()
req.destroyed = true
expect(req.destroyed).toBe(true)
})
})
describe('Exponential Backoff Simulation', () => {
it('should increase poll interval with backoff', () => {
const config = {
pollIntervalMs: 200,
maxPollIntervalMs: 2000,
backoffFactor: 1.5,
jitterRatio: 0 // 禁用抖动以便测试
}
let interval = config.pollIntervalMs
const intervals = [interval]
for (let i = 0; i < 5; i++) {
interval = Math.min(interval * config.backoffFactor, config.maxPollIntervalMs)
intervals.push(interval)
}
// 验证指数增长
expect(intervals[1]).toBe(300) // 200 * 1.5
expect(intervals[2]).toBe(450) // 300 * 1.5
expect(intervals[3]).toBe(675) // 450 * 1.5
expect(intervals[4]).toBe(1012.5) // 675 * 1.5
expect(intervals[5]).toBe(1518.75) // 1012.5 * 1.5
})
it('should cap interval at maximum', () => {
const config = {
pollIntervalMs: 1000,
maxPollIntervalMs: 2000,
backoffFactor: 1.5
}
let interval = config.pollIntervalMs
for (let i = 0; i < 10; i++) {
interval = Math.min(interval * config.backoffFactor, config.maxPollIntervalMs)
}
expect(interval).toBe(2000)
})
it('should apply jitter within expected range', () => {
const baseInterval = 1000
const jitterRatio = 0.2 // ±20%
const results = []
for (let i = 0; i < 100; i++) {
const randomValue = Math.random()
const jitter = baseInterval * jitterRatio * (randomValue * 2 - 1)
const finalInterval = baseInterval + jitter
results.push(finalInterval)
}
const min = Math.min(...results)
const max = Math.max(...results)
// 所有结果应该在 [800, 1200] 范围内
expect(min).toBeGreaterThanOrEqual(800)
expect(max).toBeLessThanOrEqual(1200)
})
})
})
// ============================================
// 第二部分:并发竞争场景测试
// ============================================
describe('Part 2: Concurrent Race Condition Tests', () => {
beforeEach(() => {
jest.clearAllMocks()
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('Race Condition: Multiple Requests Competing for Same Slot', () => {
it('should handle race condition when multiple requests try to acquire last slot', async () => {
const keyId = 'race-test-1'
const concurrencyLimit = 1
const concurrencyState = { count: 0, holders: new Set() }
// 模拟原子的 incrConcurrency
jest.spyOn(redis, 'incrConcurrency').mockImplementation(async (key, reqId) => {
// 模拟原子操作
concurrencyState.count++
concurrencyState.holders.add(reqId)
return concurrencyState.count
})
jest.spyOn(redis, 'decrConcurrency').mockImplementation(async (key, reqId) => {
if (concurrencyState.holders.has(reqId)) {
concurrencyState.count--
concurrencyState.holders.delete(reqId)
}
return concurrencyState.count
})
// 5个请求同时竞争1个槽位
const requests = Array.from({ length: 5 }, (_, i) => `req-${i + 1}`)
const acquireResults = await Promise.all(
requests.map(async (reqId) => {
const count = await redis.incrConcurrency(keyId, reqId, 300)
const acquired = count <= concurrencyLimit
if (!acquired) {
// 超限,释放
await redis.decrConcurrency(keyId, reqId)
}
return { reqId, count, acquired }
})
)
// 只有一个请求应该成功获取槽位
const successfulAcquires = acquireResults.filter((r) => r.acquired)
expect(successfulAcquires.length).toBe(1)
// 最终并发计数应该是1
expect(concurrencyState.count).toBe(1)
})
it('should maintain consistency under high contention', async () => {
const keyId = 'race-test-2'
const concurrencyLimit = 3
const requestCount = 20
const concurrencyState = { count: 0, maxSeen: 0 }
jest.spyOn(redis, 'incrConcurrency').mockImplementation(async () => {
concurrencyState.count++
concurrencyState.maxSeen = Math.max(concurrencyState.maxSeen, concurrencyState.count)
return concurrencyState.count
})
jest.spyOn(redis, 'decrConcurrency').mockImplementation(async () => {
concurrencyState.count = Math.max(0, concurrencyState.count - 1)
return concurrencyState.count
})
// 模拟多轮请求
const activeRequests = []
for (let i = 0; i < requestCount; i++) {
const count = await redis.incrConcurrency(keyId, `req-${i}`, 300)
if (count <= concurrencyLimit) {
activeRequests.push(`req-${i}`)
// 模拟处理时间后释放
setTimeout(async () => {
await redis.decrConcurrency(keyId, `req-${i}`)
}, Math.random() * 50)
} else {
await redis.decrConcurrency(keyId, `req-${i}`)
}
// 随机延迟
await sleep(Math.random() * 10)
}
// 等待所有请求完成
await sleep(100)
// 最大并发不应超过限制
expect(concurrencyState.maxSeen).toBeLessThanOrEqual(concurrencyLimit + requestCount) // 允许短暂超限
})
})
describe('Queue Overflow Protection', () => {
it('should reject requests when queue is full', async () => {
const keyId = 'overflow-test-1'
const maxQueueSize = 5
const queueState = { count: 0 }
jest.spyOn(redis, 'incrConcurrencyQueue').mockImplementation(async () => {
queueState.count++
return queueState.count
})
jest.spyOn(redis, 'decrConcurrencyQueue').mockImplementation(async () => {
queueState.count = Math.max(0, queueState.count - 1)
return queueState.count
})
const results = []
// 尝试10个请求进入队列
for (let i = 0; i < 10; i++) {
const queueCount = await redis.incrConcurrencyQueue(keyId, 60000)
if (queueCount > maxQueueSize) {
// 队列满,释放并拒绝
await redis.decrConcurrencyQueue(keyId)
results.push({ index: i, accepted: false })
} else {
results.push({ index: i, accepted: true, position: queueCount })
}
}
const accepted = results.filter((r) => r.accepted)
const rejected = results.filter((r) => !r.accepted)
expect(accepted.length).toBe(5)
expect(rejected.length).toBe(5)
})
})
})
// ============================================
// 第三部分:真实 Redis 集成测试(可选)
// ============================================
describe('Part 3: Real Redis Integration Tests', () => {
const skipRealRedis = !process.env.REDIS_TEST
// 辅助函数:检查 Redis 连接
async function checkRedisConnection() {
try {
const client = redis.getClient()
if (!client) {
return false
}
await client.ping()
return true
} catch {
return false
}
}
beforeAll(async () => {
if (skipRealRedis) {
console.log('⏭️ Skipping real Redis tests (set REDIS_TEST=1 to enable)')
return
}
const connected = await checkRedisConnection()
if (!connected) {
console.log('⚠️ Redis not connected, skipping real Redis tests')
}
})
// 清理测试数据
afterEach(async () => {
if (skipRealRedis) {
return
}
try {
const client = redis.getClient()
if (!client) {
return
}
// 清理测试键
const testKeys = await client.keys('concurrency:queue:test-*')
if (testKeys.length > 0) {
await client.del(...testKeys)
}
} catch {
// 忽略清理错误
}
})
describe('Redis Queue Operations', () => {
const testOrSkip = skipRealRedis ? it.skip : it
testOrSkip('should atomically increment queue count with TTL', async () => {
const keyId = 'test-redis-queue-1'
const timeoutMs = 5000
const count1 = await redis.incrConcurrencyQueue(keyId, timeoutMs)
expect(count1).toBe(1)
const count2 = await redis.incrConcurrencyQueue(keyId, timeoutMs)
expect(count2).toBe(2)
// 验证 TTL 被设置
const client = redis.getClient()
const ttl = await client.ttl(`concurrency:queue:${keyId}`)
expect(ttl).toBeGreaterThan(0)
expect(ttl).toBeLessThanOrEqual(Math.ceil(timeoutMs / 1000) + 30)
})
testOrSkip('should atomically decrement and delete when zero', async () => {
const keyId = 'test-redis-queue-2'
await redis.incrConcurrencyQueue(keyId, 60000)
const count = await redis.decrConcurrencyQueue(keyId)
expect(count).toBe(0)
// 验证键已删除
const client = redis.getClient()
const exists = await client.exists(`concurrency:queue:${keyId}`)
expect(exists).toBe(0)
})
testOrSkip('should handle concurrent increments correctly', async () => {
const keyId = 'test-redis-queue-3'
const numRequests = 10
// 并发增加
const results = await Promise.all(
Array.from({ length: numRequests }, () => redis.incrConcurrencyQueue(keyId, 60000))
)
// 所有结果应该是 1 到 numRequests
const sorted = [...results].sort((a, b) => a - b)
expect(sorted).toEqual(Array.from({ length: numRequests }, (_, i) => i + 1))
})
})
describe('Redis Stats Operations', () => {
const testOrSkip = skipRealRedis ? it.skip : it
testOrSkip('should track queue statistics correctly', async () => {
const keyId = 'test-redis-stats-1'
await redis.incrConcurrencyQueueStats(keyId, 'entered')
await redis.incrConcurrencyQueueStats(keyId, 'entered')
await redis.incrConcurrencyQueueStats(keyId, 'success')
await redis.incrConcurrencyQueueStats(keyId, 'timeout')
const stats = await redis.getConcurrencyQueueStats(keyId)
expect(stats.entered).toBe(2)
expect(stats.success).toBe(1)
expect(stats.timeout).toBe(1)
expect(stats.cancelled).toBe(0)
})
testOrSkip('should record and retrieve wait times', async () => {
const keyId = 'test-redis-wait-1'
const waitTimes = [100, 200, 150, 300, 250]
for (const wt of waitTimes) {
await redis.recordQueueWaitTime(keyId, wt)
}
const recorded = await redis.getQueueWaitTimes(keyId)
// 应该按 LIFO 顺序存储
expect(recorded.length).toBe(5)
expect(recorded[0]).toBe(250) // 最后插入的在前面
})
testOrSkip('should record global wait times', async () => {
const waitTimes = [500, 600, 700]
for (const wt of waitTimes) {
await redis.recordGlobalQueueWaitTime(wt)
}
const recorded = await redis.getGlobalQueueWaitTimes()
expect(recorded.length).toBeGreaterThanOrEqual(3)
})
})
describe('Redis Cleanup Operations', () => {
const testOrSkip = skipRealRedis ? it.skip : it
testOrSkip('should clear specific queue', async () => {
const keyId = 'test-redis-clear-1'
await redis.incrConcurrencyQueue(keyId, 60000)
await redis.incrConcurrencyQueue(keyId, 60000)
const cleared = await redis.clearConcurrencyQueue(keyId)
expect(cleared).toBe(true)
const count = await redis.getConcurrencyQueueCount(keyId)
expect(count).toBe(0)
})
testOrSkip('should clear all queues but preserve stats', async () => {
const keyId1 = 'test-redis-clearall-1'
const keyId2 = 'test-redis-clearall-2'
// 创建队列和统计
await redis.incrConcurrencyQueue(keyId1, 60000)
await redis.incrConcurrencyQueue(keyId2, 60000)
await redis.incrConcurrencyQueueStats(keyId1, 'entered')
// 清理所有队列
const cleared = await redis.clearAllConcurrencyQueues()
expect(cleared).toBeGreaterThanOrEqual(2)
// 验证队列已清理
const count1 = await redis.getConcurrencyQueueCount(keyId1)
const count2 = await redis.getConcurrencyQueueCount(keyId2)
expect(count1).toBe(0)
expect(count2).toBe(0)
// 统计应该保留
const stats = await redis.getConcurrencyQueueStats(keyId1)
expect(stats.entered).toBe(1)
})
})
})
// ============================================
// 第四部分:配置服务集成测试
// ============================================
describe('Part 4: Configuration Service Integration', () => {
beforeEach(() => {
// 清除配置缓存
claudeRelayConfigService.clearCache()
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('Queue Configuration', () => {
it('should return default queue configuration', async () => {
jest.spyOn(redis, 'getClient').mockReturnValue(null)
const config = await claudeRelayConfigService.getConfig()
expect(config.concurrentRequestQueueEnabled).toBe(false)
expect(config.concurrentRequestQueueMaxSize).toBe(3)
expect(config.concurrentRequestQueueMaxSizeMultiplier).toBe(0)
expect(config.concurrentRequestQueueTimeoutMs).toBe(10000)
})
it('should calculate max queue size correctly', async () => {
const testCases = [
{ concurrencyLimit: 5, multiplier: 2, fixedMin: 3, expected: 10 }, // 5*2=10 > 3
{ concurrencyLimit: 1, multiplier: 1, fixedMin: 5, expected: 5 }, // 1*1=1 < 5
{ concurrencyLimit: 10, multiplier: 0.5, fixedMin: 3, expected: 5 }, // 10*0.5=5 > 3
{ concurrencyLimit: 2, multiplier: 1, fixedMin: 10, expected: 10 } // 2*1=2 < 10
]
for (const tc of testCases) {
const maxQueueSize = Math.max(tc.concurrencyLimit * tc.multiplier, tc.fixedMin)
expect(maxQueueSize).toBe(tc.expected)
}
})
})
})
// ============================================
// 第五部分:端到端场景测试
// ============================================
describe('Part 5: End-to-End Scenario Tests', () => {
describe('Scenario: Claude Code Agent Parallel Tool Calls', () => {
it('should handle burst of parallel tool results', async () => {
// 模拟 Claude Code Agent 发送多个并行工具结果的场景
const concurrencyLimit = 2
const maxQueueSize = 5
const state = {
concurrency: 0,
queue: 0,
completed: 0,
rejected: 0
}
// 模拟 8 个并行工具结果请求
const requests = Array.from({ length: 8 }, (_, i) => ({
id: `tool-result-${i + 1}`,
startTime: Date.now()
}))
// 模拟处理逻辑
async function processRequest(req) {
// 尝试获取并发槽位
state.concurrency++
if (state.concurrency > concurrencyLimit) {
// 超限,进入队列
state.concurrency--
state.queue++
if (state.queue > maxQueueSize) {
// 队列满,拒绝
state.queue--
state.rejected++
return { ...req, status: 'rejected', reason: 'queue_full' }
}
// 等待槽位(模拟)
await sleep(Math.random() * 100)
state.queue--
state.concurrency++
}
// 处理请求
await sleep(50) // 模拟处理时间
state.concurrency--
state.completed++
return { ...req, status: 'completed', duration: Date.now() - req.startTime }
}
const results = await Promise.all(requests.map(processRequest))
const completed = results.filter((r) => r.status === 'completed')
const rejected = results.filter((r) => r.status === 'rejected')
// 大部分请求应该完成
expect(completed.length).toBeGreaterThan(0)
// 可能有一些被拒绝
expect(state.rejected).toBe(rejected.length)
console.log(
` ✓ Completed: ${completed.length}, Rejected: ${rejected.length}, Max concurrent: ${concurrencyLimit}`
)
})
})
describe('Scenario: Graceful Degradation', () => {
it('should fallback when Redis fails', async () => {
jest
.spyOn(redis, 'incrConcurrencyQueue')
.mockRejectedValue(new Error('Redis connection lost'))
// 模拟降级行为Redis 失败时直接拒绝而不是崩溃
let result
try {
await redis.incrConcurrencyQueue('fallback-test', 60000)
result = { success: true }
} catch (error) {
// 优雅降级:返回 429 而不是 500
result = { success: false, fallback: true, error: error.message }
}
expect(result.fallback).toBe(true)
expect(result.error).toContain('Redis')
})
})
describe('Scenario: Timeout Behavior', () => {
it('should respect queue timeout', async () => {
const timeoutMs = 100
const startTime = Date.now()
// 模拟等待超时
await new Promise((resolve) => setTimeout(resolve, timeoutMs))
const elapsed = Date.now() - startTime
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10) // 允许 10ms 误差
})
it('should track timeout statistics', async () => {
const stats = { entered: 0, success: 0, timeout: 0, cancelled: 0 }
// 模拟多个请求,部分超时
const requests = [
{ id: 'req-1', willTimeout: false },
{ id: 'req-2', willTimeout: true },
{ id: 'req-3', willTimeout: false },
{ id: 'req-4', willTimeout: true }
]
for (const req of requests) {
stats.entered++
if (req.willTimeout) {
stats.timeout++
} else {
stats.success++
}
}
expect(stats.entered).toBe(4)
expect(stats.success).toBe(2)
expect(stats.timeout).toBe(2)
// 成功率应该是 50%
const successRate = (stats.success / stats.entered) * 100
expect(successRate).toBe(50)
})
})
})
})

View File

@@ -0,0 +1,278 @@
/**
* 并发请求排队功能测试
* 测试排队逻辑中的核心算法:百分位数计算、等待时间统计、指数退避等
*
* 注意Redis 方法的测试需要集成测试环境,这里主要测试纯算法逻辑
*/
// Mock logger to avoid console output during tests
jest.mock('../src/utils/logger', () => ({
api: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
database: jest.fn(),
security: jest.fn()
}))
// 使用共享的统计工具函数(与生产代码一致)
const { getPercentile, calculateWaitTimeStats } = require('../src/utils/statsHelper')
describe('ConcurrencyQueue', () => {
describe('Percentile Calculation (nearest-rank method)', () => {
// 直接测试共享工具函数,确保与生产代码行为一致
it('should return 0 for empty array', () => {
expect(getPercentile([], 50)).toBe(0)
})
it('should return single element for single-element array', () => {
expect(getPercentile([100], 50)).toBe(100)
expect(getPercentile([100], 99)).toBe(100)
})
it('should return min for percentile 0', () => {
expect(getPercentile([10, 20, 30, 40, 50], 0)).toBe(10)
})
it('should return max for percentile 100', () => {
expect(getPercentile([10, 20, 30, 40, 50], 100)).toBe(50)
})
it('should calculate P50 correctly for len=10', () => {
// For [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] (len=10)
// P50: ceil(50/100 * 10) - 1 = ceil(5) - 1 = 4 → value at index 4 = 50
const arr = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
expect(getPercentile(arr, 50)).toBe(50)
})
it('should calculate P90 correctly for len=10', () => {
// For len=10, P90: ceil(90/100 * 10) - 1 = ceil(9) - 1 = 8 → value at index 8 = 90
const arr = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
expect(getPercentile(arr, 90)).toBe(90)
})
it('should calculate P99 correctly for len=100', () => {
// For len=100, P99: ceil(99/100 * 100) - 1 = ceil(99) - 1 = 98
const arr = Array.from({ length: 100 }, (_, i) => i + 1)
expect(getPercentile(arr, 99)).toBe(99)
})
it('should handle two-element array correctly', () => {
// For [10, 20] (len=2)
// P50: ceil(50/100 * 2) - 1 = ceil(1) - 1 = 0 → value = 10
expect(getPercentile([10, 20], 50)).toBe(10)
})
it('should handle negative percentile as 0', () => {
expect(getPercentile([10, 20, 30], -10)).toBe(10)
})
it('should handle percentile > 100 as 100', () => {
expect(getPercentile([10, 20, 30], 150)).toBe(30)
})
})
describe('Wait Time Stats Calculation', () => {
// 直接测试共享工具函数
it('should return null for empty array', () => {
expect(calculateWaitTimeStats([])).toBeNull()
})
it('should return null for null input', () => {
expect(calculateWaitTimeStats(null)).toBeNull()
})
it('should return null for undefined input', () => {
expect(calculateWaitTimeStats(undefined)).toBeNull()
})
it('should calculate stats correctly for typical data', () => {
const waitTimes = [100, 200, 150, 300, 250, 180, 220, 280, 190, 210]
const stats = calculateWaitTimeStats(waitTimes)
expect(stats.count).toBe(10)
expect(stats.min).toBe(100)
expect(stats.max).toBe(300)
// Sum: 100+150+180+190+200+210+220+250+280+300 = 2080
expect(stats.avg).toBe(208)
expect(stats.sampleSizeWarning).toBeUndefined()
})
it('should add warning for small sample size (< 10)', () => {
const waitTimes = [100, 200, 300]
const stats = calculateWaitTimeStats(waitTimes)
expect(stats.count).toBe(3)
expect(stats.sampleSizeWarning).toBe('Results may be inaccurate due to small sample size')
})
it('should handle single value', () => {
const stats = calculateWaitTimeStats([500])
expect(stats.count).toBe(1)
expect(stats.min).toBe(500)
expect(stats.max).toBe(500)
expect(stats.avg).toBe(500)
expect(stats.p50).toBe(500)
expect(stats.p90).toBe(500)
expect(stats.p99).toBe(500)
})
it('should sort input array before calculating', () => {
const waitTimes = [500, 100, 300, 200, 400]
const stats = calculateWaitTimeStats(waitTimes)
expect(stats.min).toBe(100)
expect(stats.max).toBe(500)
})
it('should not modify original array', () => {
const waitTimes = [500, 100, 300]
calculateWaitTimeStats(waitTimes)
expect(waitTimes).toEqual([500, 100, 300])
})
})
describe('Exponential Backoff with Jitter', () => {
/**
* 指数退避计算函数(与 auth.js 中的实现一致)
* @param {number} currentInterval - 当前轮询间隔
* @param {number} backoffFactor - 退避系数
* @param {number} jitterRatio - 抖动比例
* @param {number} maxInterval - 最大间隔
* @param {number} randomValue - 随机值 [0, 1),用于确定性测试
*/
function calculateNextInterval(
currentInterval,
backoffFactor,
jitterRatio,
maxInterval,
randomValue
) {
let nextInterval = currentInterval * backoffFactor
// 抖动范围:[-jitterRatio, +jitterRatio]
const jitter = nextInterval * jitterRatio * (randomValue * 2 - 1)
nextInterval = nextInterval + jitter
return Math.max(1, Math.min(nextInterval, maxInterval))
}
it('should apply exponential backoff without jitter (randomValue=0.5)', () => {
// randomValue = 0.5 gives jitter = 0
const next = calculateNextInterval(100, 1.5, 0.2, 1000, 0.5)
expect(next).toBe(150) // 100 * 1.5 = 150
})
it('should apply maximum positive jitter (randomValue=1.0)', () => {
// randomValue = 1.0 gives maximum positive jitter (+20%)
const next = calculateNextInterval(100, 1.5, 0.2, 1000, 1.0)
// 100 * 1.5 = 150, jitter = 150 * 0.2 * 1 = 30
expect(next).toBe(180) // 150 + 30
})
it('should apply maximum negative jitter (randomValue=0.0)', () => {
// randomValue = 0.0 gives maximum negative jitter (-20%)
const next = calculateNextInterval(100, 1.5, 0.2, 1000, 0.0)
// 100 * 1.5 = 150, jitter = 150 * 0.2 * -1 = -30
expect(next).toBe(120) // 150 - 30
})
it('should respect maximum interval', () => {
const next = calculateNextInterval(800, 1.5, 0.2, 1000, 1.0)
// 800 * 1.5 = 1200, with +20% jitter = 1440, capped at 1000
expect(next).toBe(1000)
})
it('should never go below 1ms even with extreme negative jitter', () => {
const next = calculateNextInterval(1, 1.0, 0.9, 1000, 0.0)
// 1 * 1.0 = 1, jitter = 1 * 0.9 * -1 = -0.9
// 1 - 0.9 = 0.1, but Math.max(1, ...) ensures minimum is 1
expect(next).toBe(1)
})
it('should handle zero jitter ratio', () => {
const next = calculateNextInterval(100, 2.0, 0, 1000, 0.0)
expect(next).toBe(200) // Pure exponential, no jitter
})
it('should handle large backoff factor', () => {
const next = calculateNextInterval(100, 3.0, 0.1, 1000, 0.5)
expect(next).toBe(300) // 100 * 3.0 = 300
})
describe('jitter distribution', () => {
it('should produce values in expected range', () => {
const results = []
// Test with various random values
for (let r = 0; r <= 1; r += 0.1) {
results.push(calculateNextInterval(100, 1.5, 0.2, 1000, r))
}
// All values should be between 120 (150 - 30) and 180 (150 + 30)
expect(Math.min(...results)).toBeGreaterThanOrEqual(120)
expect(Math.max(...results)).toBeLessThanOrEqual(180)
})
})
})
describe('Queue Size Calculation', () => {
/**
* 最大排队数计算(与 auth.js 中的实现一致)
*/
function calculateMaxQueueSize(concurrencyLimit, multiplier, fixedMin) {
return Math.max(concurrencyLimit * multiplier, fixedMin)
}
it('should use multiplier when result is larger', () => {
// concurrencyLimit=10, multiplier=2, fixedMin=5
// max(10*2, 5) = max(20, 5) = 20
expect(calculateMaxQueueSize(10, 2, 5)).toBe(20)
})
it('should use fixed minimum when multiplier result is smaller', () => {
// concurrencyLimit=2, multiplier=1, fixedMin=5
// max(2*1, 5) = max(2, 5) = 5
expect(calculateMaxQueueSize(2, 1, 5)).toBe(5)
})
it('should handle zero multiplier', () => {
// concurrencyLimit=10, multiplier=0, fixedMin=3
// max(10*0, 3) = max(0, 3) = 3
expect(calculateMaxQueueSize(10, 0, 3)).toBe(3)
})
it('should handle fractional multiplier', () => {
// concurrencyLimit=10, multiplier=1.5, fixedMin=5
// max(10*1.5, 5) = max(15, 5) = 15
expect(calculateMaxQueueSize(10, 1.5, 5)).toBe(15)
})
})
describe('TTL Calculation', () => {
/**
* 排队计数器 TTL 计算(与 redis.js 中的实现一致)
*/
function calculateQueueTtl(timeoutMs, bufferSeconds = 30) {
return Math.ceil(timeoutMs / 1000) + bufferSeconds
}
it('should calculate TTL with default buffer', () => {
// 60000ms = 60s + 30s buffer = 90s
expect(calculateQueueTtl(60000)).toBe(90)
})
it('should round up milliseconds to seconds', () => {
// 61500ms = ceil(61.5) = 62s + 30s = 92s
expect(calculateQueueTtl(61500)).toBe(92)
})
it('should handle custom buffer', () => {
// 30000ms = 30s + 60s buffer = 90s
expect(calculateQueueTtl(30000, 60)).toBe(90)
})
it('should handle very short timeout', () => {
// 1000ms = 1s + 30s = 31s
expect(calculateQueueTtl(1000)).toBe(31)
})
})
})

View File

@@ -179,88 +179,6 @@ describe('UserMessageQueueService', () => {
})
})
describe('startLockRenewal', () => {
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
jest.restoreAllMocks()
})
it('should periodically refresh lock while enabled', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
const refreshSpy = jest.spyOn(redis, 'refreshUserMessageLock').mockResolvedValue(true)
const stop = await userMessageQueueService.startLockRenewal('acct-1', 'req-1')
jest.advanceTimersByTime(60000) // 半个TTL
await Promise.resolve()
expect(refreshSpy).toHaveBeenCalledWith('acct-1', 'req-1', 120000)
stop()
})
it('should no-op when queue disabled', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: false,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
const refreshSpy = jest.spyOn(redis, 'refreshUserMessageLock').mockResolvedValue(true)
const stop = await userMessageQueueService.startLockRenewal('acct-1', 'req-1')
jest.advanceTimersByTime(120000)
await Promise.resolve()
expect(refreshSpy).not.toHaveBeenCalled()
stop()
})
it('should track active renewal timer', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
jest.spyOn(redis, 'refreshUserMessageLock').mockResolvedValue(true)
expect(userMessageQueueService.getActiveRenewalCount()).toBe(0)
const stop = await userMessageQueueService.startLockRenewal('acct-1', 'req-1')
expect(userMessageQueueService.getActiveRenewalCount()).toBe(1)
stop()
expect(userMessageQueueService.getActiveRenewalCount()).toBe(0)
})
it('should stop all renewal timers on service shutdown', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
jest.spyOn(redis, 'refreshUserMessageLock').mockResolvedValue(true)
await userMessageQueueService.startLockRenewal('acct-1', 'req-1')
await userMessageQueueService.startLockRenewal('acct-2', 'req-2')
expect(userMessageQueueService.getActiveRenewalCount()).toBe(2)
userMessageQueueService.stopAllRenewalTimers()
expect(userMessageQueueService.getActiveRenewalCount()).toBe(0)
})
})
describe('acquireQueueLock', () => {
afterEach(() => {
jest.restoreAllMocks()

View File

@@ -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
>
@@ -1320,10 +1350,10 @@
class="rounded-lg bg-blue-100 px-3 py-1 text-xs text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
type="button"
@click="
addPresetMapping('claude-sonnet-4-20250514', 'claude-sonnet-4-20250514')
addPresetMapping('claude-opus-4-5-20251101', 'claude-opus-4-5-20251101')
"
>
+ Sonnet 4
+ Opus 4.5
</button>
<button
class="rounded-lg bg-indigo-100 px-3 py-1 text-xs text-indigo-700 transition-colors hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50"
@@ -1334,24 +1364,6 @@
>
+ Sonnet 4.5
</button>
<button
class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50"
type="button"
@click="
addPresetMapping('claude-opus-4-1-20250805', 'claude-opus-4-1-20250805')
"
>
+ Opus 4.1
</button>
<button
class="rounded-lg bg-green-100 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50"
type="button"
@click="
addPresetMapping('claude-3-5-haiku-20241022', 'claude-3-5-haiku-20241022')
"
>
+ Haiku 3.5
</button>
<button
class="rounded-lg bg-emerald-100 px-3 py-1 text-xs text-emerald-700 transition-colors hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-900/50"
type="button"
@@ -1801,7 +1813,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
@@ -1838,12 +1850,14 @@
文件中的凭证, 请勿使用 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 的机器上获取
<code class="rounded bg-blue-100 px-1 py-0.5 font-mono dark:bg-blue-900/50"
>~/.config/gemini/credentials.json</code
>~/.config/.gemini/oauth_creds.json</code
>
文件中的凭证。
</p>
@@ -2568,7 +2582,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
>
@@ -3819,7 +3833,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'
@@ -3954,7 +3968,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'
})(),
@@ -4293,7 +4308,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'
}
@@ -4330,7 +4345,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(
@@ -4700,9 +4719,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
}
@@ -5122,6 +5146,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'
@@ -5173,7 +5201,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)

View File

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

View File

@@ -284,7 +284,7 @@
</div>
<!-- 详细限制信息 -->
<div v-if="hasModelRestrictions" class="card p-4 md:p-6">
<div v-if="hasModelRestrictions" class="card !overflow-visible p-4 md:p-6">
<h3
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
>
@@ -301,7 +301,7 @@
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
受限模型列表
</h4>
<div class="space-y-1 md:space-y-2">
<div class="max-h-64 space-y-1 overflow-y-auto pr-1 md:max-h-80 md:space-y-2">
<div
v-for="model in statsData.restrictions.restrictedModels"
:key="model"

View File

@@ -2049,7 +2049,7 @@ const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X
const accountGroups = ref([])
const groupFilter = ref('all')
const platformFilter = ref('all')
const statusFilter = ref('normal') // 状态过滤 (normal/rateLimited/other/all)
const statusFilter = ref('all') // 状态过滤 (normal/rateLimited/other/all)
const searchKeyword = ref('')
const PAGE_SIZE_STORAGE_KEY = 'accountsPageSize'
const getInitialPageSize = () => {
@@ -2804,11 +2804,12 @@ const loadAccounts = async (forceReload = false) => {
let openaiResponsesRaw = []
const appendAccounts = (platform, data) => {
if (!data || data.length === 0) return
const list = Array.isArray(data) ? data : []
if (list.length === 0) return
switch (platform) {
case 'claude': {
const items = data.map((acc) => {
const items = list.map((acc) => {
const boundApiKeysCount = counts.claudeAccountId?.[acc.id] || 0
return { ...acc, platform: 'claude', boundApiKeysCount }
})
@@ -2816,7 +2817,7 @@ const loadAccounts = async (forceReload = false) => {
break
}
case 'claude-console': {
const items = data.map((acc) => {
const items = list.map((acc) => {
const boundApiKeysCount = counts.claudeConsoleAccountId?.[acc.id] || 0
return { ...acc, platform: 'claude-console', boundApiKeysCount }
})
@@ -2824,12 +2825,12 @@ const loadAccounts = async (forceReload = false) => {
break
}
case 'bedrock': {
const items = data.map((acc) => ({ ...acc, platform: 'bedrock', boundApiKeysCount: 0 }))
const items = list.map((acc) => ({ ...acc, platform: 'bedrock', boundApiKeysCount: 0 }))
allAccounts.push(...items)
break
}
case 'gemini': {
const items = data.map((acc) => {
const items = list.map((acc) => {
const boundApiKeysCount = counts.geminiAccountId?.[acc.id] || 0
return { ...acc, platform: 'gemini', boundApiKeysCount }
})
@@ -2837,7 +2838,7 @@ const loadAccounts = async (forceReload = false) => {
break
}
case 'openai': {
const items = data.map((acc) => {
const items = list.map((acc) => {
const boundApiKeysCount = counts.openaiAccountId?.[acc.id] || 0
return { ...acc, platform: 'openai', boundApiKeysCount }
})
@@ -2845,7 +2846,7 @@ const loadAccounts = async (forceReload = false) => {
break
}
case 'azure_openai': {
const items = data.map((acc) => {
const items = list.map((acc) => {
const boundApiKeysCount = counts.azureOpenaiAccountId?.[acc.id] || 0
return { ...acc, platform: 'azure_openai', boundApiKeysCount }
})
@@ -2853,16 +2854,16 @@ const loadAccounts = async (forceReload = false) => {
break
}
case 'openai-responses': {
openaiResponsesRaw = data
openaiResponsesRaw = list
break
}
case 'ccr': {
const items = data.map((acc) => ({ ...acc, platform: 'ccr', boundApiKeysCount: 0 }))
const items = list.map((acc) => ({ ...acc, platform: 'ccr', boundApiKeysCount: 0 }))
allAccounts.push(...items)
break
}
case 'droid': {
const items = data.map((acc) => {
const items = list.map((acc) => {
const boundApiKeysCount = counts.droidAccountId?.[acc.id] || acc.boundApiKeysCount || 0
return { ...acc, platform: 'droid', boundApiKeysCount }
})
@@ -2870,7 +2871,7 @@ const loadAccounts = async (forceReload = false) => {
break
}
case 'gemini-api': {
const items = data.map((acc) => {
const items = list.map((acc) => {
const boundApiKeysCount = counts.geminiAccountId?.[`api:${acc.id}`] || 0
return { ...acc, platform: 'gemini-api', boundApiKeysCount }
})

View File

@@ -898,6 +898,120 @@
</div>
</div>
<!-- 并发请求排队 -->
<div
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<div
class="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-lg"
>
<i class="fas fa-layer-group text-xl"></i>
</div>
<div class="ml-4">
<h4 class="text-lg font-semibold text-gray-900 dark:text-white">
并发请求排队
</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">
API Key 并发请求超限时进入队列等待而非直接拒绝
</p>
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
v-model="claudeConfig.concurrentRequestQueueEnabled"
class="peer sr-only"
type="checkbox"
@change="saveClaudeConfig"
/>
<div
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
></div>
</label>
</div>
<!-- 排队配置详情仅在启用时显示 -->
<div v-if="claudeConfig.concurrentRequestQueueEnabled" class="mt-6 space-y-4">
<!-- 固定最小排队数 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-list-ol mr-2 text-gray-400"></i>
固定最小排队数
</label>
<input
v-model.number="claudeConfig.concurrentRequestQueueMaxSize"
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
max="100"
min="1"
placeholder="3"
type="number"
@change="saveClaudeConfig"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
最大排队数的固定最小值1-100
</p>
</div>
<!-- 排队数倍数 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-times mr-2 text-gray-400"></i>
排队数倍数
</label>
<input
v-model.number="claudeConfig.concurrentRequestQueueMaxSizeMultiplier"
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
max="10"
min="0"
placeholder="1"
step="0.5"
type="number"
@change="saveClaudeConfig"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
最大排队数 = MAX(倍数 × 并发限制, 固定值)设为 0 则仅使用固定值
</p>
</div>
<!-- 排队超时时间 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-stopwatch mr-2 text-gray-400"></i>
排队超时时间毫秒
</label>
<input
v-model.number="claudeConfig.concurrentRequestQueueTimeoutMs"
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
max="300000"
min="5000"
placeholder="10000"
type="number"
@change="saveClaudeConfig"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
请求在排队中等待的最大时间超时将返回 429 错误5-5分钟默认10秒
</p>
</div>
</div>
<div class="mt-4 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="flex">
<i class="fas fa-info-circle mt-0.5 text-blue-500"></i>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-300">
<strong>工作原理</strong> API Key 的并发请求超过
<code class="rounded bg-blue-100 px-1 dark:bg-blue-800"
>concurrencyLimit</code
>
超限请求会进入队列等待而非直接返回 429适合 Claude Code Agent
并行工具调用场景
</p>
</div>
</div>
</div>
</div>
<!-- 配置更新信息 -->
<div
v-if="claudeConfig.updatedAt"
@@ -1563,9 +1677,13 @@ const claudeConfig = ref({
globalSessionBindingEnabled: false,
sessionBindingErrorMessage: '你的本地session已污染请清理后使用。',
sessionBindingTtlDays: 30,
userMessageQueueEnabled: true,
userMessageQueueEnabled: false, // 与后端默认值保持一致
userMessageQueueDelayMs: 200,
userMessageQueueTimeoutMs: 30000,
userMessageQueueTimeoutMs: 5000, // 与后端默认值保持一致(优化后锁持有时间短无需长等待)
concurrentRequestQueueEnabled: false,
concurrentRequestQueueMaxSize: 3,
concurrentRequestQueueMaxSizeMultiplier: 0,
concurrentRequestQueueTimeoutMs: 10000,
updatedAt: null,
updatedBy: null
})
@@ -1835,9 +1953,14 @@ const loadClaudeConfig = async () => {
sessionBindingErrorMessage:
response.config?.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。',
sessionBindingTtlDays: response.config?.sessionBindingTtlDays ?? 30,
userMessageQueueEnabled: response.config?.userMessageQueueEnabled ?? true,
userMessageQueueEnabled: response.config?.userMessageQueueEnabled ?? false, // 与后端默认值保持一致
userMessageQueueDelayMs: response.config?.userMessageQueueDelayMs ?? 200,
userMessageQueueTimeoutMs: response.config?.userMessageQueueTimeoutMs ?? 30000,
userMessageQueueTimeoutMs: response.config?.userMessageQueueTimeoutMs ?? 5000, // 与后端默认值保持一致
concurrentRequestQueueEnabled: response.config?.concurrentRequestQueueEnabled ?? false,
concurrentRequestQueueMaxSize: response.config?.concurrentRequestQueueMaxSize ?? 3,
concurrentRequestQueueMaxSizeMultiplier:
response.config?.concurrentRequestQueueMaxSizeMultiplier ?? 0,
concurrentRequestQueueTimeoutMs: response.config?.concurrentRequestQueueTimeoutMs ?? 10000,
updatedAt: response.config?.updatedAt || null,
updatedBy: response.config?.updatedBy || null
}
@@ -1865,7 +1988,12 @@ const saveClaudeConfig = async () => {
sessionBindingTtlDays: claudeConfig.value.sessionBindingTtlDays,
userMessageQueueEnabled: claudeConfig.value.userMessageQueueEnabled,
userMessageQueueDelayMs: claudeConfig.value.userMessageQueueDelayMs,
userMessageQueueTimeoutMs: claudeConfig.value.userMessageQueueTimeoutMs
userMessageQueueTimeoutMs: claudeConfig.value.userMessageQueueTimeoutMs,
concurrentRequestQueueEnabled: claudeConfig.value.concurrentRequestQueueEnabled,
concurrentRequestQueueMaxSize: claudeConfig.value.concurrentRequestQueueMaxSize,
concurrentRequestQueueMaxSizeMultiplier:
claudeConfig.value.concurrentRequestQueueMaxSizeMultiplier,
concurrentRequestQueueTimeoutMs: claudeConfig.value.concurrentRequestQueueTimeoutMs
}
const response = await apiClient.put('/admin/claude-relay-config', payload, {