mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Compare commits
163 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ebef1b116 | ||
|
|
35f755246e | ||
|
|
338d44faee | ||
|
|
968398ffa5 | ||
|
|
645ab43675 | ||
|
|
1027a2e3e2 | ||
|
|
0f5321b0ef | ||
|
|
c7d7bf47d6 | ||
|
|
ebc30b6026 | ||
|
|
d5a7af2d7d | ||
|
|
81a3e26e27 | ||
|
|
64db4a270d | ||
|
|
ca027ecb90 | ||
|
|
21e6944abb | ||
|
|
4ea3d4830f | ||
|
|
3000632d4e | ||
|
|
9e3a4cf45a | ||
|
|
eb992697b6 | ||
|
|
35ab34d687 | ||
|
|
bc4b050c69 | ||
|
|
189d53d793 | ||
|
|
b148537428 | ||
|
|
9d1a451027 | ||
|
|
ba815de08f | ||
|
|
b26027731e | ||
|
|
f535b35a1c | ||
|
|
962e01b080 | ||
|
|
fcc6ac4e22 | ||
|
|
3a03147ac9 | ||
|
|
94f239b56a | ||
|
|
b07873772c | ||
|
|
549c95eb80 | ||
|
|
b397954ea4 | ||
|
|
ed835d0c28 | ||
|
|
28b27e6a7b | ||
|
|
810fe9fe90 | ||
|
|
141b07db78 | ||
|
|
1dad810d15 | ||
|
|
4723328be4 | ||
|
|
944ef096b3 | ||
|
|
114e9facee | ||
|
|
e20ce86ad4 | ||
|
|
6caabb5444 | ||
|
|
b924c3c559 | ||
|
|
6682e0a982 | ||
|
|
b9c088ce58 | ||
|
|
2ff74c21d2 | ||
|
|
8a4dadbbc0 | ||
|
|
adf2890f65 | ||
|
|
7d892a69f1 | ||
|
|
a749ddfede | ||
|
|
dbd4fb19cf | ||
|
|
39ba345a43 | ||
|
|
2693fd77b7 | ||
|
|
3cc3219a90 | ||
|
|
1b834ffcdb | ||
|
|
41999f56b4 | ||
|
|
b81c2b946f | ||
|
|
0a59a0f9d4 | ||
|
|
c4448db6ab | ||
|
|
c67d2bce9d | ||
|
|
a345812cd7 | ||
|
|
a0cbafd759 | ||
|
|
3c64038fa7 | ||
|
|
45b81bd478 | ||
|
|
fc57133230 | ||
|
|
1f06af4a56 | ||
|
|
6165fad090 | ||
|
|
d53a399d41 | ||
|
|
3f98267738 | ||
|
|
e187b8946a | ||
|
|
8917019a78 | ||
|
|
9960f237b8 | ||
|
|
b6da77cabe | ||
|
|
e561387e81 | ||
|
|
982cca1020 | ||
|
|
792ba51290 | ||
|
|
74d138a2fb | ||
|
|
b88698191e | ||
|
|
11c38b23d1 | ||
|
|
b2dfc2eb25 | ||
|
|
18a493e805 | ||
|
|
59ce0f091c | ||
|
|
67c20fa30e | ||
|
|
671451253f | ||
|
|
534fbf6ac2 | ||
|
|
0173ab224b | ||
|
|
11fb77c8bd | ||
|
|
3d67f0b124 | ||
|
|
84f19b348b | ||
|
|
8ec8a59b07 | ||
|
|
00d8ac4bec | ||
|
|
b6f3459522 | ||
|
|
e56d797d87 | ||
|
|
4c6879a9c2 | ||
|
|
1c8084a3b1 | ||
|
|
f6f4b5cfec | ||
|
|
26ca696b91 | ||
|
|
ce496ed9e6 | ||
|
|
f6ed420401 | ||
|
|
5863816882 | ||
|
|
638d2ff189 | ||
|
|
fa2fc2fb16 | ||
|
|
6d56601550 | ||
|
|
dd8a0c95c3 | ||
|
|
126eee3712 | ||
|
|
26bfdd6892 | ||
|
|
cd3f51e9e2 | ||
|
|
9977245d59 | ||
|
|
09cf951cdc | ||
|
|
33ea26f2ac | ||
|
|
ba93ae55a9 | ||
|
|
53cda0fd18 | ||
|
|
151cb7536c | ||
|
|
0994eb346f | ||
|
|
4863a37328 | ||
|
|
052e236a93 | ||
|
|
c79ea19aa1 | ||
|
|
79f2cebdb8 | ||
|
|
bd7b8884ab | ||
|
|
38e0adb499 | ||
|
|
7698f5ce11 | ||
|
|
ce13e5ddb1 | ||
|
|
baafebbf7b | ||
|
|
87426133a2 | ||
|
|
60f5cbe780 | ||
|
|
86d8ed52d7 | ||
|
|
07633ddbf8 | ||
|
|
dd90c426e4 | ||
|
|
059357f834 | ||
|
|
ceee3a9295 | ||
|
|
403f609f69 | ||
|
|
304c8dda4e | ||
|
|
c4d923c46f | ||
|
|
fa9f9146a2 | ||
|
|
dc9409a5a6 | ||
|
|
51aa8dc381 | ||
|
|
5061f4d9fd | ||
|
|
4337af06d4 | ||
|
|
d226d57325 | ||
|
|
9f92c58640 | ||
|
|
8901994644 | ||
|
|
e3ca555df7 | ||
|
|
3b9c96dff8 | ||
|
|
cb94a4260e | ||
|
|
ac9499aa6d | ||
|
|
fc25840f95 | ||
|
|
b409adf9d8 | ||
|
|
b76776d7b0 | ||
|
|
8499992abd | ||
|
|
dc96447d72 | ||
|
|
f5d1c25295 | ||
|
|
95870883a1 | ||
|
|
aa71c58400 | ||
|
|
698f3d7daa | ||
|
|
5af5e55d80 | ||
|
|
5a18f54abd | ||
|
|
6d3b51510a | ||
|
|
c79fdc4d71 | ||
|
|
659072075d | ||
|
|
cf93128a96 | ||
|
|
909b5ad37f | ||
|
|
bab7073822 |
58
.env.example
58
.env.example
@@ -33,6 +33,59 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages
|
|||||||
CLAUDE_API_VERSION=2023-06-01
|
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
|
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
|
||||||
|
|
||||||
|
# 🤖 Gemini OAuth / Antigravity 配置(可选)
|
||||||
|
# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret,可在此覆盖
|
||||||
|
# GEMINI_OAUTH_CLIENT_ID=
|
||||||
|
# GEMINI_OAUTH_CLIENT_SECRET=
|
||||||
|
# Gemini CLI OAuth redirect_uri(可选,默认 https://codeassist.google.com/authcode)
|
||||||
|
# GEMINI_OAUTH_REDIRECT_URI=
|
||||||
|
# ANTIGRAVITY_OAUTH_CLIENT_ID=
|
||||||
|
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||||
|
# Antigravity OAuth redirect_uri(可选,默认 http://localhost:45462;用于避免 redirect_uri_mismatch)
|
||||||
|
# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462
|
||||||
|
# Antigravity 上游地址(可选,默认 sandbox)
|
||||||
|
# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com
|
||||||
|
# Antigravity User-Agent(可选)
|
||||||
|
# ANTIGRAVITY_USER_AGENT=antigravity/1.11.3 windows/amd64
|
||||||
|
|
||||||
|
# Claude Code(Anthropic Messages API)路由分流(无需额外环境变量):
|
||||||
|
# - /api -> Claude 账号池(默认)
|
||||||
|
# - /antigravity/api -> Antigravity OAuth
|
||||||
|
# - /gemini-cli/api -> Gemini CLI OAuth
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 🐛 调试 Dump 配置(可选)
|
||||||
|
# ============================================================================
|
||||||
|
# 以下开启后会在项目根目录写入 .jsonl 调试文件,便于排查问题。
|
||||||
|
# ⚠️ 生产环境建议关闭,避免磁盘占用。
|
||||||
|
#
|
||||||
|
# 📄 输出文件列表:
|
||||||
|
# - anthropic-requests-dump.jsonl (客户端请求)
|
||||||
|
# - anthropic-responses-dump.jsonl (返回给客户端的响应)
|
||||||
|
# - anthropic-tools-dump.jsonl (工具定义快照)
|
||||||
|
# - antigravity-upstream-requests-dump.jsonl (发往上游的请求)
|
||||||
|
# - antigravity-upstream-responses-dump.jsonl (上游 SSE 响应)
|
||||||
|
#
|
||||||
|
# 📌 开关配置:
|
||||||
|
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
|
||||||
|
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
|
||||||
|
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
|
||||||
|
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
|
||||||
|
# ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true
|
||||||
|
#
|
||||||
|
# 📏 单条记录大小上限(字节),默认 2MB:
|
||||||
|
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
|
||||||
|
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
|
||||||
|
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
|
||||||
|
#
|
||||||
|
# 📦 整个 Dump 文件大小上限(字节),超过后自动轮转为 .bak 文件,默认 10MB:
|
||||||
|
# DUMP_MAX_FILE_SIZE_BYTES=10485760
|
||||||
|
#
|
||||||
|
# 🔧 工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务
|
||||||
|
# (仅 /antigravity/api 分流生效)
|
||||||
|
# ANTHROPIC_TOOL_ERROR_CONTINUE=true
|
||||||
|
|
||||||
|
|
||||||
# 🚫 529错误处理配置
|
# 🚫 529错误处理配置
|
||||||
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
||||||
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
|
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
|
||||||
@@ -61,6 +114,9 @@ PROXY_USE_IPV4=true
|
|||||||
# ⏱️ 请求超时配置
|
# ⏱️ 请求超时配置
|
||||||
REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟
|
REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟
|
||||||
|
|
||||||
|
# 🔧 请求体大小配置
|
||||||
|
REQUEST_MAX_SIZE_MB=60
|
||||||
|
|
||||||
# 📈 使用限制
|
# 📈 使用限制
|
||||||
DEFAULT_TOKEN_LIMIT=1000000
|
DEFAULT_TOKEN_LIMIT=1000000
|
||||||
|
|
||||||
@@ -75,6 +131,8 @@ TOKEN_USAGE_RETENTION=2592000000
|
|||||||
HEALTH_CHECK_INTERVAL=60000
|
HEALTH_CHECK_INTERVAL=60000
|
||||||
TIMEZONE_OFFSET=8 # UTC偏移小时数,默认+8(中国时区)
|
TIMEZONE_OFFSET=8 # UTC偏移小时数,默认+8(中国时区)
|
||||||
METRICS_WINDOW=5 # 实时指标统计窗口(分钟),可选1-60,默认5分钟
|
METRICS_WINDOW=5 # 实时指标统计窗口(分钟),可选1-60,默认5分钟
|
||||||
|
# 启动时清理残留的并发排队计数器(默认true,多实例部署时建议设为false)
|
||||||
|
CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=true
|
||||||
|
|
||||||
# 🎨 Web 界面配置
|
# 🎨 Web 界面配置
|
||||||
WEB_TITLE=Claude Relay Service
|
WEB_TITLE=Claude Relay Service
|
||||||
|
|||||||
53
CLAUDE.md
53
CLAUDE.md
@@ -22,6 +22,7 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
|
|||||||
- **权限控制**: API Key支持权限配置(all/claude/gemini/openai等),控制可访问的服务类型
|
- **权限控制**: API Key支持权限配置(all/claude/gemini/openai等),控制可访问的服务类型
|
||||||
- **客户端限制**: 基于User-Agent的客户端识别和限制,支持ClaudeCode、Gemini-CLI等预定义客户端
|
- **客户端限制**: 基于User-Agent的客户端识别和限制,支持ClaudeCode、Gemini-CLI等预定义客户端
|
||||||
- **模型黑名单**: 支持API Key级别的模型访问限制
|
- **模型黑名单**: 支持API Key级别的模型访问限制
|
||||||
|
- **并发请求排队**: 当API Key并发数超限时,请求进入队列等待而非立即返回429,支持配置最大排队数、超时时间,适用于Claude Code Agent并行工具调用场景
|
||||||
|
|
||||||
### 主要服务组件
|
### 主要服务组件
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
|
|||||||
|
|
||||||
- **apiKeyService.js**: API Key管理,验证、限流、使用统计、成本计算
|
- **apiKeyService.js**: API Key管理,验证、限流、使用统计、成本计算
|
||||||
- **userService.js**: 用户管理系统,支持用户注册、登录、API Key管理
|
- **userService.js**: 用户管理系统,支持用户注册、登录、API Key管理
|
||||||
|
- **userMessageQueueService.js**: 用户消息串行队列,防止同账户并发用户消息触发限流
|
||||||
- **pricingService.js**: 定价服务,模型价格管理和成本计算
|
- **pricingService.js**: 定价服务,模型价格管理和成本计算
|
||||||
- **costInitService.js**: 成本数据初始化服务
|
- **costInitService.js**: 成本数据初始化服务
|
||||||
- **webhookService.js**: Webhook通知服务
|
- **webhookService.js**: Webhook通知服务
|
||||||
@@ -185,12 +187,17 @@ npm run service:stop # 停止服务
|
|||||||
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间(分钟,0表示禁用)
|
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间(分钟,0表示禁用)
|
||||||
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL(小时,默认1)
|
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL(小时,默认1)
|
||||||
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值(分钟,默认0)
|
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值(分钟,默认0)
|
||||||
|
- `USER_MESSAGE_QUEUE_ENABLED`: 启用用户消息串行队列(默认false)
|
||||||
|
- `USER_MESSAGE_QUEUE_DELAY_MS`: 用户消息请求间隔(毫秒,默认200)
|
||||||
|
- `USER_MESSAGE_QUEUE_TIMEOUT_MS`: 队列等待超时(毫秒,默认5000,锁持有时间短无需长等待)
|
||||||
|
- `USER_MESSAGE_QUEUE_LOCK_TTL_MS`: 锁TTL(毫秒,默认5000,请求发送后立即释放无需长TTL)
|
||||||
- `METRICS_WINDOW`: 实时指标统计窗口(分钟,1-60,默认5)
|
- `METRICS_WINDOW`: 实时指标统计窗口(分钟,1-60,默认5)
|
||||||
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量(默认1)
|
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量(默认1)
|
||||||
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys(默认false)
|
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys(默认false)
|
||||||
- `DEBUG_HTTP_TRAFFIC`: 启用HTTP请求/响应调试日志(默认false,仅开发环境)
|
- `DEBUG_HTTP_TRAFFIC`: 启用HTTP请求/响应调试日志(默认false,仅开发环境)
|
||||||
- `PROXY_USE_IPV4`: 代理使用IPv4(默认true)
|
- `PROXY_USE_IPV4`: 代理使用IPv4(默认true)
|
||||||
- `REQUEST_TIMEOUT`: 请求超时时间(毫秒,默认600000即10分钟)
|
- `REQUEST_TIMEOUT`: 请求超时时间(毫秒,默认600000即10分钟)
|
||||||
|
- `CLEAR_CONCURRENCY_QUEUES_ON_STARTUP`: 启动时清理残留的并发排队计数器(默认true,多实例部署时建议设为false)
|
||||||
|
|
||||||
#### AWS Bedrock配置(可选)
|
#### AWS Bedrock配置(可选)
|
||||||
- `CLAUDE_CODE_USE_BEDROCK`: 启用Bedrock(设置为1启用)
|
- `CLAUDE_CODE_USE_BEDROCK`: 启用Bedrock(设置为1启用)
|
||||||
@@ -337,6 +344,35 @@ npm run setup # 自动生成密钥并创建管理员账户
|
|||||||
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
|
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
|
||||||
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据,检查pricingService是否正确加载模型价格
|
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据,检查pricingService是否正确加载模型价格
|
||||||
13. **缓存命中率低**: 查看缓存监控统计,调整LRU缓存大小配置
|
13. **缓存命中率低**: 查看缓存监控统计,调整LRU缓存大小配置
|
||||||
|
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` 配置
|
||||||
|
- **后续升级说明**: 如有需要,后续版本可能提供可选的轻量级心跳机制
|
||||||
|
|
||||||
### 调试工具
|
### 调试工具
|
||||||
|
|
||||||
@@ -449,6 +485,15 @@ npm run setup # 自动生成密钥并创建管理员账户
|
|||||||
- **缓存优化**: 多层LRU缓存(解密缓存、账户缓存),全局缓存监控和统计
|
- **缓存优化**: 多层LRU缓存(解密缓存、账户缓存),全局缓存监控和统计
|
||||||
- **成本追踪**: 实时token使用统计(input/output/cache_create/cache_read)和成本计算(基于pricingService)
|
- **成本追踪**: 实时token使用统计(input/output/cache_create/cache_read)和成本计算(基于pricingService)
|
||||||
- **并发控制**: Redis Sorted Set实现的并发计数,支持自动过期清理
|
- **并发控制**: 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等)
|
- **客户端识别**: 基于User-Agent的客户端限制,支持预定义客户端(ClaudeCode、Gemini-CLI等)
|
||||||
- **错误处理**: 529错误自动标记账户过载状态,配置时长内自动排除该账户
|
- **错误处理**: 529错误自动标记账户过载状态,配置时长内自动排除该账户
|
||||||
|
|
||||||
@@ -508,8 +553,16 @@ npm run setup # 自动生成密钥并创建管理员账户
|
|||||||
- `overload:{accountId}` - 账户过载状态(529错误)
|
- `overload:{accountId}` - 账户过载状态(529错误)
|
||||||
- **并发控制**:
|
- **并发控制**:
|
||||||
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
|
- `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配置**:
|
||||||
- `webhook_config:{id}` - Webhook配置
|
- `webhook_config:{id}` - Webhook配置
|
||||||
|
- **用户消息队列**:
|
||||||
|
- `user_msg_queue_lock:{accountId}` - 用户消息队列锁(当前持有者requestId)
|
||||||
|
- `user_msg_queue_last:{accountId}` - 上次请求完成时间戳(用于延迟计算)
|
||||||
- **系统信息**:
|
- **系统信息**:
|
||||||
- `system_info` - 系统状态缓存
|
- `system_info` - 系统状态缓存
|
||||||
- `model_pricing` - 模型价格数据(pricingService)
|
- `model_pricing` - 模型价格数据(pricingService)
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -1,5 +1,10 @@
|
|||||||
# Claude Relay Service
|
# Claude Relay Service
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **安全更新通知**:v1.1.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
|
||||||
|
>
|
||||||
|
> **请立即更新到 v1.1.249+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
@@ -389,6 +394,9 @@ docker-compose.yml 已包含:
|
|||||||
|
|
||||||
**Claude Code 设置环境变量:**
|
**Claude Code 设置环境变量:**
|
||||||
|
|
||||||
|
|
||||||
|
**使用标准 Claude 账号池**
|
||||||
|
|
||||||
默认使用标准 Claude 账号池:
|
默认使用标准 Claude 账号池:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -396,6 +404,24 @@ export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你
|
|||||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**使用 Antigravity 账户池**
|
||||||
|
|
||||||
|
适用于通过 Antigravity 渠道使用 Claude 模型(如 `claude-opus-4-5` 等)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 设置 Base URL 为 Antigravity 专用路径
|
||||||
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
||||||
|
|
||||||
|
# 2. 设置 API Key(在后台创建,权限需包含 'all' 或 'gemini')
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||||
|
|
||||||
|
# 3. 指定模型名称(直接使用短名,无需前缀!)
|
||||||
|
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||||
|
|
||||||
|
# 4. 启动
|
||||||
|
claude
|
||||||
|
```
|
||||||
|
|
||||||
**VSCode Claude 插件配置:**
|
**VSCode Claude 插件配置:**
|
||||||
|
|
||||||
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
|
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
|
||||||
@@ -408,6 +434,8 @@ export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
|||||||
|
|
||||||
如果该文件不存在,请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
|
如果该文件不存在,请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
|
||||||
|
|
||||||
|
> 💡 **IntelliJ IDEA 用户推荐**:[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE,支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。
|
||||||
|
|
||||||
**Gemini CLI 设置环境变量:**
|
**Gemini CLI 设置环境变量:**
|
||||||
|
|
||||||
**方式一(推荐):通过 Gemini Assist API 方式访问**
|
**方式一(推荐):通过 Gemini Assist API 方式访问**
|
||||||
@@ -597,6 +625,7 @@ gpt-5 # Codex使用固定模型ID
|
|||||||
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
||||||
- 根据不同的路由前缀自动识别账号类型
|
- 根据不同的路由前缀自动识别账号类型
|
||||||
- `/claude/` - 使用Claude账号池
|
- `/claude/` - 使用Claude账号池
|
||||||
|
- `/antigravity/api/` - 使用Antigravity账号池(推荐用于Claude Code)
|
||||||
- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用)
|
- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用)
|
||||||
- `/gemini/` - 使用Gemini账号池
|
- `/gemini/` - 使用Gemini账号池
|
||||||
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Claude Relay Service
|
# Claude Relay Service
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
|
||||||
|
>
|
||||||
|
> **Please update to v1.1.249+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|||||||
21
SECURITY.md
Normal file
21
SECURITY.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Use this section to tell people about which versions of your project are
|
||||||
|
currently being supported with security updates.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 5.1.x | :white_check_mark: |
|
||||||
|
| 5.0.x | :x: |
|
||||||
|
| 4.0.x | :white_check_mark: |
|
||||||
|
| < 4.0 | :x: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Use this section to tell people how to report a vulnerability.
|
||||||
|
|
||||||
|
Tell them where to go, how often they can expect to get an update on a
|
||||||
|
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||||
|
declined, etc.
|
||||||
@@ -203,6 +203,23 @@ const config = {
|
|||||||
development: {
|
development: {
|
||||||
debug: process.env.DEBUG === 'true',
|
debug: process.env.DEBUG === 'true',
|
||||||
hotReload: process.env.HOT_RELOAD === 'true'
|
hotReload: process.env.HOT_RELOAD === 'true'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 💰 账户余额相关配置
|
||||||
|
accountBalance: {
|
||||||
|
// 是否允许执行自定义余额脚本(安全开关)
|
||||||
|
// 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启
|
||||||
|
// 默认保持开启;如需禁用请显式设置:BALANCE_SCRIPT_ENABLED=false
|
||||||
|
enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 📬 用户消息队列配置
|
||||||
|
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
|
||||||
|
userMessageQueue: {
|
||||||
|
enabled: process.env.USER_MESSAGE_QUEUE_ENABLED === 'true', // 默认关闭
|
||||||
|
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秒足以覆盖请求发送
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ services:
|
|||||||
- PORT=3000
|
- PORT=3000
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
|
|
||||||
|
# 🔧 请求体大小配置
|
||||||
|
- REQUEST_MAX_SIZE_MB=60
|
||||||
|
|
||||||
# 🔐 安全配置(必填)
|
# 🔐 安全配置(必填)
|
||||||
- JWT_SECRET=${JWT_SECRET} # 必填:至少32字符的随机字符串
|
- JWT_SECRET=${JWT_SECRET} # 必填:至少32字符的随机字符串
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填:32字符的加密密钥
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填:32字符的加密密钥
|
||||||
|
|||||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -26,6 +26,7 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
"ora": "^5.4.1",
|
"ora": "^5.4.1",
|
||||||
"rate-limiter-flexible": "^5.0.5",
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
@@ -891,7 +892,6 @@
|
|||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -3000,7 +3000,6 @@
|
|||||||
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -3082,7 +3081,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3538,7 +3536,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001737",
|
"caniuse-lite": "^1.0.30001737",
|
||||||
"electron-to-chromium": "^1.5.211",
|
"electron-to-chromium": "^1.5.211",
|
||||||
@@ -4426,7 +4423,6 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -4483,7 +4479,6 @@
|
|||||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
@@ -7034,6 +7029,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-cron": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
@@ -7582,7 +7586,6 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -9101,7 +9104,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
||||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colors/colors": "^1.6.0",
|
"@colors/colors": "^1.6.0",
|
||||||
"@dabh/diagnostics": "^2.0.2",
|
"@dabh/diagnostics": "^2.0.2",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
"ora": "^5.4.1",
|
"ora": "^5.4.1",
|
||||||
"rate-limiter-flexible": "^5.0.5",
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
|
|||||||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
@@ -59,6 +59,9 @@ importers:
|
|||||||
morgan:
|
morgan:
|
||||||
specifier: ^1.10.0
|
specifier: ^1.10.0
|
||||||
version: 1.10.1
|
version: 1.10.1
|
||||||
|
node-cron:
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1
|
||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^7.0.6
|
specifier: ^7.0.6
|
||||||
version: 7.0.11
|
version: 7.0.11
|
||||||
@@ -108,6 +111,9 @@ importers:
|
|||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.7.4
|
version: 3.7.4
|
||||||
|
prettier-plugin-tailwindcss:
|
||||||
|
specifier: ^0.7.2
|
||||||
|
version: 0.7.2(prettier@3.7.4)
|
||||||
supertest:
|
supertest:
|
||||||
specifier: ^6.3.3
|
specifier: ^6.3.3
|
||||||
version: 6.3.4
|
version: 6.3.4
|
||||||
@@ -2144,6 +2150,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
node-cron@4.2.1:
|
||||||
|
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
node-domexception@1.0.0:
|
node-domexception@1.0.0:
|
||||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||||
engines: {node: '>=10.5.0'}
|
engines: {node: '>=10.5.0'}
|
||||||
@@ -2302,6 +2312,61 @@ packages:
|
|||||||
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
|
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
prettier-plugin-tailwindcss@0.7.2:
|
||||||
|
resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==}
|
||||||
|
engines: {node: '>=20.19'}
|
||||||
|
peerDependencies:
|
||||||
|
'@ianvs/prettier-plugin-sort-imports': '*'
|
||||||
|
'@prettier/plugin-hermes': '*'
|
||||||
|
'@prettier/plugin-oxc': '*'
|
||||||
|
'@prettier/plugin-pug': '*'
|
||||||
|
'@shopify/prettier-plugin-liquid': '*'
|
||||||
|
'@trivago/prettier-plugin-sort-imports': '*'
|
||||||
|
'@zackad/prettier-plugin-twig': '*'
|
||||||
|
prettier: ^3.0
|
||||||
|
prettier-plugin-astro: '*'
|
||||||
|
prettier-plugin-css-order: '*'
|
||||||
|
prettier-plugin-jsdoc: '*'
|
||||||
|
prettier-plugin-marko: '*'
|
||||||
|
prettier-plugin-multiline-arrays: '*'
|
||||||
|
prettier-plugin-organize-attributes: '*'
|
||||||
|
prettier-plugin-organize-imports: '*'
|
||||||
|
prettier-plugin-sort-imports: '*'
|
||||||
|
prettier-plugin-svelte: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@ianvs/prettier-plugin-sort-imports':
|
||||||
|
optional: true
|
||||||
|
'@prettier/plugin-hermes':
|
||||||
|
optional: true
|
||||||
|
'@prettier/plugin-oxc':
|
||||||
|
optional: true
|
||||||
|
'@prettier/plugin-pug':
|
||||||
|
optional: true
|
||||||
|
'@shopify/prettier-plugin-liquid':
|
||||||
|
optional: true
|
||||||
|
'@trivago/prettier-plugin-sort-imports':
|
||||||
|
optional: true
|
||||||
|
'@zackad/prettier-plugin-twig':
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-astro:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-css-order:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-jsdoc:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-marko:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-multiline-arrays:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-organize-attributes:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-organize-imports:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-sort-imports:
|
||||||
|
optional: true
|
||||||
|
prettier-plugin-svelte:
|
||||||
|
optional: true
|
||||||
|
|
||||||
prettier@3.7.4:
|
prettier@3.7.4:
|
||||||
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
|
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -5692,6 +5757,8 @@ snapshots:
|
|||||||
|
|
||||||
negotiator@0.6.4: {}
|
negotiator@0.6.4: {}
|
||||||
|
|
||||||
|
node-cron@4.2.1: {}
|
||||||
|
|
||||||
node-domexception@1.0.0: {}
|
node-domexception@1.0.0: {}
|
||||||
|
|
||||||
node-fetch@3.3.2:
|
node-fetch@3.3.2:
|
||||||
@@ -5840,6 +5907,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fast-diff: 1.3.0
|
fast-diff: 1.3.0
|
||||||
|
|
||||||
|
prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4):
|
||||||
|
dependencies:
|
||||||
|
prettier: 3.7.4
|
||||||
|
|
||||||
prettier@3.7.4: {}
|
prettier@3.7.4: {}
|
||||||
|
|
||||||
pretty-format@29.7.0:
|
pretty-format@29.7.0:
|
||||||
|
|||||||
181
src/app.js
181
src/app.js
@@ -52,6 +52,16 @@ class Application {
|
|||||||
await redis.connect()
|
await redis.connect()
|
||||||
logger.success('✅ Redis connected successfully')
|
logger.success('✅ Redis connected successfully')
|
||||||
|
|
||||||
|
// 💳 初始化账户余额查询服务(Provider 注册)
|
||||||
|
try {
|
||||||
|
const accountBalanceService = require('./services/accountBalanceService')
|
||||||
|
const { registerAllProviders } = require('./services/balanceProviders')
|
||||||
|
registerAllProviders(accountBalanceService)
|
||||||
|
logger.info('✅ 账户余额查询服务已初始化')
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message)
|
||||||
|
}
|
||||||
|
|
||||||
// 💰 初始化价格服务
|
// 💰 初始化价格服务
|
||||||
logger.info('🔄 Initializing pricing service...')
|
logger.info('🔄 Initializing pricing service...')
|
||||||
await pricingService.initialize()
|
await pricingService.initialize()
|
||||||
@@ -68,6 +78,10 @@ class Application {
|
|||||||
logger.info('🔄 Initializing admin credentials...')
|
logger.info('🔄 Initializing admin credentials...')
|
||||||
await this.initializeAdmin()
|
await this.initializeAdmin()
|
||||||
|
|
||||||
|
// 🔒 安全启动:清理无效/伪造的管理员会话
|
||||||
|
logger.info('🔒 Cleaning up invalid admin sessions...')
|
||||||
|
await this.cleanupInvalidSessions()
|
||||||
|
|
||||||
// 💰 初始化费用数据
|
// 💰 初始化费用数据
|
||||||
logger.info('💰 Checking cost data initialization...')
|
logger.info('💰 Checking cost data initialization...')
|
||||||
const costInitService = require('./services/costInitService')
|
const costInitService = require('./services/costInitService')
|
||||||
@@ -80,6 +94,15 @@ class Application {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 💰 启动回填:本周 Claude 周费用(用于 API Key 维度周限额)
|
||||||
|
try {
|
||||||
|
logger.info('💰 Backfilling current-week Claude weekly cost...')
|
||||||
|
const weeklyClaudeCostInitService = require('./services/weeklyClaudeCostInitService')
|
||||||
|
await weeklyClaudeCostInitService.backfillCurrentWeekClaudeCosts()
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('⚠️ Weekly Claude cost backfill failed (startup continues):', error.message)
|
||||||
|
}
|
||||||
|
|
||||||
// 🕐 初始化Claude账户会话窗口
|
// 🕐 初始化Claude账户会话窗口
|
||||||
logger.info('🕐 Initializing Claude account session windows...')
|
logger.info('🕐 Initializing Claude account session windows...')
|
||||||
const claudeAccountService = require('./services/claudeAccountService')
|
const claudeAccountService = require('./services/claudeAccountService')
|
||||||
@@ -165,7 +188,7 @@ class Application {
|
|||||||
// 🔧 基础中间件
|
// 🔧 基础中间件
|
||||||
this.app.use(
|
this.app.use(
|
||||||
express.json({
|
express.json({
|
||||||
limit: '10mb',
|
limit: '100mb',
|
||||||
verify: (req, res, buf, encoding) => {
|
verify: (req, res, buf, encoding) => {
|
||||||
// 验证JSON格式
|
// 验证JSON格式
|
||||||
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
||||||
@@ -174,7 +197,7 @@ class Application {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
this.app.use(express.urlencoded({ extended: true, limit: '100mb' }))
|
||||||
this.app.use(securityMiddleware)
|
this.app.use(securityMiddleware)
|
||||||
|
|
||||||
// 🎯 信任代理
|
// 🎯 信任代理
|
||||||
@@ -264,6 +287,25 @@ class Application {
|
|||||||
this.app.use('/api', apiRoutes)
|
this.app.use('/api', apiRoutes)
|
||||||
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
||||||
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
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('/admin', adminRoutes)
|
||||||
this.app.use('/users', userRoutes)
|
this.app.use('/users', userRoutes)
|
||||||
// 使用 web 路由(包含 auth 和页面重定向)
|
// 使用 web 路由(包含 auth 和页面重定向)
|
||||||
@@ -426,6 +468,54 @@ class Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 清理无效/伪造的管理员会话(安全启动检查)
|
||||||
|
async cleanupInvalidSessions() {
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
|
||||||
|
// 获取所有 session:* 键
|
||||||
|
const sessionKeys = await client.keys('session:*')
|
||||||
|
|
||||||
|
let validCount = 0
|
||||||
|
let invalidCount = 0
|
||||||
|
|
||||||
|
for (const key of sessionKeys) {
|
||||||
|
// 跳过 admin_credentials(系统凭据)
|
||||||
|
if (key === 'session:admin_credentials') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = await client.hgetall(key)
|
||||||
|
|
||||||
|
// 检查会话完整性:必须有 username 和 loginTime
|
||||||
|
const hasUsername = !!sessionData.username
|
||||||
|
const hasLoginTime = !!sessionData.loginTime
|
||||||
|
|
||||||
|
if (!hasUsername || !hasLoginTime) {
|
||||||
|
// 无效会话 - 可能是漏洞利用创建的伪造会话
|
||||||
|
invalidCount++
|
||||||
|
logger.security(
|
||||||
|
`🔒 Removing invalid session: ${key} (username: ${hasUsername}, loginTime: ${hasLoginTime})`
|
||||||
|
)
|
||||||
|
await client.del(key)
|
||||||
|
} else {
|
||||||
|
validCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidCount > 0) {
|
||||||
|
logger.security(`🔒 Startup security check: Removed ${invalidCount} invalid sessions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
// 清理失败不应阻止服务启动
|
||||||
|
logger.error('❌ Failed to cleanup invalid sessions:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔍 Redis健康检查
|
// 🔍 Redis健康检查
|
||||||
async checkRedisHealth() {
|
async checkRedisHealth() {
|
||||||
try {
|
try {
|
||||||
@@ -581,15 +671,40 @@ class Application {
|
|||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
let totalCleaned = 0
|
let totalCleaned = 0
|
||||||
|
let legacyCleaned = 0
|
||||||
|
|
||||||
// 使用 Lua 脚本批量清理所有过期项
|
// 使用 Lua 脚本批量清理所有过期项
|
||||||
for (const key of keys) {
|
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 {
|
try {
|
||||||
const cleaned = await redis.client.eval(
|
// 使用原子 Lua 脚本:先检查类型,再执行清理
|
||||||
|
// 返回值:0 = 正常清理无删除,1 = 清理后删除空键,-1 = 遗留键已删除
|
||||||
|
const result = await redis.client.eval(
|
||||||
`
|
`
|
||||||
local key = KEYS[1]
|
local key = KEYS[1]
|
||||||
local now = tonumber(ARGV[1])
|
local now = tonumber(ARGV[1])
|
||||||
|
|
||||||
|
-- 先检查键类型,只对 Sorted Set 执行清理
|
||||||
|
local keyType = redis.call('TYPE', key)
|
||||||
|
if keyType.ok ~= 'zset' then
|
||||||
|
-- 非 ZSET 类型的遗留键,直接删除
|
||||||
|
redis.call('DEL', key)
|
||||||
|
return -1
|
||||||
|
end
|
||||||
|
|
||||||
-- 清理过期项
|
-- 清理过期项
|
||||||
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
||||||
|
|
||||||
@@ -608,8 +723,10 @@ class Application {
|
|||||||
key,
|
key,
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
if (cleaned === 1) {
|
if (result === 1) {
|
||||||
totalCleaned++
|
totalCleaned++
|
||||||
|
} else if (result === -1) {
|
||||||
|
legacyCleaned++
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
|
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
|
||||||
@@ -619,12 +736,50 @@ class Application {
|
|||||||
if (totalCleaned > 0) {
|
if (totalCleaned > 0) {
|
||||||
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
|
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
|
||||||
}
|
}
|
||||||
|
if (legacyCleaned > 0) {
|
||||||
|
logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Concurrency cleanup task failed:', error)
|
logger.error('❌ Concurrency cleanup task failed:', error)
|
||||||
}
|
}
|
||||||
}, 60000) // 每分钟执行一次
|
}, 60000) // 每分钟执行一次
|
||||||
|
|
||||||
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
|
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
|
||||||
|
|
||||||
|
// 📬 启动用户消息队列服务
|
||||||
|
const userMessageQueueService = require('./services/userMessageQueueService')
|
||||||
|
// 先清理服务重启后残留的锁,防止旧锁阻塞新请求
|
||||||
|
userMessageQueueService.cleanupStaleLocks().then(() => {
|
||||||
|
// 然后启动定时清理任务
|
||||||
|
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)'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧪 启动账户定时测试调度器
|
||||||
|
// 根据配置定期测试账户连通性并保存测试历史
|
||||||
|
const accountTestSchedulerEnabled =
|
||||||
|
process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' &&
|
||||||
|
config.accountTestScheduler?.enabled !== false
|
||||||
|
if (accountTestSchedulerEnabled) {
|
||||||
|
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
|
||||||
|
accountTestSchedulerService.start()
|
||||||
|
logger.info('🧪 Account test scheduler service started')
|
||||||
|
} else {
|
||||||
|
logger.info('🧪 Account test scheduler service disabled')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupGracefulShutdown() {
|
setupGracefulShutdown() {
|
||||||
@@ -661,6 +816,15 @@ class Application {
|
|||||||
logger.error('❌ Error stopping rate limit cleanup service:', error)
|
logger.error('❌ Error stopping rate limit cleanup service:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停止用户消息队列清理服务
|
||||||
|
try {
|
||||||
|
const userMessageQueueService = require('./services/userMessageQueueService')
|
||||||
|
userMessageQueueService.stopCleanupTask()
|
||||||
|
logger.info('📬 User message queue service stopped')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error stopping user message queue service:', error)
|
||||||
|
}
|
||||||
|
|
||||||
// 停止费用排序索引服务
|
// 停止费用排序索引服务
|
||||||
try {
|
try {
|
||||||
const costRankService = require('./services/costRankService')
|
const costRankService = require('./services/costRankService')
|
||||||
@@ -670,6 +834,15 @@ class Application {
|
|||||||
logger.error('❌ Error stopping cost rank service:', error)
|
logger.error('❌ Error stopping cost rank service:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停止账户定时测试调度器
|
||||||
|
try {
|
||||||
|
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
|
||||||
|
accountTestSchedulerService.stop()
|
||||||
|
logger.info('🧪 Account test scheduler service stopped')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error stopping account test scheduler service:', error)
|
||||||
|
}
|
||||||
|
|
||||||
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
||||||
try {
|
try {
|
||||||
logger.info('🔢 Cleaning up all concurrency counters...')
|
logger.info('🔢 Cleaning up all concurrency counters...')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const logger = require('../utils/logger')
|
|||||||
const geminiAccountService = require('../services/geminiAccountService')
|
const geminiAccountService = require('../services/geminiAccountService')
|
||||||
const geminiApiAccountService = require('../services/geminiApiAccountService')
|
const geminiApiAccountService = require('../services/geminiApiAccountService')
|
||||||
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
|
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
|
||||||
|
const { sendAntigravityRequest } = require('../services/antigravityRelayService')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||||
@@ -86,8 +87,7 @@ function generateSessionHash(req) {
|
|||||||
* 检查 API Key 权限
|
* 检查 API Key 权限
|
||||||
*/
|
*/
|
||||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||||
const permissions = apiKeyData?.permissions || 'all'
|
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||||
return permissions === 'all' || permissions === requiredPermission
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -508,7 +508,23 @@ async function handleMessages(req, res) {
|
|||||||
// OAuth 账户:使用现有的 sendGeminiRequest
|
// OAuth 账户:使用现有的 sendGeminiRequest
|
||||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||||
const effectiveProjectId = account.projectId || account.tempProjectId || null
|
const effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
|
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({
|
geminiResponse = await sendGeminiRequest({
|
||||||
messages,
|
messages,
|
||||||
model,
|
model,
|
||||||
@@ -523,6 +539,7 @@ async function handleMessages(req, res) {
|
|||||||
accountId: account.id
|
accountId: account.id
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
// 设置流式响应头
|
// 设置流式响应头
|
||||||
@@ -754,8 +771,16 @@ async function handleModels(req, res) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// OAuth 账户:使用 OAuth token 获取模型列表
|
// OAuth 账户:根据 OAuth provider 选择上游
|
||||||
models = await getAvailableModels(account.accessToken, account.proxy)
|
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({
|
res.json({
|
||||||
@@ -837,7 +862,7 @@ async function handleKeyInfo(req, res) {
|
|||||||
res.json({
|
res.json({
|
||||||
id: keyData.id,
|
id: keyData.id,
|
||||||
name: keyData.name,
|
name: keyData.name,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions,
|
||||||
token_limit: keyData.tokenLimit,
|
token_limit: keyData.tokenLimit,
|
||||||
tokens_used: keyData.usage.total.tokens,
|
tokens_used: keyData.usage.total.tokens,
|
||||||
tokens_remaining:
|
tokens_remaining:
|
||||||
@@ -927,7 +952,8 @@ function handleSimpleEndpoint(apiMethod) {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
// 直接转发请求体,不做特殊处理
|
// 直接转发请求体,不做特殊处理
|
||||||
@@ -1006,7 +1032,12 @@ async function handleLoadCodeAssist(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
const client = await geminiAccountService.getOauthClient(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 智能处理项目ID
|
// 智能处理项目ID
|
||||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||||
@@ -1104,7 +1135,12 @@ async function handleOnboardUser(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
const client = await geminiAccountService.getOauthClient(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 智能处理项目ID
|
// 智能处理项目ID
|
||||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||||
@@ -1152,6 +1188,110 @@ async function handleOnboardUser(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 retrieveUserQuota 请求
|
||||||
|
* POST /v1internal:retrieveUserQuota
|
||||||
|
*
|
||||||
|
* 功能:查询用户在各个Gemini模型上的配额使用情况
|
||||||
|
* 请求体:{ "project": "项目ID" }
|
||||||
|
* 响应:{ "buckets": [...] }
|
||||||
|
*/
|
||||||
|
async function handleRetrieveUserQuota(req, res) {
|
||||||
|
try {
|
||||||
|
// 1. 权限检查
|
||||||
|
if (!ensureGeminiPermission(req, res)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 会话哈希
|
||||||
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 3. 账户选择
|
||||||
|
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||||
|
const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||||
|
req.apiKey,
|
||||||
|
sessionHash,
|
||||||
|
requestedModel
|
||||||
|
)
|
||||||
|
const { accountId, accountType } = schedulerResult
|
||||||
|
|
||||||
|
// 4. 账户类型验证 - v1internal 路由只支持 OAuth 账户
|
||||||
|
if (accountType === 'gemini-api') {
|
||||||
|
logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message:
|
||||||
|
'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.',
|
||||||
|
type: 'invalid_account_type'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 获取账户
|
||||||
|
const account = await geminiAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Gemini account not found',
|
||||||
|
type: 'account_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const { accessToken, refreshToken, projectId } = account
|
||||||
|
|
||||||
|
// 6. 从请求体提取项目字段(注意:字段名是 "project",不是 "cloudaicompanionProject")
|
||||||
|
const requestProject = req.body.project
|
||||||
|
|
||||||
|
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||||
|
logger.info(`RetrieveUserQuota request (${version})`, {
|
||||||
|
requestedProject: requestProject || null,
|
||||||
|
accountProject: projectId || null,
|
||||||
|
apiKeyId: req.apiKey?.id || 'unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. 解析账户的代理配置
|
||||||
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
|
// 8. 获取OAuth客户端
|
||||||
|
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||||
|
|
||||||
|
// 9. 智能处理项目ID(与其他 v1internal 接口保持一致)
|
||||||
|
const effectiveProject = projectId || requestProject || null
|
||||||
|
|
||||||
|
logger.info('📋 retrieveUserQuota项目ID处理逻辑', {
|
||||||
|
accountProjectId: projectId,
|
||||||
|
requestProject,
|
||||||
|
effectiveProject,
|
||||||
|
decision: projectId ? '使用账户配置' : requestProject ? '使用请求参数' : '不使用项目ID'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 10. 构建请求体(注入 effectiveProject)
|
||||||
|
const requestBody = { ...req.body }
|
||||||
|
if (effectiveProject) {
|
||||||
|
requestBody.project = effectiveProject
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. 调用底层服务转发请求
|
||||||
|
const response = await geminiAccountService.forwardToCodeAssist(
|
||||||
|
client,
|
||||||
|
'retrieveUserQuota',
|
||||||
|
requestBody,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json(response)
|
||||||
|
} catch (error) {
|
||||||
|
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||||
|
logger.error(`Error in retrieveUserQuota endpoint (${version})`, {
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 countTokens 请求
|
* 处理 countTokens 请求
|
||||||
*/
|
*/
|
||||||
@@ -1256,7 +1396,8 @@ async function handleCountTokens(req, res) {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
|
response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
|
||||||
}
|
}
|
||||||
@@ -1366,13 +1507,20 @@ async function handleGenerateContent(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
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
|
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -1388,6 +1536,12 @@ async function handleGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!effectiveProjectId && oauthProvider === 'antigravity') {
|
||||||
|
// Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
|
||||||
// 如果还是没有项目ID,返回错误
|
// 如果还是没有项目ID,返回错误
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -1410,7 +1564,17 @@ async function handleGenerateContent(req, res) {
|
|||||||
: '从loadCodeAssist获取'
|
: '从loadCodeAssist获取'
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await geminiAccountService.generateContent(
|
const response =
|
||||||
|
oauthProvider === 'antigravity'
|
||||||
|
? await geminiAccountService.generateContentAntigravity(
|
||||||
|
client,
|
||||||
|
{ model, request: actualRequestData },
|
||||||
|
user_prompt_id,
|
||||||
|
effectiveProjectId,
|
||||||
|
req.apiKey?.id,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
: await geminiAccountService.generateContent(
|
||||||
client,
|
client,
|
||||||
{ model, request: actualRequestData },
|
{ model, request: actualRequestData },
|
||||||
user_prompt_id,
|
user_prompt_id,
|
||||||
@@ -1578,13 +1742,20 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
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
|
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -1600,6 +1771,11 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!effectiveProjectId && oauthProvider === 'antigravity') {
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
|
||||||
// 如果还是没有项目ID,返回错误
|
// 如果还是没有项目ID,返回错误
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -1622,7 +1798,18 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
: '从loadCodeAssist获取'
|
: '从loadCodeAssist获取'
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamResponse = await geminiAccountService.generateContentStream(
|
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,
|
client,
|
||||||
{ model, request: actualRequestData },
|
{ model, request: actualRequestData },
|
||||||
user_prompt_id,
|
user_prompt_id,
|
||||||
@@ -1978,15 +2165,23 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
} else {
|
} else {
|
||||||
// OAuth 账户
|
// OAuth 账户
|
||||||
const { accessToken, refreshToken } = account
|
const { accessToken, refreshToken } = account
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
oauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId) {
|
||||||
|
// Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
} else if (!effectiveProjectId) {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -2024,6 +2219,16 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
|
|
||||||
const userPromptId = `${crypto.randomUUID()}########0`
|
const userPromptId = `${crypto.randomUUID()}########0`
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
|
response = await geminiAccountService.generateContentAntigravity(
|
||||||
|
client,
|
||||||
|
{ model, request: actualRequestData },
|
||||||
|
userPromptId,
|
||||||
|
effectiveProjectId,
|
||||||
|
req.apiKey?.id,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
} else {
|
||||||
response = await geminiAccountService.generateContent(
|
response = await geminiAccountService.generateContent(
|
||||||
client,
|
client,
|
||||||
{ model, request: actualRequestData },
|
{ model, request: actualRequestData },
|
||||||
@@ -2033,6 +2238,7 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
proxyConfig
|
proxyConfig
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 记录使用统计
|
// 记录使用统计
|
||||||
if (response?.response?.usageMetadata) {
|
if (response?.response?.usageMetadata) {
|
||||||
@@ -2263,12 +2469,20 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId) {
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
} else if (!effectiveProjectId) {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -2306,6 +2520,17 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
|
|
||||||
const userPromptId = `${crypto.randomUUID()}########0`
|
const userPromptId = `${crypto.randomUUID()}########0`
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
|
streamResponse = await geminiAccountService.generateContentStreamAntigravity(
|
||||||
|
client,
|
||||||
|
{ model, request: actualRequestData },
|
||||||
|
userPromptId,
|
||||||
|
effectiveProjectId,
|
||||||
|
req.apiKey?.id,
|
||||||
|
abortController.signal,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
} else {
|
||||||
streamResponse = await geminiAccountService.generateContentStream(
|
streamResponse = await geminiAccountService.generateContentStream(
|
||||||
client,
|
client,
|
||||||
{ model, request: actualRequestData },
|
{ model, request: actualRequestData },
|
||||||
@@ -2316,6 +2541,7 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
proxyConfig
|
proxyConfig
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 设置 SSE 响应头
|
// 设置 SSE 响应头
|
||||||
res.setHeader('Content-Type', 'text/event-stream')
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
@@ -2576,6 +2802,7 @@ module.exports = {
|
|||||||
handleSimpleEndpoint,
|
handleSimpleEndpoint,
|
||||||
handleLoadCodeAssist,
|
handleLoadCodeAssist,
|
||||||
handleOnboardUser,
|
handleOnboardUser,
|
||||||
|
handleRetrieveUserQuota,
|
||||||
handleCountTokens,
|
handleCountTokens,
|
||||||
handleGenerateContent,
|
handleGenerateContent,
|
||||||
handleStreamGenerateContent,
|
handleStreamGenerateContent,
|
||||||
|
|||||||
@@ -6,6 +6,105 @@ const logger = require('../utils/logger')
|
|||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
||||||
const ClientValidator = require('../validators/clientValidator')
|
const ClientValidator = require('../validators/clientValidator')
|
||||||
|
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||||
|
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||||
|
const { calculateWaitTimeStats } = require('../utils/statsHelper')
|
||||||
|
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
function sleep(ms) {
|
||||||
|
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 = {
|
const FALLBACK_CONCURRENCY_CONFIG = {
|
||||||
leaseSeconds: 300,
|
leaseSeconds: 300,
|
||||||
@@ -126,9 +225,223 @@ function isTokenCountRequest(req) {
|
|||||||
return false
|
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 读取完请求体后,req(IncomingMessage 可读流)
|
||||||
|
// 的 '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验证中间件(优化版)
|
// 🔑 API Key验证中间件(优化版)
|
||||||
const authenticateApiKey = async (req, res, next) => {
|
const authenticateApiKey = async (req, res, next) => {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
let authErrored = false
|
||||||
|
let concurrencyCleanup = null
|
||||||
|
let hasConcurrencySlot = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 安全提取API Key,支持多种格式(包括Gemini CLI支持)
|
// 安全提取API Key,支持多种格式(包括Gemini CLI支持)
|
||||||
@@ -201,6 +514,53 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 检查全局 Claude Code 限制(与 API Key 级别是 OR 逻辑)
|
||||||
|
// 仅对 Claude 服务端点生效 (/api/v1/messages 和 /claude/v1/messages)
|
||||||
|
if (!skipKeyRestrictions) {
|
||||||
|
const normalizedPath = (req.originalUrl || req.path || '').toLowerCase()
|
||||||
|
const isClaudeMessagesEndpoint =
|
||||||
|
normalizedPath.includes('/v1/messages') &&
|
||||||
|
(normalizedPath.startsWith('/api') || normalizedPath.startsWith('/claude'))
|
||||||
|
|
||||||
|
if (isClaudeMessagesEndpoint) {
|
||||||
|
try {
|
||||||
|
const globalClaudeCodeOnly = await claudeRelayConfigService.isClaudeCodeOnlyEnabled()
|
||||||
|
|
||||||
|
// API Key 级别的 Claude Code 限制
|
||||||
|
const keyClaudeCodeOnly =
|
||||||
|
validation.keyData.enableClientRestriction &&
|
||||||
|
Array.isArray(validation.keyData.allowedClients) &&
|
||||||
|
validation.keyData.allowedClients.length === 1 &&
|
||||||
|
validation.keyData.allowedClients.includes('claude_code')
|
||||||
|
|
||||||
|
// OR 逻辑:全局开启 或 API Key 级别限制为仅 claude_code
|
||||||
|
if (globalClaudeCodeOnly || keyClaudeCodeOnly) {
|
||||||
|
const isClaudeCode = ClaudeCodeValidator.validate(req)
|
||||||
|
|
||||||
|
if (!isClaudeCode) {
|
||||||
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||||
|
logger.api(
|
||||||
|
`❌ Claude Code client validation failed (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly}) from ${clientIP}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'client_validation_error',
|
||||||
|
message: 'This endpoint only accepts requests from Claude Code CLI'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.api(
|
||||||
|
`✅ Claude Code client validated (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error checking Claude Code restriction:', error)
|
||||||
|
// 配置服务出错时不阻断请求
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查并发限制
|
// 检查并发限制
|
||||||
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
||||||
if (!skipKeyRestrictions && concurrencyLimit > 0) {
|
if (!skipKeyRestrictions && concurrencyLimit > 0) {
|
||||||
@@ -216,33 +576,86 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
const requestId = uuidv4()
|
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(
|
const currentConcurrency = await redis.incrConcurrency(
|
||||||
validation.keyData.id,
|
validation.keyData.id,
|
||||||
requestId,
|
requestId,
|
||||||
leaseSeconds
|
leaseSeconds
|
||||||
)
|
)
|
||||||
|
hasConcurrencySlot = true
|
||||||
|
setTemporaryConcurrencyCleanup()
|
||||||
logger.api(
|
logger.api(
|
||||||
`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}`
|
`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}`
|
||||||
)
|
)
|
||||||
|
|
||||||
if (currentConcurrency > concurrencyLimit) {
|
if (currentConcurrency > concurrencyLimit) {
|
||||||
// 如果超过限制,立即减少计数(添加 try-catch 防止异常导致并发泄漏)
|
// 1. 先释放刚占用的槽位
|
||||||
try {
|
try {
|
||||||
const newCount = await redis.decrConcurrency(validation.keyData.id, requestId)
|
await redis.decrConcurrency(validation.keyData.id, requestId)
|
||||||
logger.api(
|
|
||||||
`📉 Decremented concurrency (429 rejected) for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}`
|
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to decrement concurrency after limit exceeded for key ${validation.keyData.id}:`,
|
`Failed to decrement concurrency after limit exceeded for key ${validation.keyData.id}:`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
hasConcurrencySlot = false
|
||||||
|
concurrencyCleanup = null
|
||||||
|
|
||||||
|
// 2. 获取排队配置
|
||||||
|
const queueConfig = await claudeRelayConfigService.getConfig()
|
||||||
|
|
||||||
|
// 3. 排队功能未启用,直接返回 429(保持现有行为)
|
||||||
|
if (!queueConfig.concurrentRequestQueueEnabled) {
|
||||||
logger.security(
|
logger.security(
|
||||||
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
|
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
|
||||||
validation.keyData.name
|
validation.keyData.name
|
||||||
}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`
|
}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`
|
||||||
)
|
)
|
||||||
|
// 建议客户端在短暂延迟后重试(并发场景下通常很快会有槽位释放)
|
||||||
|
res.set('Retry-After', '1')
|
||||||
return res.status(429).json({
|
return res.status(429).json({
|
||||||
error: 'Concurrency limit exceeded',
|
error: 'Concurrency limit exceeded',
|
||||||
message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`,
|
message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`,
|
||||||
@@ -251,6 +664,260 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 计算最大排队数
|
||||||
|
const maxQueueSize = Math.max(
|
||||||
|
concurrencyLimit * queueConfig.concurrentRequestQueueMaxSizeMultiplier,
|
||||||
|
queueConfig.concurrentRequestQueueMaxSize
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 =
|
const renewIntervalMs =
|
||||||
renewIntervalSeconds > 0 ? Math.max(renewIntervalSeconds * 1000, 15000) : 0
|
renewIntervalSeconds > 0 ? Math.max(renewIntervalSeconds * 1000, 15000) : 0
|
||||||
|
|
||||||
@@ -309,6 +976,7 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
const decrementConcurrency = async () => {
|
const decrementConcurrency = async () => {
|
||||||
if (!concurrencyDecremented) {
|
if (!concurrencyDecremented) {
|
||||||
concurrencyDecremented = true
|
concurrencyDecremented = true
|
||||||
|
hasConcurrencySlot = false
|
||||||
if (leaseRenewInterval) {
|
if (leaseRenewInterval) {
|
||||||
clearInterval(leaseRenewInterval)
|
clearInterval(leaseRenewInterval)
|
||||||
leaseRenewInterval = null
|
leaseRenewInterval = null
|
||||||
@@ -323,6 +991,11 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 升级为完整清理函数(包含 leaseRenewInterval 清理逻辑)
|
||||||
|
// 此时请求已通过认证,后续由 res.close/req.close 事件触发清理
|
||||||
|
if (hasConcurrencySlot) {
|
||||||
|
concurrencyCleanup = decrementConcurrency
|
||||||
|
}
|
||||||
|
|
||||||
// 监听最可靠的事件(避免重复监听)
|
// 监听最可靠的事件(避免重复监听)
|
||||||
// res.on('close') 是最可靠的,会在连接关闭时触发
|
// res.on('close') 是最可靠的,会在连接关闭时触发
|
||||||
@@ -567,20 +1240,20 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
|
// 检查 Claude 周费用限制
|
||||||
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
|
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
|
||||||
if (weeklyOpusCostLimit > 0) {
|
if (weeklyOpusCostLimit > 0) {
|
||||||
// 从请求中获取模型信息
|
// 从请求中获取模型信息
|
||||||
const requestBody = req.body || {}
|
const requestBody = req.body || {}
|
||||||
const model = requestBody.model || ''
|
const model = requestBody.model || ''
|
||||||
|
|
||||||
// 判断是否为 Opus 模型
|
// 判断是否为 Claude 模型
|
||||||
if (model && model.toLowerCase().includes('claude-opus')) {
|
if (isClaudeFamilyModel(model)) {
|
||||||
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
|
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
|
||||||
|
|
||||||
if (weeklyOpusCost >= weeklyOpusCostLimit) {
|
if (weeklyOpusCost >= weeklyOpusCostLimit) {
|
||||||
logger.security(
|
logger.security(
|
||||||
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${
|
`💰 Weekly Claude cost limit exceeded for key: ${validation.keyData.id} (${
|
||||||
validation.keyData.name
|
validation.keyData.name
|
||||||
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||||
)
|
)
|
||||||
@@ -594,17 +1267,17 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
resetDate.setHours(0, 0, 0, 0)
|
resetDate.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
return res.status(429).json({
|
return res.status(429).json({
|
||||||
error: 'Weekly Opus cost limit exceeded',
|
error: 'Weekly Claude cost limit exceeded',
|
||||||
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
message: `已达到 Claude 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
||||||
currentCost: weeklyOpusCost,
|
currentCost: weeklyOpusCost,
|
||||||
costLimit: weeklyOpusCostLimit,
|
costLimit: weeklyOpusCostLimit,
|
||||||
resetAt: resetDate.toISOString() // 下周一重置
|
resetAt: resetDate.toISOString() // 下周一重置
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录当前 Opus 费用使用情况
|
// 记录当前 Claude 费用使用情况
|
||||||
logger.api(
|
logger.api(
|
||||||
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${
|
`💰 Claude weekly cost usage for key: ${validation.keyData.id} (${
|
||||||
validation.keyData.name
|
validation.keyData.name
|
||||||
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||||
)
|
)
|
||||||
@@ -648,6 +1321,7 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
|
|
||||||
return next()
|
return next()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
authErrored = true
|
||||||
const authDuration = Date.now() - startTime
|
const authDuration = Date.now() - startTime
|
||||||
logger.error(`❌ Authentication middleware error (${authDuration}ms):`, {
|
logger.error(`❌ Authentication middleware error (${authDuration}ms):`, {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
@@ -661,6 +1335,14 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
error: 'Authentication error',
|
error: 'Authentication error',
|
||||||
message: 'Internal server error during authentication'
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,6 +1390,18 @@ const authenticateAdmin = async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 安全修复:验证会话必须字段(防止伪造会话绕过认证)
|
||||||
|
if (!adminSession.username || !adminSession.loginTime) {
|
||||||
|
logger.security(
|
||||||
|
`🔒 Corrupted admin session from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
|
||||||
|
)
|
||||||
|
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid session',
|
||||||
|
message: 'Session data corrupted or incomplete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 检查会话活跃性(可选:检查最后活动时间)
|
// 检查会话活跃性(可选:检查最后活动时间)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime)
|
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime)
|
||||||
@@ -741,7 +1435,6 @@ const authenticateAdmin = async (req, res, next) => {
|
|||||||
|
|
||||||
// 设置管理员信息(只包含必要信息)
|
// 设置管理员信息(只包含必要信息)
|
||||||
req.admin = {
|
req.admin = {
|
||||||
id: adminSession.adminId || 'admin',
|
|
||||||
username: adminSession.username,
|
username: adminSession.username,
|
||||||
sessionId: token,
|
sessionId: token,
|
||||||
loginTime: adminSession.loginTime
|
loginTime: adminSession.loginTime
|
||||||
@@ -874,8 +1567,15 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const adminSession = await redis.getSession(adminToken)
|
const adminSession = await redis.getSession(adminToken)
|
||||||
if (adminSession && Object.keys(adminSession).length > 0) {
|
if (adminSession && Object.keys(adminSession).length > 0) {
|
||||||
|
// 🔒 安全修复:验证会话必须字段(与 authenticateAdmin 保持一致)
|
||||||
|
if (!adminSession.username || !adminSession.loginTime) {
|
||||||
|
logger.security(
|
||||||
|
`🔒 Corrupted admin session in authenticateUserOrAdmin from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
|
||||||
|
)
|
||||||
|
await redis.deleteSession(adminToken) // 清理无效/伪造的会话
|
||||||
|
// 不返回 401,继续尝试用户认证
|
||||||
|
} else {
|
||||||
req.admin = {
|
req.admin = {
|
||||||
id: adminSession.adminId || 'admin',
|
|
||||||
username: adminSession.username,
|
username: adminSession.username,
|
||||||
sessionId: adminToken,
|
sessionId: adminToken,
|
||||||
loginTime: adminSession.loginTime
|
loginTime: adminSession.loginTime
|
||||||
@@ -886,6 +1586,7 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
|||||||
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug('Admin authentication failed, trying user authentication:', error.message)
|
logger.debug('Admin authentication failed, trying user authentication:', error.message)
|
||||||
}
|
}
|
||||||
@@ -1063,10 +1764,14 @@ const requestLogger = (req, res, next) => {
|
|||||||
const referer = req.get('Referer') || 'none'
|
const referer = req.get('Referer') || 'none'
|
||||||
|
|
||||||
// 记录请求开始
|
// 记录请求开始
|
||||||
|
const isDebugRoute = req.originalUrl.includes('event_logging')
|
||||||
if (req.originalUrl !== '/health') {
|
if (req.originalUrl !== '/health') {
|
||||||
// 避免健康检查日志过多
|
if (isDebugRoute) {
|
||||||
|
logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||||
|
} else {
|
||||||
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
const duration = Date.now() - start
|
const duration = Date.now() - start
|
||||||
@@ -1097,8 +1802,15 @@ const requestLogger = (req, res, next) => {
|
|||||||
logMetadata
|
logMetadata
|
||||||
)
|
)
|
||||||
} else if (req.originalUrl !== '/health') {
|
} else if (req.originalUrl !== '/health') {
|
||||||
|
if (isDebugRoute) {
|
||||||
|
logger.debug(
|
||||||
|
`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
|
||||||
|
logMetadata
|
||||||
|
)
|
||||||
|
} else {
|
||||||
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
|
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// API Key相关日志
|
// API Key相关日志
|
||||||
if (req.apiKey) {
|
if (req.apiKey) {
|
||||||
@@ -1339,7 +2051,8 @@ const globalRateLimit = async (req, res, next) =>
|
|||||||
|
|
||||||
// 📊 请求大小限制中间件
|
// 📊 请求大小限制中间件
|
||||||
const requestSizeLimit = (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 || '100', 10)
|
||||||
|
const maxSize = MAX_SIZE_MB * 1024 * 1024
|
||||||
const contentLength = parseInt(req.headers['content-length'] || '0')
|
const contentLength = parseInt(req.headers['content-length'] || '0')
|
||||||
|
|
||||||
if (contentLength > maxSize) {
|
if (contentLength > maxSize) {
|
||||||
@@ -1347,7 +2060,7 @@ const requestSizeLimit = (req, res, next) => {
|
|||||||
return res.status(413).json({
|
return res.status(413).json({
|
||||||
error: 'Payload Too Large',
|
error: 'Payload Too Large',
|
||||||
message: 'Request body size exceeds limit',
|
message: 'Request body size exceeds limit',
|
||||||
limit: '10MB'
|
limit: `${MAX_SIZE_MB}MB`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1121
src/models/redis.js
1121
src/models/redis.js
File diff suppressed because it is too large
Load Diff
214
src/routes/admin/accountBalance.js
Normal file
214
src/routes/admin/accountBalance.js
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const accountBalanceService = require('../../services/accountBalanceService')
|
||||||
|
const balanceScriptService = require('../../services/balanceScriptService')
|
||||||
|
const { isBalanceScriptEnabled } = require('../../utils/featureFlags')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const ensureValidPlatform = (rawPlatform) => {
|
||||||
|
const normalized = accountBalanceService.normalizePlatform(rawPlatform)
|
||||||
|
if (!normalized) {
|
||||||
|
return { ok: false, status: 400, error: '缺少 platform 参数' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = accountBalanceService.getSupportedPlatforms()
|
||||||
|
if (!supported.includes(normalized)) {
|
||||||
|
return { ok: false, status: 400, error: `不支持的平台: ${normalized}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, platform: normalized }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 获取账户余额(默认本地统计优先,可选触发 Provider)
|
||||||
|
// GET /admin/accounts/:accountId/balance?platform=xxx&queryApi=false
|
||||||
|
router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform, queryApi } = req.query
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
const balance = await accountBalanceService.getAccountBalance(accountId, valid.platform, {
|
||||||
|
queryApi
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!balance) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(balance)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取账户余额失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2) 强制刷新账户余额(强制触发查询:优先脚本;Provider 仅为降级)
|
||||||
|
// POST /admin/accounts/:accountId/balance/refresh
|
||||||
|
// Body: { platform: 'xxx' }
|
||||||
|
router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform } = req.body || {}
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`手动刷新余额: ${valid.platform}:${accountId}`)
|
||||||
|
|
||||||
|
const balance = await accountBalanceService.refreshAccountBalance(accountId, valid.platform)
|
||||||
|
if (!balance) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(balance)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('刷新账户余额失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3) 批量获取平台所有账户余额
|
||||||
|
// GET /admin/accounts/balance/platform/:platform?queryApi=false
|
||||||
|
router.get('/accounts/balance/platform/:platform', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform } = req.params
|
||||||
|
const { queryApi } = req.query
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await accountBalanceService.getAllAccountsBalance(valid.platform, { queryApi })
|
||||||
|
|
||||||
|
return res.json({ success: true, data: balances })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('批量获取余额失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4) 获取余额汇总(Dashboard 用)
|
||||||
|
// GET /admin/accounts/balance/summary
|
||||||
|
router.get('/accounts/balance/summary', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const summary = await accountBalanceService.getBalanceSummary()
|
||||||
|
return res.json({ success: true, data: summary })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取余额汇总失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5) 清除缓存
|
||||||
|
// DELETE /admin/accounts/:accountId/balance/cache?platform=xxx
|
||||||
|
router.delete('/accounts/:accountId/balance/cache', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform } = req.query
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
await accountBalanceService.clearCache(accountId, valid.platform)
|
||||||
|
|
||||||
|
return res.json({ success: true, message: '缓存已清除' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('清除缓存失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 6) 获取/保存/测试余额脚本配置(单账户)
|
||||||
|
router.get('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform } = req.query
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await accountBalanceService.redis.getBalanceScriptConfig(
|
||||||
|
valid.platform,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
return res.json({ success: true, data: config || null })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取余额脚本配置失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform } = req.query
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = req.body || {}
|
||||||
|
await accountBalanceService.redis.setBalanceScriptConfig(valid.platform, accountId, payload)
|
||||||
|
return res.json({ success: true, data: payload })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('保存余额脚本配置失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform } = req.query
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBalanceScriptEnabled()) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = req.body || {}
|
||||||
|
const { scriptBody } = payload
|
||||||
|
if (!scriptBody) {
|
||||||
|
return res.status(400).json({ success: false, error: '脚本内容不能为空' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await balanceScriptService.execute({
|
||||||
|
scriptBody,
|
||||||
|
timeoutSeconds: payload.timeoutSeconds || 10,
|
||||||
|
variables: {
|
||||||
|
baseUrl: payload.baseUrl || '',
|
||||||
|
apiKey: payload.apiKey || '',
|
||||||
|
token: payload.token || '',
|
||||||
|
accountId,
|
||||||
|
platform: valid.platform,
|
||||||
|
extra: payload.extra || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('测试余额脚本失败', error)
|
||||||
|
return res.status(400).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -8,6 +8,43 @@ const config = require('../../../config/config')
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 有效的权限值列表
|
||||||
|
const VALID_PERMISSIONS = ['claude', 'gemini', 'openai', 'droid']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证权限数组格式
|
||||||
|
* @param {any} permissions - 权限值(可以是数组或其他)
|
||||||
|
* @returns {string|null} - 返回错误消息,null 表示验证通过
|
||||||
|
*/
|
||||||
|
function validatePermissions(permissions) {
|
||||||
|
// 空值或未定义表示全部服务
|
||||||
|
if (permissions === undefined || permissions === null || permissions === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// 兼容旧格式字符串
|
||||||
|
if (typeof permissions === 'string') {
|
||||||
|
if (permissions === 'all' || VALID_PERMISSIONS.includes(permissions)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `Invalid permissions value. Must be an array of: ${VALID_PERMISSIONS.join(', ')}`
|
||||||
|
}
|
||||||
|
// 新格式数组
|
||||||
|
if (Array.isArray(permissions)) {
|
||||||
|
// 空数组表示全部服务
|
||||||
|
if (permissions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// 验证数组中的每个值
|
||||||
|
for (const perm of permissions) {
|
||||||
|
if (!VALID_PERMISSIONS.includes(perm)) {
|
||||||
|
return `Invalid permission value "${perm}". Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
||||||
|
}
|
||||||
|
|
||||||
// 👥 用户管理 (用于API Key分配)
|
// 👥 用户管理 (用于API Key分配)
|
||||||
|
|
||||||
// 获取所有用户列表(用于API Key分配)
|
// 获取所有用户列表(用于API Key分配)
|
||||||
@@ -919,6 +956,86 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
// 去重(避免日数据和月数据重复计算)
|
// 去重(避免日数据和月数据重复计算)
|
||||||
const uniqueKeys = [...new Set(allKeys)]
|
const uniqueKeys = [...new Set(allKeys)]
|
||||||
|
|
||||||
|
// 获取实时限制数据(窗口数据不受时间范围筛选影响,始终获取当前窗口状态)
|
||||||
|
let dailyCost = 0
|
||||||
|
let currentWindowCost = 0
|
||||||
|
let windowRemainingSeconds = null
|
||||||
|
let windowStartTime = null
|
||||||
|
let windowEndTime = null
|
||||||
|
let allTimeCost = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先获取 API Key 配置,判断是否需要查询限制相关数据
|
||||||
|
const apiKey = await redis.getApiKey(keyId)
|
||||||
|
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
|
||||||
|
const dailyCostLimit = parseFloat(apiKey?.dailyCostLimit) || 0
|
||||||
|
const totalCostLimit = parseFloat(apiKey?.totalCostLimit) || 0
|
||||||
|
|
||||||
|
// 只在启用了每日费用限制时查询
|
||||||
|
if (dailyCostLimit > 0) {
|
||||||
|
dailyCost = await redis.getDailyCost(keyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在启用了总费用限制时查询
|
||||||
|
if (totalCostLimit > 0) {
|
||||||
|
const totalCostKey = `usage:cost:total:${keyId}`
|
||||||
|
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}`
|
||||||
|
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||||||
|
|
||||||
|
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
||||||
|
|
||||||
|
// 获取窗口开始时间和计算剩余时间
|
||||||
|
const windowStart = await client.get(windowStartKey)
|
||||||
|
if (windowStart) {
|
||||||
|
const now = Date.now()
|
||||||
|
windowStartTime = parseInt(windowStart)
|
||||||
|
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
|
||||||
|
windowEndTime = windowStartTime + windowDuration
|
||||||
|
|
||||||
|
// 如果窗口还有效
|
||||||
|
if (now < windowEndTime) {
|
||||||
|
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
||||||
|
} else {
|
||||||
|
// 窗口已过期
|
||||||
|
windowRemainingSeconds = 0
|
||||||
|
currentWindowCost = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有使用数据,返回零值但包含窗口数据
|
||||||
if (uniqueKeys.length === 0) {
|
if (uniqueKeys.length === 0) {
|
||||||
return {
|
return {
|
||||||
requests: 0,
|
requests: 0,
|
||||||
@@ -928,7 +1045,14 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
cacheCreateTokens: 0,
|
cacheCreateTokens: 0,
|
||||||
cacheReadTokens: 0,
|
cacheReadTokens: 0,
|
||||||
cost: 0,
|
cost: 0,
|
||||||
formattedCost: '$0.00'
|
formattedCost: '$0.00',
|
||||||
|
// 实时限制数据(始终返回,不受时间范围影响)
|
||||||
|
dailyCost,
|
||||||
|
currentWindowCost,
|
||||||
|
windowRemainingSeconds,
|
||||||
|
windowStartTime,
|
||||||
|
windowEndTime,
|
||||||
|
allTimeCost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,12 +1067,10 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
const modelStatsMap = new Map()
|
const modelStatsMap = new Map()
|
||||||
let totalRequests = 0
|
let totalRequests = 0
|
||||||
|
|
||||||
// 用于去重:只统计日数据,避免与月数据重复
|
// 用于去重:先统计月数据,避免与日数据重复
|
||||||
const dailyKeyPattern = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
const dailyKeyPattern = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||||
const monthlyKeyPattern = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
const monthlyKeyPattern = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||||
|
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||||
// 检查是否有日数据
|
|
||||||
const hasDailyData = uniqueKeys.some((key) => dailyKeyPattern.test(key))
|
|
||||||
|
|
||||||
for (let i = 0; i < results.length; i++) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
const [err, data] = results[i]
|
const [err, data] = results[i]
|
||||||
@@ -975,8 +1097,12 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有日数据,则跳过月数据以避免重复
|
// 跳过当前月的月数据
|
||||||
if (hasDailyData && isMonthly) {
|
if (isMonthly && key.includes(`:${currentMonth}`)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 跳过非当前月的日数据
|
||||||
|
if (!isMonthly && !key.includes(`:${currentMonth}-`)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,55 +1155,6 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
|
|
||||||
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||||
|
|
||||||
// 获取实时限制数据
|
|
||||||
let dailyCost = 0
|
|
||||||
let currentWindowCost = 0
|
|
||||||
let windowRemainingSeconds = null
|
|
||||||
let windowStartTime = null
|
|
||||||
let windowEndTime = null
|
|
||||||
let allTimeCost = 0
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取当日费用
|
|
||||||
dailyCost = await redis.getDailyCost(keyId)
|
|
||||||
|
|
||||||
// 获取历史总费用(用于总费用限制进度条,不受时间范围影响)
|
|
||||||
const totalCostKey = `usage:cost:total:${keyId}`
|
|
||||||
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
|
||||||
|
|
||||||
// 获取 API Key 配置信息以判断是否需要窗口数据
|
|
||||||
const apiKey = await redis.getApiKey(keyId)
|
|
||||||
// 显式转换为整数,与 apiStats.js 保持一致,避免字符串比较问题
|
|
||||||
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
|
|
||||||
|
|
||||||
if (rateLimitWindow > 0) {
|
|
||||||
const costCountKey = `rate_limit:cost:${keyId}`
|
|
||||||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
|
||||||
|
|
||||||
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
|
||||||
|
|
||||||
// 获取窗口开始时间和计算剩余时间
|
|
||||||
const windowStart = await client.get(windowStartKey)
|
|
||||||
if (windowStart) {
|
|
||||||
const now = Date.now()
|
|
||||||
windowStartTime = parseInt(windowStart)
|
|
||||||
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
|
|
||||||
windowEndTime = windowStartTime + windowDuration
|
|
||||||
|
|
||||||
// 如果窗口还有效
|
|
||||||
if (now < windowEndTime) {
|
|
||||||
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
|
||||||
} else {
|
|
||||||
// 窗口已过期
|
|
||||||
windowRemainingSeconds = 0
|
|
||||||
currentWindowCost = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requests: totalRequests,
|
requests: totalRequests,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -1342,16 +1419,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证服务权限字段
|
// 验证服务权限字段(支持数组格式)
|
||||||
if (
|
const permissionsError = validatePermissions(permissions)
|
||||||
permissions !== undefined &&
|
if (permissionsError) {
|
||||||
permissions !== null &&
|
return res.status(400).json({ error: permissionsError })
|
||||||
permissions !== '' &&
|
|
||||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newKey = await apiKeyService.generateApiKey({
|
const newKey = await apiKeyService.generateApiKey({
|
||||||
@@ -1441,15 +1512,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
|
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// 验证服务权限字段(支持数组格式)
|
||||||
permissions !== undefined &&
|
const batchPermissionsError = validatePermissions(permissions)
|
||||||
permissions !== null &&
|
if (batchPermissionsError) {
|
||||||
permissions !== '' &&
|
return res.status(400).json({ error: batchPermissionsError })
|
||||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成批量API Keys
|
// 生成批量API Keys
|
||||||
@@ -1552,13 +1618,12 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// 验证服务权限字段(支持数组格式)
|
||||||
updates.permissions !== undefined &&
|
if (updates.permissions !== undefined) {
|
||||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions)
|
const updatePermissionsError = validatePermissions(updates.permissions)
|
||||||
) {
|
if (updatePermissionsError) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ error: updatePermissionsError })
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1833,11 +1898,10 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (permissions !== undefined) {
|
if (permissions !== undefined) {
|
||||||
// 验证权限值
|
// 验证服务权限字段(支持数组格式)
|
||||||
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) {
|
const singlePermissionsError = validatePermissions(permissions)
|
||||||
return res.status(400).json({
|
if (singlePermissionsError) {
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
return res.status(400).json({ error: singlePermissionsError })
|
||||||
})
|
|
||||||
}
|
}
|
||||||
updates.permissions = permissions
|
updates.permissions = permissions
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/routes/admin/balanceScripts.js
Normal file
41
src/routes/admin/balanceScripts.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const balanceScriptService = require('../../services/balanceScriptService')
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 获取全部脚本配置列表
|
||||||
|
router.get('/balance-scripts', authenticateAdmin, (req, res) => {
|
||||||
|
const items = balanceScriptService.listConfigs()
|
||||||
|
return res.json({ success: true, data: items })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取单个脚本配置
|
||||||
|
router.get('/balance-scripts/:name', authenticateAdmin, (req, res) => {
|
||||||
|
const { name } = req.params
|
||||||
|
const config = balanceScriptService.getConfig(name || 'default')
|
||||||
|
return res.json({ success: true, data: config })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 保存脚本配置
|
||||||
|
router.put('/balance-scripts/:name', authenticateAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params
|
||||||
|
const saved = balanceScriptService.saveConfig(name || 'default', req.body || {})
|
||||||
|
return res.json({ success: true, data: saved })
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 测试脚本(不落库)
|
||||||
|
router.post('/balance-scripts/:name/test', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params
|
||||||
|
const result = await balanceScriptService.testScript(name || 'default', req.body || {})
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -122,6 +122,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
|||||||
description,
|
description,
|
||||||
region,
|
region,
|
||||||
awsCredentials,
|
awsCredentials,
|
||||||
|
bearerToken,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
priority,
|
priority,
|
||||||
accountType,
|
accountType,
|
||||||
@@ -145,9 +146,9 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证credentialType的有效性
|
// 验证credentialType的有效性
|
||||||
if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) {
|
if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +157,11 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
|||||||
description: description || '',
|
description: description || '',
|
||||||
region: region || 'us-east-1',
|
region: region || 'us-east-1',
|
||||||
awsCredentials,
|
awsCredentials,
|
||||||
|
bearerToken,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
priority: priority || 50,
|
priority: priority || 50,
|
||||||
accountType: accountType || 'shared',
|
accountType: accountType || 'shared',
|
||||||
credentialType: credentialType || 'default'
|
credentialType: credentialType || 'access_key'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -206,10 +208,10 @@ router.put('/:accountId', authenticateAdmin, async (req, res) => {
|
|||||||
// 验证credentialType的有效性
|
// 验证credentialType的有效性
|
||||||
if (
|
if (
|
||||||
mappedUpdates.credentialType &&
|
mappedUpdates.credentialType &&
|
||||||
!['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
!['access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,22 +351,15 @@ router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res)
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 测试Bedrock账户连接
|
// 测试Bedrock账户连接(SSE 流式)
|
||||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { accountId } = req.params
|
const { accountId } = req.params
|
||||||
|
|
||||||
const result = await bedrockAccountService.testAccount(accountId)
|
await bedrockAccountService.testAccountConnection(accountId, res)
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return res.status(500).json({ error: 'Account test failed', message: result.error })
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`)
|
|
||||||
return res.json({ success: true, data: result.data })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to test Bedrock account:', error)
|
logger.error('❌ Failed to test Bedrock account:', error)
|
||||||
return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message })
|
// 错误已在服务层处理,这里仅做日志记录
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const router = express.Router()
|
|||||||
const claudeAccountService = require('../../services/claudeAccountService')
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
const claudeRelayService = require('../../services/claudeRelayService')
|
const claudeRelayService = require('../../services/claudeRelayService')
|
||||||
const accountGroupService = require('../../services/accountGroupService')
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const accountTestSchedulerService = require('../../services/accountTestSchedulerService')
|
||||||
const apiKeyService = require('../../services/apiKeyService')
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
const redis = require('../../models/redis')
|
const redis = require('../../models/redis')
|
||||||
const { authenticateAdmin } = require('../../middleware/auth')
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
@@ -277,7 +278,7 @@ router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req,
|
|||||||
|
|
||||||
logger.info('🍪 Starting Cookie-based OAuth authorization', {
|
logger.info('🍪 Starting Cookie-based OAuth authorization', {
|
||||||
sessionKeyLength: trimmedSessionKey.length,
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
|
||||||
hasProxy: !!proxy
|
hasProxy: !!proxy
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -326,7 +327,7 @@ router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async
|
|||||||
|
|
||||||
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
|
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
|
||||||
sessionKeyLength: trimmedSessionKey.length,
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
|
||||||
hasProxy: !!proxy
|
hasProxy: !!proxy
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -583,7 +584,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
useUnifiedClientId,
|
useUnifiedClientId,
|
||||||
unifiedClientId,
|
unifiedClientId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
extInfo
|
extInfo,
|
||||||
|
maxConcurrency,
|
||||||
|
interceptWarmup
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -628,7 +631,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
|
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
|
||||||
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
|
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
|
||||||
expiresAt: expiresAt || null, // 账户订阅到期时间
|
expiresAt: expiresAt || null, // 账户订阅到期时间
|
||||||
extInfo: extInfo || null
|
extInfo: extInfo || null,
|
||||||
|
maxConcurrency: maxConcurrency || 0, // 账户级串行队列:0=使用全局配置,>0=强制启用
|
||||||
|
interceptWarmup: interceptWarmup === true // 拦截预热请求:默认为false
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组
|
// 如果是分组类型,将账户添加到分组
|
||||||
@@ -903,4 +908,219 @@ router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, r
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 账户定时测试相关端点
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// 获取账户测试历史
|
||||||
|
router.get('/claude-accounts/:accountId/test-history', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const history = await redis.getAccountTestHistory(accountId, 'claude')
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
history
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get test history for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get test history',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取账户定时测试配置
|
||||||
|
router.get('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const testConfig = await redis.getAccountTestConfig(accountId, 'claude')
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
config: testConfig || {
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: '0 8 * * *',
|
||||||
|
model: 'claude-sonnet-4-5-20250929'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get test config for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get test config',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置账户定时测试配置
|
||||||
|
router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { enabled, cronExpression, model } = req.body
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证 enabled 参数
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'enabled must be a boolean'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 cronExpression 参数
|
||||||
|
if (!cronExpression || typeof cronExpression !== 'string') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'cronExpression is required and must be a string'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制 cronExpression 长度防止 DoS
|
||||||
|
const MAX_CRON_LENGTH = 100
|
||||||
|
if (cronExpression.length > MAX_CRON_LENGTH) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: `cronExpression too long (max ${MAX_CRON_LENGTH} characters)`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 service 的方法验证 cron 表达式
|
||||||
|
if (!accountTestSchedulerService.validateCronExpression(cronExpression)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: `Invalid cron expression: ${cronExpression}. Format: "minute hour day month weekday" (e.g., "0 8 * * *" for daily at 8:00)`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证模型参数
|
||||||
|
const testModel = model || 'claude-sonnet-4-5-20250929'
|
||||||
|
if (typeof testModel !== 'string' || testModel.length > 256) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'model must be a valid string (max 256 characters)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查账户是否存在
|
||||||
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Account not found',
|
||||||
|
message: `Claude account ${accountId} not found`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
await redis.saveAccountTestConfig(accountId, 'claude', {
|
||||||
|
enabled,
|
||||||
|
cronExpression,
|
||||||
|
model: testModel
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`📝 Updated test config for Claude account ${accountId}: enabled=${enabled}, cronExpression=${cronExpression}, model=${testModel}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Test config updated successfully',
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
config: { enabled, cronExpression, model: testModel }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to update test config for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to update test config',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动触发账户测试(非流式,返回JSON结果)
|
||||||
|
router.post('/claude-accounts/:accountId/test-sync', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查账户是否存在
|
||||||
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Account not found',
|
||||||
|
message: `Claude account ${accountId} not found`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🧪 Manual sync test triggered for Claude account: ${accountId}`)
|
||||||
|
|
||||||
|
// 执行测试
|
||||||
|
const testResult = await claudeRelayService.testAccountConnectionSync(accountId)
|
||||||
|
|
||||||
|
// 保存测试结果到历史
|
||||||
|
await redis.saveAccountTestResult(accountId, 'claude', testResult)
|
||||||
|
await redis.setAccountLastTestTime(accountId, 'claude')
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform: 'claude',
|
||||||
|
result: testResult
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to run sync test for account ${accountId}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to run test',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 批量获取多个账户的测试历史
|
||||||
|
router.post('/claude-accounts/batch-test-history', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountIds } = req.body
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!Array.isArray(accountIds) || accountIds.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid parameter',
|
||||||
|
message: 'accountIds must be a non-empty array'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制批量查询数量
|
||||||
|
const limitedIds = accountIds.slice(0, 100)
|
||||||
|
|
||||||
|
const accounts = limitedIds.map((accountId) => ({
|
||||||
|
accountId,
|
||||||
|
platform: 'claude'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const historyMap = await redis.getAccountsTestHistory(accounts)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: historyMap
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get batch test history:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get batch test history',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
dailyQuota,
|
dailyQuota,
|
||||||
quotaResetTime,
|
quotaResetTime,
|
||||||
maxConcurrentTasks,
|
maxConcurrentTasks,
|
||||||
disableAutoProtection
|
disableAutoProtection,
|
||||||
|
interceptWarmup
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
if (!name || !apiUrl || !apiKey) {
|
if (!name || !apiUrl || !apiKey) {
|
||||||
@@ -186,7 +187,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
||||||
? Number(maxConcurrentTasks)
|
? Number(maxConcurrentTasks)
|
||||||
: 0,
|
: 0,
|
||||||
disableAutoProtection: normalizedDisableAutoProtection
|
disableAutoProtection: normalizedDisableAutoProtection,
|
||||||
|
interceptWarmup: interceptWarmup === true || interceptWarmup === 'true'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||||
|
|||||||
239
src/routes/admin/claudeRelayConfig.js
Normal file
239
src/routes/admin/claudeRelayConfig.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* Claude 转发配置 API 路由
|
||||||
|
* 管理全局 Claude Code 限制和会话绑定配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const claudeRelayConfigService = require('../../services/claudeRelayConfigService')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/claude-relay-config
|
||||||
|
* 获取 Claude 转发配置
|
||||||
|
*/
|
||||||
|
router.get('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = await claudeRelayConfigService.getConfig()
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
config
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude relay config:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get configuration',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /admin/claude-relay-config
|
||||||
|
* 更新 Claude 转发配置
|
||||||
|
*/
|
||||||
|
router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
claudeCodeOnlyEnabled,
|
||||||
|
globalSessionBindingEnabled,
|
||||||
|
sessionBindingErrorMessage,
|
||||||
|
sessionBindingTtlDays,
|
||||||
|
userMessageQueueEnabled,
|
||||||
|
userMessageQueueDelayMs,
|
||||||
|
userMessageQueueTimeoutMs,
|
||||||
|
concurrentRequestQueueEnabled,
|
||||||
|
concurrentRequestQueueMaxSize,
|
||||||
|
concurrentRequestQueueMaxSizeMultiplier,
|
||||||
|
concurrentRequestQueueTimeoutMs
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
// 验证输入
|
||||||
|
if (claudeCodeOnlyEnabled !== undefined && typeof claudeCodeOnlyEnabled !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'claudeCodeOnlyEnabled must be a boolean' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
globalSessionBindingEnabled !== undefined &&
|
||||||
|
typeof globalSessionBindingEnabled !== 'boolean'
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'globalSessionBindingEnabled must be a boolean' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionBindingErrorMessage !== undefined) {
|
||||||
|
if (typeof sessionBindingErrorMessage !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'sessionBindingErrorMessage must be a string' })
|
||||||
|
}
|
||||||
|
if (sessionBindingErrorMessage.length > 500) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'sessionBindingErrorMessage must be less than 500 characters' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionBindingTtlDays !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof sessionBindingTtlDays !== 'number' ||
|
||||||
|
sessionBindingTtlDays < 1 ||
|
||||||
|
sessionBindingTtlDays > 365
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'sessionBindingTtlDays must be a number between 1 and 365' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户消息队列配置
|
||||||
|
if (userMessageQueueEnabled !== undefined && typeof userMessageQueueEnabled !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'userMessageQueueEnabled must be a boolean' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userMessageQueueDelayMs !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof userMessageQueueDelayMs !== 'number' ||
|
||||||
|
userMessageQueueDelayMs < 0 ||
|
||||||
|
userMessageQueueDelayMs > 10000
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'userMessageQueueDelayMs must be a number between 0 and 10000' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userMessageQueueTimeoutMs !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof userMessageQueueTimeoutMs !== 'number' ||
|
||||||
|
userMessageQueueTimeoutMs < 1000 ||
|
||||||
|
userMessageQueueTimeoutMs > 300000
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'userMessageQueueTimeoutMs must be a number between 1000 and 300000' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证并发请求排队配置
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if (globalSessionBindingEnabled !== undefined) {
|
||||||
|
updateData.globalSessionBindingEnabled = globalSessionBindingEnabled
|
||||||
|
}
|
||||||
|
if (sessionBindingErrorMessage !== undefined) {
|
||||||
|
updateData.sessionBindingErrorMessage = sessionBindingErrorMessage
|
||||||
|
}
|
||||||
|
if (sessionBindingTtlDays !== undefined) {
|
||||||
|
updateData.sessionBindingTtlDays = sessionBindingTtlDays
|
||||||
|
}
|
||||||
|
if (userMessageQueueEnabled !== undefined) {
|
||||||
|
updateData.userMessageQueueEnabled = userMessageQueueEnabled
|
||||||
|
}
|
||||||
|
if (userMessageQueueDelayMs !== undefined) {
|
||||||
|
updateData.userMessageQueueDelayMs = userMessageQueueDelayMs
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
req.admin?.username || 'unknown'
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Configuration updated successfully',
|
||||||
|
config: updatedConfig
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude relay config:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to update configuration',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/claude-relay-config/session-bindings
|
||||||
|
* 获取会话绑定统计
|
||||||
|
*/
|
||||||
|
router.get('/claude-relay-config/session-bindings', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stats = await claudeRelayConfigService.getSessionBindingStats()
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get session binding stats:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get session binding statistics',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -7,26 +7,40 @@ const express = require('express')
|
|||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const redis = require('../../models/redis')
|
const redis = require('../../models/redis')
|
||||||
const logger = require('../../utils/logger')
|
const logger = require('../../utils/logger')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const { calculateWaitTimeStats } = require('../../utils/statsHelper')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /admin/concurrency
|
* GET /admin/concurrency
|
||||||
* 获取所有并发状态
|
* 获取所有并发状态
|
||||||
*/
|
*/
|
||||||
router.get('/concurrency', async (req, res) => {
|
router.get('/concurrency', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const status = await redis.getAllConcurrencyStatus()
|
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 = {
|
const summary = {
|
||||||
totalKeys: status.length,
|
totalKeys: statusWithQueue.length,
|
||||||
totalActiveRequests: status.reduce((sum, s) => sum + s.activeCount, 0),
|
totalActiveRequests: statusWithQueue.reduce((sum, s) => sum + s.activeCount, 0),
|
||||||
totalExpiredRequests: status.reduce((sum, s) => sum + s.expiredCount, 0)
|
totalExpiredRequests: statusWithQueue.reduce((sum, s) => sum + s.expiredCount, 0),
|
||||||
|
totalQueuedRequests: statusWithQueue.reduce((sum, s) => sum + s.queueCount, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
summary,
|
summary,
|
||||||
concurrencyStatus: status
|
concurrencyStatus: statusWithQueue
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to get concurrency status:', error)
|
logger.error('❌ Failed to get concurrency status:', error)
|
||||||
@@ -39,17 +53,171 @@ router.get('/concurrency', async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /admin/concurrency/:apiKeyId
|
* GET /admin/concurrency-queue/stats
|
||||||
* 获取特定 API Key 的并发状态详情
|
* 获取排队统计信息
|
||||||
*/
|
*/
|
||||||
router.get('/concurrency/:apiKeyId', async (req, res) => {
|
router.get('/concurrency-queue/stats', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { apiKeyId } = req.params
|
// 获取所有有统计数据的 API Key
|
||||||
const status = await redis.getConcurrencyStatus(apiKeyId)
|
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({
|
res.json({
|
||||||
success: true,
|
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) {
|
} catch (error) {
|
||||||
logger.error(`❌ Failed to get concurrency status for ${req.params.apiKeyId}:`, 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
|
* DELETE /admin/concurrency/:apiKeyId
|
||||||
* 强制清理特定 API Key 的并发计数
|
* 强制清理特定 API Key 的并发计数
|
||||||
*/
|
*/
|
||||||
router.delete('/concurrency/:apiKeyId', async (req, res) => {
|
router.delete('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { apiKeyId } = req.params
|
const { apiKeyId } = req.params
|
||||||
const result = await redis.forceClearConcurrency(apiKeyId)
|
const result = await redis.forceClearConcurrency(apiKeyId)
|
||||||
@@ -93,7 +261,7 @@ router.delete('/concurrency/:apiKeyId', async (req, res) => {
|
|||||||
* DELETE /admin/concurrency
|
* DELETE /admin/concurrency
|
||||||
* 强制清理所有并发计数
|
* 强制清理所有并发计数
|
||||||
*/
|
*/
|
||||||
router.delete('/concurrency', async (req, res) => {
|
router.delete('/concurrency', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await redis.forceClearAllConcurrency()
|
const result = await redis.forceClearAllConcurrency()
|
||||||
|
|
||||||
@@ -118,7 +286,7 @@ router.delete('/concurrency', async (req, res) => {
|
|||||||
* POST /admin/concurrency/cleanup
|
* POST /admin/concurrency/cleanup
|
||||||
* 清理过期的并发条目(不影响活跃请求)
|
* 清理过期的并发条目(不影响活跃请求)
|
||||||
*/
|
*/
|
||||||
router.post('/concurrency/cleanup', async (req, res) => {
|
router.post('/concurrency/cleanup', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { apiKeyId } = req.body
|
const { apiKeyId } = req.body
|
||||||
const result = await redis.cleanupExpiredConcurrency(apiKeyId || null)
|
const result = await redis.cleanupExpiredConcurrency(apiKeyId || null)
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ const bedrockAccountService = require('../../services/bedrockAccountService')
|
|||||||
const ccrAccountService = require('../../services/ccrAccountService')
|
const ccrAccountService = require('../../services/ccrAccountService')
|
||||||
const geminiAccountService = require('../../services/geminiAccountService')
|
const geminiAccountService = require('../../services/geminiAccountService')
|
||||||
const droidAccountService = require('../../services/droidAccountService')
|
const droidAccountService = require('../../services/droidAccountService')
|
||||||
const openaiAccountService = require('../../services/openaiAccountService')
|
|
||||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||||
const redis = require('../../models/redis')
|
const redis = require('../../models/redis')
|
||||||
const { authenticateAdmin } = require('../../middleware/auth')
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
const logger = require('../../utils/logger')
|
const logger = require('../../utils/logger')
|
||||||
const CostCalculator = require('../../utils/costCalculator')
|
const CostCalculator = require('../../utils/costCalculator')
|
||||||
const pricingService = require('../../services/pricingService')
|
|
||||||
const config = require('../../../config/config')
|
const config = require('../../../config/config')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|||||||
@@ -11,14 +11,19 @@ const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
|||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
// 🤖 Gemini OAuth 账户管理
|
// 🤖 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
|
// 生成 Gemini OAuth 授权 URL
|
||||||
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { state, proxy } = req.body // 接收代理配置
|
const { state, proxy, oauthProvider } = req.body // 接收代理配置与OAuth Provider
|
||||||
|
|
||||||
// 使用新的 codeassist.google.com 回调地址
|
const redirectUri = getDefaultRedirectUri(oauthProvider)
|
||||||
const redirectUri = 'https://codeassist.google.com/authcode'
|
|
||||||
|
|
||||||
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
|
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
|
||||||
|
|
||||||
@@ -26,8 +31,9 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
|||||||
authUrl,
|
authUrl,
|
||||||
state: authState,
|
state: authState,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
redirectUri: finalRedirectUri
|
redirectUri: finalRedirectUri,
|
||||||
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy)
|
oauthProvider: resolvedOauthProvider
|
||||||
|
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy, oauthProvider)
|
||||||
|
|
||||||
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
|
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
|
||||||
const sessionId = authState
|
const sessionId = authState
|
||||||
@@ -37,6 +43,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
|||||||
redirectUri: finalRedirectUri,
|
redirectUri: finalRedirectUri,
|
||||||
codeVerifier, // 保存 PKCE code verifier
|
codeVerifier, // 保存 PKCE code verifier
|
||||||
proxy: proxy || null, // 保存代理配置
|
proxy: proxy || null, // 保存代理配置
|
||||||
|
oauthProvider: resolvedOauthProvider,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -45,7 +52,8 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
authUrl,
|
authUrl,
|
||||||
sessionId
|
sessionId,
|
||||||
|
oauthProvider: resolvedOauthProvider
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -80,13 +88,14 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
|
|||||||
// 交换 Gemini 授权码
|
// 交换 Gemini 授权码
|
||||||
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { code, sessionId, proxy: requestProxy } = req.body
|
const { code, sessionId, proxy: requestProxy, oauthProvider } = req.body
|
||||||
|
let resolvedOauthProvider = oauthProvider
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return res.status(400).json({ error: 'Authorization code is required' })
|
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 codeVerifier = null
|
||||||
let proxyConfig = null
|
let proxyConfig = null
|
||||||
|
|
||||||
@@ -97,11 +106,16 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
|||||||
const {
|
const {
|
||||||
redirectUri: sessionRedirectUri,
|
redirectUri: sessionRedirectUri,
|
||||||
codeVerifier: sessionCodeVerifier,
|
codeVerifier: sessionCodeVerifier,
|
||||||
proxy
|
proxy,
|
||||||
|
oauthProvider: sessionOauthProvider
|
||||||
} = sessionData
|
} = sessionData
|
||||||
redirectUri = sessionRedirectUri || redirectUri
|
redirectUri = sessionRedirectUri || redirectUri
|
||||||
codeVerifier = sessionCodeVerifier
|
codeVerifier = sessionCodeVerifier
|
||||||
proxyConfig = proxy // 获取代理配置
|
proxyConfig = proxy // 获取代理配置
|
||||||
|
if (!resolvedOauthProvider && sessionOauthProvider) {
|
||||||
|
// 会话里保存的 provider 仅作为兜底
|
||||||
|
resolvedOauthProvider = sessionOauthProvider
|
||||||
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
|
`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,
|
code,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
proxyConfig // 传递代理配置
|
proxyConfig, // 传递代理配置
|
||||||
|
resolvedOauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
// 清理 OAuth 会话
|
// 清理 OAuth 会话
|
||||||
@@ -129,7 +144,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.success('✅ Successfully exchanged Gemini authorization code')
|
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) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
||||||
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
|
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts')
|
|||||||
const droidAccountsRoutes = require('./droidAccounts')
|
const droidAccountsRoutes = require('./droidAccounts')
|
||||||
const dashboardRoutes = require('./dashboard')
|
const dashboardRoutes = require('./dashboard')
|
||||||
const usageStatsRoutes = require('./usageStats')
|
const usageStatsRoutes = require('./usageStats')
|
||||||
|
const accountBalanceRoutes = require('./accountBalance')
|
||||||
const systemRoutes = require('./system')
|
const systemRoutes = require('./system')
|
||||||
const concurrencyRoutes = require('./concurrency')
|
const concurrencyRoutes = require('./concurrency')
|
||||||
|
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||||
|
const syncRoutes = require('./sync')
|
||||||
|
|
||||||
// 挂载所有子路由
|
// 挂载所有子路由
|
||||||
// 使用完整路径的模块(直接挂载到根路径)
|
// 使用完整路径的模块(直接挂载到根路径)
|
||||||
@@ -35,8 +38,11 @@ router.use('/', openaiResponsesAccountsRoutes)
|
|||||||
router.use('/', droidAccountsRoutes)
|
router.use('/', droidAccountsRoutes)
|
||||||
router.use('/', dashboardRoutes)
|
router.use('/', dashboardRoutes)
|
||||||
router.use('/', usageStatsRoutes)
|
router.use('/', usageStatsRoutes)
|
||||||
|
router.use('/', accountBalanceRoutes)
|
||||||
router.use('/', systemRoutes)
|
router.use('/', systemRoutes)
|
||||||
router.use('/', concurrencyRoutes)
|
router.use('/', concurrencyRoutes)
|
||||||
|
router.use('/', claudeRelayConfigRoutes)
|
||||||
|
router.use('/', syncRoutes)
|
||||||
|
|
||||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||||
router.use('/account-groups', accountGroupsRoutes)
|
router.use('/account-groups', accountGroupsRoutes)
|
||||||
|
|||||||
460
src/routes/admin/sync.js
Normal file
460
src/routes/admin/sync.js
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
/**
|
||||||
|
* Admin Routes - Sync / Export (for migration)
|
||||||
|
* Exports account data (including secrets) for safe server-to-server syncing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
|
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||||
|
const openaiAccountService = require('../../services/openaiAccountService')
|
||||||
|
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
|
||||||
|
function toBool(value, defaultValue = false) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
if (value === true || value === 'true') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (value === false || value === 'false') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxy(proxy) {
|
||||||
|
if (!proxy || typeof proxy !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = proxy.protocol || proxy.type || proxy.scheme || ''
|
||||||
|
const host = proxy.host || ''
|
||||||
|
const port = Number(proxy.port || 0)
|
||||||
|
|
||||||
|
if (!protocol || !host || !Number.isFinite(port) || port <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: String(protocol),
|
||||||
|
host: String(host),
|
||||||
|
port,
|
||||||
|
username: proxy.username ? String(proxy.username) : '',
|
||||||
|
password: proxy.password ? String(proxy.password) : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModelMappingFromSupportedModels(supportedModels) {
|
||||||
|
if (!supportedModels) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(supportedModels)) {
|
||||||
|
const mapping = {}
|
||||||
|
for (const model of supportedModels) {
|
||||||
|
if (typeof model === 'string' && model.trim()) {
|
||||||
|
mapping[model.trim()] = model.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(mapping).length ? mapping : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof supportedModels === 'object') {
|
||||||
|
const mapping = {}
|
||||||
|
for (const [from, to] of Object.entries(supportedModels)) {
|
||||||
|
if (typeof from === 'string' && typeof to === 'string' && from.trim() && to.trim()) {
|
||||||
|
mapping[from.trim()] = to.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(mapping).length ? mapping : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseJson(raw, fallback = null) {
|
||||||
|
if (!raw || typeof raw !== 'string') {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (_) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export accounts for migration (includes secrets).
|
||||||
|
// GET /admin/sync/export-accounts?include_secrets=true
|
||||||
|
router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const includeSecrets = toBool(req.query.include_secrets, false)
|
||||||
|
if (!includeSecrets) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'include_secrets_required',
|
||||||
|
message: 'Set include_secrets=true to export secrets'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Claude official OAuth / Setup Token accounts =====
|
||||||
|
const rawClaudeAccounts = await redis.getAllClaudeAccounts()
|
||||||
|
const claudeAccounts = rawClaudeAccounts.map((account) => {
|
||||||
|
// Backward compatible extraction: prefer individual fields, fallback to claudeAiOauth JSON blob.
|
||||||
|
let decryptedClaudeAiOauth = null
|
||||||
|
if (account.claudeAiOauth) {
|
||||||
|
try {
|
||||||
|
const raw = claudeAccountService._decryptSensitiveData(account.claudeAiOauth)
|
||||||
|
decryptedClaudeAiOauth = raw ? JSON.parse(raw) : null
|
||||||
|
} catch (_) {
|
||||||
|
decryptedClaudeAiOauth = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawScopes =
|
||||||
|
account.scopes && account.scopes.trim()
|
||||||
|
? account.scopes
|
||||||
|
: decryptedClaudeAiOauth?.scopes
|
||||||
|
? decryptedClaudeAiOauth.scopes.join(' ')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const scopes = rawScopes && rawScopes.trim() ? rawScopes.trim().split(' ') : []
|
||||||
|
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||||
|
const authType = isOAuth ? 'oauth' : 'setup-token'
|
||||||
|
|
||||||
|
const accessToken =
|
||||||
|
account.accessToken && String(account.accessToken).trim()
|
||||||
|
? claudeAccountService._decryptSensitiveData(account.accessToken)
|
||||||
|
: decryptedClaudeAiOauth?.accessToken || ''
|
||||||
|
|
||||||
|
const refreshToken =
|
||||||
|
account.refreshToken && String(account.refreshToken).trim()
|
||||||
|
? claudeAccountService._decryptSensitiveData(account.refreshToken)
|
||||||
|
: decryptedClaudeAiOauth?.refreshToken || ''
|
||||||
|
|
||||||
|
let expiresAt = null
|
||||||
|
const expiresAtMs = Number.parseInt(account.expiresAt, 10)
|
||||||
|
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0) {
|
||||||
|
expiresAt = new Date(expiresAtMs).toISOString()
|
||||||
|
} else if (decryptedClaudeAiOauth?.expiresAt) {
|
||||||
|
try {
|
||||||
|
expiresAt = new Date(Number(decryptedClaudeAiOauth.expiresAt)).toISOString()
|
||||||
|
} catch (_) {
|
||||||
|
expiresAt = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = account.proxy ? normalizeProxy(safeParseJson(account.proxy)) : null
|
||||||
|
|
||||||
|
// 🔧 Parse subscriptionInfo to extract org_uuid and account_uuid
|
||||||
|
let orgUuid = null
|
||||||
|
let accountUuid = null
|
||||||
|
if (account.subscriptionInfo) {
|
||||||
|
try {
|
||||||
|
const subscriptionInfo = JSON.parse(account.subscriptionInfo)
|
||||||
|
orgUuid = subscriptionInfo.organizationUuid || null
|
||||||
|
accountUuid = subscriptionInfo.accountUuid || null
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 Calculate expires_in from expires_at
|
||||||
|
let expiresIn = null
|
||||||
|
if (expiresAt) {
|
||||||
|
try {
|
||||||
|
const expiresAtTime = new Date(expiresAt).getTime()
|
||||||
|
const nowTime = Date.now()
|
||||||
|
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||||
|
if (diffSeconds > 0) {
|
||||||
|
expiresIn = diffSeconds
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore calculation errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 🔧 Use default expires_in if calculation failed (Anthropic OAuth: 8 hours)
|
||||||
|
if (!expiresIn && isOAuth) {
|
||||||
|
expiresIn = 28800 // 8 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken || undefined,
|
||||||
|
expires_at: expiresAt || undefined,
|
||||||
|
expires_in: expiresIn || undefined,
|
||||||
|
scope: scopes.join(' ') || undefined,
|
||||||
|
token_type: 'Bearer'
|
||||||
|
}
|
||||||
|
// 🔧 Add auth info as top-level credentials fields
|
||||||
|
if (orgUuid) {
|
||||||
|
credentials.org_uuid = orgUuid
|
||||||
|
}
|
||||||
|
if (accountUuid) {
|
||||||
|
credentials.account_uuid = accountUuid
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 Store complete original CRS data in extra
|
||||||
|
const extra = {
|
||||||
|
crs_account_id: account.id,
|
||||||
|
crs_kind: 'claude-account',
|
||||||
|
crs_id: account.id,
|
||||||
|
crs_name: account.name,
|
||||||
|
crs_description: account.description || '',
|
||||||
|
crs_platform: account.platform || 'claude',
|
||||||
|
crs_auth_type: authType,
|
||||||
|
crs_is_active: account.isActive === 'true',
|
||||||
|
crs_schedulable: account.schedulable !== 'false',
|
||||||
|
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
crs_status: account.status || 'active',
|
||||||
|
crs_scopes: scopes,
|
||||||
|
crs_subscription_info: account.subscriptionInfo || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'claude-account',
|
||||||
|
id: account.id,
|
||||||
|
name: account.name,
|
||||||
|
description: account.description || '',
|
||||||
|
platform: account.platform || 'claude',
|
||||||
|
authType,
|
||||||
|
isActive: account.isActive === 'true',
|
||||||
|
schedulable: account.schedulable !== 'false',
|
||||||
|
priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
status: account.status || 'active',
|
||||||
|
proxy,
|
||||||
|
credentials,
|
||||||
|
extra
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Claude Console API Key accounts =====
|
||||||
|
const claudeConsoleSummaries = await claudeConsoleAccountService.getAllAccounts()
|
||||||
|
const claudeConsoleAccounts = []
|
||||||
|
for (const summary of claudeConsoleSummaries) {
|
||||||
|
const full = await claudeConsoleAccountService.getAccount(summary.id)
|
||||||
|
if (!full) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = normalizeProxy(full.proxy)
|
||||||
|
const modelMapping = buildModelMappingFromSupportedModels(full.supportedModels)
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
api_key: full.apiKey,
|
||||||
|
base_url: full.apiUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelMapping) {
|
||||||
|
credentials.model_mapping = modelMapping
|
||||||
|
}
|
||||||
|
|
||||||
|
if (full.userAgent) {
|
||||||
|
credentials.user_agent = full.userAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeConsoleAccounts.push({
|
||||||
|
kind: 'claude-console-account',
|
||||||
|
id: full.id,
|
||||||
|
name: full.name,
|
||||||
|
description: full.description || '',
|
||||||
|
platform: full.platform || 'claude-console',
|
||||||
|
isActive: full.isActive === true,
|
||||||
|
schedulable: full.schedulable !== false,
|
||||||
|
priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
status: full.status || 'active',
|
||||||
|
proxy,
|
||||||
|
maxConcurrentTasks: Number.parseInt(full.maxConcurrentTasks, 10) || 0,
|
||||||
|
credentials,
|
||||||
|
extra: {
|
||||||
|
crs_account_id: full.id,
|
||||||
|
crs_kind: 'claude-console-account',
|
||||||
|
crs_id: full.id,
|
||||||
|
crs_name: full.name,
|
||||||
|
crs_description: full.description || '',
|
||||||
|
crs_platform: full.platform || 'claude-console',
|
||||||
|
crs_is_active: full.isActive === true,
|
||||||
|
crs_schedulable: full.schedulable !== false,
|
||||||
|
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
crs_status: full.status || 'active'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== OpenAI OAuth accounts =====
|
||||||
|
const openaiOAuthAccounts = []
|
||||||
|
{
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const openaiKeys = await client.keys('openai:account:*')
|
||||||
|
for (const key of openaiKeys) {
|
||||||
|
const id = key.split(':').slice(2).join(':')
|
||||||
|
const account = await openaiAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = account.accessToken
|
||||||
|
? openaiAccountService.decrypt(account.accessToken)
|
||||||
|
: ''
|
||||||
|
if (!accessToken) {
|
||||||
|
// Skip broken/legacy records without decryptable token
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes =
|
||||||
|
account.scopes && typeof account.scopes === 'string' && account.scopes.trim()
|
||||||
|
? account.scopes.trim().split(' ')
|
||||||
|
: []
|
||||||
|
|
||||||
|
const proxy = normalizeProxy(account.proxy)
|
||||||
|
|
||||||
|
// 🔧 Calculate expires_in from expires_at
|
||||||
|
let expiresIn = null
|
||||||
|
if (account.expiresAt) {
|
||||||
|
try {
|
||||||
|
const expiresAtTime = new Date(account.expiresAt).getTime()
|
||||||
|
const nowTime = Date.now()
|
||||||
|
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||||
|
if (diffSeconds > 0) {
|
||||||
|
expiresIn = diffSeconds
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore calculation errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 🔧 Use default expires_in if calculation failed (OpenAI OAuth: 10 days)
|
||||||
|
if (!expiresIn) {
|
||||||
|
expiresIn = 864000 // 10 days
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: account.refreshToken || undefined,
|
||||||
|
id_token: account.idToken || undefined,
|
||||||
|
expires_at: account.expiresAt || undefined,
|
||||||
|
expires_in: expiresIn || undefined,
|
||||||
|
scope: scopes.join(' ') || undefined,
|
||||||
|
token_type: 'Bearer'
|
||||||
|
}
|
||||||
|
// 🔧 Add auth info as top-level credentials fields
|
||||||
|
if (account.accountId) {
|
||||||
|
credentials.chatgpt_account_id = account.accountId
|
||||||
|
}
|
||||||
|
if (account.chatgptUserId) {
|
||||||
|
credentials.chatgpt_user_id = account.chatgptUserId
|
||||||
|
}
|
||||||
|
if (account.organizationId) {
|
||||||
|
credentials.organization_id = account.organizationId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 Store complete original CRS data in extra
|
||||||
|
const extra = {
|
||||||
|
crs_account_id: account.id,
|
||||||
|
crs_kind: 'openai-oauth-account',
|
||||||
|
crs_id: account.id,
|
||||||
|
crs_name: account.name,
|
||||||
|
crs_description: account.description || '',
|
||||||
|
crs_platform: account.platform || 'openai',
|
||||||
|
crs_is_active: account.isActive === 'true',
|
||||||
|
crs_schedulable: account.schedulable !== 'false',
|
||||||
|
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
crs_status: account.status || 'active',
|
||||||
|
crs_scopes: scopes,
|
||||||
|
crs_email: account.email || undefined,
|
||||||
|
crs_chatgpt_account_id: account.accountId || undefined,
|
||||||
|
crs_chatgpt_user_id: account.chatgptUserId || undefined,
|
||||||
|
crs_organization_id: account.organizationId || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiOAuthAccounts.push({
|
||||||
|
kind: 'openai-oauth-account',
|
||||||
|
id: account.id,
|
||||||
|
name: account.name,
|
||||||
|
description: account.description || '',
|
||||||
|
platform: account.platform || 'openai',
|
||||||
|
authType: 'oauth',
|
||||||
|
isActive: account.isActive === 'true',
|
||||||
|
schedulable: account.schedulable !== 'false',
|
||||||
|
priority: Number.parseInt(account.priority, 10) || 50,
|
||||||
|
status: account.status || 'active',
|
||||||
|
proxy,
|
||||||
|
credentials,
|
||||||
|
extra
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== OpenAI Responses API Key accounts =====
|
||||||
|
const openaiResponsesAccounts = []
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const openaiResponseKeys = await client.keys('openai_responses_account:*')
|
||||||
|
for (const key of openaiResponseKeys) {
|
||||||
|
const id = key.split(':').slice(1).join(':')
|
||||||
|
const full = await openaiResponsesAccountService.getAccount(id)
|
||||||
|
if (!full) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = normalizeProxy(full.proxy)
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
api_key: full.apiKey,
|
||||||
|
base_url: full.baseApi
|
||||||
|
}
|
||||||
|
|
||||||
|
if (full.userAgent) {
|
||||||
|
credentials.user_agent = full.userAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiResponsesAccounts.push({
|
||||||
|
kind: 'openai-responses-account',
|
||||||
|
id: full.id,
|
||||||
|
name: full.name,
|
||||||
|
description: full.description || '',
|
||||||
|
platform: full.platform || 'openai-responses',
|
||||||
|
isActive: full.isActive === 'true',
|
||||||
|
schedulable: full.schedulable !== 'false',
|
||||||
|
priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
status: full.status || 'active',
|
||||||
|
proxy,
|
||||||
|
credentials,
|
||||||
|
extra: {
|
||||||
|
crs_account_id: full.id,
|
||||||
|
crs_kind: 'openai-responses-account',
|
||||||
|
crs_id: full.id,
|
||||||
|
crs_name: full.name,
|
||||||
|
crs_description: full.description || '',
|
||||||
|
crs_platform: full.platform || 'openai-responses',
|
||||||
|
crs_is_active: full.isActive === 'true',
|
||||||
|
crs_schedulable: full.schedulable !== 'false',
|
||||||
|
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||||
|
crs_status: full.status || 'active'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
claudeAccounts,
|
||||||
|
claudeConsoleAccounts,
|
||||||
|
openaiOAuthAccounts,
|
||||||
|
openaiResponsesAccounts
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to export accounts for sync:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'export_failed',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -8,6 +8,7 @@ const geminiApiAccountService = require('../../services/geminiApiAccountService'
|
|||||||
const openaiAccountService = require('../../services/openaiAccountService')
|
const openaiAccountService = require('../../services/openaiAccountService')
|
||||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||||
const droidAccountService = require('../../services/droidAccountService')
|
const droidAccountService = require('../../services/droidAccountService')
|
||||||
|
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||||
const redis = require('../../models/redis')
|
const redis = require('../../models/redis')
|
||||||
const { authenticateAdmin } = require('../../middleware/auth')
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
const logger = require('../../utils/logger')
|
const logger = require('../../utils/logger')
|
||||||
@@ -25,6 +26,7 @@ const accountTypeNames = {
|
|||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
'gemini-api': 'Gemini API',
|
'gemini-api': 'Gemini API',
|
||||||
droid: 'Droid',
|
droid: 'Droid',
|
||||||
|
bedrock: 'AWS Bedrock',
|
||||||
unknown: '未知渠道'
|
unknown: '未知渠道'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +39,8 @@ const resolveAccountByPlatform = async (accountId, platform) => {
|
|||||||
openai: openaiAccountService,
|
openai: openaiAccountService,
|
||||||
'openai-responses': openaiResponsesAccountService,
|
'openai-responses': openaiResponsesAccountService,
|
||||||
droid: droidAccountService,
|
droid: droidAccountService,
|
||||||
ccr: ccrAccountService
|
ccr: ccrAccountService,
|
||||||
|
bedrock: bedrockAccountService
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform && serviceMap[platform]) {
|
if (platform && serviceMap[platform]) {
|
||||||
@@ -161,7 +164,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
'openai-responses',
|
'openai-responses',
|
||||||
'gemini',
|
'gemini',
|
||||||
'gemini-api',
|
'gemini-api',
|
||||||
'droid'
|
'droid',
|
||||||
|
'bedrock'
|
||||||
]
|
]
|
||||||
if (!allowedPlatforms.includes(platform)) {
|
if (!allowedPlatforms.includes(platform)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -174,7 +178,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
openai: 'openai',
|
openai: 'openai',
|
||||||
'openai-responses': 'openai-responses',
|
'openai-responses': 'openai-responses',
|
||||||
'gemini-api': 'gemini-api',
|
'gemini-api': 'gemini-api',
|
||||||
droid: 'droid'
|
droid: 'droid',
|
||||||
|
bedrock: 'bedrock'
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackModelMap = {
|
const fallbackModelMap = {
|
||||||
@@ -184,7 +189,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
'openai-responses': 'gpt-4o-mini-2024-07-18',
|
'openai-responses': 'gpt-4o-mini-2024-07-18',
|
||||||
gemini: 'gemini-1.5-flash',
|
gemini: 'gemini-1.5-flash',
|
||||||
'gemini-api': 'gemini-2.0-flash',
|
'gemini-api': 'gemini-2.0-flash',
|
||||||
droid: 'unknown'
|
droid: 'unknown',
|
||||||
|
bedrock: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取账户信息以获取创建时间
|
// 获取账户信息以获取创建时间
|
||||||
@@ -215,6 +221,11 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
case 'droid':
|
case 'droid':
|
||||||
accountData = await droidAccountService.getAccount(accountId)
|
accountData = await droidAccountService.getAccount(accountId)
|
||||||
break
|
break
|
||||||
|
case 'bedrock': {
|
||||||
|
const result = await bedrockAccountService.getAccount(accountId)
|
||||||
|
accountData = result?.success ? result.data : null
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountData && accountData.createdAt) {
|
if (accountData && accountData.createdAt) {
|
||||||
@@ -882,7 +893,7 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
|
const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
|
||||||
|
|
||||||
const allowedGroups = ['claude', 'openai', 'gemini', 'droid']
|
const allowedGroups = ['claude', 'openai', 'gemini', 'droid', 'bedrock']
|
||||||
if (!allowedGroups.includes(group)) {
|
if (!allowedGroups.includes(group)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -894,7 +905,8 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
|||||||
claude: 'Claude账户',
|
claude: 'Claude账户',
|
||||||
openai: 'OpenAI账户',
|
openai: 'OpenAI账户',
|
||||||
gemini: 'Gemini账户',
|
gemini: 'Gemini账户',
|
||||||
droid: 'Droid账户'
|
droid: 'Droid账户',
|
||||||
|
bedrock: 'Bedrock账户'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拉取各平台账号列表
|
// 拉取各平台账号列表
|
||||||
@@ -988,6 +1000,18 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
|||||||
platform: 'droid'
|
platform: 'droid'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} else if (group === 'bedrock') {
|
||||||
|
const result = await bedrockAccountService.getAllAccounts()
|
||||||
|
const bedrockAccounts = result?.success ? result.data : []
|
||||||
|
accounts = bedrockAccounts.map((account) => {
|
||||||
|
const id = String(account.id || '')
|
||||||
|
const shortId = id ? id.slice(0, 8) : '未知'
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: account.name || `Bedrock账号 ${shortId}`,
|
||||||
|
platform: 'bedrock'
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accounts || accounts.length === 0) {
|
if (!accounts || accounts.length === 0) {
|
||||||
|
|||||||
@@ -11,7 +11,20 @@ const logger = require('../utils/logger')
|
|||||||
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
|
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||||
|
const claudeAccountService = require('../services/claudeAccountService')
|
||||||
|
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||||
|
const {
|
||||||
|
isWarmupRequest,
|
||||||
|
buildMockWarmupResponse,
|
||||||
|
sendMockWarmupStream
|
||||||
|
} = require('../utils/warmupInterceptor')
|
||||||
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
||||||
|
const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump')
|
||||||
|
const {
|
||||||
|
handleAnthropicMessagesToGemini,
|
||||||
|
handleAnthropicCountTokensToGemini
|
||||||
|
} = require('../services/anthropicGeminiBridgeService')
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||||
@@ -37,21 +50,90 @@ function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '')
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为旧会话(污染的会话)
|
||||||
|
* Claude Code 发送的请求特点:
|
||||||
|
* - messages 数组通常只有 1 个元素
|
||||||
|
* - 历史对话记录嵌套在单个 message 的 content 数组中
|
||||||
|
* - content 数组中包含 <system-reminder> 开头的系统注入内容
|
||||||
|
*
|
||||||
|
* 污染会话的特征:
|
||||||
|
* 1. messages.length > 1
|
||||||
|
* 2. messages.length === 1 但 content 中有多个用户输入
|
||||||
|
* 3. "warmup" 请求:单条简单消息 + 无 tools(真正新会话会带 tools)
|
||||||
|
*
|
||||||
|
* @param {Object} body - 请求体
|
||||||
|
* @returns {boolean} 是否为旧会话
|
||||||
|
*/
|
||||||
|
function isOldSession(body) {
|
||||||
|
const messages = body?.messages
|
||||||
|
const tools = body?.tools
|
||||||
|
|
||||||
|
if (!messages || messages.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 多条消息 = 旧会话
|
||||||
|
if (messages.length > 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 单条消息,分析 content
|
||||||
|
const firstMessage = messages[0]
|
||||||
|
const content = firstMessage?.content
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 content 是字符串,只有一条输入,需要检查 tools
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
// 有 tools = 正常新会话,无 tools = 可疑
|
||||||
|
return !tools || tools.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 content 是数组,统计非 system-reminder 的元素
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const userInputs = content.filter((item) => {
|
||||||
|
if (item.type !== 'text') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const text = item.text || ''
|
||||||
|
// 剔除以 <system-reminder> 开头的
|
||||||
|
return !text.trimStart().startsWith('<system-reminder>')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 多个用户输入 = 旧会话
|
||||||
|
if (userInputs.length > 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warmup 检测:单个消息 + 无 tools = 旧会话
|
||||||
|
if (userInputs.length === 1 && (!tools || tools.length === 0)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 共享的消息处理函数
|
// 🔧 共享的消息处理函数
|
||||||
async function handleMessagesRequest(req, res) {
|
async function handleMessagesRequest(req, res) {
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
// Claude 服务权限校验,阻止未授权的 Key
|
const forcedVendor = req._anthropicVendor || null
|
||||||
if (
|
const requiredService =
|
||||||
req.apiKey.permissions &&
|
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
|
||||||
req.apiKey.permissions !== 'all' &&
|
|
||||||
req.apiKey.permissions !== 'claude'
|
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
|
||||||
) {
|
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
type: 'permission_error',
|
type: 'permission_error',
|
||||||
message: '此 API Key 无权访问 Claude 服务'
|
message:
|
||||||
|
requiredService === 'gemini'
|
||||||
|
? '此 API Key 无权访问 Gemini 服务'
|
||||||
|
: '此 API Key 无权访问 Claude 服务'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -100,6 +182,25 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.api('📥 /v1/messages request received', {
|
||||||
|
model: req.body.model || null,
|
||||||
|
forcedVendor,
|
||||||
|
stream: req.body.stream === true
|
||||||
|
})
|
||||||
|
|
||||||
|
dumpAnthropicMessagesRequest(req, {
|
||||||
|
route: '/v1/messages',
|
||||||
|
forcedVendor,
|
||||||
|
model: req.body?.model || null,
|
||||||
|
stream: req.body?.stream === true
|
||||||
|
})
|
||||||
|
|
||||||
|
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
||||||
|
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
|
||||||
|
const baseModel = (req.body.model || '').trim()
|
||||||
|
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否为流式请求
|
// 检查是否为流式请求
|
||||||
const isStream = req.body.stream === true
|
const isStream = req.body.stream === true
|
||||||
|
|
||||||
@@ -122,12 +223,42 @@ async function handleMessagesRequest(req, res) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (isStream) {
|
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数据
|
// 流式响应 - 只使用官方真实usage数据
|
||||||
res.setHeader('Content-Type', 'text/event-stream')
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
res.setHeader('Cache-Control', 'no-cache')
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
res.setHeader('Connection', 'keep-alive')
|
res.setHeader('Connection', 'keep-alive')
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲
|
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 算法,确保数据立即发送
|
// 禁用 Nagle 算法,确保数据立即发送
|
||||||
if (res.socket && typeof res.socket.setNoDelay === 'function') {
|
if (res.socket && typeof res.socket.setNoDelay === 'function') {
|
||||||
@@ -141,6 +272,56 @@ async function handleMessagesRequest(req, res) {
|
|||||||
// 生成会话哈希用于sticky会话
|
// 生成会话哈希用于sticky会话
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 🔒 全局会话绑定验证
|
||||||
|
let forcedAccount = null
|
||||||
|
let needSessionBinding = false
|
||||||
|
let originalSessionIdForBinding = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
|
||||||
|
|
||||||
|
if (globalBindingEnabled) {
|
||||||
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
|
|
||||||
|
if (originalSessionId) {
|
||||||
|
const validation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
req.body,
|
||||||
|
originalSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
logger.api(
|
||||||
|
`❌ Session binding validation failed: ${validation.code} for session ${originalSessionId}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: validation.error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已有绑定,使用绑定的账户
|
||||||
|
if (validation.binding) {
|
||||||
|
forcedAccount = validation.binding
|
||||||
|
logger.api(
|
||||||
|
`🔗 Using bound account for session ${originalSessionId}: ${forcedAccount.accountId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记需要在调度成功后建立绑定
|
||||||
|
if (validation.isNewSession) {
|
||||||
|
needSessionBinding = true
|
||||||
|
originalSessionIdForBinding = originalSessionId
|
||||||
|
logger.api(`📝 New session detected, will create binding: ${originalSessionId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error in global session binding check:', error)
|
||||||
|
// 配置服务出错时不阻断请求
|
||||||
|
}
|
||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 使用统一调度选择账号(传递请求的模型)
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model
|
||||||
let accountId
|
let accountId
|
||||||
@@ -149,10 +330,21 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
requestedModel
|
requestedModel,
|
||||||
|
forcedAccount
|
||||||
)
|
)
|
||||||
;({ accountId, accountType } = selection)
|
;({ accountId, accountType } = selection)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 处理会话绑定账户不可用的错误
|
||||||
|
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
|
||||||
|
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
||||||
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
||||||
error.rateLimitEndAt
|
error.rateLimitEndAt
|
||||||
@@ -170,14 +362,72 @@ async function handleMessagesRequest(req, res) {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔗 在成功调度后建立会话绑定(仅 claude-official 类型)
|
||||||
|
// claude-official 只接受:1) 新会话 2) 已绑定的会话
|
||||||
|
if (
|
||||||
|
needSessionBinding &&
|
||||||
|
originalSessionIdForBinding &&
|
||||||
|
accountId &&
|
||||||
|
accountType === 'claude-official'
|
||||||
|
) {
|
||||||
|
// 🚫 检测旧会话(污染的会话)
|
||||||
|
if (isOldSession(req.body)) {
|
||||||
|
const cfg = await claudeRelayConfigService.getConfig()
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Old session rejected: sessionId=${originalSessionIdForBinding}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建绑定
|
||||||
|
try {
|
||||||
|
await claudeRelayConfigService.setOriginalSessionBinding(
|
||||||
|
originalSessionIdForBinding,
|
||||||
|
accountId,
|
||||||
|
accountType
|
||||||
|
)
|
||||||
|
} catch (bindingError) {
|
||||||
|
logger.warn(`⚠️ Failed to create session binding:`, bindingError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 预热请求拦截检查(在转发之前)
|
||||||
|
if (accountType === 'claude-official' || accountType === 'claude-console') {
|
||||||
|
const account =
|
||||||
|
accountType === 'claude-official'
|
||||||
|
? await claudeAccountService.getAccount(accountId)
|
||||||
|
: await claudeConsoleAccountService.getAccount(accountId)
|
||||||
|
|
||||||
|
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
|
||||||
|
logger.api(`🔥 Warmup request intercepted for account: ${account.name} (${accountId})`)
|
||||||
|
if (isStream) {
|
||||||
|
return sendMockWarmupStream(res, req.body.model)
|
||||||
|
} else {
|
||||||
|
return res.json(buildMockWarmupResponse(req.body.model))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根据账号类型选择对应的转发服务并调用
|
// 根据账号类型选择对应的转发服务并调用
|
||||||
if (accountType === 'claude-official') {
|
if (accountType === 'claude-official') {
|
||||||
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||||
|
// 🧹 内存优化:提取需要的值,避免闭包捕获整个 req 对象
|
||||||
|
const _apiKeyId = req.apiKey.id
|
||||||
|
const _rateLimitInfo = req.rateLimitInfo
|
||||||
|
const _requestBody = req.body // 传递后清除引用
|
||||||
|
const _apiKey = req.apiKey
|
||||||
|
const _headers = req.headers
|
||||||
|
|
||||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||||
req.body,
|
_requestBody,
|
||||||
req.apiKey,
|
_apiKey,
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headers,
|
||||||
(usageData) => {
|
(usageData) => {
|
||||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -227,13 +477,13 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude')
|
.recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, 'claude')
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record stream usage:', error)
|
logger.error('❌ Failed to record stream usage:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfo,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -258,11 +508,18 @@ async function handleMessagesRequest(req, res) {
|
|||||||
)
|
)
|
||||||
} else if (accountType === 'claude-console') {
|
} else if (accountType === 'claude-console') {
|
||||||
// Claude Console账号使用Console转发服务(需要传递accountId)
|
// Claude Console账号使用Console转发服务(需要传递accountId)
|
||||||
|
// 🧹 内存优化:提取需要的值
|
||||||
|
const _apiKeyIdConsole = req.apiKey.id
|
||||||
|
const _rateLimitInfoConsole = req.rateLimitInfo
|
||||||
|
const _requestBodyConsole = req.body
|
||||||
|
const _apiKeyConsole = req.apiKey
|
||||||
|
const _headersConsole = req.headers
|
||||||
|
|
||||||
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
||||||
req.body,
|
_requestBodyConsole,
|
||||||
req.apiKey,
|
_apiKeyConsole,
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersConsole,
|
||||||
(usageData) => {
|
(usageData) => {
|
||||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -313,7 +570,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsageWithDetails(
|
.recordUsageWithDetails(
|
||||||
req.apiKey.id,
|
_apiKeyIdConsole,
|
||||||
usageObject,
|
usageObject,
|
||||||
model,
|
model,
|
||||||
usageAccountId,
|
usageAccountId,
|
||||||
@@ -324,7 +581,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoConsole,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -350,6 +607,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
)
|
)
|
||||||
} else if (accountType === 'bedrock') {
|
} else if (accountType === 'bedrock') {
|
||||||
// Bedrock账号使用Bedrock转发服务
|
// Bedrock账号使用Bedrock转发服务
|
||||||
|
// 🧹 内存优化:提取需要的值
|
||||||
|
const _apiKeyIdBedrock = req.apiKey.id
|
||||||
|
const _rateLimitInfoBedrock = req.rateLimitInfo
|
||||||
|
const _requestBodyBedrock = req.body
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
|
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
|
||||||
if (!bedrockAccountResult.success) {
|
if (!bedrockAccountResult.success) {
|
||||||
@@ -357,7 +619,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await bedrockRelayService.handleStreamRequest(
|
const result = await bedrockRelayService.handleStreamRequest(
|
||||||
req.body,
|
_requestBodyBedrock,
|
||||||
bedrockAccountResult.data,
|
bedrockAccountResult.data,
|
||||||
res
|
res
|
||||||
)
|
)
|
||||||
@@ -368,13 +630,21 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const outputTokens = result.usage.output_tokens || 0
|
const outputTokens = result.usage.output_tokens || 0
|
||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId)
|
.recordUsage(
|
||||||
|
_apiKeyIdBedrock,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
result.model,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record Bedrock stream usage:', error)
|
logger.error('❌ Failed to record Bedrock stream usage:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoBedrock,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -399,11 +669,18 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
} else if (accountType === 'ccr') {
|
} else if (accountType === 'ccr') {
|
||||||
// CCR账号使用CCR转发服务(需要传递accountId)
|
// CCR账号使用CCR转发服务(需要传递accountId)
|
||||||
|
// 🧹 内存优化:提取需要的值
|
||||||
|
const _apiKeyIdCcr = req.apiKey.id
|
||||||
|
const _rateLimitInfoCcr = req.rateLimitInfo
|
||||||
|
const _requestBodyCcr = req.body
|
||||||
|
const _apiKeyCcr = req.apiKey
|
||||||
|
const _headersCcr = req.headers
|
||||||
|
|
||||||
await ccrRelayService.relayStreamRequestWithUsageCapture(
|
await ccrRelayService.relayStreamRequestWithUsageCapture(
|
||||||
req.body,
|
_requestBodyCcr,
|
||||||
req.apiKey,
|
_apiKeyCcr,
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersCcr,
|
||||||
(usageData) => {
|
(usageData) => {
|
||||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -453,13 +730,13 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr')
|
.recordUsageWithDetails(_apiKeyIdCcr, usageObject, model, usageAccountId, 'ccr')
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record CCR stream usage:', error)
|
logger.error('❌ Failed to record CCR stream usage:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoCcr,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -494,15 +771,121 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
}, 1000) // 1秒后检查
|
}, 1000) // 1秒后检查
|
||||||
} else {
|
} else {
|
||||||
|
// 🧹 内存优化:提取需要的值,避免后续回调捕获整个 req
|
||||||
|
const _apiKeyIdNonStream = req.apiKey.id
|
||||||
|
const _apiKeyNameNonStream = req.apiKey.name
|
||||||
|
const _rateLimitInfoNonStream = req.rateLimitInfo
|
||||||
|
const _requestBodyNonStream = req.body
|
||||||
|
const _apiKeyNonStream = req.apiKey
|
||||||
|
const _headersNonStream = req.headers
|
||||||
|
|
||||||
|
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
|
||||||
|
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Client disconnected before non-stream request could start for key: ${_apiKeyNameNonStream || 'unknown'}`
|
||||||
|
)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
// 非流式响应 - 只使用官方真实usage数据
|
// 非流式响应 - 只使用官方真实usage数据
|
||||||
logger.info('📄 Starting non-streaming request', {
|
logger.info('📄 Starting non-streaming request', {
|
||||||
apiKeyId: req.apiKey.id,
|
apiKeyId: _apiKeyIdNonStream,
|
||||||
apiKeyName: req.apiKey.name
|
apiKeyName: _apiKeyNameNonStream
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 📊 监听 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会话
|
// 生成会话哈希用于sticky会话
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 🔒 全局会话绑定验证(非流式)
|
||||||
|
let forcedAccountNonStream = null
|
||||||
|
let needSessionBindingNonStream = false
|
||||||
|
let originalSessionIdForBindingNonStream = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
|
||||||
|
|
||||||
|
if (globalBindingEnabled) {
|
||||||
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
|
|
||||||
|
if (originalSessionId) {
|
||||||
|
const validation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
req.body,
|
||||||
|
originalSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
logger.api(
|
||||||
|
`❌ Session binding validation failed (non-stream): ${validation.code} for session ${originalSessionId}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: validation.error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.binding) {
|
||||||
|
forcedAccountNonStream = validation.binding
|
||||||
|
logger.api(
|
||||||
|
`🔗 Using bound account for session (non-stream) ${originalSessionId}: ${forcedAccountNonStream.accountId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.isNewSession) {
|
||||||
|
needSessionBindingNonStream = true
|
||||||
|
originalSessionIdForBindingNonStream = originalSessionId
|
||||||
|
logger.api(
|
||||||
|
`📝 New session detected (non-stream), will create binding: ${originalSessionId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error in global session binding check (non-stream):', error)
|
||||||
|
}
|
||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 使用统一调度选择账号(传递请求的模型)
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model
|
||||||
let accountId
|
let accountId
|
||||||
@@ -511,10 +894,20 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
requestedModel
|
requestedModel,
|
||||||
|
forcedAccountNonStream
|
||||||
)
|
)
|
||||||
;({ accountId, accountType } = selection)
|
;({ accountId, accountType } = selection)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
|
||||||
|
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
||||||
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
||||||
error.rateLimitEndAt
|
error.rateLimitEndAt
|
||||||
@@ -527,6 +920,55 @@ async function handleMessagesRequest(req, res) {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型)
|
||||||
|
// claude-official 只接受:1) 新会话 2) 已绑定的会话
|
||||||
|
if (
|
||||||
|
needSessionBindingNonStream &&
|
||||||
|
originalSessionIdForBindingNonStream &&
|
||||||
|
accountId &&
|
||||||
|
accountType === 'claude-official'
|
||||||
|
) {
|
||||||
|
// 🚫 检测旧会话(污染的会话)
|
||||||
|
if (isOldSession(req.body)) {
|
||||||
|
const cfg = await claudeRelayConfigService.getConfig()
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Old session rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建绑定
|
||||||
|
try {
|
||||||
|
await claudeRelayConfigService.setOriginalSessionBinding(
|
||||||
|
originalSessionIdForBindingNonStream,
|
||||||
|
accountId,
|
||||||
|
accountType
|
||||||
|
)
|
||||||
|
} catch (bindingError) {
|
||||||
|
logger.warn(`⚠️ Failed to create session binding (non-stream):`, bindingError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 预热请求拦截检查(非流式,在转发之前)
|
||||||
|
if (accountType === 'claude-official' || accountType === 'claude-console') {
|
||||||
|
const account =
|
||||||
|
accountType === 'claude-official'
|
||||||
|
? await claudeAccountService.getAccount(accountId)
|
||||||
|
: await claudeConsoleAccountService.getAccount(accountId)
|
||||||
|
|
||||||
|
if (account?.interceptWarmup === 'true' && isWarmupRequest(_requestBodyNonStream)) {
|
||||||
|
logger.api(
|
||||||
|
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
|
||||||
|
)
|
||||||
|
return res.json(buildMockWarmupResponse(_requestBodyNonStream.model))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根据账号类型选择对应的转发服务
|
// 根据账号类型选择对应的转发服务
|
||||||
let response
|
let response
|
||||||
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
||||||
@@ -536,11 +978,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
if (accountType === 'claude-official') {
|
if (accountType === 'claude-official') {
|
||||||
// 官方Claude账号使用原有的转发服务
|
// 官方Claude账号使用原有的转发服务
|
||||||
response = await claudeRelayService.relayRequest(
|
response = await claudeRelayService.relayRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
req.apiKey,
|
_apiKeyNonStream,
|
||||||
req,
|
req, // clientRequest 用于断开检测,保留但服务层已优化
|
||||||
res,
|
res,
|
||||||
req.headers
|
_headersNonStream
|
||||||
)
|
)
|
||||||
} else if (accountType === 'claude-console') {
|
} else if (accountType === 'claude-console') {
|
||||||
// Claude Console账号使用Console转发服务
|
// Claude Console账号使用Console转发服务
|
||||||
@@ -548,11 +990,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
|
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
|
||||||
)
|
)
|
||||||
response = await claudeConsoleRelayService.relayRequest(
|
response = await claudeConsoleRelayService.relayRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
req.apiKey,
|
_apiKeyNonStream,
|
||||||
req,
|
req, // clientRequest 保留用于断开检测
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersNonStream,
|
||||||
accountId
|
accountId
|
||||||
)
|
)
|
||||||
} else if (accountType === 'bedrock') {
|
} else if (accountType === 'bedrock') {
|
||||||
@@ -564,9 +1006,9 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await bedrockRelayService.handleNonStreamRequest(
|
const result = await bedrockRelayService.handleNonStreamRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
bedrockAccountResult.data,
|
bedrockAccountResult.data,
|
||||||
req.headers
|
_headersNonStream
|
||||||
)
|
)
|
||||||
|
|
||||||
// 构建标准响应格式
|
// 构建标准响应格式
|
||||||
@@ -596,11 +1038,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
// CCR账号使用CCR转发服务
|
// CCR账号使用CCR转发服务
|
||||||
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
|
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
|
||||||
response = await ccrRelayService.relayRequest(
|
response = await ccrRelayService.relayRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
req.apiKey,
|
_apiKeyNonStream,
|
||||||
req,
|
req, // clientRequest 保留用于断开检测
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersNonStream,
|
||||||
accountId
|
accountId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -611,6 +1053,15 @@ async function handleMessagesRequest(req, res) {
|
|||||||
bodyLength: response.body ? response.body.length : 0
|
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)
|
res.status(response.statusCode)
|
||||||
|
|
||||||
// 设置响应头,避免 Content-Length 和 Transfer-Encoding 冲突
|
// 设置响应头,避免 Content-Length 和 Transfer-Encoding 冲突
|
||||||
@@ -640,14 +1091,14 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
||||||
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
||||||
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
// 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 rawModel = jsonData.model || _requestBodyNonStream.model || 'unknown'
|
||||||
const { baseModel } = parseVendorPrefixedModel(rawModel)
|
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
|
||||||
const model = baseModel || rawModel
|
const model = usageBaseModel || rawModel
|
||||||
|
|
||||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||||
const { accountId: responseAccountId } = response
|
const { accountId: responseAccountId } = response
|
||||||
await apiKeyService.recordUsage(
|
await apiKeyService.recordUsage(
|
||||||
req.apiKey.id,
|
_apiKeyIdNonStream,
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
cacheCreateTokens,
|
cacheCreateTokens,
|
||||||
@@ -657,7 +1108,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await queueRateLimitUpdate(
|
await queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoNonStream,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -676,10 +1127,12 @@ async function handleMessagesRequest(req, res) {
|
|||||||
logger.warn('⚠️ No usage data found in Claude API JSON response')
|
logger.warn('⚠️ No usage data found in Claude API JSON response')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用 Express 内建的 res.json() 发送响应(简单可靠)
|
||||||
res.json(jsonData)
|
res.json(jsonData)
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message)
|
logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message)
|
||||||
logger.info('📄 Raw response body:', response.body)
|
logger.info('📄 Raw response body:', response.body)
|
||||||
|
// 使用 Express 内建的 res.send() 发送响应(简单可靠)
|
||||||
res.send(response.body)
|
res.send(response.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,6 +1269,65 @@ router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest)
|
|||||||
// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini
|
// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini
|
||||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Claude Code / Anthropic baseUrl 的分流:/antigravity/api/v1/models 返回 Antigravity 实时模型列表
|
||||||
|
//(通过 v1internal:fetchAvailableModels),避免依赖静态 modelService 列表。
|
||||||
|
const forcedVendor = req._anthropicVendor || null
|
||||||
|
if (forcedVendor === 'antigravity') {
|
||||||
|
if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'permission_error',
|
||||||
|
message: '此 API Key 无权访问 Gemini 服务'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||||
|
const geminiAccountService = require('../services/geminiAccountService')
|
||||||
|
|
||||||
|
let accountSelection
|
||||||
|
try {
|
||||||
|
accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||||
|
req.apiKey,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
{ oauthProvider: 'antigravity' }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to select Gemini OAuth account (antigravity models):', error)
|
||||||
|
return res.status(503).json({ error: 'No available Gemini OAuth accounts' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await geminiAccountService.getAccount(accountSelection.accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(503).json({ error: 'Gemini OAuth account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let proxyConfig = null
|
||||||
|
if (account.proxy) {
|
||||||
|
try {
|
||||||
|
proxyConfig =
|
||||||
|
typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to parse proxy configuration:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||||
|
account.accessToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.refreshToken
|
||||||
|
)
|
||||||
|
|
||||||
|
// 可选:根据 API Key 的模型限制过滤(黑名单语义)
|
||||||
|
let filteredModels = models
|
||||||
|
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
|
||||||
|
filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ object: 'list', data: filteredModels })
|
||||||
|
}
|
||||||
|
|
||||||
const modelService = require('../services/modelService')
|
const modelService = require('../services/modelService')
|
||||||
|
|
||||||
// 从 modelService 获取所有支持的模型
|
// 从 modelService 获取所有支持的模型
|
||||||
@@ -952,20 +1464,62 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
|
|||||||
|
|
||||||
// 🔢 Token计数端点 - count_tokens beta API
|
// 🔢 Token计数端点 - count_tokens beta API
|
||||||
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
||||||
// 检查权限
|
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
||||||
if (
|
const forcedVendor = req._anthropicVendor || null
|
||||||
req.apiKey.permissions &&
|
const requiredService =
|
||||||
req.apiKey.permissions !== 'all' &&
|
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
|
||||||
req.apiKey.permissions !== 'claude'
|
|
||||||
) {
|
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
type: 'permission_error',
|
type: 'permission_error',
|
||||||
message: 'This API key does not have permission to access Claude'
|
message:
|
||||||
|
requiredService === 'gemini'
|
||||||
|
? 'This API key does not have permission to access Gemini'
|
||||||
|
: 'This API key does not have permission to access Claude'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requiredService === 'gemini') {
|
||||||
|
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔗 会话绑定验证(与 messages 端点保持一致)
|
||||||
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
|
const sessionValidation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
req.body,
|
||||||
|
originalSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!sessionValidation.valid) {
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Session binding validation failed (count_tokens): ${sessionValidation.code} for session ${originalSessionId}`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: sessionValidation.error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔗 检测旧会话(污染的会话)- 仅对需要绑定的新会话检查
|
||||||
|
if (sessionValidation.isNewSession && originalSessionId) {
|
||||||
|
if (isOldSession(req.body)) {
|
||||||
|
const cfg = await claudeRelayConfigService.getConfig()
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Old session rejected (count_tokens): sessionId=${originalSessionId}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
|
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
|
||||||
|
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
@@ -973,9 +1527,6 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
|||||||
const maxAttempts = 2
|
const maxAttempts = 2
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
|
|
||||||
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
|
|
||||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
|
||||||
|
|
||||||
const processRequest = async () => {
|
const processRequest = async () => {
|
||||||
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
@@ -1171,5 +1722,10 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Claude Code 客户端遥测端点 - 返回成功响应避免 404 日志
|
||||||
|
router.post('/api/event_logging/batch', (req, res) => {
|
||||||
|
res.status(200).json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
module.exports.handleMessagesRequest = handleMessagesRequest
|
module.exports.handleMessagesRequest = handleMessagesRequest
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
restrictedModels,
|
restrictedModels,
|
||||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||||
allowedClients,
|
allowedClients,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions,
|
||||||
// 添加激活相关字段
|
// 添加激活相关字段
|
||||||
expirationMode: keyData.expirationMode || 'fixed',
|
expirationMode: keyData.expirationMode || 'fixed',
|
||||||
isActivated: keyData.isActivated === 'true',
|
isActivated: keyData.isActivated === 'true',
|
||||||
@@ -206,14 +206,24 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
// 获取验证结果中的完整keyData(包含isActive状态和cost信息)
|
// 获取验证结果中的完整keyData(包含isActive状态和cost信息)
|
||||||
const fullKeyData = keyData
|
const fullKeyData = keyData
|
||||||
|
|
||||||
// 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算)
|
// 🔧 FIX: 使用 allTimeCost 而不是扫描月度键
|
||||||
|
// 计算总费用 - 优先使用持久化的总费用计数器
|
||||||
let totalCost = 0
|
let totalCost = 0
|
||||||
let formattedCost = '$0.000000'
|
let formattedCost = '$0.000000'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
|
|
||||||
// 获取所有月度模型统计(与model-stats接口相同的逻辑)
|
// 读取累积的总费用(没有 TTL 的持久键)
|
||||||
|
const totalCostKey = `usage:cost:total:${keyId}`
|
||||||
|
const allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
||||||
|
|
||||||
|
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 allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
|
||||||
const modelUsageMap = new Map()
|
const modelUsageMap = new Map()
|
||||||
|
|
||||||
@@ -272,8 +282,9 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
formattedCost = CostCalculator.formatCost(totalCost)
|
formattedCost = CostCalculator.formatCost(totalCost)
|
||||||
|
}
|
||||||
} catch (error) {
|
} 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) {
|
if (fullKeyData.usage?.total?.allTokens > 0) {
|
||||||
const usage = fullKeyData.usage.total
|
const usage = fullKeyData.usage.total
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ const { authenticateApiKey } = require('../middleware/auth')
|
|||||||
const droidRelayService = require('../services/droidRelayService')
|
const droidRelayService = require('../services/droidRelayService')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
function hasDroidPermission(apiKeyData) {
|
function hasDroidPermission(apiKeyData) {
|
||||||
const permissions = apiKeyData?.permissions || 'all'
|
return apiKeyService.hasPermission(apiKeyData?.permissions, 'droid')
|
||||||
return permissions === 'all' || permissions === 'droid'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const {
|
|||||||
handleStreamGenerateContent,
|
handleStreamGenerateContent,
|
||||||
handleLoadCodeAssist,
|
handleLoadCodeAssist,
|
||||||
handleOnboardUser,
|
handleOnboardUser,
|
||||||
|
handleRetrieveUserQuota,
|
||||||
handleCountTokens,
|
handleCountTokens,
|
||||||
handleStandardGenerateContent,
|
handleStandardGenerateContent,
|
||||||
handleStandardStreamGenerateContent,
|
handleStandardStreamGenerateContent,
|
||||||
@@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
|
|||||||
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// v1internal 独有路由(listExperiments)
|
// v1internal 独有路由
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +82,12 @@ router.post(
|
|||||||
handleSimpleEndpoint('listExperiments')
|
handleSimpleEndpoint('listExperiments')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /v1internal:retrieveUserQuota
|
||||||
|
* 获取用户配额信息(Gemini CLI 0.22.2+ 需要)
|
||||||
|
*/
|
||||||
|
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /v1beta/models/:modelName:listExperiments
|
* POST /v1beta/models/:modelName:listExperiments
|
||||||
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const router = express.Router()
|
|||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const { authenticateApiKey } = require('../middleware/auth')
|
const { authenticateApiKey } = require('../middleware/auth')
|
||||||
const claudeRelayService = require('../services/claudeRelayService')
|
const claudeRelayService = require('../services/claudeRelayService')
|
||||||
|
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||||
const openaiToClaude = require('../services/openaiToClaude')
|
const openaiToClaude = require('../services/openaiToClaude')
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||||
@@ -19,8 +20,7 @@ const { getEffectiveModel } = require('../utils/modelHelper')
|
|||||||
|
|
||||||
// 🔧 辅助函数:检查 API Key 权限
|
// 🔧 辅助函数:检查 API Key 权限
|
||||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||||
const permissions = apiKeyData.permissions || 'all'
|
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||||
return permissions === 'all' || permissions === requiredPermission
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||||
@@ -235,7 +235,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
const { accountId } = accountSelection
|
const { accountId, accountType } = accountSelection
|
||||||
|
|
||||||
// 获取该账号存储的 Claude Code headers
|
// 获取该账号存储的 Claude Code headers
|
||||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||||
@@ -265,13 +265,9 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
// 使用转换后的响应流 (根据账户类型选择转发服务)
|
||||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
// 创建 usage 回调函数
|
||||||
claudeRequest,
|
const usageCallback = (usage) => {
|
||||||
apiKeyData,
|
|
||||||
res,
|
|
||||||
claudeCodeHeaders,
|
|
||||||
(usage) => {
|
|
||||||
// 记录使用统计
|
// 记录使用统计
|
||||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||||
const model = usage.model || claudeRequest.model
|
const model = usage.model || claudeRequest.model
|
||||||
@@ -288,7 +284,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
apiKeyData.id,
|
apiKeyData.id,
|
||||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||||
model,
|
model,
|
||||||
accountId
|
accountId,
|
||||||
|
accountType
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record usage:', error)
|
logger.error('❌ Failed to record usage:', error)
|
||||||
@@ -303,27 +300,62 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
cacheReadTokens
|
cacheReadTokens
|
||||||
},
|
},
|
||||||
model,
|
model,
|
||||||
'openai-claude-stream'
|
`openai-${accountType}-stream`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
// 流转换器
|
|
||||||
(() => {
|
// 创建流转换器
|
||||||
// 为每个请求创建独立的会话ID
|
|
||||||
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||||||
return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
const streamTransformer = (chunk) =>
|
||||||
})(),
|
openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
||||||
|
|
||||||
|
// 根据账户类型选择转发服务
|
||||||
|
if (accountType === 'claude-console') {
|
||||||
|
// Claude Console 账户使用 Console 转发服务
|
||||||
|
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
||||||
|
claudeRequest,
|
||||||
|
apiKeyData,
|
||||||
|
res,
|
||||||
|
claudeCodeHeaders,
|
||||||
|
usageCallback,
|
||||||
|
accountId,
|
||||||
|
streamTransformer
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Claude Official 账户使用标准转发服务
|
||||||
|
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||||
|
claudeRequest,
|
||||||
|
apiKeyData,
|
||||||
|
res,
|
||||||
|
claudeCodeHeaders,
|
||||||
|
usageCallback,
|
||||||
|
streamTransformer,
|
||||||
{
|
{
|
||||||
betaHeader:
|
betaHeader:
|
||||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 非流式请求
|
// 非流式请求
|
||||||
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
|
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
|
||||||
|
|
||||||
// 发送请求到 Claude (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
// 根据账户类型选择转发服务
|
||||||
const claudeResponse = await claudeRelayService.relayRequest(
|
let claudeResponse
|
||||||
|
if (accountType === 'claude-console') {
|
||||||
|
// Claude Console 账户使用 Console 转发服务
|
||||||
|
claudeResponse = await claudeConsoleRelayService.relayRequest(
|
||||||
|
claudeRequest,
|
||||||
|
apiKeyData,
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
claudeCodeHeaders,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Claude Official 账户使用标准转发服务
|
||||||
|
claudeResponse = await claudeRelayService.relayRequest(
|
||||||
claudeRequest,
|
claudeRequest,
|
||||||
apiKeyData,
|
apiKeyData,
|
||||||
req,
|
req,
|
||||||
@@ -331,6 +363,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
claudeCodeHeaders,
|
claudeCodeHeaders,
|
||||||
{ betaHeader: 'oauth-2025-04-20' }
|
{ betaHeader: 'oauth-2025-04-20' }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 解析 Claude 响应
|
// 解析 Claude 响应
|
||||||
let claudeData
|
let claudeData
|
||||||
@@ -376,7 +409,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
apiKeyData.id,
|
apiKeyData.id,
|
||||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||||
claudeRequest.model,
|
claudeRequest.model,
|
||||||
accountId
|
accountId,
|
||||||
|
accountType
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record usage:', error)
|
logger.error('❌ Failed to record usage:', error)
|
||||||
@@ -391,7 +425,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
cacheReadTokens
|
cacheReadTokens
|
||||||
},
|
},
|
||||||
claudeRequest.model,
|
claudeRequest.model,
|
||||||
'openai-claude-non-stream'
|
`openai-${accountType}-non-stream`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,8 +436,19 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
|
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||||
|
if (error.message === 'Client disconnected') {
|
||||||
|
logger.info('🔌 OpenAI-Claude stream ended: Client disconnected')
|
||||||
|
} else {
|
||||||
logger.error('❌ OpenAI-Claude request error:', error)
|
logger.error('❌ OpenAI-Claude request error:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
|
||||||
|
if (!res.headersSent) {
|
||||||
|
// 客户端断开使用 499 状态码 (Client Closed Request)
|
||||||
|
if (error.message === 'Client disconnected') {
|
||||||
|
res.status(499).end()
|
||||||
|
} else {
|
||||||
const status = error.status || 500
|
const status = error.status || 500
|
||||||
res.status(status).json({
|
res.status(status).json({
|
||||||
error: {
|
error: {
|
||||||
@@ -412,6 +457,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
code: 'internal_error'
|
code: 'internal_error'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// 清理资源
|
// 清理资源
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const geminiAccountService = require('../services/geminiAccountService')
|
|||||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||||
const { getAvailableModels } = require('../services/geminiRelayService')
|
const { getAvailableModels } = require('../services/geminiRelayService')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
|
|
||||||
// 生成会话哈希
|
// 生成会话哈希
|
||||||
function generateSessionHash(req) {
|
function generateSessionHash(req) {
|
||||||
@@ -19,10 +20,19 @@ function generateSessionHash(req) {
|
|||||||
return crypto.createHash('sha256').update(sessionData).digest('hex')
|
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 权限
|
// 检查 API Key 权限
|
||||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||||
const permissions = apiKeyData.permissions || 'all'
|
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||||
return permissions === 'all' || permissions === requiredPermission
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换 OpenAI 消息格式到 Gemini 格式
|
// 转换 OpenAI 消息格式到 Gemini 格式
|
||||||
@@ -335,21 +345,44 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
account.accessToken,
|
account.accessToken,
|
||||||
account.refreshToken,
|
account.refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
if (actualStream) {
|
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', {
|
logger.info('StreamGenerateContent request', {
|
||||||
model,
|
model,
|
||||||
projectId: account.projectId,
|
projectId,
|
||||||
apiKeyId: apiKeyData.id
|
apiKeyId: apiKeyData.id
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamResponse = await geminiAccountService.generateContentStream(
|
const streamResponse =
|
||||||
|
oauthProvider === 'antigravity'
|
||||||
|
? await geminiAccountService.generateContentStreamAntigravity(
|
||||||
client,
|
client,
|
||||||
{ model, request: geminiRequestBody },
|
{ model, request: geminiRequestBody },
|
||||||
null, // user_prompt_id
|
null, // user_prompt_id
|
||||||
account.projectId, // 使用有权限的项目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
|
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||||
abortController.signal, // 传递中止信号
|
abortController.signal, // 传递中止信号
|
||||||
proxyConfig // 传递代理配置
|
proxyConfig // 传递代理配置
|
||||||
@@ -499,7 +532,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
// 记录使用统计
|
// 记录使用统计
|
||||||
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
||||||
try {
|
try {
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
|
||||||
await apiKeyService.recordUsage(
|
await apiKeyService.recordUsage(
|
||||||
apiKeyData.id,
|
apiKeyData.id,
|
||||||
totalUsage.promptTokenCount || 0,
|
totalUsage.promptTokenCount || 0,
|
||||||
@@ -559,17 +591,38 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
})
|
})
|
||||||
} else {
|
} 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', {
|
logger.info('GenerateContent request', {
|
||||||
model,
|
model,
|
||||||
projectId: account.projectId,
|
projectId,
|
||||||
apiKeyId: apiKeyData.id
|
apiKeyId: apiKeyData.id
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await geminiAccountService.generateContent(
|
const response =
|
||||||
|
oauthProvider === 'antigravity'
|
||||||
|
? await geminiAccountService.generateContentAntigravity(
|
||||||
client,
|
client,
|
||||||
{ model, request: geminiRequestBody },
|
{ model, request: geminiRequestBody },
|
||||||
null, // user_prompt_id
|
null, // user_prompt_id
|
||||||
account.projectId, // 使用有权限的项目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
|
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||||
proxyConfig // 传递代理配置
|
proxyConfig // 传递代理配置
|
||||||
)
|
)
|
||||||
@@ -580,7 +633,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
// 记录使用统计
|
// 记录使用统计
|
||||||
if (openaiResponse.usage) {
|
if (openaiResponse.usage) {
|
||||||
try {
|
try {
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
|
||||||
await apiKeyService.recordUsage(
|
await apiKeyService.recordUsage(
|
||||||
apiKeyData.id,
|
apiKeyData.id,
|
||||||
openaiResponse.usage.prompt_tokens || 0,
|
openaiResponse.usage.prompt_tokens || 0,
|
||||||
@@ -604,7 +656,15 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
|
logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
|
||||||
} catch (error) {
|
} 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) {
|
if (error.status === 429) {
|
||||||
@@ -613,6 +673,12 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
|
||||||
|
if (!res.headersSent) {
|
||||||
|
// 客户端断开使用 499 状态码 (Client Closed Request)
|
||||||
|
if (error.message === 'Client disconnected') {
|
||||||
|
res.status(499).end()
|
||||||
|
} else {
|
||||||
// 返回 OpenAI 格式的错误响应
|
// 返回 OpenAI 格式的错误响应
|
||||||
const status = error.status || 500
|
const status = error.status || 500
|
||||||
const errorResponse = {
|
const errorResponse = {
|
||||||
@@ -622,8 +688,9 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
code: 'internal_error'
|
code: 'internal_error'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(status).json(errorResponse)
|
res.status(status).json(errorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// 清理资源
|
// 清理资源
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
@@ -633,8 +700,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// OpenAI 兼容的模型列表端点
|
// 获取可用模型列表的共享处理器
|
||||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
async function handleGetModels(req, res) {
|
||||||
try {
|
try {
|
||||||
const apiKeyData = req.apiKey
|
const apiKeyData = req.apiKey
|
||||||
|
|
||||||
@@ -665,8 +732,21 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
let models = []
|
let models = []
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
// 获取实际的模型列表
|
// 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性)
|
||||||
models = await getAvailableModels(account.accessToken, account.proxy)
|
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 {
|
} else {
|
||||||
// 返回默认模型列表
|
// 返回默认模型列表
|
||||||
models = [
|
models = [
|
||||||
@@ -679,6 +759,17 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!models || models.length === 0) {
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
id: 'gemini-2.0-flash-exp',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// 如果启用了模型限制,过滤模型列表
|
// 如果启用了模型限制,过滤模型列表
|
||||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||||
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
||||||
@@ -698,8 +789,13 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return undefined
|
}
|
||||||
})
|
|
||||||
|
// OpenAI 兼容的模型列表端点 (带 v1 版)
|
||||||
|
router.get('/v1/models', authenticateApiKey, handleGetModels)
|
||||||
|
|
||||||
|
// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载)
|
||||||
|
router.get('/models', authenticateApiKey, handleGetModels)
|
||||||
|
|
||||||
// OpenAI 兼容的模型详情端点
|
// OpenAI 兼容的模型详情端点
|
||||||
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ function createProxyAgent(proxy) {
|
|||||||
|
|
||||||
// 检查 API Key 是否具备 OpenAI 权限
|
// 检查 API Key 是否具备 OpenAI 权限
|
||||||
function checkOpenAIPermissions(apiKeyData) {
|
function checkOpenAIPermissions(apiKeyData) {
|
||||||
const permissions = apiKeyData?.permissions || 'all'
|
return apiKeyService.hasPermission(apiKeyData?.permissions, 'openai')
|
||||||
return permissions === 'all' || permissions === 'openai'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHeaders(headers = {}) {
|
function normalizeHeaders(headers = {}) {
|
||||||
@@ -275,7 +274,9 @@ const handleResponses = async (req, res) => {
|
|||||||
'text_formatting',
|
'text_formatting',
|
||||||
'truncation',
|
'truncation',
|
||||||
'text',
|
'text',
|
||||||
'service_tier'
|
'service_tier',
|
||||||
|
'prompt_cache_retention',
|
||||||
|
'safety_identifier'
|
||||||
]
|
]
|
||||||
fieldsToRemove.forEach((field) => {
|
fieldsToRemove.forEach((field) => {
|
||||||
delete req.body[field]
|
delete req.body[field]
|
||||||
@@ -905,7 +906,7 @@ router.get('/key-info', authenticateApiKey, async (req, res) => {
|
|||||||
id: keyData.id,
|
id: keyData.id,
|
||||||
name: keyData.name,
|
name: keyData.name,
|
||||||
description: keyData.description,
|
description: keyData.description,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions,
|
||||||
token_limit: keyData.tokenLimit,
|
token_limit: keyData.tokenLimit,
|
||||||
tokens_used: keyData.usage.total.tokens,
|
tokens_used: keyData.usage.total.tokens,
|
||||||
tokens_remaining:
|
tokens_remaining:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const {
|
|||||||
handleStreamGenerateContent: geminiHandleStreamGenerateContent
|
handleStreamGenerateContent: geminiHandleStreamGenerateContent
|
||||||
} = require('../handlers/geminiHandlers')
|
} = require('../handlers/geminiHandlers')
|
||||||
const openaiRoutes = require('./openaiRoutes')
|
const openaiRoutes = require('./openaiRoutes')
|
||||||
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -45,11 +46,11 @@ async function routeToBackend(req, res, requestedModel) {
|
|||||||
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
|
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
|
||||||
|
|
||||||
// 检查权限
|
// 检查权限
|
||||||
const permissions = req.apiKey.permissions || 'all'
|
const { permissions } = req.apiKey
|
||||||
|
|
||||||
if (backend === 'claude') {
|
if (backend === 'claude') {
|
||||||
// Claude 后端:通过 OpenAI 兼容层
|
// Claude 后端:通过 OpenAI 兼容层
|
||||||
if (permissions !== 'all' && permissions !== 'claude') {
|
if (!apiKeyService.hasPermission(permissions, 'claude')) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'This API key does not have permission to access Claude',
|
message: 'This API key does not have permission to access Claude',
|
||||||
@@ -61,7 +62,7 @@ async function routeToBackend(req, res, requestedModel) {
|
|||||||
await handleChatCompletion(req, res, req.apiKey)
|
await handleChatCompletion(req, res, req.apiKey)
|
||||||
} else if (backend === 'openai') {
|
} else if (backend === 'openai') {
|
||||||
// OpenAI 后端
|
// OpenAI 后端
|
||||||
if (permissions !== 'all' && permissions !== 'openai') {
|
if (!apiKeyService.hasPermission(permissions, 'openai')) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'This API key does not have permission to access OpenAI',
|
message: 'This API key does not have permission to access OpenAI',
|
||||||
@@ -73,7 +74,7 @@ async function routeToBackend(req, res, requestedModel) {
|
|||||||
return await openaiRoutes.handleResponses(req, res)
|
return await openaiRoutes.handleResponses(req, res)
|
||||||
} else if (backend === 'gemini') {
|
} else if (backend === 'gemini') {
|
||||||
// Gemini 后端
|
// Gemini 后端
|
||||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
if (!apiKeyService.hasPermission(permissions, 'gemini')) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'This API key does not have permission to access Gemini',
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
|||||||
@@ -164,13 +164,27 @@ router.post('/auth/change-password', async (req, res) => {
|
|||||||
|
|
||||||
// 获取当前会话
|
// 获取当前会话
|
||||||
const sessionData = await redis.getSession(token)
|
const sessionData = await redis.getSession(token)
|
||||||
if (!sessionData) {
|
|
||||||
|
// 🔒 安全修复:检查空对象
|
||||||
|
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Invalid token',
|
error: 'Invalid token',
|
||||||
message: 'Session expired or invalid'
|
message: 'Session expired or invalid'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 安全修复:验证会话完整性
|
||||||
|
if (!sessionData.username || !sessionData.loginTime) {
|
||||||
|
logger.security(
|
||||||
|
`🔒 Invalid session structure in /auth/change-password from ${req.ip || 'unknown'}`
|
||||||
|
)
|
||||||
|
await redis.deleteSession(token)
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid session',
|
||||||
|
message: 'Session data corrupted or incomplete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 获取当前管理员信息
|
// 获取当前管理员信息
|
||||||
const adminData = await redis.getSession('admin_credentials')
|
const adminData = await redis.getSession('admin_credentials')
|
||||||
if (!adminData) {
|
if (!adminData) {
|
||||||
@@ -269,13 +283,25 @@ router.get('/auth/user', async (req, res) => {
|
|||||||
|
|
||||||
// 获取当前会话
|
// 获取当前会话
|
||||||
const sessionData = await redis.getSession(token)
|
const sessionData = await redis.getSession(token)
|
||||||
if (!sessionData) {
|
|
||||||
|
// 🔒 安全修复:检查空对象
|
||||||
|
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Invalid token',
|
error: 'Invalid token',
|
||||||
message: 'Session expired or invalid'
|
message: 'Session expired or invalid'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 安全修复:验证会话完整性
|
||||||
|
if (!sessionData.username || !sessionData.loginTime) {
|
||||||
|
logger.security(`🔒 Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
|
||||||
|
await redis.deleteSession(token)
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid session',
|
||||||
|
message: 'Session data corrupted or incomplete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 获取管理员信息
|
// 获取管理员信息
|
||||||
const adminData = await redis.getSession('admin_credentials')
|
const adminData = await redis.getSession('admin_credentials')
|
||||||
if (!adminData) {
|
if (!adminData) {
|
||||||
@@ -316,13 +342,24 @@ router.post('/auth/refresh', async (req, res) => {
|
|||||||
|
|
||||||
const sessionData = await redis.getSession(token)
|
const sessionData = await redis.getSession(token)
|
||||||
|
|
||||||
if (!sessionData) {
|
// 🔒 安全修复:检查空对象(hgetall 对不存在的 key 返回 {})
|
||||||
|
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Invalid token',
|
error: 'Invalid token',
|
||||||
message: 'Session expired or invalid'
|
message: 'Session expired or invalid'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime)
|
||||||
|
if (!sessionData.username || !sessionData.loginTime) {
|
||||||
|
logger.security(`🔒 Invalid session structure detected from ${req.ip || 'unknown'}`)
|
||||||
|
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid session',
|
||||||
|
message: 'Session data corrupted or incomplete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 更新最后活动时间
|
// 更新最后活动时间
|
||||||
sessionData.lastActivity = new Date().toISOString()
|
sessionData.lastActivity = new Date().toISOString()
|
||||||
await redis.setSession(token, sessionData, config.security.adminSessionTimeout)
|
await redis.setSession(token, sessionData, config.security.adminSessionTimeout)
|
||||||
|
|||||||
789
src/services/accountBalanceService.js
Normal file
789
src/services/accountBalanceService.js
Normal file
@@ -0,0 +1,789 @@
|
|||||||
|
const redis = require('../models/redis')
|
||||||
|
const balanceScriptService = require('./balanceScriptService')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
|
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||||
|
|
||||||
|
class AccountBalanceService {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.redis = options.redis || redis
|
||||||
|
this.logger = options.logger || logger
|
||||||
|
|
||||||
|
this.providers = new Map()
|
||||||
|
|
||||||
|
this.CACHE_TTL_SECONDS = 3600
|
||||||
|
this.LOCAL_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
this.LOW_BALANCE_THRESHOLD = 10
|
||||||
|
this.HIGH_USAGE_THRESHOLD_PERCENT = 90
|
||||||
|
this.DEFAULT_CONCURRENCY = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedPlatforms() {
|
||||||
|
return [
|
||||||
|
'claude',
|
||||||
|
'claude-console',
|
||||||
|
'gemini',
|
||||||
|
'gemini-api',
|
||||||
|
'openai',
|
||||||
|
'openai-responses',
|
||||||
|
'azure_openai',
|
||||||
|
'bedrock',
|
||||||
|
'droid',
|
||||||
|
'ccr'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePlatform(platform) {
|
||||||
|
if (!platform) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = String(platform).trim().toLowerCase()
|
||||||
|
|
||||||
|
// 兼容实施文档与历史命名
|
||||||
|
if (value === 'claude-official') {
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
if (value === 'azure-openai') {
|
||||||
|
return 'azure_openai'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持前端平台键一致
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProvider(platform, provider) {
|
||||||
|
const normalized = this.normalizePlatform(platform)
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('registerProvider: 缺少 platform')
|
||||||
|
}
|
||||||
|
if (!provider || typeof provider.queryBalance !== 'function') {
|
||||||
|
throw new Error(`registerProvider: Provider 无效 (${normalized})`)
|
||||||
|
}
|
||||||
|
this.providers.set(normalized, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountBalance(accountId, platform, options = {}) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||||
|
if (!account) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return await this._getAccountBalanceForAccount(account, normalizedPlatform, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAccountBalance(accountId, platform) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||||
|
if (!account) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this._getAccountBalanceForAccount(account, normalizedPlatform, {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAccountsBalance(platform, options = {}) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const accounts = await this.getAllAccountsByPlatform(normalizedPlatform)
|
||||||
|
const queryApi = this._parseBoolean(options.queryApi) || false
|
||||||
|
const useCache = options.useCache !== false
|
||||||
|
|
||||||
|
const results = await this._mapWithConcurrency(
|
||||||
|
accounts,
|
||||||
|
this.DEFAULT_CONCURRENCY,
|
||||||
|
async (acc) => {
|
||||||
|
try {
|
||||||
|
const balance = await this._getAccountBalanceForAccount(acc, normalizedPlatform, {
|
||||||
|
queryApi,
|
||||||
|
useCache
|
||||||
|
})
|
||||||
|
return { ...balance, name: acc.name || '' }
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`批量获取余额失败: ${normalizedPlatform}:${acc?.id}`, error)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId: acc?.id,
|
||||||
|
platform: normalizedPlatform,
|
||||||
|
balance: null,
|
||||||
|
quota: null,
|
||||||
|
statistics: {},
|
||||||
|
source: 'local',
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
cacheExpiresAt: null,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message || '批量查询失败'
|
||||||
|
},
|
||||||
|
name: acc?.name || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalanceSummary() {
|
||||||
|
const platforms = this.getSupportedPlatforms()
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
totalBalance: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
lowBalanceCount: 0,
|
||||||
|
platforms: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const platform of platforms) {
|
||||||
|
const accounts = await this.getAllAccountsByPlatform(platform)
|
||||||
|
const platformData = {
|
||||||
|
count: accounts.length,
|
||||||
|
totalBalance: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
lowBalanceCount: 0,
|
||||||
|
accounts: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await this._mapWithConcurrency(
|
||||||
|
accounts,
|
||||||
|
this.DEFAULT_CONCURRENCY,
|
||||||
|
async (acc) => {
|
||||||
|
const balance = await this._getAccountBalanceForAccount(acc, platform, {
|
||||||
|
queryApi: false,
|
||||||
|
useCache: true
|
||||||
|
})
|
||||||
|
return { ...balance, name: acc.name || '' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const item of balances) {
|
||||||
|
platformData.accounts.push(item)
|
||||||
|
|
||||||
|
const amount = item?.data?.balance?.amount
|
||||||
|
const percentage = item?.data?.quota?.percentage
|
||||||
|
const totalCost = Number(item?.data?.statistics?.totalCost || 0)
|
||||||
|
|
||||||
|
const hasAmount = typeof amount === 'number' && Number.isFinite(amount)
|
||||||
|
const isLowBalance = hasAmount && amount < this.LOW_BALANCE_THRESHOLD
|
||||||
|
const isHighUsage =
|
||||||
|
typeof percentage === 'number' &&
|
||||||
|
Number.isFinite(percentage) &&
|
||||||
|
percentage > this.HIGH_USAGE_THRESHOLD_PERCENT
|
||||||
|
|
||||||
|
if (hasAmount) {
|
||||||
|
platformData.totalBalance += amount
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLowBalance || isHighUsage) {
|
||||||
|
platformData.lowBalanceCount += 1
|
||||||
|
summary.lowBalanceCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
platformData.totalCost += totalCost
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.platforms[platform] = platformData
|
||||||
|
summary.totalBalance += platformData.totalBalance
|
||||||
|
summary.totalCost += platformData.totalCost
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCache(accountId, platform) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
if (!normalizedPlatform) {
|
||||||
|
throw new Error('缺少 platform 参数')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.deleteAccountBalance(normalizedPlatform, accountId)
|
||||||
|
this.logger.info(`余额缓存已清除: ${normalizedPlatform}:${accountId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccount(accountId, platform) {
|
||||||
|
if (!accountId || !platform) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceMap = {
|
||||||
|
claude: require('./claudeAccountService'),
|
||||||
|
'claude-console': require('./claudeConsoleAccountService'),
|
||||||
|
gemini: require('./geminiAccountService'),
|
||||||
|
'gemini-api': require('./geminiApiAccountService'),
|
||||||
|
openai: require('./openaiAccountService'),
|
||||||
|
'openai-responses': require('./openaiResponsesAccountService'),
|
||||||
|
azure_openai: require('./azureOpenaiAccountService'),
|
||||||
|
bedrock: require('./bedrockAccountService'),
|
||||||
|
droid: require('./droidAccountService'),
|
||||||
|
ccr: require('./ccrAccountService')
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = serviceMap[platform]
|
||||||
|
if (!service || typeof service.getAccount !== 'function') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.getAccount(accountId)
|
||||||
|
|
||||||
|
// 处理不同服务返回格式的差异
|
||||||
|
// Bedrock/CCR/Droid 等服务返回 { success, data } 格式
|
||||||
|
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
|
||||||
|
return result.success ? result.data : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAccountsByPlatform(platform) {
|
||||||
|
if (!platform) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceMap = {
|
||||||
|
claude: require('./claudeAccountService'),
|
||||||
|
'claude-console': require('./claudeConsoleAccountService'),
|
||||||
|
gemini: require('./geminiAccountService'),
|
||||||
|
'gemini-api': require('./geminiApiAccountService'),
|
||||||
|
openai: require('./openaiAccountService'),
|
||||||
|
'openai-responses': require('./openaiResponsesAccountService'),
|
||||||
|
azure_openai: require('./azureOpenaiAccountService'),
|
||||||
|
bedrock: require('./bedrockAccountService'),
|
||||||
|
droid: require('./droidAccountService'),
|
||||||
|
ccr: require('./ccrAccountService')
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = serviceMap[platform]
|
||||||
|
if (!service) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock 特殊:返回 { success, data }
|
||||||
|
if (platform === 'bedrock' && typeof service.getAllAccounts === 'function') {
|
||||||
|
const result = await service.getAllAccounts()
|
||||||
|
return result?.success ? result.data || [] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'openai-responses') {
|
||||||
|
return await service.getAllAccounts(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof service.getAllAccounts !== 'function') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return await service.getAllAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getAccountBalanceForAccount(account, platform, options = {}) {
|
||||||
|
const queryMode = this._parseQueryMode(options.queryApi)
|
||||||
|
const useCache = options.useCache !== false
|
||||||
|
|
||||||
|
const accountId = account?.id
|
||||||
|
if (!accountId) {
|
||||||
|
// 如果账户缺少 id,返回空响应而不是抛出错误,避免接口报错和UI错误
|
||||||
|
this.logger.warn('账户缺少 id,返回空余额数据', { account, platform })
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: '账户数据异常',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
statistics: {},
|
||||||
|
lastRefreshAt: new Date().toISOString()
|
||||||
|
},
|
||||||
|
'unknown',
|
||||||
|
platform,
|
||||||
|
'local',
|
||||||
|
null,
|
||||||
|
{ scriptEnabled: false, scriptConfigured: false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 余额脚本配置状态(用于前端控制"刷新余额"按钮)
|
||||||
|
let scriptConfig = null
|
||||||
|
let scriptConfigured = false
|
||||||
|
if (typeof this.redis?.getBalanceScriptConfig === 'function') {
|
||||||
|
scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId)
|
||||||
|
scriptConfigured = !!(
|
||||||
|
scriptConfig &&
|
||||||
|
scriptConfig.scriptBody &&
|
||||||
|
String(scriptConfig.scriptBody).trim().length > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const scriptEnabled = isBalanceScriptEnabled()
|
||||||
|
const scriptMeta = { scriptEnabled, scriptConfigured }
|
||||||
|
|
||||||
|
const localBalance = await this._getBalanceFromLocal(accountId, platform)
|
||||||
|
const localStatistics = localBalance.statistics || {}
|
||||||
|
|
||||||
|
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
|
||||||
|
|
||||||
|
// 安全限制:queryApi=auto 仅用于 Antigravity(gemini + oauthProvider=antigravity)账户
|
||||||
|
const effectiveQueryMode =
|
||||||
|
queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity')
|
||||||
|
? 'local'
|
||||||
|
: queryMode
|
||||||
|
|
||||||
|
// local: 仅本地统计/缓存;auto: 优先缓存,无缓存则尝试远程 Provider(并缓存结果)
|
||||||
|
if (effectiveQueryMode !== 'api') {
|
||||||
|
if (useCache) {
|
||||||
|
const cached = await this.redis.getAccountBalance(platform, accountId)
|
||||||
|
if (cached && cached.status === 'success') {
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: cached.status,
|
||||||
|
errorMessage: cached.errorMessage,
|
||||||
|
balance: quotaFromLocal.balance ?? cached.balance,
|
||||||
|
currency: quotaFromLocal.currency || cached.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota || cached.quota || null,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: cached.lastRefreshAt
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'cache',
|
||||||
|
cached.ttlSeconds,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveQueryMode === 'local') {
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: 'success',
|
||||||
|
errorMessage: null,
|
||||||
|
balance: quotaFromLocal.balance,
|
||||||
|
currency: quotaFromLocal.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: localBalance.lastCalculated
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'local',
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计
|
||||||
|
let providerResult
|
||||||
|
|
||||||
|
if (scriptEnabled && scriptConfigured) {
|
||||||
|
providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform)
|
||||||
|
} else {
|
||||||
|
const provider = this.providers.get(platform)
|
||||||
|
if (!provider) {
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: `不支持的平台: ${platform}`,
|
||||||
|
balance: quotaFromLocal.balance,
|
||||||
|
currency: quotaFromLocal.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: new Date().toISOString()
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'local',
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
providerResult = await this._getBalanceFromProvider(provider, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRemoteSuccess =
|
||||||
|
providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod)
|
||||||
|
|
||||||
|
// 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h
|
||||||
|
if (isRemoteSuccess) {
|
||||||
|
await this.redis.setAccountBalance(
|
||||||
|
platform,
|
||||||
|
accountId,
|
||||||
|
providerResult,
|
||||||
|
this.CACHE_TTL_SECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = isRemoteSuccess ? 'api' : 'local'
|
||||||
|
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: providerResult.status,
|
||||||
|
errorMessage: providerResult.errorMessage,
|
||||||
|
balance: quotaFromLocal.balance ?? providerResult.balance,
|
||||||
|
currency: quotaFromLocal.currency || providerResult.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota || providerResult.quota || null,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: providerResult.lastRefreshAt
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
source,
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getBalanceFromScript(scriptConfig, accountId, platform) {
|
||||||
|
try {
|
||||||
|
const result = await balanceScriptService.execute({
|
||||||
|
scriptBody: scriptConfig.scriptBody,
|
||||||
|
timeoutSeconds: scriptConfig.timeoutSeconds || 10,
|
||||||
|
variables: {
|
||||||
|
baseUrl: scriptConfig.baseUrl || '',
|
||||||
|
apiKey: scriptConfig.apiKey || '',
|
||||||
|
token: scriptConfig.token || '',
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
extra: scriptConfig.extra || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapped = result?.mapped || {}
|
||||||
|
return {
|
||||||
|
status: mapped.status || 'error',
|
||||||
|
balance: typeof mapped.balance === 'number' ? mapped.balance : null,
|
||||||
|
currency: mapped.currency || 'USD',
|
||||||
|
quota: mapped.quota || null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: mapped.rawData || result?.response?.data || null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: mapped.errorMessage || ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: error.message || '脚本执行失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getBalanceFromProvider(provider, account) {
|
||||||
|
try {
|
||||||
|
const result = await provider.queryBalance(account)
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
balance: typeof result?.balance === 'number' ? result.balance : null,
|
||||||
|
currency: result?.currency || 'USD',
|
||||||
|
quota: result?.quota || null,
|
||||||
|
queryMethod: result?.queryMethod || 'api',
|
||||||
|
rawData: result?.rawData || null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: error.message || '查询失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getBalanceFromLocal(accountId, platform) {
|
||||||
|
const cached = await this.redis.getLocalBalance(platform, accountId)
|
||||||
|
if (cached && cached.statistics) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const statistics = await this._computeLocalStatistics(accountId)
|
||||||
|
const localBalance = {
|
||||||
|
status: 'success',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
statistics,
|
||||||
|
queryMethod: 'local',
|
||||||
|
lastCalculated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.setLocalBalance(platform, accountId, localBalance, this.LOCAL_TTL_SECONDS)
|
||||||
|
return localBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeLocalStatistics(accountId) {
|
||||||
|
const safeNumber = (value) => {
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usageStats = await this.redis.getAccountUsageStats(accountId)
|
||||||
|
const dailyCost = safeNumber(usageStats?.daily?.cost || 0)
|
||||||
|
const monthlyCost = await this._computeMonthlyCost(accountId)
|
||||||
|
const totalCost = await this._computeTotalCost(accountId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCost,
|
||||||
|
dailyCost,
|
||||||
|
monthlyCost,
|
||||||
|
totalRequests: safeNumber(usageStats?.total?.requests || 0),
|
||||||
|
dailyRequests: safeNumber(usageStats?.daily?.requests || 0),
|
||||||
|
monthlyRequests: safeNumber(usageStats?.monthly?.requests || 0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`本地统计计算失败: ${accountId}`, error)
|
||||||
|
return {
|
||||||
|
totalCost: 0,
|
||||||
|
dailyCost: 0,
|
||||||
|
monthlyCost: 0,
|
||||||
|
totalRequests: 0,
|
||||||
|
dailyRequests: 0,
|
||||||
|
monthlyRequests: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeMonthlyCost(accountId) {
|
||||||
|
const tzDate = this.redis.getDateInTimezone(new Date())
|
||||||
|
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
||||||
|
2,
|
||||||
|
'0'
|
||||||
|
)}`
|
||||||
|
|
||||||
|
const pattern = `account_usage:model:monthly:${accountId}:*:${currentMonth}`
|
||||||
|
return await this._sumModelCostsByKeysPattern(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeTotalCost(accountId) {
|
||||||
|
const pattern = `account_usage:model:monthly:${accountId}:*:*`
|
||||||
|
return await this._sumModelCostsByKeysPattern(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sumModelCostsByKeysPattern(pattern) {
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
let totalCost = 0
|
||||||
|
let cursor = '0'
|
||||||
|
const scanCount = 200
|
||||||
|
let iterations = 0
|
||||||
|
const maxIterations = 2000
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount)
|
||||||
|
cursor = nextCursor
|
||||||
|
iterations += 1
|
||||||
|
|
||||||
|
if (!keys || keys.length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
keys.forEach((key) => pipeline.hgetall(key))
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
for (let i = 0; i < results.length; i += 1) {
|
||||||
|
const [, data] = results[i] || []
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = String(keys[i]).split(':')
|
||||||
|
const model = parts[4] || 'unknown'
|
||||||
|
|
||||||
|
const usage = {
|
||||||
|
input_tokens: parseInt(data.inputTokens || 0),
|
||||||
|
output_tokens: parseInt(data.outputTokens || 0),
|
||||||
|
cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0),
|
||||||
|
cache_read_input_tokens: parseInt(data.cacheReadTokens || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const costResult = CostCalculator.calculateCost(usage, model)
|
||||||
|
totalCost += costResult.costs.total || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iterations >= maxIterations) {
|
||||||
|
this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
return totalCost
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`汇总模型费用失败: ${pattern}`, error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildQuotaFromLocal(account, statistics) {
|
||||||
|
if (!account || !Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||||
|
return { balance: null, currency: null, quota: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyQuota = Number(account.dailyQuota || 0)
|
||||||
|
const used = Number(statistics?.dailyCost || 0)
|
||||||
|
|
||||||
|
const resetAt = this._computeNextResetAt(account.quotaResetTime || '00:00')
|
||||||
|
|
||||||
|
// 不限制
|
||||||
|
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: Infinity,
|
||||||
|
used,
|
||||||
|
remaining: Infinity,
|
||||||
|
percentage: 0,
|
||||||
|
unlimited: true,
|
||||||
|
resetAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = Math.max(0, dailyQuota - used)
|
||||||
|
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: remaining,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: dailyQuota,
|
||||||
|
used,
|
||||||
|
remaining,
|
||||||
|
resetAt,
|
||||||
|
percentage: Math.round(percentage * 100) / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeNextResetAt(resetTime) {
|
||||||
|
const now = new Date()
|
||||||
|
const tzNow = this.redis.getDateInTimezone(now)
|
||||||
|
const offsetMs = tzNow.getTime() - now.getTime()
|
||||||
|
|
||||||
|
const [h, m] = String(resetTime || '00:00')
|
||||||
|
.split(':')
|
||||||
|
.map((n) => parseInt(n, 10))
|
||||||
|
|
||||||
|
const resetHour = Number.isFinite(h) ? h : 0
|
||||||
|
const resetMinute = Number.isFinite(m) ? m : 0
|
||||||
|
|
||||||
|
const year = tzNow.getUTCFullYear()
|
||||||
|
const month = tzNow.getUTCMonth()
|
||||||
|
const day = tzNow.getUTCDate()
|
||||||
|
|
||||||
|
let resetAtMs = Date.UTC(year, month, day, resetHour, resetMinute, 0, 0) - offsetMs
|
||||||
|
if (resetAtMs <= now.getTime()) {
|
||||||
|
resetAtMs += 24 * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(resetAtMs).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null, extraData = {}) {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null
|
||||||
|
const currency = balanceData.currency || 'USD'
|
||||||
|
|
||||||
|
let cacheExpiresAt = null
|
||||||
|
if (source === 'cache') {
|
||||||
|
const ttl =
|
||||||
|
typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : this.CACHE_TTL_SECONDS
|
||||||
|
cacheExpiresAt = new Date(Date.now() + ttl * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
balance:
|
||||||
|
typeof amount === 'number'
|
||||||
|
? {
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
formattedAmount: this._formatCurrency(amount, currency)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
quota: balanceData.quota || null,
|
||||||
|
statistics: balanceData.statistics || {},
|
||||||
|
source,
|
||||||
|
lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(),
|
||||||
|
cacheExpiresAt,
|
||||||
|
status: balanceData.status || 'success',
|
||||||
|
error: balanceData.errorMessage || null,
|
||||||
|
...(extraData && typeof extraData === 'object' ? extraData : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatCurrency(amount, currency = 'USD') {
|
||||||
|
try {
|
||||||
|
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
|
||||||
|
return 'N/A'
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
||||||
|
} catch (error) {
|
||||||
|
return `$${amount.toFixed(2)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseBoolean(value) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseQueryMode(value) {
|
||||||
|
if (value === 'auto') {
|
||||||
|
return 'auto'
|
||||||
|
}
|
||||||
|
const parsed = this._parseBoolean(value)
|
||||||
|
return parsed ? 'api' : 'local'
|
||||||
|
}
|
||||||
|
|
||||||
|
async _mapWithConcurrency(items, limit, mapper) {
|
||||||
|
const concurrency = Math.max(1, Number(limit) || 1)
|
||||||
|
const list = Array.isArray(items) ? items : []
|
||||||
|
|
||||||
|
const results = new Array(list.length)
|
||||||
|
let nextIndex = 0
|
||||||
|
|
||||||
|
const workers = new Array(Math.min(concurrency, list.length)).fill(null).map(async () => {
|
||||||
|
while (nextIndex < list.length) {
|
||||||
|
const currentIndex = nextIndex
|
||||||
|
nextIndex += 1
|
||||||
|
results[currentIndex] = await mapper(list[currentIndex], currentIndex)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(workers)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountBalanceService = new AccountBalanceService()
|
||||||
|
module.exports = accountBalanceService
|
||||||
|
module.exports.AccountBalanceService = AccountBalanceService
|
||||||
420
src/services/accountTestSchedulerService.js
Normal file
420
src/services/accountTestSchedulerService.js
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
/**
|
||||||
|
* 账户定时测试调度服务
|
||||||
|
* 使用 node-cron 支持 crontab 表达式,为每个账户创建独立的定时任务
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cron = require('node-cron')
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
class AccountTestSchedulerService {
|
||||||
|
constructor() {
|
||||||
|
// 存储每个账户的 cron 任务: Map<string, { task: ScheduledTask, cronExpression: string }>
|
||||||
|
this.scheduledTasks = new Map()
|
||||||
|
// 定期刷新配置的间隔 (毫秒)
|
||||||
|
this.refreshIntervalMs = 60 * 1000
|
||||||
|
this.refreshInterval = null
|
||||||
|
// 当前正在测试的账户
|
||||||
|
this.testingAccounts = new Set()
|
||||||
|
// 是否已启动
|
||||||
|
this.isStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 cron 表达式是否有效
|
||||||
|
* @param {string} cronExpression - cron 表达式
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
validateCronExpression(cronExpression) {
|
||||||
|
// 长度检查(防止 DoS)
|
||||||
|
if (!cronExpression || cronExpression.length > 100) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return cron.validate(cronExpression)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动调度器
|
||||||
|
*/
|
||||||
|
async start() {
|
||||||
|
if (this.isStarted) {
|
||||||
|
logger.warn('⚠️ Account test scheduler is already running')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isStarted = true
|
||||||
|
logger.info('🚀 Starting account test scheduler service (node-cron mode)')
|
||||||
|
|
||||||
|
// 初始化所有已配置账户的定时任务
|
||||||
|
await this._refreshAllTasks()
|
||||||
|
|
||||||
|
// 定期刷新配置,以便动态添加/修改的配置能生效
|
||||||
|
this.refreshInterval = setInterval(() => {
|
||||||
|
this._refreshAllTasks()
|
||||||
|
}, this.refreshIntervalMs)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`📅 Account test scheduler started (refreshing configs every ${this.refreshIntervalMs / 1000}s)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止调度器
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval)
|
||||||
|
this.refreshInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止所有 cron 任务
|
||||||
|
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||||
|
taskInfo.task.stop()
|
||||||
|
logger.debug(`🛑 Stopped cron task for ${accountKey}`)
|
||||||
|
}
|
||||||
|
this.scheduledTasks.clear()
|
||||||
|
|
||||||
|
this.isStarted = false
|
||||||
|
logger.info('🛑 Account test scheduler stopped')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新所有账户的定时任务
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _refreshAllTasks() {
|
||||||
|
try {
|
||||||
|
const platforms = ['claude', 'gemini', 'openai']
|
||||||
|
const activeAccountKeys = new Set()
|
||||||
|
|
||||||
|
// 并行加载所有平台的配置
|
||||||
|
const allEnabledAccounts = await Promise.all(
|
||||||
|
platforms.map((platform) =>
|
||||||
|
redis
|
||||||
|
.getEnabledTestAccounts(platform)
|
||||||
|
.then((accounts) => accounts.map((acc) => ({ ...acc, platform })))
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn(`⚠️ Failed to load test accounts for platform ${platform}:`, error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 展平平台数据
|
||||||
|
const flatAccounts = allEnabledAccounts.flat()
|
||||||
|
|
||||||
|
for (const { accountId, cronExpression, model, platform } of flatAccounts) {
|
||||||
|
if (!cronExpression) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Account ${accountId} (${platform}) has no valid cron expression, skipping`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountKey = `${platform}:${accountId}`
|
||||||
|
activeAccountKeys.add(accountKey)
|
||||||
|
|
||||||
|
// 检查是否需要更新任务
|
||||||
|
const existingTask = this.scheduledTasks.get(accountKey)
|
||||||
|
if (existingTask) {
|
||||||
|
// 如果 cron 表达式和模型都没变,不需要更新
|
||||||
|
if (existingTask.cronExpression === cronExpression && existingTask.model === model) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 配置变了,停止旧任务
|
||||||
|
existingTask.task.stop()
|
||||||
|
logger.info(`🔄 Updating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
|
||||||
|
} else {
|
||||||
|
logger.info(`➕ Creating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的 cron 任务
|
||||||
|
this._createCronTask(accountId, platform, cronExpression, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理已删除或禁用的账户任务
|
||||||
|
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||||
|
if (!activeAccountKeys.has(accountKey)) {
|
||||||
|
taskInfo.task.stop()
|
||||||
|
this.scheduledTasks.delete(accountKey)
|
||||||
|
logger.info(`➖ Removed cron task for ${accountKey} (disabled or deleted)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error refreshing account test tasks:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为单个账户创建 cron 任务
|
||||||
|
* @param {string} accountId
|
||||||
|
* @param {string} platform
|
||||||
|
* @param {string} cronExpression
|
||||||
|
* @param {string} model - 测试使用的模型
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_createCronTask(accountId, platform, cronExpression, model) {
|
||||||
|
const accountKey = `${platform}:${accountId}`
|
||||||
|
|
||||||
|
// 验证 cron 表达式
|
||||||
|
if (!this.validateCronExpression(cronExpression)) {
|
||||||
|
logger.error(`❌ Invalid cron expression for ${accountKey}: ${cronExpression}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = cron.schedule(
|
||||||
|
cronExpression,
|
||||||
|
async () => {
|
||||||
|
await this._runAccountTest(accountId, platform, model)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scheduled: true,
|
||||||
|
timezone: process.env.TZ || 'Asia/Shanghai'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.scheduledTasks.set(accountKey, {
|
||||||
|
task,
|
||||||
|
cronExpression,
|
||||||
|
model,
|
||||||
|
accountId,
|
||||||
|
platform
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行单个账户测试
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @param {string} model - 测试使用的模型
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _runAccountTest(accountId, platform, model) {
|
||||||
|
const accountKey = `${platform}:${accountId}`
|
||||||
|
|
||||||
|
// 避免重复测试
|
||||||
|
if (this.testingAccounts.has(accountKey)) {
|
||||||
|
logger.debug(`⏳ Account ${accountKey} is already being tested, skipping`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.testingAccounts.add(accountKey)
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
`🧪 Running scheduled test for ${platform} account: ${accountId} (model: ${model})`
|
||||||
|
)
|
||||||
|
|
||||||
|
let testResult
|
||||||
|
|
||||||
|
// 根据平台调用对应的测试方法
|
||||||
|
switch (platform) {
|
||||||
|
case 'claude':
|
||||||
|
testResult = await this._testClaudeAccount(accountId, model)
|
||||||
|
break
|
||||||
|
case 'gemini':
|
||||||
|
testResult = await this._testGeminiAccount(accountId, model)
|
||||||
|
break
|
||||||
|
case 'openai':
|
||||||
|
testResult = await this._testOpenAIAccount(accountId, model)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
testResult = {
|
||||||
|
success: false,
|
||||||
|
error: `Unsupported platform: ${platform}`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存测试结果
|
||||||
|
await redis.saveAccountTestResult(accountId, platform, testResult)
|
||||||
|
|
||||||
|
// 更新最后测试时间
|
||||||
|
await redis.setAccountLastTestTime(accountId, platform)
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
if (testResult.success) {
|
||||||
|
logger.info(
|
||||||
|
`✅ Scheduled test passed for ${platform} account ${accountId} (${testResult.latencyMs}ms)`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`❌ Scheduled test failed for ${platform} account ${accountId}: ${testResult.error}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return testResult
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Error testing ${platform} account ${accountId}:`, error)
|
||||||
|
|
||||||
|
const errorResult = {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.saveAccountTestResult(accountId, platform, errorResult)
|
||||||
|
await redis.setAccountLastTestTime(accountId, platform)
|
||||||
|
|
||||||
|
return errorResult
|
||||||
|
} finally {
|
||||||
|
this.testingAccounts.delete(accountKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 Claude 账户
|
||||||
|
* @param {string} accountId
|
||||||
|
* @param {string} model - 测试使用的模型
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _testClaudeAccount(accountId, model) {
|
||||||
|
const claudeRelayService = require('./claudeRelayService')
|
||||||
|
return await claudeRelayService.testAccountConnectionSync(accountId, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 Gemini 账户
|
||||||
|
* @param {string} _accountId
|
||||||
|
* @param {string} _model
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _testGeminiAccount(_accountId, _model) {
|
||||||
|
// Gemini 测试暂时返回未实现
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Gemini scheduled test not implemented yet',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 OpenAI 账户
|
||||||
|
* @param {string} _accountId
|
||||||
|
* @param {string} _model
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _testOpenAIAccount(_accountId, _model) {
|
||||||
|
// OpenAI 测试暂时返回未实现
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'OpenAI scheduled test not implemented yet',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发账户测试
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @param {string} model - 测试使用的模型
|
||||||
|
* @returns {Promise<Object>} 测试结果
|
||||||
|
*/
|
||||||
|
async triggerTest(accountId, platform, model = 'claude-sonnet-4-5-20250929') {
|
||||||
|
logger.info(`🎯 Manual test triggered for ${platform} account: ${accountId} (model: ${model})`)
|
||||||
|
return await this._runAccountTest(accountId, platform, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户测试历史
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @returns {Promise<Array>} 测试历史
|
||||||
|
*/
|
||||||
|
async getTestHistory(accountId, platform) {
|
||||||
|
return await redis.getAccountTestHistory(accountId, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户测试配置
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
async getTestConfig(accountId, platform) {
|
||||||
|
return await redis.getAccountTestConfig(accountId, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置账户测试配置
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} platform - 平台类型
|
||||||
|
* @param {Object} testConfig - 测试配置 { enabled: boolean, cronExpression: string, model: string }
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async setTestConfig(accountId, platform, testConfig) {
|
||||||
|
// 验证 cron 表达式
|
||||||
|
if (testConfig.cronExpression && !this.validateCronExpression(testConfig.cronExpression)) {
|
||||||
|
throw new Error(`Invalid cron expression: ${testConfig.cronExpression}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.saveAccountTestConfig(accountId, platform, testConfig)
|
||||||
|
logger.info(
|
||||||
|
`📝 Test config updated for ${platform} account ${accountId}: enabled=${testConfig.enabled}, cronExpression=${testConfig.cronExpression}, model=${testConfig.model}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 立即刷新任务,使配置立即生效
|
||||||
|
if (this.isStarted) {
|
||||||
|
await this._refreshAllTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新单个账户的定时任务(配置变更时调用)
|
||||||
|
* @param {string} accountId
|
||||||
|
* @param {string} platform
|
||||||
|
*/
|
||||||
|
async refreshAccountTask(accountId, platform) {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountKey = `${platform}:${accountId}`
|
||||||
|
const testConfig = await redis.getAccountTestConfig(accountId, platform)
|
||||||
|
|
||||||
|
// 停止现有任务
|
||||||
|
const existingTask = this.scheduledTasks.get(accountKey)
|
||||||
|
if (existingTask) {
|
||||||
|
existingTask.task.stop()
|
||||||
|
this.scheduledTasks.delete(accountKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果启用且有有效的 cron 表达式,创建新任务
|
||||||
|
if (testConfig?.enabled && testConfig?.cronExpression) {
|
||||||
|
this._createCronTask(accountId, platform, testConfig.cronExpression, testConfig.model)
|
||||||
|
logger.info(
|
||||||
|
`🔄 Refreshed cron task for ${accountKey}: ${testConfig.cronExpression}, model: ${testConfig.model}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取调度器状态
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
const tasks = []
|
||||||
|
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||||
|
tasks.push({
|
||||||
|
accountKey,
|
||||||
|
accountId: taskInfo.accountId,
|
||||||
|
platform: taskInfo.platform,
|
||||||
|
cronExpression: taskInfo.cronExpression,
|
||||||
|
model: taskInfo.model
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
running: this.isStarted,
|
||||||
|
refreshIntervalMs: this.refreshIntervalMs,
|
||||||
|
scheduledTasksCount: this.scheduledTasks.size,
|
||||||
|
scheduledTasks: tasks,
|
||||||
|
currentlyTesting: Array.from(this.testingAccounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例模式
|
||||||
|
const accountTestSchedulerService = new AccountTestSchedulerService()
|
||||||
|
|
||||||
|
module.exports = accountTestSchedulerService
|
||||||
3083
src/services/anthropicGeminiBridgeService.js
Normal file
3083
src/services/anthropicGeminiBridgeService.js
Normal file
File diff suppressed because it is too large
Load Diff
595
src/services/antigravityClient.js
Normal file
595
src/services/antigravityClient.js
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
const axios = require('axios')
|
||||||
|
const https = require('https')
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
|
||||||
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const {
|
||||||
|
mapAntigravityUpstreamModel,
|
||||||
|
normalizeAntigravityModelInput,
|
||||||
|
getAntigravityModelMetadata
|
||||||
|
} = require('../utils/antigravityModel')
|
||||||
|
const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner')
|
||||||
|
const { dumpAntigravityUpstreamRequest } = require('../utils/antigravityUpstreamDump')
|
||||||
|
|
||||||
|
const keepAliveAgent = new https.Agent({
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveMsecs: 30000,
|
||||||
|
timeout: 120000,
|
||||||
|
maxSockets: 100,
|
||||||
|
maxFreeSockets: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
function getAntigravityApiUrl() {
|
||||||
|
return process.env.ANTIGRAVITY_API_URL || 'https://daily-cloudcode-pa.sandbox.googleapis.com'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url) {
|
||||||
|
const str = String(url || '').trim()
|
||||||
|
return str.endsWith('/') ? str.slice(0, -1) : str
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityApiUrlCandidates() {
|
||||||
|
const configured = normalizeBaseUrl(getAntigravityApiUrl())
|
||||||
|
const daily = 'https://daily-cloudcode-pa.sandbox.googleapis.com'
|
||||||
|
const prod = 'https://cloudcode-pa.googleapis.com'
|
||||||
|
|
||||||
|
// 若显式配置了自定义 base url,则只使用该地址(不做 fallback,避免意外路由到别的环境)。
|
||||||
|
if (process.env.ANTIGRAVITY_API_URL) {
|
||||||
|
return [configured]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认行为:优先 daily(与旧逻辑一致),失败时再尝试 prod(对齐 CLIProxyAPI)。
|
||||||
|
if (configured === normalizeBaseUrl(daily)) {
|
||||||
|
return [configured, prod]
|
||||||
|
}
|
||||||
|
if (configured === normalizeBaseUrl(prod)) {
|
||||||
|
return [configured, daily]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [configured, prod, daily].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityHeaders(accessToken, baseUrl) {
|
||||||
|
const resolvedBaseUrl = baseUrl || getAntigravityApiUrl()
|
||||||
|
let host = 'daily-cloudcode-pa.sandbox.googleapis.com'
|
||||||
|
try {
|
||||||
|
host = new URL(resolvedBaseUrl).host || host
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Host: host,
|
||||||
|
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept-Encoding': 'gzip',
|
||||||
|
requestType: 'agent'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAntigravityProjectId() {
|
||||||
|
return `ag-${uuidv4().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAntigravitySessionId() {
|
||||||
|
return `sess-${uuidv4()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAntigravityProjectId(projectId, requestData) {
|
||||||
|
const candidate = projectId || requestData?.project || requestData?.projectId || null
|
||||||
|
return candidate || generateAntigravityProjectId()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAntigravitySessionId(sessionId, requestData) {
|
||||||
|
const candidate =
|
||||||
|
sessionId || requestData?.request?.sessionId || requestData?.request?.session_id || null
|
||||||
|
return candidate || generateAntigravitySessionId()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAntigravityEnvelope({ requestData, projectId, sessionId, userPromptId }) {
|
||||||
|
const model = mapAntigravityUpstreamModel(requestData?.model)
|
||||||
|
const resolvedProjectId = resolveAntigravityProjectId(projectId, requestData)
|
||||||
|
const resolvedSessionId = resolveAntigravitySessionId(sessionId, requestData)
|
||||||
|
const requestPayload = {
|
||||||
|
...(requestData?.request || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestPayload.session_id !== undefined) {
|
||||||
|
delete requestPayload.session_id
|
||||||
|
}
|
||||||
|
requestPayload.sessionId = resolvedSessionId
|
||||||
|
|
||||||
|
const envelope = {
|
||||||
|
project: resolvedProjectId,
|
||||||
|
requestId: `req-${uuidv4()}`,
|
||||||
|
model,
|
||||||
|
userAgent: 'antigravity',
|
||||||
|
request: {
|
||||||
|
...requestPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userPromptId) {
|
||||||
|
envelope.user_prompt_id = userPromptId
|
||||||
|
envelope.userPromptId = userPromptId
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeAntigravityEnvelope(envelope)
|
||||||
|
return { model, envelope }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAntigravityThinking(model, requestPayload) {
|
||||||
|
if (!requestPayload || typeof requestPayload !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { generationConfig } = requestPayload
|
||||||
|
if (!generationConfig || typeof generationConfig !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { thinkingConfig } = generationConfig
|
||||||
|
if (!thinkingConfig || typeof thinkingConfig !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedModel = normalizeAntigravityModelInput(model)
|
||||||
|
if (thinkingConfig.thinkingLevel && !normalizedModel.startsWith('gemini-3-')) {
|
||||||
|
delete thinkingConfig.thinkingLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = getAntigravityModelMetadata(normalizedModel)
|
||||||
|
if (metadata && !metadata.thinking) {
|
||||||
|
delete generationConfig.thinkingConfig
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!metadata || !metadata.thinking) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetRaw = Number(thinkingConfig.thinkingBudget)
|
||||||
|
if (!Number.isFinite(budgetRaw)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let budget = Math.trunc(budgetRaw)
|
||||||
|
|
||||||
|
const minBudget = Number.isFinite(metadata.thinking.min) ? metadata.thinking.min : null
|
||||||
|
const maxBudget = Number.isFinite(metadata.thinking.max) ? metadata.thinking.max : null
|
||||||
|
|
||||||
|
if (maxBudget !== null && budget > maxBudget) {
|
||||||
|
budget = maxBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
let effectiveMax = Number.isFinite(generationConfig.maxOutputTokens)
|
||||||
|
? generationConfig.maxOutputTokens
|
||||||
|
: null
|
||||||
|
let setDefaultMax = false
|
||||||
|
if (!effectiveMax && metadata.maxCompletionTokens) {
|
||||||
|
effectiveMax = metadata.maxCompletionTokens
|
||||||
|
setDefaultMax = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveMax && budget >= effectiveMax) {
|
||||||
|
budget = Math.max(0, effectiveMax - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minBudget !== null && budget >= 0 && budget < minBudget) {
|
||||||
|
delete generationConfig.thinkingConfig
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thinkingConfig.thinkingBudget = budget
|
||||||
|
if (setDefaultMax) {
|
||||||
|
generationConfig.maxOutputTokens = effectiveMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAntigravityEnvelope(envelope) {
|
||||||
|
if (!envelope || typeof envelope !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const model = String(envelope.model || '')
|
||||||
|
const requestPayload = envelope.request
|
||||||
|
if (!requestPayload || typeof requestPayload !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestPayload.safetySettings !== undefined) {
|
||||||
|
delete requestPayload.safetySettings
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对齐 CLIProxyAPI:有 tools 时默认启用 VALIDATED(除非显式 NONE)
|
||||||
|
if (Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) {
|
||||||
|
const existing = requestPayload?.toolConfig?.functionCallingConfig || null
|
||||||
|
if (existing?.mode !== 'NONE') {
|
||||||
|
const nextCfg = { ...(existing || {}), mode: 'VALIDATED' }
|
||||||
|
requestPayload.toolConfig = { functionCallingConfig: nextCfg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对齐 CLIProxyAPI:非 Claude 模型移除 maxOutputTokens(Antigravity 环境不稳定)
|
||||||
|
normalizeAntigravityThinking(model, requestPayload)
|
||||||
|
if (!model.includes('claude')) {
|
||||||
|
if (requestPayload.generationConfig && typeof requestPayload.generationConfig === 'object') {
|
||||||
|
delete requestPayload.generationConfig.maxOutputTokens
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude 模型:parametersJsonSchema -> parameters + schema 清洗(避免 $schema / additionalProperties 等触发 400)
|
||||||
|
if (!Array.isArray(requestPayload.tools)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tool of requestPayload.tools) {
|
||||||
|
if (!tool || typeof tool !== 'object') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const decls = Array.isArray(tool.functionDeclarations)
|
||||||
|
? tool.functionDeclarations
|
||||||
|
: Array.isArray(tool.function_declarations)
|
||||||
|
? tool.function_declarations
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!decls) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const decl of decls) {
|
||||||
|
if (!decl || typeof decl !== 'object') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let schema =
|
||||||
|
decl.parametersJsonSchema !== undefined ? decl.parametersJsonSchema : decl.parameters
|
||||||
|
if (typeof schema === 'string' && schema) {
|
||||||
|
try {
|
||||||
|
schema = JSON.parse(schema)
|
||||||
|
} catch (_) {
|
||||||
|
schema = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decl.parameters = cleanJsonSchemaForGemini(schema)
|
||||||
|
delete decl.parametersJsonSchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request({
|
||||||
|
accessToken,
|
||||||
|
proxyConfig = null,
|
||||||
|
requestData,
|
||||||
|
projectId = null,
|
||||||
|
sessionId = null,
|
||||||
|
userPromptId = null,
|
||||||
|
stream = false,
|
||||||
|
signal = null,
|
||||||
|
params = null,
|
||||||
|
timeoutMs = null
|
||||||
|
}) {
|
||||||
|
const { model, envelope } = buildAntigravityEnvelope({
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
userPromptId
|
||||||
|
})
|
||||||
|
|
||||||
|
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
|
let endpoints = getAntigravityApiUrlCandidates()
|
||||||
|
|
||||||
|
// Claude 模型在 sandbox(daily) 环境下对 tool_use/tool_result 的兼容性不稳定,优先走 prod。
|
||||||
|
// 保持可配置优先:若用户显式设置了 ANTIGRAVITY_API_URL,则不改变顺序。
|
||||||
|
if (!process.env.ANTIGRAVITY_API_URL && String(model).includes('claude')) {
|
||||||
|
const prodHost = 'cloudcode-pa.googleapis.com'
|
||||||
|
const dailyHost = 'daily-cloudcode-pa.sandbox.googleapis.com'
|
||||||
|
const ordered = []
|
||||||
|
for (const u of endpoints) {
|
||||||
|
if (String(u).includes(prodHost)) {
|
||||||
|
ordered.push(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const u of endpoints) {
|
||||||
|
if (!String(u).includes(prodHost)) {
|
||||||
|
ordered.push(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 去重并保持 prod -> daily 的稳定顺序
|
||||||
|
endpoints = Array.from(new Set(ordered)).sort((a, b) => {
|
||||||
|
const av = String(a)
|
||||||
|
const bv = String(b)
|
||||||
|
const aScore = av.includes(prodHost) ? 0 : av.includes(dailyHost) ? 1 : 2
|
||||||
|
const bScore = bv.includes(prodHost) ? 0 : bv.includes(dailyHost) ? 1 : 2
|
||||||
|
return aScore - bScore
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRetryable = (error) => {
|
||||||
|
// 处理网络层面的连接重置或超时(常见于长请求被中间节点切断)
|
||||||
|
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = error?.response?.status
|
||||||
|
if (status === 429) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 400/404 的 “model unavailable / not found” 在不同环境间可能表现不同,允许 fallback。
|
||||||
|
if (status === 400 || status === 404) {
|
||||||
|
const data = error?.response?.data
|
||||||
|
const safeToString = (value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
// axios responseType=stream 时,data 可能是 stream(存在循环引用),不能 JSON.stringify
|
||||||
|
if (typeof value === 'object' && typeof value.pipe === 'function') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(value)) {
|
||||||
|
try {
|
||||||
|
return value.toString('utf8')
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = safeToString(data)
|
||||||
|
const msg = (text || '').toLowerCase()
|
||||||
|
return (
|
||||||
|
msg.includes('requested model is currently unavailable') ||
|
||||||
|
msg.includes('tool_use') ||
|
||||||
|
msg.includes('tool_result') ||
|
||||||
|
msg.includes('requested entity was not found') ||
|
||||||
|
msg.includes('not found')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError = null
|
||||||
|
let retriedAfterDelay = false
|
||||||
|
|
||||||
|
const attemptRequest = async () => {
|
||||||
|
for (let index = 0; index < endpoints.length; index += 1) {
|
||||||
|
const baseUrl = endpoints[index]
|
||||||
|
const url = `${baseUrl}/v1internal:${stream ? 'streamGenerateContent' : 'generateContent'}`
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
...(params ? { params } : {}),
|
||||||
|
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||||
|
data: envelope,
|
||||||
|
timeout: stream ? 0 : timeoutMs || 600000,
|
||||||
|
...(stream ? { responseType: 'stream' } : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyAgent) {
|
||||||
|
axiosConfig.httpsAgent = proxyAgent
|
||||||
|
axiosConfig.proxy = false
|
||||||
|
if (index === 0) {
|
||||||
|
logger.info(
|
||||||
|
`🌐 Using proxy for Antigravity ${stream ? 'streamGenerateContent' : 'generateContent'}: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
axiosConfig.httpsAgent = keepAliveAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
axiosConfig.signal = signal
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
dumpAntigravityUpstreamRequest({
|
||||||
|
requestId: envelope.requestId,
|
||||||
|
model,
|
||||||
|
stream,
|
||||||
|
url,
|
||||||
|
baseUrl,
|
||||||
|
params: axiosConfig.params || null,
|
||||||
|
headers: axiosConfig.headers,
|
||||||
|
envelope
|
||||||
|
}).catch(() => {})
|
||||||
|
const response = await axios(axiosConfig)
|
||||||
|
return { model, response }
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
const status = error?.response?.status || null
|
||||||
|
|
||||||
|
const hasNext = index + 1 < endpoints.length
|
||||||
|
if (hasNext && isRetryable(error)) {
|
||||||
|
logger.warn('⚠️ Antigravity upstream error, retrying with fallback baseUrl', {
|
||||||
|
status,
|
||||||
|
from: baseUrl,
|
||||||
|
to: endpoints[index + 1],
|
||||||
|
model
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Antigravity request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await attemptRequest()
|
||||||
|
} catch (error) {
|
||||||
|
// 如果是 429 RESOURCE_EXHAUSTED 且尚未重试过,等待 2 秒后重试一次
|
||||||
|
const status = error?.response?.status
|
||||||
|
if (status === 429 && !retriedAfterDelay && !signal?.aborted) {
|
||||||
|
const data = error?.response?.data
|
||||||
|
|
||||||
|
// 安全地将 data 转为字符串,避免 stream 对象导致循环引用崩溃
|
||||||
|
const safeDataToString = (value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
// stream 对象存在循环引用,不能 JSON.stringify
|
||||||
|
if (typeof value === 'object' && typeof value.pipe === 'function') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(value)) {
|
||||||
|
try {
|
||||||
|
return value.toString('utf8')
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = safeDataToString(data)
|
||||||
|
if (
|
||||||
|
msg.toLowerCase().includes('resource_exhausted') ||
|
||||||
|
msg.toLowerCase().includes('no capacity')
|
||||||
|
) {
|
||||||
|
retriedAfterDelay = true
|
||||||
|
logger.warn('⏳ Antigravity 429 RESOURCE_EXHAUSTED, waiting 2s before retry', { model })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||||
|
return await attemptRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAvailableModels({ accessToken, proxyConfig = null, timeoutMs = 30000 }) {
|
||||||
|
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
|
const endpoints = getAntigravityApiUrlCandidates()
|
||||||
|
|
||||||
|
let lastError = null
|
||||||
|
for (let index = 0; index < endpoints.length; index += 1) {
|
||||||
|
const baseUrl = endpoints[index]
|
||||||
|
const url = `${baseUrl}/v1internal:fetchAvailableModels`
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||||
|
data: {},
|
||||||
|
timeout: timeoutMs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyAgent) {
|
||||||
|
axiosConfig.httpsAgent = proxyAgent
|
||||||
|
axiosConfig.proxy = false
|
||||||
|
if (index === 0) {
|
||||||
|
logger.info(
|
||||||
|
`🌐 Using proxy for Antigravity fetchAvailableModels: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
axiosConfig.httpsAgent = keepAliveAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios(axiosConfig)
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
const status = error?.response?.status
|
||||||
|
const hasNext = index + 1 < endpoints.length
|
||||||
|
if (hasNext && (status === 429 || status === 404)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Antigravity fetchAvailableModels failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countTokens({
|
||||||
|
accessToken,
|
||||||
|
proxyConfig = null,
|
||||||
|
contents,
|
||||||
|
model,
|
||||||
|
timeoutMs = 30000
|
||||||
|
}) {
|
||||||
|
const upstreamModel = mapAntigravityUpstreamModel(model)
|
||||||
|
|
||||||
|
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
|
const endpoints = getAntigravityApiUrlCandidates()
|
||||||
|
|
||||||
|
let lastError = null
|
||||||
|
for (let index = 0; index < endpoints.length; index += 1) {
|
||||||
|
const baseUrl = endpoints[index]
|
||||||
|
const url = `${baseUrl}/v1internal:countTokens`
|
||||||
|
const axiosConfig = {
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAntigravityHeaders(accessToken, baseUrl),
|
||||||
|
data: {
|
||||||
|
request: {
|
||||||
|
model: `models/${upstreamModel}`,
|
||||||
|
contents
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout: timeoutMs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyAgent) {
|
||||||
|
axiosConfig.httpsAgent = proxyAgent
|
||||||
|
axiosConfig.proxy = false
|
||||||
|
if (index === 0) {
|
||||||
|
logger.info(
|
||||||
|
`🌐 Using proxy for Antigravity countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
axiosConfig.httpsAgent = keepAliveAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios(axiosConfig)
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
const status = error?.response?.status
|
||||||
|
const hasNext = index + 1 < endpoints.length
|
||||||
|
if (hasNext && (status === 429 || status === 404)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Antigravity countTokens failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAntigravityApiUrl,
|
||||||
|
getAntigravityApiUrlCandidates,
|
||||||
|
getAntigravityHeaders,
|
||||||
|
buildAntigravityEnvelope,
|
||||||
|
request,
|
||||||
|
fetchAvailableModels,
|
||||||
|
countTokens
|
||||||
|
}
|
||||||
170
src/services/antigravityRelayService.js
Normal file
170
src/services/antigravityRelayService.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
const apiKeyService = require('./apiKeyService')
|
||||||
|
const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService')
|
||||||
|
const { normalizeAntigravityModelInput } = require('../utils/antigravityModel')
|
||||||
|
const antigravityClient = require('./antigravityClient')
|
||||||
|
|
||||||
|
function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) {
|
||||||
|
const requestedModel = normalizeAntigravityModelInput(model)
|
||||||
|
const { contents, systemInstruction } = convertMessagesToGemini(messages)
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
model: requestedModel,
|
||||||
|
request: {
|
||||||
|
contents,
|
||||||
|
generationConfig: {
|
||||||
|
temperature,
|
||||||
|
maxOutputTokens: maxTokens,
|
||||||
|
candidateCount: 1,
|
||||||
|
topP: 0.95,
|
||||||
|
topK: 40
|
||||||
|
},
|
||||||
|
...(sessionId ? { sessionId } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemInstruction) {
|
||||||
|
requestData.request.systemInstruction = { parts: [{ text: systemInstruction }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestData
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* handleStreamResponse(response, model, apiKeyId, accountId) {
|
||||||
|
let buffer = ''
|
||||||
|
let totalUsage = {
|
||||||
|
promptTokenCount: 0,
|
||||||
|
candidatesTokenCount: 0,
|
||||||
|
totalTokenCount: 0
|
||||||
|
}
|
||||||
|
let usageRecorded = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunk of response.data) {
|
||||||
|
buffer += chunk.toString()
|
||||||
|
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let jsonData = line
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
jsonData = line.substring(6).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jsonData || jsonData === '[DONE]') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonData)
|
||||||
|
const payload = data?.response || data
|
||||||
|
|
||||||
|
if (payload?.usageMetadata) {
|
||||||
|
totalUsage = payload.usageMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
const openaiChunk = convertGeminiResponse(payload, model, true)
|
||||||
|
if (openaiChunk) {
|
||||||
|
yield `data: ${JSON.stringify(openaiChunk)}\n\n`
|
||||||
|
const finishReason = openaiChunk.choices?.[0]?.finish_reason
|
||||||
|
if (finishReason === 'stop') {
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
|
|
||||||
|
if (apiKeyId && totalUsage.totalTokenCount > 0) {
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
apiKeyId,
|
||||||
|
totalUsage.promptTokenCount || 0,
|
||||||
|
totalUsage.candidatesTokenCount || 0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
model,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
usageRecorded = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore chunk parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!usageRecorded && apiKeyId && totalUsage.totalTokenCount > 0) {
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
apiKeyId,
|
||||||
|
totalUsage.promptTokenCount || 0,
|
||||||
|
totalUsage.candidatesTokenCount || 0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
model,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAntigravityRequest({
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
temperature = 0.7,
|
||||||
|
maxTokens = 4096,
|
||||||
|
stream = false,
|
||||||
|
accessToken,
|
||||||
|
proxy,
|
||||||
|
apiKeyId,
|
||||||
|
signal,
|
||||||
|
projectId,
|
||||||
|
accountId = null
|
||||||
|
}) {
|
||||||
|
const requestedModel = normalizeAntigravityModelInput(model)
|
||||||
|
|
||||||
|
const requestData = buildRequestData({
|
||||||
|
messages,
|
||||||
|
model: requestedModel,
|
||||||
|
temperature,
|
||||||
|
maxTokens,
|
||||||
|
sessionId: apiKeyId
|
||||||
|
})
|
||||||
|
|
||||||
|
const { response } = await antigravityClient.request({
|
||||||
|
accessToken,
|
||||||
|
proxyConfig: proxy,
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId: apiKeyId,
|
||||||
|
stream,
|
||||||
|
signal,
|
||||||
|
params: { alt: 'sse' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
return handleStreamResponse(response, requestedModel, apiKeyId, accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = response.data?.response || response.data
|
||||||
|
const openaiResponse = convertGeminiResponse(payload, requestedModel, false)
|
||||||
|
|
||||||
|
if (apiKeyId && openaiResponse?.usage) {
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
apiKeyId,
|
||||||
|
openaiResponse.usage.prompt_tokens || 0,
|
||||||
|
openaiResponse.usage.completion_tokens || 0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
requestedModel,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return openaiResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendAntigravityRequest
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ const { v4: uuidv4 } = require('uuid')
|
|||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||||
|
|
||||||
const ACCOUNT_TYPE_CONFIG = {
|
const ACCOUNT_TYPE_CONFIG = {
|
||||||
claude: { prefix: 'claude:account:' },
|
claude: { prefix: 'claude:account:' },
|
||||||
@@ -37,6 +38,58 @@ const ACCOUNT_CATEGORY_MAP = {
|
|||||||
droid: 'droid'
|
droid: 'droid'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化权限数据,兼容旧格式(字符串)和新格式(数组)
|
||||||
|
* @param {string|array} permissions - 权限数据
|
||||||
|
* @returns {array} - 权限数组,空数组表示全部服务
|
||||||
|
*/
|
||||||
|
function normalizePermissions(permissions) {
|
||||||
|
if (!permissions) {
|
||||||
|
return [] // 空 = 全部服务
|
||||||
|
}
|
||||||
|
if (Array.isArray(permissions)) {
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
// 尝试解析 JSON 字符串(新格式存储)
|
||||||
|
if (typeof permissions === 'string') {
|
||||||
|
if (permissions.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(permissions)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败,继续处理为普通字符串
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 旧格式 'all' 转为空数组
|
||||||
|
if (permissions === 'all') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
// 兼容逗号分隔格式(修复历史错误数据,如 "claude,openai")
|
||||||
|
if (permissions.includes(',')) {
|
||||||
|
return permissions
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
// 旧单个字符串转为数组
|
||||||
|
return [permissions]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有访问特定服务的权限
|
||||||
|
* @param {string|array} permissions - 权限数据
|
||||||
|
* @param {string} service - 服务名称(claude/gemini/openai/droid)
|
||||||
|
* @returns {boolean} - 是否有权限
|
||||||
|
*/
|
||||||
|
function hasPermission(permissions, service) {
|
||||||
|
const perms = normalizePermissions(permissions)
|
||||||
|
return perms.length === 0 || perms.includes(service) // 空数组 = 全部服务
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAccountTypeKey(type) {
|
function normalizeAccountTypeKey(type) {
|
||||||
if (!type) {
|
if (!type) {
|
||||||
return null
|
return null
|
||||||
@@ -89,7 +142,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId = null,
|
azureOpenaiAccountId = null,
|
||||||
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
||||||
droidAccountId = null,
|
droidAccountId = null,
|
||||||
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all'
|
permissions = [], // 数组格式,空数组表示全部服务,如 ['claude', 'gemini']
|
||||||
isActive = true,
|
isActive = true,
|
||||||
concurrencyLimit = 0,
|
concurrencyLimit = 0,
|
||||||
rateLimitWindow = null,
|
rateLimitWindow = null,
|
||||||
@@ -132,7 +185,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
||||||
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
||||||
droidAccountId: droidAccountId || '',
|
droidAccountId: droidAccountId || '',
|
||||||
permissions: permissions || 'all',
|
permissions: JSON.stringify(normalizePermissions(permissions)),
|
||||||
enableModelRestriction: String(enableModelRestriction),
|
enableModelRestriction: String(enableModelRestriction),
|
||||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||||
enableClientRestriction: String(enableClientRestriction || false),
|
enableClientRestriction: String(enableClientRestriction || false),
|
||||||
@@ -186,7 +239,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||||
droidAccountId: keyData.droidAccountId,
|
droidAccountId: keyData.droidAccountId,
|
||||||
permissions: keyData.permissions,
|
permissions: normalizePermissions(keyData.permissions),
|
||||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||||
@@ -338,7 +391,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||||
droidAccountId: keyData.droidAccountId,
|
droidAccountId: keyData.droidAccountId,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: normalizePermissions(keyData.permissions),
|
||||||
tokenLimit: parseInt(keyData.tokenLimit),
|
tokenLimit: parseInt(keyData.tokenLimit),
|
||||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||||
@@ -467,7 +520,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId,
|
bedrockAccountId: keyData.bedrockAccountId,
|
||||||
droidAccountId: keyData.droidAccountId,
|
droidAccountId: keyData.droidAccountId,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: normalizePermissions(keyData.permissions),
|
||||||
tokenLimit: parseInt(keyData.tokenLimit),
|
tokenLimit: parseInt(keyData.tokenLimit),
|
||||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||||
@@ -525,7 +578,7 @@ class ApiKeyService {
|
|||||||
key.isActive = key.isActive === 'true'
|
key.isActive = key.isActive === 'true'
|
||||||
key.enableModelRestriction = key.enableModelRestriction === 'true'
|
key.enableModelRestriction = key.enableModelRestriction === 'true'
|
||||||
key.enableClientRestriction = key.enableClientRestriction === 'true'
|
key.enableClientRestriction = key.enableClientRestriction === 'true'
|
||||||
key.permissions = key.permissions || 'all' // 兼容旧数据
|
key.permissions = normalizePermissions(key.permissions)
|
||||||
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
|
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
|
||||||
key.totalCostLimit = parseFloat(key.totalCostLimit || 0)
|
key.totalCostLimit = parseFloat(key.totalCostLimit || 0)
|
||||||
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
|
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
|
||||||
@@ -708,6 +761,9 @@ class ApiKeyService {
|
|||||||
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
|
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
|
||||||
// 特殊处理数组字段
|
// 特殊处理数组字段
|
||||||
updatedData[field] = JSON.stringify(value || [])
|
updatedData[field] = JSON.stringify(value || [])
|
||||||
|
} else if (field === 'permissions') {
|
||||||
|
// 权限字段:规范化后JSON序列化,与createApiKey保持一致
|
||||||
|
updatedData[field] = JSON.stringify(normalizePermissions(value))
|
||||||
} else if (
|
} else if (
|
||||||
field === 'enableModelRestriction' ||
|
field === 'enableModelRestriction' ||
|
||||||
field === 'enableClientRestriction' ||
|
field === 'enableClientRestriction' ||
|
||||||
@@ -974,6 +1030,9 @@ class ApiKeyService {
|
|||||||
logger.database(
|
logger.database(
|
||||||
`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`
|
`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 记录 Claude 周费用(如果适用)
|
||||||
|
await this.recordClaudeWeeklyCost(keyId, costInfo.costs.total, model, null)
|
||||||
} else {
|
} else {
|
||||||
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
|
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
|
||||||
}
|
}
|
||||||
@@ -1037,35 +1096,31 @@ class ApiKeyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户)
|
// 📊 记录 Claude 模型周费用(API Key 维度)
|
||||||
async recordOpusCost(keyId, cost, model, accountType) {
|
async recordClaudeWeeklyCost(keyId, cost, model, accountType) {
|
||||||
try {
|
try {
|
||||||
// 判断是否为 Opus 模型
|
// 判断是否为 Claude 系列模型(包含 Bedrock 格式等)
|
||||||
if (!model || !model.toLowerCase().includes('claude-opus')) {
|
if (!isClaudeFamilyModel(model)) {
|
||||||
return // 不是 Opus 模型,直接返回
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否为 claude、claude-console 或 ccr 账户
|
// 记录 Claude 周费用
|
||||||
if (
|
|
||||||
!accountType ||
|
|
||||||
(accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr')
|
|
||||||
) {
|
|
||||||
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`)
|
|
||||||
return // 不是 claude 账户,直接返回
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录 Opus 周费用
|
|
||||||
await redis.incrementWeeklyOpusCost(keyId, cost)
|
await redis.incrementWeeklyOpusCost(keyId, cost)
|
||||||
logger.database(
|
logger.database(
|
||||||
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(
|
`💰 Recorded Claude weekly cost for ${keyId}: $${cost.toFixed(
|
||||||
6
|
6
|
||||||
)}, model: ${model}, account type: ${accountType}`
|
)}, model: ${model}${accountType ? `, account type: ${accountType}` : ''}`
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to record Opus cost:', error)
|
logger.error('❌ Failed to record Claude weekly cost:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 向后兼容:旧名字是 Opus-only 口径;现在周费用统计已扩展为 Claude 全模型口径。
|
||||||
|
async recordOpusCost(keyId, cost, model, accountType) {
|
||||||
|
return this.recordClaudeWeeklyCost(keyId, cost, model, accountType)
|
||||||
|
}
|
||||||
|
|
||||||
// 📊 记录使用情况(新版本,支持详细的缓存类型)
|
// 📊 记录使用情况(新版本,支持详细的缓存类型)
|
||||||
async recordUsageWithDetails(
|
async recordUsageWithDetails(
|
||||||
keyId,
|
keyId,
|
||||||
@@ -1165,8 +1220,8 @@ class ApiKeyService {
|
|||||||
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
|
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 记录 Opus 周费用(如果适用)
|
// 记录 Claude 周费用(如果适用)
|
||||||
await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType)
|
await this.recordClaudeWeeklyCost(keyId, costInfo.totalCost, model, accountType)
|
||||||
|
|
||||||
// 记录详细的缓存费用(如果有)
|
// 记录详细的缓存费用(如果有)
|
||||||
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
|
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
|
||||||
@@ -1568,7 +1623,7 @@ class ApiKeyService {
|
|||||||
userId: keyData.userId,
|
userId: keyData.userId,
|
||||||
userUsername: keyData.userUsername,
|
userUsername: keyData.userUsername,
|
||||||
createdBy: keyData.createdBy,
|
createdBy: keyData.createdBy,
|
||||||
permissions: keyData.permissions,
|
permissions: normalizePermissions(keyData.permissions),
|
||||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||||
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
||||||
// 所有平台账户绑定字段
|
// 所有平台账户绑定字段
|
||||||
@@ -1820,4 +1875,8 @@ const apiKeyService = new ApiKeyService()
|
|||||||
// 为了方便其他服务调用,导出 recordUsage 方法
|
// 为了方便其他服务调用,导出 recordUsage 方法
|
||||||
apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService)
|
apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService)
|
||||||
|
|
||||||
|
// 导出权限辅助函数供路由使用
|
||||||
|
apiKeyService.hasPermission = hasPermission
|
||||||
|
apiKeyService.normalizePermissions = normalizePermissions
|
||||||
|
|
||||||
module.exports = apiKeyService
|
module.exports = apiKeyService
|
||||||
|
|||||||
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
const axios = require('axios')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const ProxyHelper = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider 抽象基类
|
||||||
|
* 各平台 Provider 需继承并实现 queryBalance(account)
|
||||||
|
*/
|
||||||
|
class BaseBalanceProvider {
|
||||||
|
constructor(platform) {
|
||||||
|
this.platform = platform
|
||||||
|
this.logger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询余额(抽象方法)
|
||||||
|
* @param {object} account - 账户对象
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
* 形如:
|
||||||
|
* {
|
||||||
|
* balance: number|null,
|
||||||
|
* currency?: string,
|
||||||
|
* quota?: { daily, used, remaining, resetAt, percentage, unlimited? },
|
||||||
|
* queryMethod?: 'api'|'field'|'local',
|
||||||
|
* rawData?: any
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
async queryBalance(_account) {
|
||||||
|
throw new Error('queryBalance 方法必须由子类实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用 HTTP 请求方法(支持代理)
|
||||||
|
* @param {string} url
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} account
|
||||||
|
*/
|
||||||
|
async makeRequest(url, options = {}, account = {}) {
|
||||||
|
const config = {
|
||||||
|
url,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: options.headers || {},
|
||||||
|
timeout: options.timeout || 15000,
|
||||||
|
data: options.data,
|
||||||
|
params: options.params,
|
||||||
|
responseType: options.responseType
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyConfig = account.proxyConfig || account.proxy
|
||||||
|
if (proxyConfig) {
|
||||||
|
const agent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
|
if (agent) {
|
||||||
|
config.httpAgent = agent
|
||||||
|
config.httpsAgent = agent
|
||||||
|
config.proxy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios(config)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data,
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.response?.status
|
||||||
|
const message = error.response?.data?.message || error.message || '请求失败'
|
||||||
|
this.logger.debug(`余额 Provider HTTP 请求失败: ${url} (${this.platform})`, {
|
||||||
|
status,
|
||||||
|
message
|
||||||
|
})
|
||||||
|
return { success: false, status, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从账户字段读取 dailyQuota / dailyUsage(通用降级方案)
|
||||||
|
* 注意:部分平台 dailyUsage 字段可能不是实时值,最终以 AccountBalanceService 的本地统计为准
|
||||||
|
*/
|
||||||
|
readQuotaFromFields(account) {
|
||||||
|
const dailyQuota = Number(account?.dailyQuota || 0)
|
||||||
|
const dailyUsage = Number(account?.dailyUsage || 0)
|
||||||
|
|
||||||
|
// 无限制
|
||||||
|
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: Infinity,
|
||||||
|
used: Number.isFinite(dailyUsage) ? dailyUsage : 0,
|
||||||
|
remaining: Infinity,
|
||||||
|
percentage: 0,
|
||||||
|
unlimited: true
|
||||||
|
},
|
||||||
|
queryMethod: 'field'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = Number.isFinite(dailyUsage) ? dailyUsage : 0
|
||||||
|
const remaining = Math.max(0, dailyQuota - used)
|
||||||
|
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: remaining,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: dailyQuota,
|
||||||
|
used,
|
||||||
|
remaining,
|
||||||
|
percentage: Math.round(percentage * 100) / 100
|
||||||
|
},
|
||||||
|
queryMethod: 'field'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCurrency(data) {
|
||||||
|
return data?.currency || data?.Currency || 'USD'
|
||||||
|
}
|
||||||
|
|
||||||
|
async safeExecute(fn, fallbackValue = null) {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`余额 Provider 执行失败: ${this.platform}`, error)
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BaseBalanceProvider
|
||||||
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
const claudeAccountService = require('../claudeAccountService')
|
||||||
|
|
||||||
|
class ClaudeBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('claude')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude(OAuth):优先尝试获取 OAuth usage(用于配额/使用信息),不强行提供余额金额
|
||||||
|
*/
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`查询 Claude 余额(OAuth usage): ${account?.id}`)
|
||||||
|
|
||||||
|
// 仅 OAuth 账户可用;失败时降级
|
||||||
|
const usageData = await claudeAccountService.fetchOAuthUsage(account.id).catch(() => null)
|
||||||
|
if (!usageData) {
|
||||||
|
return { balance: null, currency: 'USD', queryMethod: 'local' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: usageData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ClaudeBalanceProvider
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
|
||||||
|
class ClaudeConsoleBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('claude-console')
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`查询 Claude Console 余额(字段): ${account?.id}`)
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ClaudeConsoleBalanceProvider
|
||||||
250
src/services/balanceProviders/geminiBalanceProvider.js
Normal file
250
src/services/balanceProviders/geminiBalanceProvider.js
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
const antigravityClient = require('../antigravityClient')
|
||||||
|
const geminiAccountService = require('../geminiAccountService')
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||||
|
|
||||||
|
function clamp01(value) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (value < 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (value > 1) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuotaCategory(displayName, modelId) {
|
||||||
|
const name = String(displayName || '')
|
||||||
|
const id = String(modelId || '')
|
||||||
|
|
||||||
|
if (name.includes('Gemini') && name.includes('Pro')) {
|
||||||
|
return 'Gemini Pro'
|
||||||
|
}
|
||||||
|
if (name.includes('Gemini') && name.includes('Flash')) {
|
||||||
|
return 'Gemini Flash'
|
||||||
|
}
|
||||||
|
if (name.includes('Gemini') && name.toLowerCase().includes('image')) {
|
||||||
|
return 'Gemini Image'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes('Claude') || name.includes('GPT-OSS')) {
|
||||||
|
return 'Claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.startsWith('gemini-3-pro-') || id.startsWith('gemini-2.5-pro')) {
|
||||||
|
return 'Gemini Pro'
|
||||||
|
}
|
||||||
|
if (id.startsWith('gemini-3-flash') || id.startsWith('gemini-2.5-flash')) {
|
||||||
|
return 'Gemini Flash'
|
||||||
|
}
|
||||||
|
if (id.includes('image')) {
|
||||||
|
return 'Gemini Image'
|
||||||
|
}
|
||||||
|
if (id.includes('claude') || id.includes('gpt-oss')) {
|
||||||
|
return 'Claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
return name || id || 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAntigravityQuota(modelsResponse) {
|
||||||
|
const models = modelsResponse && typeof modelsResponse === 'object' ? modelsResponse.models : null
|
||||||
|
|
||||||
|
if (!models || typeof models !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseRemainingFraction = (quotaInfo) => {
|
||||||
|
if (!quotaInfo || typeof quotaInfo !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw =
|
||||||
|
quotaInfo.remainingFraction ??
|
||||||
|
quotaInfo.remaining_fraction ??
|
||||||
|
quotaInfo.remaining ??
|
||||||
|
undefined
|
||||||
|
|
||||||
|
const num = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
|
||||||
|
if (!Number.isFinite(num)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return clamp01(num)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedCategories = new Set(['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image'])
|
||||||
|
const fixedOrder = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
|
||||||
|
|
||||||
|
const categoryMap = new Map()
|
||||||
|
|
||||||
|
for (const [modelId, modelDataRaw] of Object.entries(models)) {
|
||||||
|
if (!modelDataRaw || typeof modelDataRaw !== 'object') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = modelDataRaw.displayName || modelDataRaw.display_name || modelId
|
||||||
|
const quotaInfo = modelDataRaw.quotaInfo || modelDataRaw.quota_info || null
|
||||||
|
|
||||||
|
const remainingFraction = parseRemainingFraction(quotaInfo)
|
||||||
|
if (remainingFraction === null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingPercent = round2(remainingFraction * 100)
|
||||||
|
const usedPercent = round2(100 - remainingPercent)
|
||||||
|
const resetAt = quotaInfo?.resetTime || quotaInfo?.reset_time || null
|
||||||
|
|
||||||
|
const category = normalizeQuotaCategory(displayName, modelId)
|
||||||
|
if (!allowedCategories.has(category)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const entry = {
|
||||||
|
category,
|
||||||
|
modelId,
|
||||||
|
displayName: String(displayName || modelId || category),
|
||||||
|
remainingPercent,
|
||||||
|
usedPercent,
|
||||||
|
resetAt: typeof resetAt === 'string' && resetAt.trim() ? resetAt : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = categoryMap.get(category)
|
||||||
|
if (!existing || entry.remainingPercent < existing.remainingPercent) {
|
||||||
|
categoryMap.set(category, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = fixedOrder.map((category) => {
|
||||||
|
const existing = categoryMap.get(category) || null
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
modelId: '',
|
||||||
|
displayName: category,
|
||||||
|
remainingPercent: null,
|
||||||
|
usedPercent: null,
|
||||||
|
resetAt: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (buckets.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const critical = buckets
|
||||||
|
.filter((item) => item.remainingPercent !== null)
|
||||||
|
.reduce((min, item) => {
|
||||||
|
if (!min) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
return (item.remainingPercent ?? 0) < (min.remainingPercent ?? 0) ? item : min
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
if (!critical) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
type: 'antigravity',
|
||||||
|
total: 100,
|
||||||
|
used: critical.usedPercent,
|
||||||
|
remaining: critical.remainingPercent,
|
||||||
|
percentage: critical.usedPercent,
|
||||||
|
resetAt: critical.resetAt,
|
||||||
|
buckets: buckets.map((item) => ({
|
||||||
|
category: item.category,
|
||||||
|
remaining: item.remainingPercent,
|
||||||
|
used: item.usedPercent,
|
||||||
|
percentage: item.usedPercent,
|
||||||
|
resetAt: item.resetAt
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: {
|
||||||
|
modelsCount: Object.keys(models).length,
|
||||||
|
bucketCount: buckets.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeminiBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('gemini')
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryBalance(account) {
|
||||||
|
const oauthProvider = account?.oauthProvider
|
||||||
|
if (oauthProvider !== OAUTH_PROVIDER_ANTIGRAVITY) {
|
||||||
|
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
return { balance: null, currency: 'USD', queryMethod: 'local' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = String(account?.accessToken || '').trim()
|
||||||
|
const refreshToken = String(account?.refreshToken || '').trim()
|
||||||
|
const proxyConfig = account?.proxyConfig || account?.proxy || null
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Antigravity 账户缺少 accessToken')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetch = async (token) =>
|
||||||
|
await antigravityClient.fetchAvailableModels({
|
||||||
|
accessToken: token,
|
||||||
|
proxyConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
data = await fetch(accessToken)
|
||||||
|
} catch (error) {
|
||||||
|
const status = error?.response?.status
|
||||||
|
if ((status === 401 || status === 403) && refreshToken) {
|
||||||
|
const refreshed = await geminiAccountService.refreshAccessToken(
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
OAUTH_PROVIDER_ANTIGRAVITY
|
||||||
|
)
|
||||||
|
const nextToken = String(refreshed?.access_token || '').trim()
|
||||||
|
if (!nextToken) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
data = await fetch(nextToken)
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = buildAntigravityQuota(data)
|
||||||
|
if (!mapped) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: data || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GeminiBalanceProvider
|
||||||
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
|
||||||
|
class GenericBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor(platform) {
|
||||||
|
super(platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`${this.platform} 暂无专用余额 API,实现降级策略`)
|
||||||
|
|
||||||
|
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
queryMethod: 'local'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GenericBalanceProvider
|
||||||
25
src/services/balanceProviders/index.js
Normal file
25
src/services/balanceProviders/index.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const ClaudeBalanceProvider = require('./claudeBalanceProvider')
|
||||||
|
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
|
||||||
|
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
|
||||||
|
const GenericBalanceProvider = require('./genericBalanceProvider')
|
||||||
|
const GeminiBalanceProvider = require('./geminiBalanceProvider')
|
||||||
|
|
||||||
|
function registerAllProviders(balanceService) {
|
||||||
|
// Claude
|
||||||
|
balanceService.registerProvider('claude', new ClaudeBalanceProvider())
|
||||||
|
balanceService.registerProvider('claude-console', new ClaudeConsoleBalanceProvider())
|
||||||
|
|
||||||
|
// OpenAI / Codex
|
||||||
|
balanceService.registerProvider('openai-responses', new OpenAIResponsesBalanceProvider())
|
||||||
|
balanceService.registerProvider('openai', new GenericBalanceProvider('openai'))
|
||||||
|
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
|
||||||
|
|
||||||
|
// 其他平台(降级)
|
||||||
|
balanceService.registerProvider('gemini', new GeminiBalanceProvider())
|
||||||
|
balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api'))
|
||||||
|
balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock'))
|
||||||
|
balanceService.registerProvider('droid', new GenericBalanceProvider('droid'))
|
||||||
|
balanceService.registerProvider('ccr', new GenericBalanceProvider('ccr'))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerAllProviders }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
|
||||||
|
class OpenAIResponsesBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('openai-responses')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI-Responses:
|
||||||
|
* - 优先使用 dailyQuota 字段(如果配置了额度)
|
||||||
|
* - 可选:尝试调用兼容 API(不同服务商实现不一,失败自动降级)
|
||||||
|
*/
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`查询 OpenAI Responses 余额: ${account?.id}`)
|
||||||
|
|
||||||
|
// 配置了额度时直接返回(字段法)
|
||||||
|
if (account?.dailyQuota && Number(account.dailyQuota) > 0) {
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试调用 usage 接口(兼容性不保证)
|
||||||
|
if (account?.apiKey && account?.baseApi) {
|
||||||
|
const baseApi = String(account.baseApi).replace(/\/$/, '')
|
||||||
|
const response = await this.makeRequest(
|
||||||
|
`${baseApi}/v1/usage`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${account.apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
account
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: this.parseCurrency(response.data),
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
queryMethod: 'local'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OpenAIResponsesBalanceProvider
|
||||||
210
src/services/balanceScriptService.js
Normal file
210
src/services/balanceScriptService.js
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
const vm = require('vm')
|
||||||
|
const axios = require('axios')
|
||||||
|
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSRF防护:检查URL是否访问内网或敏感地址
|
||||||
|
* @param {string} url - 要检查的URL
|
||||||
|
* @returns {boolean} - true表示URL安全
|
||||||
|
*/
|
||||||
|
function isUrlSafe(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const hostname = parsed.hostname.toLowerCase()
|
||||||
|
|
||||||
|
// 禁止的协议
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁止访问localhost和私有IP
|
||||||
|
const privatePatterns = [
|
||||||
|
/^localhost$/i,
|
||||||
|
/^127\./,
|
||||||
|
/^10\./,
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
||||||
|
/^192\.168\./,
|
||||||
|
/^169\.254\./, // AWS metadata
|
||||||
|
/^0\./, // 0.0.0.0
|
||||||
|
/^::1$/,
|
||||||
|
/^fc00:/i,
|
||||||
|
/^fe80:/i,
|
||||||
|
/\.local$/i,
|
||||||
|
/\.internal$/i,
|
||||||
|
/\.localhost$/i
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pattern of privatePatterns) {
|
||||||
|
if (pattern.test(hostname)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可配置脚本余额查询执行器
|
||||||
|
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
|
||||||
|
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
|
||||||
|
*/
|
||||||
|
class BalanceScriptService {
|
||||||
|
/**
|
||||||
|
* 执行脚本:返回标准余额结构 + 原始响应
|
||||||
|
* @param {object} options
|
||||||
|
* - scriptBody: string
|
||||||
|
* - variables: Record<string,string>
|
||||||
|
* - timeoutSeconds: number
|
||||||
|
*/
|
||||||
|
async execute(options = {}) {
|
||||||
|
if (!isBalanceScriptEnabled()) {
|
||||||
|
const error = new Error('余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)')
|
||||||
|
error.code = 'BALANCE_SCRIPT_DISABLED'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptBody = options.scriptBody?.trim()
|
||||||
|
if (!scriptBody) {
|
||||||
|
throw new Error('脚本内容为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = Math.max(1, (options.timeoutSeconds || 10) * 1000)
|
||||||
|
const sandbox = {
|
||||||
|
console,
|
||||||
|
Math,
|
||||||
|
Date
|
||||||
|
}
|
||||||
|
|
||||||
|
let scriptResult
|
||||||
|
try {
|
||||||
|
const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})`
|
||||||
|
const script = new vm.Script(wrapped)
|
||||||
|
scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs })
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`脚本解析失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scriptResult || typeof scriptResult !== 'object') {
|
||||||
|
throw new Error('脚本返回格式无效(需返回 { request, extractor })')
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = options.variables || {}
|
||||||
|
const request = this.applyTemplates(scriptResult.request || {}, variables)
|
||||||
|
const { extractor } = scriptResult
|
||||||
|
|
||||||
|
if (!request?.url || typeof request.url !== 'string') {
|
||||||
|
throw new Error('脚本 request.url 不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSRF防护:验证URL安全性
|
||||||
|
if (!isUrlSafe(request.url)) {
|
||||||
|
throw new Error('脚本 request.url 不安全:禁止访问内网地址、localhost或使用非HTTP(S)协议')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof extractor !== 'function') {
|
||||||
|
throw new Error('脚本 extractor 必须是函数')
|
||||||
|
}
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
url: request.url,
|
||||||
|
method: (request.method || 'GET').toUpperCase(),
|
||||||
|
headers: request.headers || {},
|
||||||
|
timeout: timeoutMs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params) {
|
||||||
|
axiosConfig.params = request.params
|
||||||
|
}
|
||||||
|
if (request.body || request.data) {
|
||||||
|
axiosConfig.data = request.body || request.data
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpResponse
|
||||||
|
try {
|
||||||
|
httpResponse = await axios(axiosConfig)
|
||||||
|
} catch (error) {
|
||||||
|
const { response } = error || {}
|
||||||
|
const { status, data } = response || {}
|
||||||
|
throw new Error(
|
||||||
|
`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = httpResponse?.data
|
||||||
|
|
||||||
|
let extracted = {}
|
||||||
|
try {
|
||||||
|
extracted = extractor(responseData) || {}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`extractor 执行失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = this.mapExtractorResult(extracted, responseData)
|
||||||
|
return {
|
||||||
|
mapped,
|
||||||
|
extracted,
|
||||||
|
response: {
|
||||||
|
status: httpResponse?.status,
|
||||||
|
headers: httpResponse?.headers,
|
||||||
|
data: responseData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTemplates(value, variables) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.replace(/{{(\w+)}}/g, (_, key) => {
|
||||||
|
const trimmed = key.trim()
|
||||||
|
return variables[trimmed] !== undefined ? String(variables[trimmed]) : ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => this.applyTemplates(item, variables))
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const result = {}
|
||||||
|
Object.keys(value).forEach((k) => {
|
||||||
|
result[k] = this.applyTemplates(value[k], variables)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
mapExtractorResult(result = {}, responseData) {
|
||||||
|
const isValid = result.isValid !== false
|
||||||
|
const remaining = Number(result.remaining)
|
||||||
|
const total = Number(result.total)
|
||||||
|
const used = Number(result.used)
|
||||||
|
const currency = result.unit || 'USD'
|
||||||
|
|
||||||
|
const quota =
|
||||||
|
Number.isFinite(total) || Number.isFinite(used)
|
||||||
|
? {
|
||||||
|
total: Number.isFinite(total) ? total : null,
|
||||||
|
used: Number.isFinite(used) ? used : null,
|
||||||
|
remaining: Number.isFinite(remaining) ? remaining : null,
|
||||||
|
percentage:
|
||||||
|
Number.isFinite(total) && total > 0 && Number.isFinite(used)
|
||||||
|
? (used / total) * 100
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: isValid ? 'success' : 'error',
|
||||||
|
errorMessage: isValid ? '' : result.invalidMessage || '套餐无效',
|
||||||
|
balance: Number.isFinite(remaining) ? remaining : null,
|
||||||
|
currency,
|
||||||
|
quota,
|
||||||
|
planName: result.planName || null,
|
||||||
|
extra: result.extra || null,
|
||||||
|
rawData: responseData || result.raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new BalanceScriptService()
|
||||||
@@ -35,12 +35,13 @@ class BedrockAccountService {
|
|||||||
description = '',
|
description = '',
|
||||||
region = process.env.AWS_REGION || 'us-east-1',
|
region = process.env.AWS_REGION || 'us-east-1',
|
||||||
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
|
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
|
||||||
|
bearerToken = null, // AWS Bearer Token for Bedrock API Keys
|
||||||
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||||
isActive = true,
|
isActive = true,
|
||||||
accountType = 'shared', // 'dedicated' or 'shared'
|
accountType = 'shared', // 'dedicated' or 'shared'
|
||||||
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
credentialType = 'default' // 'default', 'access_key', 'bearer_token'
|
credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key)
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -71,6 +72,11 @@ class BedrockAccountService {
|
|||||||
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
|
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加密存储 Bearer Token
|
||||||
|
if (bearerToken) {
|
||||||
|
accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken })
|
||||||
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
|
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
|
||||||
|
|
||||||
@@ -106,9 +112,85 @@ class BedrockAccountService {
|
|||||||
|
|
||||||
const account = JSON.parse(accountData)
|
const account = JSON.parse(accountData)
|
||||||
|
|
||||||
// 解密AWS凭证用于内部使用
|
// 根据凭证类型解密对应的凭证
|
||||||
|
// 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底)
|
||||||
|
try {
|
||||||
|
let accessKeyDecrypted = false
|
||||||
|
let bearerTokenDecrypted = false
|
||||||
|
|
||||||
|
// 第一步:按照 credentialType 尝试解密对应的凭证
|
||||||
|
if (account.credentialType === 'access_key' && account.awsCredentials) {
|
||||||
|
// Access Key 模式:解密 AWS 凭证
|
||||||
|
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||||
|
accessKeyDecrypted = true
|
||||||
|
logger.debug(
|
||||||
|
`🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
} else if (account.credentialType === 'bearer_token' && account.bearerToken) {
|
||||||
|
// Bearer Token 模式:解密 Bearer Token
|
||||||
|
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||||
|
account.bearerToken = decrypted.token
|
||||||
|
bearerTokenDecrypted = true
|
||||||
|
logger.debug(
|
||||||
|
`🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
} else if (!account.credentialType || account.credentialType === 'default') {
|
||||||
|
// 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证
|
||||||
if (account.awsCredentials) {
|
if (account.awsCredentials) {
|
||||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||||
|
accessKeyDecrypted = true
|
||||||
|
}
|
||||||
|
if (account.bearerToken) {
|
||||||
|
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||||
|
account.bearerToken = decrypted.token
|
||||||
|
bearerTokenDecrypted = true
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段
|
||||||
|
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}`
|
||||||
|
)
|
||||||
|
if (account.awsCredentials) {
|
||||||
|
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||||
|
accessKeyDecrypted = true
|
||||||
|
logger.warn(
|
||||||
|
`🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (account.bearerToken) {
|
||||||
|
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||||
|
account.bearerToken = decrypted.token
|
||||||
|
bearerTokenDecrypted = true
|
||||||
|
logger.warn(
|
||||||
|
`🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证至少解密了一种凭证
|
||||||
|
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No valid credentials found in account data'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (decryptError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`,
|
||||||
|
decryptError
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Credentials decryption failed: ${decryptError.message}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
|
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
|
||||||
@@ -155,7 +237,11 @@ class BedrockAccountService {
|
|||||||
updatedAt: account.updatedAt,
|
updatedAt: account.updatedAt,
|
||||||
type: 'bedrock',
|
type: 'bedrock',
|
||||||
platform: 'bedrock',
|
platform: 'bedrock',
|
||||||
hasCredentials: !!account.awsCredentials
|
// 根据凭证类型判断是否有凭证
|
||||||
|
hasCredentials:
|
||||||
|
account.credentialType === 'bearer_token'
|
||||||
|
? !!account.bearerToken
|
||||||
|
: !!account.awsCredentials
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,6 +321,15 @@ class BedrockAccountService {
|
|||||||
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新 Bearer Token
|
||||||
|
if (updates.bearerToken !== undefined) {
|
||||||
|
if (updates.bearerToken) {
|
||||||
|
account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken })
|
||||||
|
} else {
|
||||||
|
delete account.bearerToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
|
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
|
||||||
if (updates.subscriptionExpiresAt !== undefined) {
|
if (updates.subscriptionExpiresAt !== undefined) {
|
||||||
@@ -345,13 +440,45 @@ class BedrockAccountService {
|
|||||||
|
|
||||||
const account = accountResult.data
|
const account = accountResult.data
|
||||||
|
|
||||||
logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`)
|
logger.info(
|
||||||
|
`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
|
||||||
// 尝试获取模型列表来测试连接
|
// 验证凭证是否已解密
|
||||||
|
const hasValidCredentials =
|
||||||
|
(account.credentialType === 'access_key' && account.awsCredentials) ||
|
||||||
|
(account.credentialType === 'bearer_token' && account.bearerToken) ||
|
||||||
|
(!account.credentialType && (account.awsCredentials || account.bearerToken))
|
||||||
|
|
||||||
|
if (!hasValidCredentials) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No valid credentials found after decryption'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试创建 Bedrock 客户端来验证凭证格式
|
||||||
|
try {
|
||||||
|
bedrockRelayService._getBedrockClient(account.region, account)
|
||||||
|
logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`)
|
||||||
|
} catch (clientError) {
|
||||||
|
logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to create Bedrock client: ${clientError.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可用模型列表(硬编码,但至少验证了凭证格式正确)
|
||||||
const models = await bedrockRelayService.getAvailableModels(account)
|
const models = await bedrockRelayService.getAvailableModels(account)
|
||||||
|
|
||||||
if (models && models.length > 0) {
|
if (models && models.length > 0) {
|
||||||
logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`)
|
logger.info(
|
||||||
|
`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -376,6 +503,135 @@ class BedrockAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🧪 测试 Bedrock 账户连接(SSE 流式返回,供前端测试页面使用)
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {Object} res - Express response 对象
|
||||||
|
* @param {string} model - 测试使用的模型
|
||||||
|
*/
|
||||||
|
async testAccountConnection(accountId, res, model = null) {
|
||||||
|
const { InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const accountResult = await this.getAccount(accountId)
|
||||||
|
if (!accountResult.success) {
|
||||||
|
throw new Error(accountResult.error || 'Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = accountResult.data
|
||||||
|
|
||||||
|
// 根据账户类型选择合适的测试模型
|
||||||
|
if (!model) {
|
||||||
|
// Access Key 模式使用 Haiku(更快更便宜)
|
||||||
|
model = account.defaultModel || 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🧪 Testing Bedrock account connection: ${account.name} (${accountId}), model: ${model}, credentialType: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no')
|
||||||
|
res.status(200)
|
||||||
|
|
||||||
|
// 发送 test_start 事件
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_start' })}\n\n`)
|
||||||
|
|
||||||
|
// 构造测试请求体(Bedrock 格式)
|
||||||
|
const bedrockPayload = {
|
||||||
|
anthropic_version: 'bedrock-2023-05-31',
|
||||||
|
max_tokens: 256,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content:
|
||||||
|
'Hello! Please respond with a simple greeting to confirm the connection is working. And tell me who are you?'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Bedrock 客户端
|
||||||
|
const region = account.region || bedrockRelayService.defaultRegion
|
||||||
|
const client = bedrockRelayService._getBedrockClient(region, account)
|
||||||
|
|
||||||
|
// 创建流式调用命令
|
||||||
|
const command = new InvokeModelWithResponseStreamCommand({
|
||||||
|
modelId: model,
|
||||||
|
body: JSON.stringify(bedrockPayload),
|
||||||
|
contentType: 'application/json',
|
||||||
|
accept: 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug(`🌊 Bedrock test stream - model: ${model}, region: ${region}`)
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
const response = await client.send(command)
|
||||||
|
|
||||||
|
// 处理流式响应
|
||||||
|
// let responseText = ''
|
||||||
|
for await (const chunk of response.body) {
|
||||||
|
if (chunk.chunk) {
|
||||||
|
const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes))
|
||||||
|
|
||||||
|
// 提取文本内容
|
||||||
|
if (chunkData.type === 'content_block_delta' && chunkData.delta?.text) {
|
||||||
|
const { text } = chunkData.delta
|
||||||
|
// responseText += text
|
||||||
|
|
||||||
|
// 发送 content 事件
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测错误
|
||||||
|
if (chunkData.type === 'error') {
|
||||||
|
throw new Error(chunkData.error?.message || 'Bedrock API error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
logger.info(`✅ Bedrock test completed - model: ${model}, duration: ${duration}ms`)
|
||||||
|
|
||||||
|
// 发送 message_stop 事件(前端兼容)
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
|
||||||
|
|
||||||
|
// 发送 test_complete 事件
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||||
|
|
||||||
|
// 结束响应
|
||||||
|
res.end()
|
||||||
|
|
||||||
|
logger.info(`✅ Test request completed for Bedrock account: ${account.name}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Test Bedrock account connection failed:`, error)
|
||||||
|
|
||||||
|
// 发送错误事件给前端
|
||||||
|
try {
|
||||||
|
// 检查响应流是否仍然可写
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
res.status(200)
|
||||||
|
}
|
||||||
|
const errorMsg = error.message || '测试失败'
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.error('Failed to write error to response stream:', writeError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不再重新抛出错误,避免路由层再次处理
|
||||||
|
// throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查账户订阅是否过期
|
* 检查账户订阅是否过期
|
||||||
* @param {Object} account - 账户对象
|
* @param {Object} account - 账户对象
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const {
|
|||||||
const { fromEnv } = require('@aws-sdk/credential-providers')
|
const { fromEnv } = require('@aws-sdk/credential-providers')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
|
const userMessageQueueService = require('./userMessageQueueService')
|
||||||
|
|
||||||
class BedrockRelayService {
|
class BedrockRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -47,13 +48,17 @@ class BedrockRelayService {
|
|||||||
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
|
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
|
||||||
sessionToken: bedrockAccount.awsCredentials.sessionToken
|
sessionToken: bedrockAccount.awsCredentials.sessionToken
|
||||||
}
|
}
|
||||||
|
} else if (bedrockAccount?.bearerToken) {
|
||||||
|
// Bearer Token 模式:AWS SDK >= 3.400.0 会自动检测环境变量
|
||||||
|
clientConfig.token = { token: bedrockAccount.bearerToken }
|
||||||
|
logger.debug(`🔑 使用 Bearer Token 认证 - 账户: ${bedrockAccount.name || 'unknown'}`)
|
||||||
} else {
|
} else {
|
||||||
// 检查是否有环境变量凭证
|
// 检查是否有环境变量凭证
|
||||||
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||||
clientConfig.credentials = fromEnv()
|
clientConfig.credentials = fromEnv()
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
|
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或Bearer Token,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +74,68 @@ class BedrockRelayService {
|
|||||||
|
|
||||||
// 处理非流式请求
|
// 处理非流式请求
|
||||||
async handleNonStreamRequest(requestBody, bedrockAccount = null) {
|
async handleNonStreamRequest(requestBody, bedrockAccount = null) {
|
||||||
|
const accountId = bedrockAccount?.id
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error('❌ accountId missing for queue lock in Bedrock handleNonStreamRequest')
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续统计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for Bedrock account ${accountId}`,
|
||||||
|
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for Bedrock account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modelId = this._selectModel(requestBody, bedrockAccount)
|
const modelId = this._selectModel(requestBody, bedrockAccount)
|
||||||
const region = this._selectRegion(modelId, bedrockAccount)
|
const region = this._selectRegion(modelId, bedrockAccount)
|
||||||
const client = this._getBedrockClient(region, bedrockAccount)
|
const client = this._getBedrockClient(region, bedrockAccount)
|
||||||
@@ -90,6 +156,23 @@ class BedrockRelayService {
|
|||||||
const response = await client.send(command)
|
const response = await client.send(command)
|
||||||
const duration = Date.now() - startTime
|
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 responseBody = JSON.parse(new TextDecoder().decode(response.body))
|
||||||
const claudeResponse = this._convertFromBedrockFormat(responseBody)
|
const claudeResponse = this._convertFromBedrockFormat(responseBody)
|
||||||
@@ -106,12 +189,94 @@ class BedrockRelayService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Bedrock非流式请求失败:', error)
|
logger.error('❌ Bedrock非流式请求失败:', error)
|
||||||
throw this._handleBedrockError(error)
|
throw this._handleBedrockError(error)
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
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}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理流式请求
|
// 处理流式请求
|
||||||
async handleStreamRequest(requestBody, bedrockAccount = null, res) {
|
async handleStreamRequest(requestBody, bedrockAccount = null, res) {
|
||||||
|
const accountId = bedrockAccount?.id
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error('❌ accountId missing for queue lock in Bedrock handleStreamRequest')
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续统计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
stream: true,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for Bedrock account ${accountId} (stream)`,
|
||||||
|
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: existingConnection || 'keep-alive',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const errorEvent = `event: error\ndata: ${JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})}\n\n`
|
||||||
|
res.write(errorEvent)
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
res.end()
|
||||||
|
return { success: false, error: errorType }
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for Bedrock account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modelId = this._selectModel(requestBody, bedrockAccount)
|
const modelId = this._selectModel(requestBody, bedrockAccount)
|
||||||
const region = this._selectRegion(modelId, bedrockAccount)
|
const region = this._selectRegion(modelId, bedrockAccount)
|
||||||
const client = this._getBedrockClient(region, bedrockAccount)
|
const client = this._getBedrockClient(region, bedrockAccount)
|
||||||
@@ -131,11 +296,35 @@ class BedrockRelayService {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const response = await client.send(command)
|
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响应头
|
// 设置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, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive',
|
Connection: existingConnection || 'keep-alive',
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||||
})
|
})
|
||||||
@@ -154,8 +343,8 @@ class BedrockRelayService {
|
|||||||
res.write(`event: ${claudeEvent.type}\n`)
|
res.write(`event: ${claudeEvent.type}\n`)
|
||||||
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
|
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
|
||||||
|
|
||||||
// 提取使用统计
|
// 提取使用统计 (usage is reported in message_delta per Claude API spec)
|
||||||
if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) {
|
if (claudeEvent.type === 'message_delta' && claudeEvent.data.usage) {
|
||||||
totalUsage = claudeEvent.data.usage
|
totalUsage = claudeEvent.data.usage
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +380,21 @@ class BedrockRelayService {
|
|||||||
res.end()
|
res.end()
|
||||||
|
|
||||||
throw this._handleBedrockError(error)
|
throw this._handleBedrockError(error)
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
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}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +435,18 @@ class BedrockRelayService {
|
|||||||
_mapToBedrockModel(modelName) {
|
_mapToBedrockModel(modelName) {
|
||||||
// 标准Claude模型名到Bedrock模型名的映射表
|
// 标准Claude模型名到Bedrock模型名的映射表
|
||||||
const modelMapping = {
|
const modelMapping = {
|
||||||
|
// Claude 4.5 Opus
|
||||||
|
'claude-opus-4-5': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
'claude-opus-4-5-20251101': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
|
||||||
|
// Claude 4.5 Sonnet
|
||||||
|
'claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
'claude-sonnet-4-5-20250929': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
|
||||||
|
// Claude 4.5 Haiku
|
||||||
|
'claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
'claude-haiku-4-5-20251001': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
|
||||||
// Claude Sonnet 4
|
// Claude Sonnet 4
|
||||||
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||||
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||||
@@ -360,8 +576,10 @@ class BedrockRelayService {
|
|||||||
return {
|
return {
|
||||||
type: 'message_start',
|
type: 'message_start',
|
||||||
data: {
|
data: {
|
||||||
type: 'message',
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
id: `msg_${Date.now()}_bedrock`,
|
id: `msg_${Date.now()}_bedrock`,
|
||||||
|
type: 'message',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [],
|
content: [],
|
||||||
model: this.defaultModel,
|
model: this.defaultModel,
|
||||||
@@ -371,21 +589,45 @@ class BedrockRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bedrockChunk.type === 'content_block_start') {
|
||||||
|
return {
|
||||||
|
type: 'content_block_start',
|
||||||
|
data: {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: bedrockChunk.index || 0,
|
||||||
|
content_block: bedrockChunk.content_block || { type: 'text', text: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (bedrockChunk.type === 'content_block_delta') {
|
if (bedrockChunk.type === 'content_block_delta') {
|
||||||
return {
|
return {
|
||||||
type: 'content_block_delta',
|
type: 'content_block_delta',
|
||||||
data: {
|
data: {
|
||||||
|
type: 'content_block_delta',
|
||||||
index: bedrockChunk.index || 0,
|
index: bedrockChunk.index || 0,
|
||||||
delta: bedrockChunk.delta || {}
|
delta: bedrockChunk.delta || {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bedrockChunk.type === 'content_block_stop') {
|
||||||
|
return {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
data: {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: bedrockChunk.index || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (bedrockChunk.type === 'message_delta') {
|
if (bedrockChunk.type === 'message_delta') {
|
||||||
return {
|
return {
|
||||||
type: 'message_delta',
|
type: 'message_delta',
|
||||||
data: {
|
data: {
|
||||||
|
type: 'message_delta',
|
||||||
delta: bedrockChunk.delta || {},
|
delta: bedrockChunk.delta || {},
|
||||||
usage: bedrockChunk.usage || {}
|
usage: bedrockChunk.usage || {}
|
||||||
}
|
}
|
||||||
@@ -396,7 +638,7 @@ class BedrockRelayService {
|
|||||||
return {
|
return {
|
||||||
type: 'message_stop',
|
type: 'message_stop',
|
||||||
data: {
|
data: {
|
||||||
usage: bedrockChunk.usage || {}
|
type: 'message_stop'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const ccrAccountService = require('./ccrAccountService')
|
|||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||||
|
const userMessageQueueService = require('./userMessageQueueService')
|
||||||
|
const { isStreamWritable } = require('../utils/streamHelper')
|
||||||
|
|
||||||
class CcrRelayService {
|
class CcrRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -21,8 +23,67 @@ class CcrRelayService {
|
|||||||
) {
|
) {
|
||||||
let abortController = null
|
let abortController = null
|
||||||
let account = null
|
let account = null
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error('❌ accountId missing for queue lock in CCR relayRequest')
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续统计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for CCR account ${accountId}`,
|
||||||
|
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for CCR account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await ccrAccountService.getAccount(accountId)
|
account = await ccrAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -162,6 +223,23 @@ class CcrRelayService {
|
|||||||
)
|
)
|
||||||
const response = await axios(requestConfig)
|
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) {
|
if (clientRequest) {
|
||||||
clientRequest.removeListener('close', handleClientDisconnect)
|
clientRequest.removeListener('close', handleClientDisconnect)
|
||||||
@@ -233,6 +311,21 @@ class CcrRelayService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
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}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +341,77 @@ class CcrRelayService {
|
|||||||
options = {}
|
options = {}
|
||||||
) {
|
) {
|
||||||
let account = null
|
let account = null
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error(
|
||||||
|
'❌ accountId missing for queue lock in CCR relayStreamRequestWithUsageCapture'
|
||||||
|
)
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续<E5908E><E7BBAD>计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
stream: true,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for CCR account ${accountId} (stream)`,
|
||||||
|
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: existingConnection || 'keep-alive',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const errorEvent = `event: error\ndata: ${JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})}\n\n`
|
||||||
|
responseStream.write(errorEvent)
|
||||||
|
responseStream.write('data: [DONE]\n\n')
|
||||||
|
responseStream.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for CCR account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await ccrAccountService.getAccount(accountId)
|
account = await ccrAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -296,14 +459,53 @@ class CcrRelayService {
|
|||||||
accountId,
|
accountId,
|
||||||
usageCallback,
|
usageCallback,
|
||||||
streamTransformer,
|
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)
|
await this._updateLastUsedTime(accountId)
|
||||||
} catch (error) {
|
} catch (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)
|
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
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}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +519,8 @@ class CcrRelayService {
|
|||||||
accountId,
|
accountId,
|
||||||
usageCallback,
|
usageCallback,
|
||||||
streamTransformer = null,
|
streamTransformer = null,
|
||||||
requestOptions = {}
|
requestOptions = {},
|
||||||
|
onResponseHeaderReceived = null
|
||||||
) {
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let aborted = false
|
let aborted = false
|
||||||
@@ -380,8 +583,11 @@ class CcrRelayService {
|
|||||||
// 发送请求
|
// 发送请求
|
||||||
const request = axios(requestConfig)
|
const request = axios(requestConfig)
|
||||||
|
|
||||||
|
// 注意:使用 .then(async ...) 模式处理响应
|
||||||
|
// - 内部的 releaseQueueLock 有独立的 try-catch,不会导致未捕获异常
|
||||||
|
// - queueLockAcquired = false 的赋值会在 finally 执行前完成(JS 单线程保证)
|
||||||
request
|
request
|
||||||
.then((response) => {
|
.then(async (response) => {
|
||||||
logger.debug(`🌊 CCR stream response status: ${response.status}`)
|
logger.debug(`🌊 CCR stream response status: ${response.status}`)
|
||||||
|
|
||||||
// 错误响应处理
|
// 错误响应处理
|
||||||
@@ -404,10 +610,13 @@ class CcrRelayService {
|
|||||||
|
|
||||||
// 设置错误响应的状态码和响应头
|
// 设置错误响应的状态码和响应头
|
||||||
if (!responseStream.headersSent) {
|
if (!responseStream.headersSent) {
|
||||||
|
const existingConnection = responseStream.getHeader
|
||||||
|
? responseStream.getHeader('Connection')
|
||||||
|
: null
|
||||||
const errorHeaders = {
|
const errorHeaders = {
|
||||||
'Content-Type': response.headers['content-type'] || 'application/json',
|
'Content-Type': response.headers['content-type'] || 'application/json',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive'
|
Connection: existingConnection || 'keep-alive'
|
||||||
}
|
}
|
||||||
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
|
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
|
||||||
delete errorHeaders['Transfer-Encoding']
|
delete errorHeaders['Transfer-Encoding']
|
||||||
@@ -417,13 +626,13 @@ class CcrRelayService {
|
|||||||
|
|
||||||
// 直接透传错误数据,不进行包装
|
// 直接透传错误数据,不进行包装
|
||||||
response.data.on('data', (chunk) => {
|
response.data.on('data', (chunk) => {
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write(chunk)
|
responseStream.write(chunk)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
response.data.on('end', () => {
|
response.data.on('end', () => {
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
resolve() // 不抛出异常,正常完成流处理
|
resolve() // 不抛出异常,正常完成流处理
|
||||||
@@ -431,6 +640,19 @@ class CcrRelayService {
|
|||||||
return
|
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) => {
|
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
@@ -444,11 +666,20 @@ class CcrRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 设置响应头
|
// 设置响应头
|
||||||
|
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
|
||||||
if (!responseStream.headersSent) {
|
if (!responseStream.headersSent) {
|
||||||
|
const existingConnection = responseStream.getHeader
|
||||||
|
? responseStream.getHeader('Connection')
|
||||||
|
: null
|
||||||
|
if (existingConnection) {
|
||||||
|
logger.debug(
|
||||||
|
`🔌 [CCR Stream] Preserving existing Connection header: ${existingConnection}`
|
||||||
|
)
|
||||||
|
}
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive',
|
Connection: existingConnection || 'keep-alive',
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||||
}
|
}
|
||||||
@@ -487,12 +718,17 @@ class CcrRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 写入到响应流
|
// 写入到响应流
|
||||||
if (outputLine && !responseStream.destroyed) {
|
if (outputLine && isStreamWritable(responseStream)) {
|
||||||
responseStream.write(`${outputLine}\n`)
|
responseStream.write(`${outputLine}\n`)
|
||||||
|
} else if (outputLine) {
|
||||||
|
// 客户端连接已断开,记录警告
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [CCR] Client disconnected during stream, skipping data for account: ${accountId}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 空行也需要传递
|
// 空行也需要传递
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write('\n')
|
responseStream.write('\n')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,10 +739,6 @@ class CcrRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
response.data.on('end', () => {
|
response.data.on('end', () => {
|
||||||
if (!responseStream.destroyed) {
|
|
||||||
responseStream.end()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果收集到使用统计数据,调用回调
|
// 如果收集到使用统计数据,调用回调
|
||||||
if (usageCallback && Object.keys(collectedUsage).length > 0) {
|
if (usageCallback && Object.keys(collectedUsage).length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -518,12 +750,26 @@ class CcrRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isStreamWritable(responseStream)) {
|
||||||
|
// 等待数据完全 flush 到客户端后再 resolve
|
||||||
|
responseStream.end(() => {
|
||||||
|
logger.debug(
|
||||||
|
`🌊 CCR stream response completed and flushed | bytesWritten: ${responseStream.bytesWritten || 'unknown'}`
|
||||||
|
)
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// 连接已断开,记录警告
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [CCR] Client disconnected before stream end, data may not have been received | account: ${accountId}`
|
||||||
|
)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
response.data.on('error', (err) => {
|
response.data.on('error', (err) => {
|
||||||
logger.error('❌ Stream data error:', err)
|
logger.error('❌ Stream data error:', err)
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
reject(err)
|
reject(err)
|
||||||
@@ -555,7 +801,7 @@ class CcrRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ class ClaudeAccountService {
|
|||||||
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
||||||
unifiedClientId = '', // 统一的客户端标识
|
unifiedClientId = '', // 统一的客户端标识
|
||||||
expiresAt = null, // 账户订阅到期时间
|
expiresAt = null, // 账户订阅到期时间
|
||||||
extInfo = null // 额外扩展信息
|
extInfo = null, // 额外扩展信息
|
||||||
|
maxConcurrency = 0, // 账户级用户消息串行队列:0=使用全局配置,>0=强制启用串行
|
||||||
|
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -136,7 +138,11 @@ class ClaudeAccountService {
|
|||||||
// 账户订阅到期时间
|
// 账户订阅到期时间
|
||||||
subscriptionExpiresAt: expiresAt || '',
|
subscriptionExpiresAt: expiresAt || '',
|
||||||
// 扩展信息
|
// 扩展信息
|
||||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
|
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||||
|
// 账户级用户消息串行队列限制
|
||||||
|
maxConcurrency: maxConcurrency.toString(),
|
||||||
|
// 拦截预热请求
|
||||||
|
interceptWarmup: interceptWarmup.toString()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 兼容旧格式
|
// 兼容旧格式
|
||||||
@@ -168,7 +174,11 @@ class ClaudeAccountService {
|
|||||||
// 账户订阅到期时间
|
// 账户订阅到期时间
|
||||||
subscriptionExpiresAt: expiresAt || '',
|
subscriptionExpiresAt: expiresAt || '',
|
||||||
// 扩展信息
|
// 扩展信息
|
||||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
|
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||||
|
// 账户级用户消息串行队列限制
|
||||||
|
maxConcurrency: maxConcurrency.toString(),
|
||||||
|
// 拦截预热请求
|
||||||
|
interceptWarmup: interceptWarmup.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +226,8 @@ class ClaudeAccountService {
|
|||||||
useUnifiedUserAgent,
|
useUnifiedUserAgent,
|
||||||
useUnifiedClientId,
|
useUnifiedClientId,
|
||||||
unifiedClientId,
|
unifiedClientId,
|
||||||
extInfo: normalizedExtInfo
|
extInfo: normalizedExtInfo,
|
||||||
|
interceptWarmup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +585,11 @@ class ClaudeAccountService {
|
|||||||
// 添加停止原因
|
// 添加停止原因
|
||||||
stoppedReason: account.stoppedReason || null,
|
stoppedReason: account.stoppedReason || null,
|
||||||
// 扩展信息
|
// 扩展信息
|
||||||
extInfo: parsedExtInfo
|
extInfo: parsedExtInfo,
|
||||||
|
// 账户级用户消息串行队列限制
|
||||||
|
maxConcurrency: parseInt(account.maxConcurrency || '0', 10),
|
||||||
|
// 拦截预热请求
|
||||||
|
interceptWarmup: account.interceptWarmup === 'true'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -666,7 +681,9 @@ class ClaudeAccountService {
|
|||||||
'useUnifiedClientId',
|
'useUnifiedClientId',
|
||||||
'unifiedClientId',
|
'unifiedClientId',
|
||||||
'subscriptionExpiresAt',
|
'subscriptionExpiresAt',
|
||||||
'extInfo'
|
'extInfo',
|
||||||
|
'maxConcurrency',
|
||||||
|
'interceptWarmup'
|
||||||
]
|
]
|
||||||
const updatedData = { ...accountData }
|
const updatedData = { ...accountData }
|
||||||
let shouldClearAutoStopFields = false
|
let shouldClearAutoStopFields = false
|
||||||
@@ -681,7 +698,7 @@ class ClaudeAccountService {
|
|||||||
updatedData[field] = this._encryptSensitiveData(value)
|
updatedData[field] = this._encryptSensitiveData(value)
|
||||||
} else if (field === 'proxy') {
|
} else if (field === 'proxy') {
|
||||||
updatedData[field] = value ? JSON.stringify(value) : ''
|
updatedData[field] = value ? JSON.stringify(value) : ''
|
||||||
} else if (field === 'priority') {
|
} else if (field === 'priority' || field === 'maxConcurrency') {
|
||||||
updatedData[field] = value.toString()
|
updatedData[field] = value.toString()
|
||||||
} else if (field === 'subscriptionInfo') {
|
} else if (field === 'subscriptionInfo') {
|
||||||
// 处理订阅信息更新
|
// 处理订阅信息更新
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ class ClaudeConsoleAccountService {
|
|||||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||||
maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制
|
maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制
|
||||||
disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
disableAutoProtection = false, // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||||
|
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// 验证必填字段
|
// 验证必填字段
|
||||||
@@ -117,7 +118,8 @@ class ClaudeConsoleAccountService {
|
|||||||
quotaResetTime, // 额度重置时间
|
quotaResetTime, // 额度重置时间
|
||||||
quotaStoppedAt: '', // 因额度停用的时间
|
quotaStoppedAt: '', // 因额度停用的时间
|
||||||
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
||||||
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护
|
||||||
|
interceptWarmup: interceptWarmup.toString() // 拦截预热请求
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
@@ -156,6 +158,7 @@ class ClaudeConsoleAccountService {
|
|||||||
quotaStoppedAt: null,
|
quotaStoppedAt: null,
|
||||||
maxConcurrentTasks, // 新增:返回并发限制配置
|
maxConcurrentTasks, // 新增:返回并发限制配置
|
||||||
disableAutoProtection, // 新增:返回自动防护开关
|
disableAutoProtection, // 新增:返回自动防护开关
|
||||||
|
interceptWarmup, // 新增:返回预热请求拦截开关
|
||||||
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +220,9 @@ class ClaudeConsoleAccountService {
|
|||||||
// 并发控制相关
|
// 并发控制相关
|
||||||
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
||||||
activeTaskCount,
|
activeTaskCount,
|
||||||
disableAutoProtection: accountData.disableAutoProtection === 'true'
|
disableAutoProtection: accountData.disableAutoProtection === 'true',
|
||||||
|
// 拦截预热请求
|
||||||
|
interceptWarmup: accountData.interceptWarmup === 'true'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,6 +380,9 @@ class ClaudeConsoleAccountService {
|
|||||||
if (updates.disableAutoProtection !== undefined) {
|
if (updates.disableAutoProtection !== undefined) {
|
||||||
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||||
}
|
}
|
||||||
|
if (updates.interceptWarmup !== undefined) {
|
||||||
|
updatedData.interceptWarmup = updates.interceptWarmup.toString()
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const {
|
|||||||
sanitizeErrorMessage,
|
sanitizeErrorMessage,
|
||||||
isAccountDisabledError
|
isAccountDisabledError
|
||||||
} = require('../utils/errorSanitizer')
|
} = require('../utils/errorSanitizer')
|
||||||
|
const userMessageQueueService = require('./userMessageQueueService')
|
||||||
|
const { isStreamWritable } = require('../utils/streamHelper')
|
||||||
|
const { filterForClaude } = require('../utils/headerFilter')
|
||||||
|
|
||||||
class ClaudeConsoleRelayService {
|
class ClaudeConsoleRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -29,8 +32,68 @@ class ClaudeConsoleRelayService {
|
|||||||
let account = null
|
let account = null
|
||||||
const requestId = uuidv4() // 用于并发追踪
|
const requestId = uuidv4() // 用于并发追踪
|
||||||
let concurrencyAcquired = false
|
let concurrencyAcquired = false
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error('❌ accountId missing for queue lock in console relayRequest')
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续统计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
apiKeyName: apiKeyData.name,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for console account ${accountId}, key: ${apiKeyData.name}`,
|
||||||
|
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for console account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await claudeConsoleAccountService.getAccount(accountId)
|
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -203,6 +266,23 @@ class ClaudeConsoleRelayService {
|
|||||||
)
|
)
|
||||||
const response = await axios(requestConfig)
|
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) {
|
if (clientRequest) {
|
||||||
clientRequest.removeListener('close', handleClientDisconnect)
|
clientRequest.removeListener('close', handleClientDisconnect)
|
||||||
@@ -366,6 +446,21 @@ class ClaudeConsoleRelayService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
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}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,8 +479,71 @@ class ClaudeConsoleRelayService {
|
|||||||
const requestId = uuidv4() // 用于并发追踪
|
const requestId = uuidv4() // 用于并发追踪
|
||||||
let concurrencyAcquired = false
|
let concurrencyAcquired = false
|
||||||
let leaseRefreshInterval = null // 租约刷新定时器
|
let leaseRefreshInterval = null // 租约刷新定时器
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
|
||||||
|
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||||
|
// 校验 accountId 非空,避免空值污染队列锁键
|
||||||
|
if (!accountId || accountId === '') {
|
||||||
|
logger.error(
|
||||||
|
'❌ accountId missing for queue lock in console relayStreamRequestWithUsageCapture'
|
||||||
|
)
|
||||||
|
throw new Error('accountId missing for queue lock')
|
||||||
|
}
|
||||||
|
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||||
|
if (!queueResult.acquired && !queueResult.skipped) {
|
||||||
|
// 区分 Redis 后端错误和队列超时
|
||||||
|
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||||
|
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||||
|
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||||
|
const errorMessage = isBackendError
|
||||||
|
? 'Queue service temporarily unavailable, please retry later'
|
||||||
|
: 'User message queue wait timeout, please retry later'
|
||||||
|
const statusCode = isBackendError ? 500 : 503
|
||||||
|
|
||||||
|
// 结构化性能日志,用于后续统计
|
||||||
|
logger.performance('user_message_queue_error', {
|
||||||
|
errorType,
|
||||||
|
errorCode,
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
stream: true,
|
||||||
|
apiKeyName: apiKeyData.name,
|
||||||
|
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue ${errorType} for console account ${accountId} (stream), key: ${apiKeyData.name}`,
|
||||||
|
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: existingConnection || 'keep-alive',
|
||||||
|
'x-user-message-queue-error': errorType
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const errorEvent = `event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: errorType, code: errorCode, message: errorMessage } })}\n\n`
|
||||||
|
responseStream.write(errorEvent)
|
||||||
|
responseStream.write('data: [DONE]\n\n')
|
||||||
|
responseStream.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (queueResult.acquired && !queueResult.skipped) {
|
||||||
|
queueLockAcquired = true
|
||||||
|
queueRequestId = queueResult.requestId
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for console account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await claudeConsoleAccountService.getAccount(accountId)
|
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -483,16 +641,40 @@ class ClaudeConsoleRelayService {
|
|||||||
accountId,
|
accountId,
|
||||||
usageCallback,
|
usageCallback,
|
||||||
streamTransformer,
|
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)
|
await this._updateLastUsedTime(accountId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||||
|
if (error.message === 'Client disconnected') {
|
||||||
|
logger.info(
|
||||||
|
`🔌 Claude Console stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
|
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
// 🛑 清理租约刷新定时器
|
// 🛑 清理租约刷新定时器
|
||||||
@@ -517,6 +699,21 @@ class ClaudeConsoleRelayService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
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}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +727,8 @@ class ClaudeConsoleRelayService {
|
|||||||
accountId,
|
accountId,
|
||||||
usageCallback,
|
usageCallback,
|
||||||
streamTransformer = null,
|
streamTransformer = null,
|
||||||
requestOptions = {}
|
requestOptions = {},
|
||||||
|
onResponseHeaderReceived = null
|
||||||
) {
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let aborted = false
|
let aborted = false
|
||||||
@@ -593,8 +791,11 @@ class ClaudeConsoleRelayService {
|
|||||||
// 发送请求
|
// 发送请求
|
||||||
const request = axios(requestConfig)
|
const request = axios(requestConfig)
|
||||||
|
|
||||||
|
// 注意:使用 .then(async ...) 模式处理响应
|
||||||
|
// - 内部的 releaseQueueLock 有独立的 try-catch,不会导致未捕获异常
|
||||||
|
// - queueLockAcquired = false 的赋值会在 finally 执行前完成(JS 单线程保证)
|
||||||
request
|
request
|
||||||
.then((response) => {
|
.then(async (response) => {
|
||||||
logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`)
|
logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`)
|
||||||
|
|
||||||
// 错误响应处理
|
// 错误响应处理
|
||||||
@@ -682,7 +883,7 @@ class ClaudeConsoleRelayService {
|
|||||||
`🧹 [Stream] [SANITIZED] Error response to client: ${JSON.stringify(sanitizedError)}`
|
`🧹 [Stream] [SANITIZED] Error response to client: ${JSON.stringify(sanitizedError)}`
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write(JSON.stringify(sanitizedError))
|
responseStream.write(JSON.stringify(sanitizedError))
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
@@ -690,7 +891,7 @@ class ClaudeConsoleRelayService {
|
|||||||
const sanitizedText = sanitizeErrorMessage(errorDataForCheck)
|
const sanitizedText = sanitizeErrorMessage(errorDataForCheck)
|
||||||
logger.error(`🧹 [Stream] [SANITIZED] Error response to client: ${sanitizedText}`)
|
logger.error(`🧹 [Stream] [SANITIZED] Error response to client: ${sanitizedText}`)
|
||||||
|
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write(sanitizedText)
|
responseStream.write(sanitizedText)
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
@@ -701,6 +902,19 @@ class ClaudeConsoleRelayService {
|
|||||||
return
|
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) => {
|
claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
@@ -714,11 +928,22 @@ class ClaudeConsoleRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 设置响应头
|
// 设置响应头
|
||||||
|
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
|
||||||
|
// 当并发队列功能启用时,auth.js 会设置 Connection: close 来禁用 Keep-Alive
|
||||||
if (!responseStream.headersSent) {
|
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, {
|
responseStream.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive',
|
Connection: connectionHeader,
|
||||||
'X-Accel-Buffering': 'no'
|
'X-Accel-Buffering': 'no'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -744,20 +969,33 @@ class ClaudeConsoleRelayService {
|
|||||||
buffer = lines.pop() || ''
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
// 转发数据并解析usage
|
// 转发数据并解析usage
|
||||||
if (lines.length > 0 && !responseStream.destroyed) {
|
if (lines.length > 0) {
|
||||||
|
// 检查流是否可写(客户端连接是否有效)
|
||||||
|
if (isStreamWritable(responseStream)) {
|
||||||
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
|
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
|
||||||
|
|
||||||
// 应用流转换器如果有
|
// 应用流转换器如果有
|
||||||
|
let dataToWrite = linesToForward
|
||||||
if (streamTransformer) {
|
if (streamTransformer) {
|
||||||
const transformed = streamTransformer(linesToForward)
|
const transformed = streamTransformer(linesToForward)
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
responseStream.write(transformed)
|
dataToWrite = transformed
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
responseStream.write(linesToForward)
|
dataToWrite = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析SSE数据寻找usage信息
|
if (dataToWrite) {
|
||||||
|
responseStream.write(dataToWrite)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 客户端连接已断开,记录警告(但仍继续解析usage)
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [Console] Client disconnected during stream, skipping ${lines.length} lines for account: ${account?.name || accountId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析SSE数据寻找usage信息(无论连接状态如何)
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data:')) {
|
if (line.startsWith('data:')) {
|
||||||
const jsonStr = line.slice(5).trimStart()
|
const jsonStr = line.slice(5).trimStart()
|
||||||
@@ -865,7 +1103,7 @@ class ClaudeConsoleRelayService {
|
|||||||
`❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`,
|
`❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||||
if (streamTransformer) {
|
if (streamTransformer) {
|
||||||
responseStream.write(
|
responseStream.write(
|
||||||
@@ -888,7 +1126,7 @@ class ClaudeConsoleRelayService {
|
|||||||
response.data.on('end', () => {
|
response.data.on('end', () => {
|
||||||
try {
|
try {
|
||||||
// 处理缓冲区中剩余的数据
|
// 处理缓冲区中剩余的数据
|
||||||
if (buffer.trim() && !responseStream.destroyed) {
|
if (buffer.trim() && isStreamWritable(responseStream)) {
|
||||||
if (streamTransformer) {
|
if (streamTransformer) {
|
||||||
const transformed = streamTransformer(buffer)
|
const transformed = streamTransformer(buffer)
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
@@ -937,12 +1175,33 @@ class ClaudeConsoleRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 确保流正确结束
|
// 确保流正确结束
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.end()
|
// 📊 诊断日志:流结束前状态
|
||||||
|
logger.info(
|
||||||
|
`📤 [STREAM] Ending response | destroyed: ${responseStream.destroyed}, ` +
|
||||||
|
`socketDestroyed: ${responseStream.socket?.destroyed}, ` +
|
||||||
|
`socketBytesWritten: ${responseStream.socket?.bytesWritten || 0}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 禁用 Nagle 算法确保数据立即发送
|
||||||
|
if (responseStream.socket && !responseStream.socket.destroyed) {
|
||||||
|
responseStream.socket.setNoDelay(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('🌊 Claude Console Claude stream response completed')
|
// 等待数据完全 flush 到客户端后再 resolve
|
||||||
|
responseStream.end(() => {
|
||||||
|
logger.info(
|
||||||
|
`✅ [STREAM] Response ended and flushed | socketBytesWritten: ${responseStream.socket?.bytesWritten || 'unknown'}`
|
||||||
|
)
|
||||||
resolve()
|
resolve()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 连接已断开,记录警告
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ [Console] Client disconnected before stream end, data may not have been received | account: ${account?.name || accountId}`
|
||||||
|
)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Error processing stream end:', error)
|
logger.error('❌ Error processing stream end:', error)
|
||||||
reject(error)
|
reject(error)
|
||||||
@@ -954,7 +1213,7 @@ class ClaudeConsoleRelayService {
|
|||||||
`❌ Claude Console stream error (Account: ${account?.name || accountId}):`,
|
`❌ Claude Console stream error (Account: ${account?.name || accountId}):`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||||
if (streamTransformer) {
|
if (streamTransformer) {
|
||||||
responseStream.write(
|
responseStream.write(
|
||||||
@@ -1002,14 +1261,17 @@ class ClaudeConsoleRelayService {
|
|||||||
|
|
||||||
// 发送错误响应
|
// 发送错误响应
|
||||||
if (!responseStream.headersSent) {
|
if (!responseStream.headersSent) {
|
||||||
|
const existingConnection = responseStream.getHeader
|
||||||
|
? responseStream.getHeader('Connection')
|
||||||
|
: null
|
||||||
responseStream.writeHead(error.response?.status || 500, {
|
responseStream.writeHead(error.response?.status || 500, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive'
|
Connection: existingConnection || 'keep-alive'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!responseStream.destroyed) {
|
if (isStreamWritable(responseStream)) {
|
||||||
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||||
if (streamTransformer) {
|
if (streamTransformer) {
|
||||||
responseStream.write(
|
responseStream.write(
|
||||||
@@ -1041,30 +1303,9 @@ class ClaudeConsoleRelayService {
|
|||||||
|
|
||||||
// 🔧 过滤客户端请求头
|
// 🔧 过滤客户端请求头
|
||||||
_filterClientHeaders(clientHeaders) {
|
_filterClientHeaders(clientHeaders) {
|
||||||
const sensitiveHeaders = [
|
// 使用统一的 headerFilter 工具类(白名单模式)
|
||||||
'content-type',
|
// 与 claudeRelayService 保持一致,避免透传 CDN headers 触发上游 API 安全检查
|
||||||
'user-agent',
|
return filterForClaude(clientHeaders)
|
||||||
'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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🕐 更新最后使用时间
|
// 🕐 更新最后使用时间
|
||||||
@@ -1179,7 +1420,7 @@ class ClaudeConsoleRelayService {
|
|||||||
'Cache-Control': 'no-cache'
|
'Cache-Control': 'no-cache'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
if (isStreamWritable(responseStream)) {
|
||||||
responseStream.write(
|
responseStream.write(
|
||||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
|
||||||
)
|
)
|
||||||
|
|||||||
453
src/services/claudeRelayConfigService.js
Normal file
453
src/services/claudeRelayConfigService.js
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* Claude 转发配置服务
|
||||||
|
* 管理全局 Claude Code 限制和会话绑定配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
const CONFIG_KEY = 'claude_relay_config'
|
||||||
|
const SESSION_BINDING_PREFIX = 'original_session_binding:'
|
||||||
|
|
||||||
|
// 默认配置
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
claudeCodeOnlyEnabled: false,
|
||||||
|
globalSessionBindingEnabled: false,
|
||||||
|
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||||
|
sessionBindingTtlDays: 30, // 会话绑定 TTL(天),默认30天
|
||||||
|
// 用户消息队列配置
|
||||||
|
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存缓存(避免频繁 Redis 查询)
|
||||||
|
let configCache = null
|
||||||
|
let configCacheTime = 0
|
||||||
|
const CONFIG_CACHE_TTL = 60000 // 1分钟缓存
|
||||||
|
|
||||||
|
class ClaudeRelayConfigService {
|
||||||
|
/**
|
||||||
|
* 从 metadata.user_id 中提取原始 sessionId
|
||||||
|
* 格式: user_{64位十六进制}_account__session_{uuid}
|
||||||
|
* @param {Object} requestBody - 请求体
|
||||||
|
* @returns {string|null} 原始 sessionId 或 null
|
||||||
|
*/
|
||||||
|
extractOriginalSessionId(requestBody) {
|
||||||
|
if (!requestBody?.metadata?.user_id) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = requestBody.metadata.user_id
|
||||||
|
const match = userId.match(/session_([a-f0-9-]{36})$/i)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取配置(带缓存)
|
||||||
|
* @returns {Promise<Object>} 配置对象
|
||||||
|
*/
|
||||||
|
async getConfig() {
|
||||||
|
try {
|
||||||
|
// 检查缓存
|
||||||
|
if (configCache && Date.now() - configCacheTime < CONFIG_CACHE_TTL) {
|
||||||
|
return configCache
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
logger.warn('⚠️ Redis not connected, using default config')
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await client.get(CONFIG_KEY)
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
configCache = { ...DEFAULT_CONFIG, ...JSON.parse(data) }
|
||||||
|
} else {
|
||||||
|
configCache = { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
|
||||||
|
configCacheTime = Date.now()
|
||||||
|
return configCache
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude relay config:', error)
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新配置
|
||||||
|
* @param {Object} newConfig - 新配置
|
||||||
|
* @param {string} updatedBy - 更新者
|
||||||
|
* @returns {Promise<Object>} 更新后的配置
|
||||||
|
*/
|
||||||
|
async updateConfig(newConfig, updatedBy) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const currentConfig = await this.getConfig()
|
||||||
|
|
||||||
|
const updatedConfig = {
|
||||||
|
...currentConfig,
|
||||||
|
...newConfig,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.set(CONFIG_KEY, JSON.stringify(updatedConfig))
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
configCache = updatedConfig
|
||||||
|
configCacheTime = Date.now()
|
||||||
|
|
||||||
|
logger.info(`✅ Claude relay config updated by ${updatedBy}:`, {
|
||||||
|
claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled,
|
||||||
|
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled,
|
||||||
|
concurrentRequestQueueEnabled: updatedConfig.concurrentRequestQueueEnabled
|
||||||
|
})
|
||||||
|
|
||||||
|
return updatedConfig
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude relay config:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否启用全局 Claude Code 限制
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isClaudeCodeOnlyEnabled() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.claudeCodeOnlyEnabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否启用全局会话绑定
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isGlobalSessionBindingEnabled() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.globalSessionBindingEnabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话绑定错误信息
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async getSessionBindingErrorMessage() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.sessionBindingErrorMessage || DEFAULT_CONFIG.sessionBindingErrorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<Object|null>} 绑定信息或 null
|
||||||
|
*/
|
||||||
|
async getOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
const data = await client.get(key)
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
return JSON.parse(data)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get session binding for ${originalSessionId}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} accountType - 账户类型
|
||||||
|
* @returns {Promise<Object>} 绑定信息
|
||||||
|
*/
|
||||||
|
async setOriginalSessionBinding(originalSessionId, accountId, accountType) {
|
||||||
|
if (!originalSessionId || !accountId || !accountType) {
|
||||||
|
throw new Error('Invalid parameters for session binding')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
|
||||||
|
const binding = {
|
||||||
|
accountId,
|
||||||
|
accountType,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUsedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用配置的 TTL(默认30天)
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||||
|
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||||
|
|
||||||
|
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding created: ${originalSessionId} -> ${accountId} (${accountType})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return binding
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to set session binding for ${originalSessionId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新会话绑定的最后使用时间(续期)
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
*/
|
||||||
|
async touchOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
if (!binding) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.lastUsedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
|
||||||
|
// 使用配置的 TTL(默认30天)
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||||
|
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||||
|
|
||||||
|
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ Failed to touch session binding for ${originalSessionId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查原始会话是否已绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isOriginalSessionBound(originalSessionId) {
|
||||||
|
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
return binding !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证绑定的账户是否可用
|
||||||
|
* @param {Object} binding - 绑定信息
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async validateBoundAccount(binding) {
|
||||||
|
if (!binding || !binding.accountId || !binding.accountType) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { accountType } = binding
|
||||||
|
const { accountId } = binding
|
||||||
|
|
||||||
|
let accountService
|
||||||
|
switch (accountType) {
|
||||||
|
case 'claude-official':
|
||||||
|
accountService = require('./claudeAccountService')
|
||||||
|
break
|
||||||
|
case 'claude-console':
|
||||||
|
accountService = require('./claudeConsoleAccountService')
|
||||||
|
break
|
||||||
|
case 'bedrock':
|
||||||
|
accountService = require('./bedrockAccountService')
|
||||||
|
break
|
||||||
|
case 'ccr':
|
||||||
|
accountService = require('./ccrAccountService')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
logger.warn(`Unknown account type for validation: ${accountType}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await accountService.getAccount(accountId)
|
||||||
|
|
||||||
|
// getAccount() 直接返回账户数据对象或 null,不是 { success, data } 格式
|
||||||
|
if (!account) {
|
||||||
|
logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountData = account
|
||||||
|
|
||||||
|
// 检查账户是否激活
|
||||||
|
if (accountData.isActive === false || accountData.isActive === 'false') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding account not active: ${accountId} (${accountType}), isActive: ${accountData.isActive}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查账户状态(如果存在)
|
||||||
|
if (accountData.status && accountData.status === 'error') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding account has error status: ${accountId} (${accountType}), status: ${accountData.status}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to validate bound account ${binding.accountId}:`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证新会话请求
|
||||||
|
* @param {Object} _requestBody - 请求体(预留参数,当前未使用)
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
|
||||||
|
*/
|
||||||
|
async validateNewSession(_requestBody, originalSessionId) {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
|
||||||
|
if (!cfg.globalSessionBindingEnabled) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有 sessionId,跳过验证(可能是非 Claude Code 客户端)
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingBinding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
|
||||||
|
// 如果会话已存在绑定
|
||||||
|
if (existingBinding) {
|
||||||
|
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||||
|
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||||
|
if (existingBinding.accountType !== 'claude-official') {
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding ignored for non-official account type: ${existingBinding.accountType}`
|
||||||
|
)
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountValid = await this.validateBoundAccount(existingBinding)
|
||||||
|
|
||||||
|
if (!accountValid) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: cfg.sessionBindingErrorMessage,
|
||||||
|
code: 'SESSION_BINDING_INVALID'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 续期
|
||||||
|
await this.touchOriginalSessionBinding(originalSessionId)
|
||||||
|
|
||||||
|
// 已有绑定,允许继续(这是正常的会话延续)
|
||||||
|
return { valid: true, binding: existingBinding }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有绑定,是新会话
|
||||||
|
// 注意:messages.length 检查在此处无法执行,因为我们不知道最终会调度到哪种账户类型
|
||||||
|
// 绑定会在调度后创建,仅针对 claude-official 账户
|
||||||
|
return { valid: true, isNewSession: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
*/
|
||||||
|
async deleteOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
await client.del(key)
|
||||||
|
logger.info(`🗑️ Session binding deleted: ${originalSessionId}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to delete session binding for ${originalSessionId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话绑定统计
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getSessionBindingStats() {
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return { totalBindings: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = '0'
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await client.scan(
|
||||||
|
cursor,
|
||||||
|
'MATCH',
|
||||||
|
`${SESSION_BINDING_PREFIX}*`,
|
||||||
|
'COUNT',
|
||||||
|
100
|
||||||
|
)
|
||||||
|
cursor = newCursor
|
||||||
|
count += keys.length
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalBindings: count
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get session binding stats:', error)
|
||||||
|
return { totalBindings: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除配置缓存(用于测试或强制刷新)
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
configCache = null
|
||||||
|
configCacheTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ClaudeRelayConfigService()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -336,7 +336,12 @@ class DroidRelayService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||||
|
if (error.message === 'Client disconnected') {
|
||||||
|
logger.info(`🔌 Droid relay ended: Client disconnected`)
|
||||||
|
} else {
|
||||||
logger.error(`❌ Droid relay error: ${error.message}`, error)
|
logger.error(`❌ Droid relay error: ${error.message}`, error)
|
||||||
|
}
|
||||||
|
|
||||||
const status = error?.response?.status
|
const status = error?.response?.status
|
||||||
if (status >= 400 && status < 500) {
|
if (status >= 400 && status < 500) {
|
||||||
@@ -634,7 +639,7 @@ class DroidRelayService {
|
|||||||
// 客户端断开连接时清理
|
// 客户端断开连接时清理
|
||||||
clientResponse.on('close', () => {
|
clientResponse.on('close', () => {
|
||||||
if (req && !req.destroyed) {
|
if (req && !req.destroyed) {
|
||||||
req.destroy()
|
req.destroy(new Error('Client disconnected'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,62 @@ const {
|
|||||||
} = require('../utils/tokenRefreshLogger')
|
} = require('../utils/tokenRefreshLogger')
|
||||||
const tokenRefreshService = require('./tokenRefreshService')
|
const tokenRefreshService = require('./tokenRefreshService')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const LRUCache = require('../utils/lruCache')
|
||||||
|
const antigravityClient = require('./antigravityClient')
|
||||||
|
|
||||||
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
|
// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用
|
||||||
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
|
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||||
const OAUTH_CLIENT_SECRET = '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'
|
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||||
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
|
||||||
|
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 配置
|
// 🌐 TCP Keep-Alive Agent 配置
|
||||||
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
|
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
|
||||||
@@ -34,6 +85,117 @@ const keepAliveAgent = new https.Agent({
|
|||||||
|
|
||||||
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
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 ALGORITHM = 'aes-256-cbc'
|
||||||
const ENCRYPTION_SALT = 'gemini-account-salt'
|
const ENCRYPTION_SALT = 'gemini-account-salt'
|
||||||
@@ -124,14 +286,15 @@ setInterval(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 创建 OAuth2 客户端(支持代理配置)
|
// 创建 OAuth2 客户端(支持代理配置)
|
||||||
function createOAuth2Client(redirectUri = null, proxyConfig = null) {
|
function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) {
|
||||||
// 如果没有提供 redirectUri,使用默认值
|
// 如果没有提供 redirectUri,使用默认值
|
||||||
const uri = redirectUri || 'http://localhost:45462'
|
const uri = redirectUri || 'http://localhost:45462'
|
||||||
|
const oauthConfig = getOauthProviderConfig(oauthProvider)
|
||||||
|
|
||||||
// 准备客户端选项
|
// 准备客户端选项
|
||||||
const clientOptions = {
|
const clientOptions = {
|
||||||
clientId: OAUTH_CLIENT_ID,
|
clientId: oauthConfig.clientId,
|
||||||
clientSecret: OAUTH_CLIENT_SECRET,
|
clientSecret: oauthConfig.clientSecret,
|
||||||
redirectUri: uri
|
redirectUri: uri
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +315,17 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成授权 URL (支持 PKCE 和代理)
|
// 生成授权 URL (支持 PKCE 和代理)
|
||||||
async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) {
|
async function generateAuthUrl(
|
||||||
|
state = null,
|
||||||
|
redirectUri = null,
|
||||||
|
proxyConfig = null,
|
||||||
|
oauthProvider = null
|
||||||
|
) {
|
||||||
// 使用新的 redirect URI
|
// 使用新的 redirect URI
|
||||||
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
|
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) {
|
if (proxyConfig) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -172,7 +342,7 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
|
|||||||
const authUrl = oAuth2Client.generateAuthUrl({
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
redirect_uri: finalRedirectUri,
|
redirect_uri: finalRedirectUri,
|
||||||
access_type: 'offline',
|
access_type: 'offline',
|
||||||
scope: OAUTH_SCOPES,
|
scope: oauthConfig.scopes,
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
code_challenge: codeVerifier.codeChallenge,
|
code_challenge: codeVerifier.codeChallenge,
|
||||||
state: stateValue,
|
state: stateValue,
|
||||||
@@ -183,7 +353,8 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
|
|||||||
authUrl,
|
authUrl,
|
||||||
state: stateValue,
|
state: stateValue,
|
||||||
codeVerifier: codeVerifier.codeVerifier,
|
codeVerifier: codeVerifier.codeVerifier,
|
||||||
redirectUri: finalRedirectUri
|
redirectUri: finalRedirectUri,
|
||||||
|
oauthProvider: normalizedProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,11 +415,14 @@ async function exchangeCodeForTokens(
|
|||||||
code,
|
code,
|
||||||
redirectUri = null,
|
redirectUri = null,
|
||||||
codeVerifier = null,
|
codeVerifier = null,
|
||||||
proxyConfig = null
|
proxyConfig = null,
|
||||||
|
oauthProvider = null
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||||
// 创建带代理配置的 OAuth2Client
|
// 创建带代理配置的 OAuth2Client
|
||||||
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig)
|
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider)
|
||||||
|
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -274,7 +448,7 @@ async function exchangeCodeForTokens(
|
|||||||
return {
|
return {
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
refresh_token: tokens.refresh_token,
|
refresh_token: tokens.refresh_token,
|
||||||
scope: tokens.scope || OAUTH_SCOPES.join(' '),
|
scope: tokens.scope || oauthConfig.scopes.join(' '),
|
||||||
token_type: tokens.token_type || 'Bearer',
|
token_type: tokens.token_type || 'Bearer',
|
||||||
expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000
|
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
|
// 创建带代理配置的 OAuth2Client
|
||||||
const oAuth2Client = createOAuth2Client(null, proxyConfig)
|
const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 设置 refresh_token
|
// 设置 refresh_token
|
||||||
@@ -319,7 +495,7 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
|||||||
return {
|
return {
|
||||||
access_token: credentials.access_token,
|
access_token: credentials.access_token,
|
||||||
refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_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',
|
token_type: credentials.token_type || 'Bearer',
|
||||||
expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期
|
expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期
|
||||||
}
|
}
|
||||||
@@ -339,6 +515,8 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
|||||||
async function createAccount(accountData) {
|
async function createAccount(accountData) {
|
||||||
const id = uuidv4()
|
const id = uuidv4()
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
const oauthProvider = normalizeOauthProvider(accountData.oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(oauthProvider)
|
||||||
|
|
||||||
// 处理凭证数据
|
// 处理凭证数据
|
||||||
let geminiOauth = null
|
let geminiOauth = null
|
||||||
@@ -371,7 +549,7 @@ async function createAccount(accountData) {
|
|||||||
geminiOauth = JSON.stringify({
|
geminiOauth = JSON.stringify({
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope: accountData.scope || OAUTH_SCOPES.join(' '),
|
scope: accountData.scope || oauthConfig.scopes.join(' '),
|
||||||
token_type: accountData.tokenType || 'Bearer',
|
token_type: accountData.tokenType || 'Bearer',
|
||||||
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
|
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
|
||||||
})
|
})
|
||||||
@@ -399,7 +577,8 @@ async function createAccount(accountData) {
|
|||||||
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
||||||
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
|
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
|
||||||
// 只有OAuth方式才有scopes,手动添加的没有
|
// 只有OAuth方式才有scopes,手动添加的没有
|
||||||
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
|
scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '',
|
||||||
|
oauthProvider,
|
||||||
|
|
||||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
||||||
@@ -508,6 +687,10 @@ async function updateAccount(accountId, updates) {
|
|||||||
updates.schedulable = updates.schedulable.toString()
|
updates.schedulable = updates.schedulable.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updates.oauthProvider !== undefined) {
|
||||||
|
updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider)
|
||||||
|
}
|
||||||
|
|
||||||
// 加密敏感字段
|
// 加密敏感字段
|
||||||
if (updates.geminiOauth) {
|
if (updates.geminiOauth) {
|
||||||
updates.geminiOauth = encrypt(
|
updates.geminiOauth = encrypt(
|
||||||
@@ -885,12 +1068,13 @@ async function refreshAccountToken(accountId) {
|
|||||||
// 重新获取账户数据(可能已被其他进程刷新)
|
// 重新获取账户数据(可能已被其他进程刷新)
|
||||||
const updatedAccount = await getAccount(accountId)
|
const updatedAccount = await getAccount(accountId)
|
||||||
if (updatedAccount && updatedAccount.accessToken) {
|
if (updatedAccount && updatedAccount.accessToken) {
|
||||||
|
const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider)
|
||||||
const accessToken = decrypt(updatedAccount.accessToken)
|
const accessToken = decrypt(updatedAccount.accessToken)
|
||||||
return {
|
return {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
|
refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
|
||||||
expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0,
|
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'
|
token_type: 'Bearer'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -904,7 +1088,11 @@ async function refreshAccountToken(accountId) {
|
|||||||
|
|
||||||
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
|
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
|
||||||
// 传入账户的代理配置
|
// 传入账户的代理配置
|
||||||
const newTokens = await refreshAccessToken(account.refreshToken, account.proxy)
|
const newTokens = await refreshAccessToken(
|
||||||
|
account.refreshToken,
|
||||||
|
account.proxy,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 更新账户信息
|
// 更新账户信息
|
||||||
const updates = {
|
const updates = {
|
||||||
@@ -1036,14 +1224,15 @@ async function getAccountRateLimitInfo(accountId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理)
|
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理)
|
||||||
async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
|
async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) {
|
||||||
const client = createOAuth2Client(null, proxyConfig)
|
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||||
|
const client = createOAuth2Client(null, proxyConfig, normalizedProvider)
|
||||||
|
|
||||||
const creds = {
|
const creds = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope:
|
scope: oauthConfig.scopes.join(' '),
|
||||||
'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email',
|
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
expiry_date: 1754269905646
|
expiry_date: 1754269905646
|
||||||
}
|
}
|
||||||
@@ -1509,6 +1698,43 @@ async function generateContent(
|
|||||||
return response.data
|
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 生成内容(流式)
|
// 调用 Code Assist API 生成内容(流式)
|
||||||
async function generateContentStream(
|
async function generateContentStream(
|
||||||
client,
|
client,
|
||||||
@@ -1593,6 +1819,46 @@ async function generateContentStream(
|
|||||||
return response.data // 返回流对象
|
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
|
// 更新账户的临时项目 ID
|
||||||
async function updateTempProjectId(accountId, tempProjectId) {
|
async function updateTempProjectId(accountId, tempProjectId) {
|
||||||
if (!tempProjectId) {
|
if (!tempProjectId) {
|
||||||
@@ -1687,10 +1953,12 @@ module.exports = {
|
|||||||
generateEncryptionKey,
|
generateEncryptionKey,
|
||||||
decryptCache, // 暴露缓存对象以便测试和监控
|
decryptCache, // 暴露缓存对象以便测试和监控
|
||||||
countTokens,
|
countTokens,
|
||||||
|
countTokensAntigravity,
|
||||||
generateContent,
|
generateContent,
|
||||||
generateContentStream,
|
generateContentStream,
|
||||||
|
generateContentAntigravity,
|
||||||
|
generateContentStreamAntigravity,
|
||||||
|
fetchAvailableModelsAntigravity,
|
||||||
updateTempProjectId,
|
updateTempProjectId,
|
||||||
resetAccountStatus,
|
resetAccountStatus
|
||||||
OAUTH_CLIENT_ID,
|
|
||||||
OAUTH_SCOPES
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ class OpenAIResponsesRelayService {
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data:')) {
|
if (line.startsWith('data:')) {
|
||||||
try {
|
try {
|
||||||
const jsonStr = line.slice(6)
|
const jsonStr = line.slice(5).trim()
|
||||||
if (jsonStr === '[DONE]') {
|
if (jsonStr === '[DONE]') {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ class RateLimitCleanupService {
|
|||||||
const results = {
|
const results = {
|
||||||
openai: { checked: 0, cleared: 0, errors: [] },
|
openai: { checked: 0, cleared: 0, errors: [] },
|
||||||
claude: { checked: 0, cleared: 0, errors: [] },
|
claude: { checked: 0, cleared: 0, errors: [] },
|
||||||
claudeConsole: { checked: 0, cleared: 0, errors: [] }
|
claudeConsole: { checked: 0, cleared: 0, errors: [] },
|
||||||
|
tokenRefresh: { checked: 0, refreshed: 0, errors: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理 OpenAI 账号
|
// 清理 OpenAI 账号
|
||||||
@@ -84,21 +85,29 @@ class RateLimitCleanupService {
|
|||||||
// 清理 Claude Console 账号
|
// 清理 Claude Console 账号
|
||||||
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
||||||
|
|
||||||
|
// 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
|
||||||
|
await this.proactiveRefreshClaudeTokens(results.tokenRefresh)
|
||||||
|
|
||||||
const totalChecked =
|
const totalChecked =
|
||||||
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
||||||
const totalCleared =
|
const totalCleared =
|
||||||
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
if (totalCleared > 0) {
|
if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
|
`✅ Rate limit cleanup completed: ${totalCleared}/${totalChecked} accounts cleared, ${results.tokenRefresh.refreshed} tokens refreshed (${duration}ms)`
|
||||||
)
|
)
|
||||||
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
||||||
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
||||||
logger.info(
|
logger.info(
|
||||||
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
||||||
)
|
)
|
||||||
|
if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) {
|
||||||
|
logger.info(
|
||||||
|
` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 发送 webhook 恢复通知
|
// 发送 webhook 恢复通知
|
||||||
if (this.clearedAccounts.length > 0) {
|
if (this.clearedAccounts.length > 0) {
|
||||||
@@ -114,7 +123,8 @@ class RateLimitCleanupService {
|
|||||||
const allErrors = [
|
const allErrors = [
|
||||||
...results.openai.errors,
|
...results.openai.errors,
|
||||||
...results.claude.errors,
|
...results.claude.errors,
|
||||||
...results.claudeConsole.errors
|
...results.claudeConsole.errors,
|
||||||
|
...results.tokenRefresh.errors
|
||||||
]
|
]
|
||||||
if (allErrors.length > 0) {
|
if (allErrors.length > 0) {
|
||||||
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
||||||
@@ -348,6 +358,75 @@ class RateLimitCleanupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动刷新 Claude 账户 Token(防止等待重置期间 Token 过期)
|
||||||
|
* 仅对因限流/配额限制而等待重置的账户执行刷新:
|
||||||
|
* - 429 限流账户(rateLimitAutoStopped=true)
|
||||||
|
* - 5小时限制自动停止账户(fiveHourAutoStopped=true)
|
||||||
|
* 不处理错误状态账户(error/temp_error)
|
||||||
|
*/
|
||||||
|
async proactiveRefreshClaudeTokens(result) {
|
||||||
|
try {
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const accounts = await redis.getAllClaudeAccounts()
|
||||||
|
const now = Date.now()
|
||||||
|
const refreshAheadMs = 30 * 60 * 1000 // 提前30分钟刷新
|
||||||
|
const recentRefreshMs = 5 * 60 * 1000 // 5分钟内刷新过则跳过
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
// 1. 必须激活
|
||||||
|
if (account.isActive !== 'true') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 必须有 refreshToken
|
||||||
|
if (!account.refreshToken) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 【优化】仅处理因限流/配额限制而等待重置的账户
|
||||||
|
// 正常调度的账户会在请求时自动刷新,无需主动刷新
|
||||||
|
// 错误状态账户的 Token 可能已失效,刷新也会失败
|
||||||
|
const isWaitingForReset =
|
||||||
|
account.rateLimitAutoStopped === 'true' || // 429 限流
|
||||||
|
account.fiveHourAutoStopped === 'true' // 5小时限制自动停止
|
||||||
|
if (!isWaitingForReset) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 【优化】如果最近 5 分钟内已刷新,跳过(避免重复刷新)
|
||||||
|
const lastRefreshAt = account.lastRefreshAt ? new Date(account.lastRefreshAt).getTime() : 0
|
||||||
|
if (now - lastRefreshAt < recentRefreshMs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 检查 Token 是否即将过期(30分钟内)
|
||||||
|
const expiresAt = parseInt(account.expiresAt)
|
||||||
|
if (expiresAt && now < expiresAt - refreshAheadMs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 符合条件,执行刷新
|
||||||
|
result.checked++
|
||||||
|
try {
|
||||||
|
await claudeAccountService.refreshAccountToken(account.id)
|
||||||
|
result.refreshed++
|
||||||
|
logger.info(`🔄 Proactively refreshed token: ${account.name} (${account.id})`)
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push({
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
logger.warn(`⚠️ Proactive refresh failed for ${account.name}: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to proactively refresh Claude tokens:', error)
|
||||||
|
result.errors.push({ error: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动触发一次清理(供 API 或 CLI 调用)
|
* 手动触发一次清理(供 API 或 CLI 调用)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ const STAINLESS_HEADER_KEYS = [
|
|||||||
'x-stainless-runtime',
|
'x-stainless-runtime',
|
||||||
'x-stainless-runtime-version'
|
'x-stainless-runtime-version'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 小写 key 到正确大小写格式的映射(用于返回给上游时)
|
||||||
|
const STAINLESS_HEADER_CASE_MAP = {
|
||||||
|
'x-stainless-retry-count': 'X-Stainless-Retry-Count',
|
||||||
|
'x-stainless-timeout': 'X-Stainless-Timeout',
|
||||||
|
'x-stainless-lang': 'X-Stainless-Lang',
|
||||||
|
'x-stainless-package-version': 'X-Stainless-Package-Version',
|
||||||
|
'x-stainless-os': 'X-Stainless-OS',
|
||||||
|
'x-stainless-arch': 'X-Stainless-Arch',
|
||||||
|
'x-stainless-runtime': 'X-Stainless-Runtime',
|
||||||
|
'x-stainless-runtime-version': 'X-Stainless-Runtime-Version'
|
||||||
|
}
|
||||||
const MIN_FINGERPRINT_FIELDS = 4
|
const MIN_FINGERPRINT_FIELDS = 4
|
||||||
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
|
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
|
||||||
|
|
||||||
@@ -135,7 +147,9 @@ function applyFingerprintToHeaders(headers, fingerprint) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
removeHeaderCaseInsensitive(nextHeaders, key)
|
removeHeaderCaseInsensitive(nextHeaders, key)
|
||||||
nextHeaders[key] = fingerprint[key]
|
// 使用正确的大小写格式返回给上游
|
||||||
|
const properCaseKey = STAINLESS_HEADER_CASE_MAP[key] || key
|
||||||
|
nextHeaders[properCaseKey] = fingerprint[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
return nextHeaders
|
return nextHeaders
|
||||||
|
|||||||
@@ -180,8 +180,56 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 统一调度Claude账号(官方和Console)
|
// 🎯 统一调度Claude账号(官方和Console)
|
||||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
async selectAccountForApiKey(
|
||||||
|
apiKeyData,
|
||||||
|
sessionHash = null,
|
||||||
|
requestedModel = null,
|
||||||
|
forcedAccount = null
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
// 🔒 如果有强制绑定的账户(全局会话绑定),仅 claude-official 类型受影响
|
||||||
|
if (forcedAccount && forcedAccount.accountId && forcedAccount.accountType) {
|
||||||
|
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||||
|
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||||
|
if (forcedAccount.accountType !== 'claude-official') {
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding ignored for non-official account type: ${forcedAccount.accountType}, proceeding with normal scheduling`
|
||||||
|
)
|
||||||
|
// 不使用 forcedAccount,继续走下面的正常调度逻辑
|
||||||
|
} else {
|
||||||
|
// claude-official 类型需要检查可用性并强制使用
|
||||||
|
logger.info(
|
||||||
|
`🔗 Forced session binding detected: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
|
||||||
|
const isAvailable = await this._isAccountAvailableForSessionBinding(
|
||||||
|
forcedAccount.accountId,
|
||||||
|
forcedAccount.accountType,
|
||||||
|
requestedModel
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isAvailable) {
|
||||||
|
logger.info(
|
||||||
|
`✅ Using forced session binding account: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
accountId: forcedAccount.accountId,
|
||||||
|
accountType: forcedAccount.accountType
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 绑定账户不可用,抛出特定错误(不 fallback)
|
||||||
|
logger.warn(
|
||||||
|
`❌ Forced session binding account unavailable: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
const error = new Error('Session binding account unavailable')
|
||||||
|
error.code = 'SESSION_BINDING_ACCOUNT_UNAVAILABLE'
|
||||||
|
error.accountId = forcedAccount.accountId
|
||||||
|
error.accountType = forcedAccount.accountType
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解析供应商前缀
|
// 解析供应商前缀
|
||||||
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
||||||
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
||||||
@@ -1711,6 +1759,67 @@ class UnifiedClaudeScheduler {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 检查 claude-official 账户是否可用于会话绑定
|
||||||
|
* 注意:此方法仅用于 claude-official 类型账户,其他类型不受会话绑定限制
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} accountType - 账户类型(应为 'claude-official')
|
||||||
|
* @param {string} _requestedModel - 请求的模型(保留参数,当前未使用)
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async _isAccountAvailableForSessionBinding(accountId, accountType, _requestedModel = null) {
|
||||||
|
try {
|
||||||
|
// 此方法仅处理 claude-official 类型
|
||||||
|
if (accountType !== 'claude-official') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding: _isAccountAvailableForSessionBinding called for non-official type: ${accountType}`
|
||||||
|
)
|
||||||
|
return true // 非 claude-official 类型不受限制
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await redis.getClaudeAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} not found`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = account.isActive === 'true' || account.isActive === true
|
||||||
|
const { status } = account
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is not active`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'error' || status === 'temp_error') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding: Claude OAuth account ${accountId} has error status: ${status}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否被限流
|
||||||
|
if (await claudeAccountService.isAccountRateLimited(accountId)) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is rate limited`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查临时不可用
|
||||||
|
if (await this.isAccountTemporarilyUnavailable(accountId, accountType)) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is temporarily unavailable`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Error checking account availability for session binding: ${accountId} (${accountType})`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new UnifiedClaudeScheduler()
|
module.exports = new UnifiedClaudeScheduler()
|
||||||
|
|||||||
@@ -4,11 +4,35 @@ const accountGroupService = require('./accountGroupService')
|
|||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const logger = require('../utils/logger')
|
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 {
|
class UnifiedGeminiScheduler {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:'
|
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) {
|
_isSchedulable(schedulable) {
|
||||||
// 如果是 undefined 或 null,默认为可调度
|
// 如果是 undefined 或 null,默认为可调度
|
||||||
@@ -32,7 +56,8 @@ class UnifiedGeminiScheduler {
|
|||||||
requestedModel = null,
|
requestedModel = null,
|
||||||
options = {}
|
options = {}
|
||||||
) {
|
) {
|
||||||
const { allowApiAccounts = false } = options
|
const { allowApiAccounts = false, oauthProvider = null } = options
|
||||||
|
const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 如果API Key绑定了专属账户或分组,优先使用
|
// 如果API Key绑定了专属账户或分组,优先使用
|
||||||
@@ -83,6 +108,14 @@ class UnifiedGeminiScheduler {
|
|||||||
this._isActive(boundAccount.isActive) &&
|
this._isActive(boundAccount.isActive) &&
|
||||||
boundAccount.status !== 'error'
|
boundAccount.status !== 'error'
|
||||||
) {
|
) {
|
||||||
|
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(
|
logger.info(
|
||||||
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
|
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
|
||||||
)
|
)
|
||||||
@@ -92,6 +125,7 @@ class UnifiedGeminiScheduler {
|
|||||||
accountId: apiKeyData.geminiAccountId,
|
accountId: apiKeyData.geminiAccountId,
|
||||||
accountType: 'gemini'
|
accountType: 'gemini'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available, falling back to pool`
|
`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available, falling back to pool`
|
||||||
@@ -102,7 +136,7 @@ class UnifiedGeminiScheduler {
|
|||||||
|
|
||||||
// 如果有会话哈希,检查是否有已映射的账户
|
// 如果有会话哈希,检查是否有已映射的账户
|
||||||
if (sessionHash) {
|
if (sessionHash) {
|
||||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
const mappedAccount = await this._getSessionMapping(sessionHash, normalizedOauthProvider)
|
||||||
if (mappedAccount) {
|
if (mappedAccount) {
|
||||||
// 验证映射的账户是否仍然可用
|
// 验证映射的账户是否仍然可用
|
||||||
const isAvailable = await this._isAccountAvailable(
|
const isAvailable = await this._isAccountAvailable(
|
||||||
@@ -111,7 +145,7 @@ class UnifiedGeminiScheduler {
|
|||||||
)
|
)
|
||||||
if (isAvailable) {
|
if (isAvailable) {
|
||||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||||
await this._extendSessionMappingTTL(sessionHash)
|
await this._extendSessionMappingTTL(sessionHash, normalizedOauthProvider)
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||||
)
|
)
|
||||||
@@ -132,11 +166,10 @@ class UnifiedGeminiScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有可用账户
|
// 获取所有可用账户
|
||||||
const availableAccounts = await this._getAllAvailableAccounts(
|
const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel, {
|
||||||
apiKeyData,
|
allowApiAccounts,
|
||||||
requestedModel,
|
oauthProvider: normalizedOauthProvider
|
||||||
allowApiAccounts
|
})
|
||||||
)
|
|
||||||
|
|
||||||
if (availableAccounts.length === 0) {
|
if (availableAccounts.length === 0) {
|
||||||
// 提供更详细的错误信息
|
// 提供更详细的错误信息
|
||||||
@@ -160,7 +193,8 @@ class UnifiedGeminiScheduler {
|
|||||||
await this._setSessionMapping(
|
await this._setSessionMapping(
|
||||||
sessionHash,
|
sessionHash,
|
||||||
selectedAccount.accountId,
|
selectedAccount.accountId,
|
||||||
selectedAccount.accountType
|
selectedAccount.accountType,
|
||||||
|
normalizedOauthProvider
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`
|
`🎯 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 = []
|
const availableAccounts = []
|
||||||
|
|
||||||
// 如果API Key绑定了专属账户,优先返回
|
// 如果API Key绑定了专属账户,优先返回
|
||||||
@@ -254,6 +299,12 @@ class UnifiedGeminiScheduler {
|
|||||||
this._isActive(boundAccount.isActive) &&
|
this._isActive(boundAccount.isActive) &&
|
||||||
boundAccount.status !== 'error'
|
boundAccount.status !== 'error'
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
normalizedOauthProvider &&
|
||||||
|
normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider
|
||||||
|
) {
|
||||||
|
return availableAccounts
|
||||||
|
}
|
||||||
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
||||||
if (!isRateLimited) {
|
if (!isRateLimited) {
|
||||||
// 检查模型支持
|
// 检查模型支持
|
||||||
@@ -303,6 +354,12 @@ class UnifiedGeminiScheduler {
|
|||||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||||
this._isSchedulable(account.schedulable)
|
this._isSchedulable(account.schedulable)
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
normalizedOauthProvider &&
|
||||||
|
normalizeOauthProvider(account.oauthProvider) !== normalizedOauthProvider
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// 检查是否可调度
|
// 检查是否可调度
|
||||||
|
|
||||||
// 检查token是否过期
|
// 检查token是否过期
|
||||||
@@ -437,9 +494,10 @@ class UnifiedGeminiScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🔗 获取会话映射
|
// 🔗 获取会话映射
|
||||||
async _getSessionMapping(sessionHash) {
|
async _getSessionMapping(sessionHash, oauthProvider = null) {
|
||||||
const client = redis.getClientSafe()
|
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) {
|
if (mappingData) {
|
||||||
try {
|
try {
|
||||||
@@ -454,27 +512,42 @@ class UnifiedGeminiScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 💾 设置会话映射
|
// 💾 设置会话映射
|
||||||
async _setSessionMapping(sessionHash, accountId, accountType) {
|
async _setSessionMapping(sessionHash, accountId, accountType, oauthProvider = null) {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
const mappingData = JSON.stringify({ accountId, accountType })
|
const mappingData = JSON.stringify({ accountId, accountType })
|
||||||
// 依据配置设置TTL(小时)
|
// 依据配置设置TTL(小时)
|
||||||
const appConfig = require('../../config/config')
|
const appConfig = require('../../config/config')
|
||||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
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) {
|
async _deleteSessionMapping(sessionHash) {
|
||||||
const client = redis.getClientSafe()
|
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:* 键),遵循会话配置
|
// 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置
|
||||||
async _extendSessionMappingTTL(sessionHash) {
|
async _extendSessionMappingTTL(sessionHash, oauthProvider = null) {
|
||||||
try {
|
try {
|
||||||
const client = redis.getClientSafe()
|
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)
|
const remainingTTL = await client.ttl(key)
|
||||||
|
|
||||||
if (remainingTTL === -2) {
|
if (remainingTTL === -2) {
|
||||||
|
|||||||
@@ -9,6 +9,26 @@ class UnifiedOpenAIScheduler {
|
|||||||
this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:'
|
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) {
|
_isSchedulable(schedulable) {
|
||||||
// 如果是 undefined 或 null,默认为可调度
|
// 如果是 undefined 或 null,默认为可调度
|
||||||
@@ -244,13 +264,7 @@ class UnifiedOpenAIScheduler {
|
|||||||
`🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}`
|
`🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}`
|
||||||
)
|
)
|
||||||
// 更新账户的最后使用时间
|
// 更新账户的最后使用时间
|
||||||
if (accountType === 'openai') {
|
await this.updateAccountLastUsed(boundAccount.id, accountType)
|
||||||
await openaiAccountService.recordUsage(boundAccount.id, 0)
|
|
||||||
} else {
|
|
||||||
await openaiResponsesAccountService.updateAccount(boundAccount.id, {
|
|
||||||
lastUsedAt: new Date().toISOString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
accountId: boundAccount.id,
|
accountId: boundAccount.id,
|
||||||
accountType
|
accountType
|
||||||
@@ -292,7 +306,7 @@ class UnifiedOpenAIScheduler {
|
|||||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
`🎯 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
|
return mappedAccount
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -321,12 +335,8 @@ class UnifiedOpenAIScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
|
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
|
||||||
const sortedAccounts = availableAccounts.sort((a, b) => {
|
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
|
||||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
|
||||||
return aLastUsed - bLastUsed // 最久未使用的优先
|
|
||||||
})
|
|
||||||
|
|
||||||
// 选择第一个账户
|
// 选择第一个账户
|
||||||
const selectedAccount = sortedAccounts[0]
|
const selectedAccount = sortedAccounts[0]
|
||||||
@@ -344,11 +354,11 @@ class UnifiedOpenAIScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
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 {
|
return {
|
||||||
accountId: selectedAccount.accountId,
|
accountId: selectedAccount.accountId,
|
||||||
@@ -494,21 +504,6 @@ class UnifiedOpenAIScheduler {
|
|||||||
return availableAccounts
|
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) {
|
async _isAccountAvailable(accountId, accountType) {
|
||||||
try {
|
try {
|
||||||
@@ -817,7 +812,7 @@ class UnifiedOpenAIScheduler {
|
|||||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
|
`🎯 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
|
return mappedAccount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,12 +904,8 @@ class UnifiedOpenAIScheduler {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
|
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
|
||||||
const sortedAccounts = availableAccounts.sort((a, b) => {
|
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
|
||||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
|
||||||
return aLastUsed - bLastUsed // 最久未使用的优先
|
|
||||||
})
|
|
||||||
|
|
||||||
// 选择第一个账户
|
// 选择第一个账户
|
||||||
const selectedAccount = sortedAccounts[0]
|
const selectedAccount = sortedAccounts[0]
|
||||||
@@ -932,11 +923,11 @@ class UnifiedOpenAIScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
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 {
|
return {
|
||||||
accountId: selectedAccount.accountId,
|
accountId: selectedAccount.accountId,
|
||||||
@@ -958,9 +949,12 @@ class UnifiedOpenAIScheduler {
|
|||||||
async updateAccountLastUsed(accountId, accountType) {
|
async updateAccountLastUsed(accountId, accountType) {
|
||||||
try {
|
try {
|
||||||
if (accountType === 'openai') {
|
if (accountType === 'openai') {
|
||||||
await openaiAccountService.updateAccount(accountId, {
|
await openaiAccountService.recordUsage(accountId, 0)
|
||||||
lastUsedAt: new Date().toISOString()
|
return
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if (accountType === 'openai-responses') {
|
||||||
|
await openaiResponsesAccountService.recordUsage(accountId, 0)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`⚠️ Failed to update last used time for account ${accountId}:`, error)
|
logger.warn(`⚠️ Failed to update last used time for account ${accountId}:`, error)
|
||||||
|
|||||||
359
src/services/userMessageQueueService.js
Normal file
359
src/services/userMessageQueueService.js
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* 用户消息队列服务
|
||||||
|
* 为 Claude 账户实现基于消息类型的串行排队机制
|
||||||
|
*
|
||||||
|
* 当请求的最后一条消息是用户输入(role: user)时,
|
||||||
|
* 同一账户的此类请求需要串行等待,并在请求之间添加延迟
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const config = require('../../config/config')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
// 清理任务间隔
|
||||||
|
const CLEANUP_INTERVAL_MS = 60000 // 1分钟
|
||||||
|
|
||||||
|
// 轮询等待配置
|
||||||
|
const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔
|
||||||
|
const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔
|
||||||
|
const POLL_BACKOFF_FACTOR = 1.5 // 退避因子
|
||||||
|
|
||||||
|
class UserMessageQueueService {
|
||||||
|
constructor() {
|
||||||
|
this.cleanupTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测请求是否为真正的用户消息请求
|
||||||
|
* 区分真正的用户输入和 tool_result 消息
|
||||||
|
*
|
||||||
|
* Claude API 消息格式:
|
||||||
|
* - 用户文本消息: { role: 'user', content: 'text' } 或 { role: 'user', content: [{ type: 'text', text: '...' }] }
|
||||||
|
* - 工具结果消息: { role: 'user', content: [{ type: 'tool_result', tool_use_id: '...', content: '...' }] }
|
||||||
|
*
|
||||||
|
* @param {Object} requestBody - 请求体
|
||||||
|
* @returns {boolean} - 是否为真正的用户消息(排除 tool_result)
|
||||||
|
*/
|
||||||
|
isUserMessageRequest(requestBody) {
|
||||||
|
const messages = requestBody?.messages
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const lastMessage = messages[messages.length - 1]
|
||||||
|
|
||||||
|
// 检查 role 是否为 user
|
||||||
|
if (lastMessage?.role !== 'user') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 content 是否包含 tool_result 类型
|
||||||
|
const { content } = lastMessage
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
// 如果 content 数组中任何元素是 tool_result,则不是真正的用户消息
|
||||||
|
const hasToolResult = content.some(
|
||||||
|
(block) => block?.type === 'tool_result' || block?.type === 'tool_use_result'
|
||||||
|
)
|
||||||
|
if (hasToolResult) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// role 是 user 且不包含 tool_result,是真正的用户消息
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前配置(支持 Web 界面配置优先)
|
||||||
|
* @returns {Promise<Object>} 配置对象
|
||||||
|
*/
|
||||||
|
async getConfig() {
|
||||||
|
// 默认配置(防止 config.userMessageQueue 未定义)
|
||||||
|
// 注意:优化后的默认值 - 锁持有时间从分钟级降到毫秒级,无需长等待
|
||||||
|
const queueConfig = config.userMessageQueue || {}
|
||||||
|
const defaults = {
|
||||||
|
enabled: queueConfig.enabled ?? false,
|
||||||
|
delayMs: queueConfig.delayMs ?? 200,
|
||||||
|
timeoutMs: queueConfig.timeoutMs ?? 5000, // 从 60000 降到 5000,因为锁持有时间短
|
||||||
|
lockTtlMs: queueConfig.lockTtlMs ?? 5000 // 从 120000 降到 5000,5秒足以覆盖请求发送
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从 claudeRelayConfigService 获取 Web 界面配置
|
||||||
|
try {
|
||||||
|
const claudeRelayConfigService = require('./claudeRelayConfigService')
|
||||||
|
const webConfig = await claudeRelayConfigService.getConfig()
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled:
|
||||||
|
webConfig.userMessageQueueEnabled !== undefined
|
||||||
|
? webConfig.userMessageQueueEnabled
|
||||||
|
: defaults.enabled,
|
||||||
|
delayMs:
|
||||||
|
webConfig.userMessageQueueDelayMs !== undefined
|
||||||
|
? webConfig.userMessageQueueDelayMs
|
||||||
|
: defaults.delayMs,
|
||||||
|
timeoutMs:
|
||||||
|
webConfig.userMessageQueueTimeoutMs !== undefined
|
||||||
|
? webConfig.userMessageQueueTimeoutMs
|
||||||
|
: defaults.timeoutMs,
|
||||||
|
lockTtlMs:
|
||||||
|
webConfig.userMessageQueueLockTtlMs !== undefined
|
||||||
|
? webConfig.userMessageQueueLockTtlMs
|
||||||
|
: defaults.lockTtlMs
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 回退到环境变量配置
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查功能是否启用
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isEnabled() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.enabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户队列锁(阻塞等待)
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} requestId - 请求ID(可选,会自动生成)
|
||||||
|
* @param {number} timeoutMs - 超时时间(可选,使用配置默认值)
|
||||||
|
* @param {Object} accountConfig - 账户级配置(可选),优先级高于全局配置
|
||||||
|
* @param {number} accountConfig.maxConcurrency - 账户级串行队列开关:>0启用,=0使用全局配置
|
||||||
|
* @returns {Promise<{acquired: boolean, requestId: string, error?: string}>}
|
||||||
|
*/
|
||||||
|
async acquireQueueLock(accountId, requestId = null, timeoutMs = null, accountConfig = null) {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
|
||||||
|
// 账户级配置优先:maxConcurrency > 0 时强制启用,忽略全局开关
|
||||||
|
let queueEnabled = cfg.enabled
|
||||||
|
if (accountConfig && accountConfig.maxConcurrency > 0) {
|
||||||
|
queueEnabled = true
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue: account-level queue enabled for account ${accountId} (maxConcurrency=${accountConfig.maxConcurrency})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!queueEnabled) {
|
||||||
|
return { acquired: true, requestId: requestId || uuidv4(), skipped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqId = requestId || uuidv4()
|
||||||
|
const timeout = timeoutMs || cfg.timeoutMs
|
||||||
|
const startTime = Date.now()
|
||||||
|
let retryCount = 0
|
||||||
|
|
||||||
|
logger.debug(`📬 User message queue: attempting to acquire lock for account ${accountId}`, {
|
||||||
|
requestId: reqId,
|
||||||
|
timeoutMs: timeout
|
||||||
|
})
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const result = await redis.acquireUserMessageLock(
|
||||||
|
accountId,
|
||||||
|
reqId,
|
||||||
|
cfg.lockTtlMs,
|
||||||
|
cfg.delayMs
|
||||||
|
)
|
||||||
|
|
||||||
|
// 检测 Redis 错误,立即返回系统错误而非继续轮询
|
||||||
|
if (result.redisError) {
|
||||||
|
logger.error(`📬 User message queue: Redis error while acquiring lock`, {
|
||||||
|
accountId,
|
||||||
|
requestId: reqId,
|
||||||
|
errorMessage: result.errorMessage
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
acquired: false,
|
||||||
|
requestId: reqId,
|
||||||
|
error: 'queue_backend_error',
|
||||||
|
errorMessage: result.errorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.acquired) {
|
||||||
|
logger.debug(`📬 User message queue: lock acquired for account ${accountId}`, {
|
||||||
|
requestId: reqId,
|
||||||
|
waitedMs: Date.now() - startTime,
|
||||||
|
retries: retryCount
|
||||||
|
})
|
||||||
|
return { acquired: true, requestId: reqId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要等待
|
||||||
|
if (result.waitMs > 0) {
|
||||||
|
// 需要延迟(上一个请求刚完成)
|
||||||
|
await this._sleep(Math.min(result.waitMs, timeout - (Date.now() - startTime)))
|
||||||
|
} else {
|
||||||
|
// 锁被占用,使用指数退避轮询等待
|
||||||
|
const basePollInterval = Math.min(
|
||||||
|
POLL_INTERVAL_BASE_MS * Math.pow(POLL_BACKOFF_FACTOR, retryCount),
|
||||||
|
POLL_INTERVAL_MAX_MS
|
||||||
|
)
|
||||||
|
// 添加 ±15% 随机抖动,避免高并发下的周期性碰撞
|
||||||
|
const jitter = basePollInterval * (0.85 + Math.random() * 0.3)
|
||||||
|
const pollInterval = Math.min(jitter, POLL_INTERVAL_MAX_MS)
|
||||||
|
await this._sleep(pollInterval)
|
||||||
|
retryCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时
|
||||||
|
logger.warn(`📬 User message queue: timeout waiting for lock`, {
|
||||||
|
accountId,
|
||||||
|
requestId: reqId,
|
||||||
|
timeoutMs: timeout
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
acquired: false,
|
||||||
|
requestId: reqId,
|
||||||
|
error: 'queue_timeout'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放账户队列锁
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} requestId - 请求ID
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async releaseQueueLock(accountId, requestId) {
|
||||||
|
if (!accountId || !requestId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const released = await redis.releaseUserMessageLock(accountId, requestId)
|
||||||
|
|
||||||
|
if (released) {
|
||||||
|
logger.debug(`📬 User message queue: lock released for account ${accountId}`, {
|
||||||
|
requestId
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.warn(`📬 User message queue: failed to release lock (not owner?)`, {
|
||||||
|
accountId,
|
||||||
|
requestId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return released
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取队列统计信息
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getQueueStats(accountId) {
|
||||||
|
return await redis.getUserMessageQueueStats(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务启动时清理所有残留的队列锁
|
||||||
|
* 防止服务重启后旧锁阻塞新请求
|
||||||
|
* @returns {Promise<number>} 清理的锁数量
|
||||||
|
*/
|
||||||
|
async cleanupStaleLocks() {
|
||||||
|
try {
|
||||||
|
const accountIds = await redis.scanUserMessageQueueLocks()
|
||||||
|
let cleanedCount = 0
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
try {
|
||||||
|
await redis.forceReleaseUserMessageLock(accountId)
|
||||||
|
cleanedCount++
|
||||||
|
logger.debug(`📬 User message queue: cleaned stale lock for account ${accountId}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`📬 User message queue: failed to clean lock for account ${accountId}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanedCount > 0) {
|
||||||
|
logger.info(`📬 User message queue: cleaned ${cleanedCount} stale lock(s) on startup`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedCount
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('📬 User message queue: failed to cleanup stale locks on startup:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动定时清理任务
|
||||||
|
* 始终启动,每次执行时检查配置以支持运行时动态启用/禁用
|
||||||
|
*/
|
||||||
|
startCleanupTask() {
|
||||||
|
if (this.cleanupTimer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanupTimer = setInterval(async () => {
|
||||||
|
// 每次运行时检查配置,以便在运行时动态启用/禁用
|
||||||
|
const currentConfig = await this.getConfig()
|
||||||
|
if (!currentConfig.enabled) {
|
||||||
|
logger.debug('📬 User message queue: cleanup skipped (feature disabled)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this._cleanupOrphanLocks()
|
||||||
|
}, CLEANUP_INTERVAL_MS)
|
||||||
|
|
||||||
|
logger.info('📬 User message queue: cleanup task started')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止定时清理任务
|
||||||
|
*/
|
||||||
|
stopCleanupTask() {
|
||||||
|
if (this.cleanupTimer) {
|
||||||
|
clearInterval(this.cleanupTimer)
|
||||||
|
this.cleanupTimer = null
|
||||||
|
logger.info('📬 User message queue: cleanup task stopped')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理孤儿锁
|
||||||
|
* 检测异常情况:锁存在但没有设置过期时间(lockTtlRaw === -1)
|
||||||
|
* 正常情况下所有锁都应该有 TTL,Redis 会自动过期
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _cleanupOrphanLocks() {
|
||||||
|
try {
|
||||||
|
const accountIds = await redis.scanUserMessageQueueLocks()
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const stats = await redis.getUserMessageQueueStats(accountId)
|
||||||
|
|
||||||
|
// 检测异常情况:锁存在(isLocked=true)但没有过期时间(lockTtlRaw=-1)
|
||||||
|
// 正常创建的锁都带有 PX 过期时间,如果没有说明是异常状态
|
||||||
|
if (stats.isLocked && stats.lockTtlRaw === -1) {
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue: cleaning up orphan lock without TTL for account ${accountId}`,
|
||||||
|
{ lockHolder: stats.lockHolder }
|
||||||
|
)
|
||||||
|
await redis.forceReleaseUserMessageLock(accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('📬 User message queue: cleanup task error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠辅助函数
|
||||||
|
* @param {number} ms - 毫秒
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new UserMessageQueueService()
|
||||||
219
src/services/weeklyClaudeCostInitService.js
Normal file
219
src/services/weeklyClaudeCostInitService.js
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const pricingService = require('./pricingService')
|
||||||
|
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||||
|
|
||||||
|
function pad2(n) {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成配置时区下的 YYYY-MM-DD 字符串。
|
||||||
|
// 注意:入参 date 必须是 redis.getDateInTimezone() 生成的“时区偏移后”的 Date。
|
||||||
|
function formatTzDateYmd(tzDate) {
|
||||||
|
return `${tzDate.getUTCFullYear()}-${pad2(tzDate.getUTCMonth() + 1)}-${pad2(tzDate.getUTCDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
class WeeklyClaudeCostInitService {
|
||||||
|
_getCurrentWeekDatesInTimezone() {
|
||||||
|
const tzNow = redis.getDateInTimezone(new Date())
|
||||||
|
const tzToday = new Date(tzNow)
|
||||||
|
tzToday.setUTCHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
// ISO 周:周一=1 ... 周日=7
|
||||||
|
const isoDay = tzToday.getUTCDay() || 7
|
||||||
|
const tzMonday = new Date(tzToday)
|
||||||
|
tzMonday.setUTCDate(tzToday.getUTCDate() - (isoDay - 1))
|
||||||
|
|
||||||
|
const dates = []
|
||||||
|
for (let d = new Date(tzMonday); d <= tzToday; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||||
|
dates.push(formatTzDateYmd(d))
|
||||||
|
}
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildWeeklyClaudeKey(keyId, weekString) {
|
||||||
|
return `usage:claude:weekly:${keyId}:${weekString}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动回填:把“本周(周一到今天)Claude 全模型”周费用从按日/按模型统计里反算出来,
|
||||||
|
* 写入 `usage:claude:weekly:*`,保证周限额在重启后不归零。
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - 只回填本周,不做历史回填(符合“只要本周数据”诉求)
|
||||||
|
* - 会加分布式锁,避免多实例重复跑
|
||||||
|
* - 会写 done 标记:同一周内重启默认不重复回填(需要时可手动删掉 done key)
|
||||||
|
*/
|
||||||
|
async backfillCurrentWeekClaudeCosts() {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
if (!client) {
|
||||||
|
logger.warn('⚠️ 本周 Claude 周费用回填跳过:Redis client 不可用')
|
||||||
|
return { success: false, reason: 'redis_unavailable' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pricingService || !pricingService.pricingData) {
|
||||||
|
logger.warn('⚠️ 本周 Claude 周费用回填跳过:pricing service 未初始化')
|
||||||
|
return { success: false, reason: 'pricing_uninitialized' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekString = redis.getWeekStringInTimezone()
|
||||||
|
const doneKey = `init:weekly_claude_cost:${weekString}:done`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const alreadyDone = await client.get(doneKey)
|
||||||
|
if (alreadyDone) {
|
||||||
|
logger.info(`ℹ️ 本周 Claude 周费用回填已完成(${weekString}),跳过`)
|
||||||
|
return { success: true, skipped: true }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 尽力而为:读取失败不阻断启动回填流程。
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockKey = `lock:init:weekly_claude_cost:${weekString}`
|
||||||
|
const lockValue = `${process.pid}:${Date.now()}`
|
||||||
|
const lockTtlMs = 15 * 60 * 1000
|
||||||
|
|
||||||
|
const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTtlMs)
|
||||||
|
if (!lockAcquired) {
|
||||||
|
logger.info(`ℹ️ 本周 Claude 周费用回填已在运行(${weekString}),跳过`)
|
||||||
|
return { success: true, skipped: true, reason: 'locked' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now()
|
||||||
|
try {
|
||||||
|
logger.info(`💰 开始回填本周 Claude 周费用:${weekString}(仅本周)...`)
|
||||||
|
|
||||||
|
const keyIds = await redis.scanApiKeyIds()
|
||||||
|
const dates = this._getCurrentWeekDatesInTimezone()
|
||||||
|
|
||||||
|
const costByKeyId = new Map()
|
||||||
|
let scannedKeys = 0
|
||||||
|
let matchedClaudeKeys = 0
|
||||||
|
|
||||||
|
const toInt = (v) => {
|
||||||
|
const n = parseInt(v || '0', 10)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描“按日 + 按模型”的使用统计 key,并反算 Claude 系列模型的费用。
|
||||||
|
for (const dateStr of dates) {
|
||||||
|
let cursor = '0'
|
||||||
|
const pattern = `usage:*:model:daily:*:${dateStr}`
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000)
|
||||||
|
cursor = nextCursor
|
||||||
|
scannedKeys += keys.length
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
for (const usageKey of keys) {
|
||||||
|
// usage:{keyId}:model:daily:{model}:{YYYY-MM-DD}
|
||||||
|
const match = usageKey.match(/^usage:([^:]+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||||
|
if (!match) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const keyId = match[1]
|
||||||
|
const model = match[2]
|
||||||
|
if (!isClaudeFamilyModel(model)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchedClaudeKeys++
|
||||||
|
entries.push({ usageKey, keyId, model })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
for (const entry of entries) {
|
||||||
|
pipeline.hgetall(entry.usageKey)
|
||||||
|
}
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const entry = entries[i]
|
||||||
|
const [, data] = results[i] || []
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputTokens = toInt(data.totalInputTokens || data.inputTokens)
|
||||||
|
const outputTokens = toInt(data.totalOutputTokens || data.outputTokens)
|
||||||
|
const cacheReadTokens = toInt(data.totalCacheReadTokens || data.cacheReadTokens)
|
||||||
|
const cacheCreateTokens = toInt(data.totalCacheCreateTokens || data.cacheCreateTokens)
|
||||||
|
const ephemeral5mTokens = toInt(data.ephemeral5mTokens)
|
||||||
|
const ephemeral1hTokens = toInt(data.ephemeral1hTokens)
|
||||||
|
|
||||||
|
const cacheCreationTotal =
|
||||||
|
ephemeral5mTokens > 0 || ephemeral1hTokens > 0
|
||||||
|
? ephemeral5mTokens + ephemeral1hTokens
|
||||||
|
: cacheCreateTokens
|
||||||
|
|
||||||
|
const usage = {
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: outputTokens,
|
||||||
|
cache_creation_input_tokens: cacheCreationTotal,
|
||||||
|
cache_read_input_tokens: cacheReadTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
|
||||||
|
usage.cache_creation = {
|
||||||
|
ephemeral_5m_input_tokens: ephemeral5mTokens,
|
||||||
|
ephemeral_1h_input_tokens: ephemeral1hTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const costInfo = pricingService.calculateCost(usage, entry.model)
|
||||||
|
const cost = costInfo && costInfo.totalCost ? costInfo.totalCost : 0
|
||||||
|
if (cost <= 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
costByKeyId.set(entry.keyId, (costByKeyId.get(entry.keyId) || 0) + cost)
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为所有 API Key 写入本周 claude:weekly key,避免读取时回退到旧 opus:weekly 造成口径混淆。
|
||||||
|
const ttlSeconds = 14 * 24 * 3600
|
||||||
|
const batchSize = 500
|
||||||
|
for (let i = 0; i < keyIds.length; i += batchSize) {
|
||||||
|
const batch = keyIds.slice(i, i + batchSize)
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
for (const keyId of batch) {
|
||||||
|
const weeklyKey = this._buildWeeklyClaudeKey(keyId, weekString)
|
||||||
|
const cost = costByKeyId.get(keyId) || 0
|
||||||
|
pipeline.set(weeklyKey, String(cost))
|
||||||
|
pipeline.expire(weeklyKey, ttlSeconds)
|
||||||
|
}
|
||||||
|
await pipeline.exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入 done 标记(保留略长于 1 周,避免同一周内重启重复回填)。
|
||||||
|
await client.set(doneKey, new Date().toISOString(), 'EX', 10 * 24 * 3600)
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startedAt
|
||||||
|
logger.info(
|
||||||
|
`✅ 本周 Claude 周费用回填完成(${weekString}):keys=${keyIds.length}, scanned=${scannedKeys}, matchedClaude=${matchedClaudeKeys}, filled=${costByKeyId.size}(${durationMs}ms)`
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
weekString,
|
||||||
|
keyCount: keyIds.length,
|
||||||
|
scannedKeys,
|
||||||
|
matchedClaudeKeys,
|
||||||
|
filledKeys: costByKeyId.size,
|
||||||
|
durationMs
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 本周 Claude 周费用回填失败(${weekString}):`, error)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
await redis.releaseAccountLock(lockKey, lockValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new WeeklyClaudeCostInitService()
|
||||||
126
src/utils/anthropicRequestDump.js
Normal file
126
src/utils/anthropicRequestDump.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
const path = require('path')
|
||||||
|
const logger = require('./logger')
|
||||||
|
const { getProjectRoot } = require('./projectPaths')
|
||||||
|
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||||
|
|
||||||
|
const REQUEST_DUMP_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP'
|
||||||
|
const REQUEST_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES'
|
||||||
|
const REQUEST_DUMP_FILENAME = 'anthropic-requests-dump.jsonl'
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
const raw = process.env[REQUEST_DUMP_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return raw === '1' || raw.toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaxBytes() {
|
||||||
|
const raw = process.env[REQUEST_DUMP_MAX_BYTES_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskSecret(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const str = String(value)
|
||||||
|
if (str.length <= 8) {
|
||||||
|
return '***'
|
||||||
|
}
|
||||||
|
return `${str.slice(0, 4)}...${str.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeHeaders(headers) {
|
||||||
|
const sensitive = new Set([
|
||||||
|
'authorization',
|
||||||
|
'proxy-authorization',
|
||||||
|
'x-api-key',
|
||||||
|
'cookie',
|
||||||
|
'set-cookie',
|
||||||
|
'x-forwarded-for',
|
||||||
|
'x-real-ip'
|
||||||
|
])
|
||||||
|
|
||||||
|
const out = {}
|
||||||
|
for (const [k, v] of Object.entries(headers || {})) {
|
||||||
|
const key = k.toLowerCase()
|
||||||
|
if (sensitive.has(key)) {
|
||||||
|
out[key] = maskSecret(v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[key] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonStringify(payload, maxBytes) {
|
||||||
|
let json = ''
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(payload)
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'anthropic_request_dump_error',
|
||||||
|
error: 'JSON.stringify_failed',
|
||||||
|
message: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'anthropic_request_dump_truncated',
|
||||||
|
maxBytes,
|
||||||
|
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||||
|
partialJson: truncated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAnthropicMessagesRequest(req, meta = {}) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), REQUEST_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
requestId: req?.requestId || null,
|
||||||
|
method: req?.method || null,
|
||||||
|
url: req?.originalUrl || req?.url || null,
|
||||||
|
ip: req?.ip || null,
|
||||||
|
meta,
|
||||||
|
headers: sanitizeHeaders(req?.headers || {}),
|
||||||
|
body: req?.body || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await safeRotatingAppend(filename, line)
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to dump Anthropic request', {
|
||||||
|
filename,
|
||||||
|
requestId: req?.requestId || null,
|
||||||
|
error: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dumpAnthropicMessagesRequest,
|
||||||
|
REQUEST_DUMP_ENV,
|
||||||
|
REQUEST_DUMP_MAX_BYTES_ENV,
|
||||||
|
REQUEST_DUMP_FILENAME
|
||||||
|
}
|
||||||
125
src/utils/anthropicResponseDump.js
Normal file
125
src/utils/anthropicResponseDump.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
const path = require('path')
|
||||||
|
const logger = require('./logger')
|
||||||
|
const { getProjectRoot } = require('./projectPaths')
|
||||||
|
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||||
|
|
||||||
|
const RESPONSE_DUMP_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP'
|
||||||
|
const RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES'
|
||||||
|
const RESPONSE_DUMP_FILENAME = 'anthropic-responses-dump.jsonl'
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
const raw = process.env[RESPONSE_DUMP_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return raw === '1' || raw.toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaxBytes() {
|
||||||
|
const raw = process.env[RESPONSE_DUMP_MAX_BYTES_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonStringify(payload, maxBytes) {
|
||||||
|
let json = ''
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(payload)
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'anthropic_response_dump_error',
|
||||||
|
error: 'JSON.stringify_failed',
|
||||||
|
message: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'anthropic_response_dump_truncated',
|
||||||
|
maxBytes,
|
||||||
|
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||||
|
partialJson: truncated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeAnthropicResponseBody(body) {
|
||||||
|
const content = Array.isArray(body?.content) ? body.content : []
|
||||||
|
const toolUses = content.filter((b) => b && b.type === 'tool_use')
|
||||||
|
const texts = content
|
||||||
|
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
||||||
|
.map((b) => b.text)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: body?.id || null,
|
||||||
|
model: body?.model || null,
|
||||||
|
stop_reason: body?.stop_reason || null,
|
||||||
|
usage: body?.usage || null,
|
||||||
|
content_blocks: content.map((b) => (b ? b.type : null)).filter(Boolean),
|
||||||
|
tool_use_names: toolUses.map((b) => b.name).filter(Boolean),
|
||||||
|
text_preview: texts ? texts.slice(0, 800) : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAnthropicResponse(req, responseInfo, meta = {}) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), RESPONSE_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
requestId: req?.requestId || null,
|
||||||
|
url: req?.originalUrl || req?.url || null,
|
||||||
|
meta,
|
||||||
|
response: responseInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
try {
|
||||||
|
await safeRotatingAppend(filename, line)
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to dump Anthropic response', {
|
||||||
|
filename,
|
||||||
|
requestId: req?.requestId || null,
|
||||||
|
error: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAnthropicNonStreamResponse(req, statusCode, body, meta = {}) {
|
||||||
|
return dumpAnthropicResponse(
|
||||||
|
req,
|
||||||
|
{ kind: 'non-stream', statusCode, summary: summarizeAnthropicResponseBody(body), body },
|
||||||
|
meta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAnthropicStreamSummary(req, summary, meta = {}) {
|
||||||
|
return dumpAnthropicResponse(req, { kind: 'stream', summary }, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAnthropicStreamError(req, error, meta = {}) {
|
||||||
|
return dumpAnthropicResponse(req, { kind: 'stream-error', error }, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dumpAnthropicNonStreamResponse,
|
||||||
|
dumpAnthropicStreamSummary,
|
||||||
|
dumpAnthropicStreamError,
|
||||||
|
RESPONSE_DUMP_ENV,
|
||||||
|
RESPONSE_DUMP_MAX_BYTES_ENV,
|
||||||
|
RESPONSE_DUMP_FILENAME
|
||||||
|
}
|
||||||
138
src/utils/antigravityModel.js
Normal file
138
src/utils/antigravityModel.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
const DEFAULT_ANTIGRAVITY_MODEL = 'gemini-2.5-flash'
|
||||||
|
|
||||||
|
const UPSTREAM_TO_ALIAS = {
|
||||||
|
'rev19-uic3-1p': 'gemini-2.5-computer-use-preview-10-2025',
|
||||||
|
'gemini-3-pro-image': 'gemini-3-pro-image-preview',
|
||||||
|
'gemini-3-pro-high': 'gemini-3-pro-preview',
|
||||||
|
'gemini-3-flash': 'gemini-3-flash-preview',
|
||||||
|
'claude-sonnet-4-5': 'gemini-claude-sonnet-4-5',
|
||||||
|
'claude-sonnet-4-5-thinking': 'gemini-claude-sonnet-4-5-thinking',
|
||||||
|
'claude-opus-4-5-thinking': 'gemini-claude-opus-4-5-thinking',
|
||||||
|
chat_20706: '',
|
||||||
|
chat_23310: '',
|
||||||
|
'gemini-2.5-flash-thinking': '',
|
||||||
|
'gemini-3-pro-low': '',
|
||||||
|
'gemini-2.5-pro': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALIAS_TO_UPSTREAM = {
|
||||||
|
'gemini-2.5-computer-use-preview-10-2025': 'rev19-uic3-1p',
|
||||||
|
'gemini-3-pro-image-preview': 'gemini-3-pro-image',
|
||||||
|
'gemini-3-pro-preview': 'gemini-3-pro-high',
|
||||||
|
'gemini-3-flash-preview': 'gemini-3-flash',
|
||||||
|
'gemini-claude-sonnet-4-5': 'claude-sonnet-4-5',
|
||||||
|
'gemini-claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
|
||||||
|
'gemini-claude-opus-4-5-thinking': 'claude-opus-4-5-thinking'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANTIGRAVITY_MODEL_METADATA = {
|
||||||
|
'gemini-2.5-flash': {
|
||||||
|
thinking: { min: 0, max: 24576, zeroAllowed: true, dynamicAllowed: true },
|
||||||
|
name: 'models/gemini-2.5-flash'
|
||||||
|
},
|
||||||
|
'gemini-2.5-flash-lite': {
|
||||||
|
thinking: { min: 0, max: 24576, zeroAllowed: true, dynamicAllowed: true },
|
||||||
|
name: 'models/gemini-2.5-flash-lite'
|
||||||
|
},
|
||||||
|
'gemini-2.5-computer-use-preview-10-2025': {
|
||||||
|
name: 'models/gemini-2.5-computer-use-preview-10-2025'
|
||||||
|
},
|
||||||
|
'gemini-3-pro-preview': {
|
||||||
|
thinking: {
|
||||||
|
min: 128,
|
||||||
|
max: 32768,
|
||||||
|
zeroAllowed: false,
|
||||||
|
dynamicAllowed: true,
|
||||||
|
levels: ['low', 'high']
|
||||||
|
},
|
||||||
|
name: 'models/gemini-3-pro-preview'
|
||||||
|
},
|
||||||
|
'gemini-3-pro-image-preview': {
|
||||||
|
thinking: {
|
||||||
|
min: 128,
|
||||||
|
max: 32768,
|
||||||
|
zeroAllowed: false,
|
||||||
|
dynamicAllowed: true,
|
||||||
|
levels: ['low', 'high']
|
||||||
|
},
|
||||||
|
name: 'models/gemini-3-pro-image-preview'
|
||||||
|
},
|
||||||
|
'gemini-3-flash-preview': {
|
||||||
|
thinking: {
|
||||||
|
min: 128,
|
||||||
|
max: 32768,
|
||||||
|
zeroAllowed: false,
|
||||||
|
dynamicAllowed: true,
|
||||||
|
levels: ['minimal', 'low', 'medium', 'high']
|
||||||
|
},
|
||||||
|
name: 'models/gemini-3-flash-preview'
|
||||||
|
},
|
||||||
|
'gemini-claude-sonnet-4-5-thinking': {
|
||||||
|
thinking: { min: 1024, max: 200000, zeroAllowed: false, dynamicAllowed: true },
|
||||||
|
maxCompletionTokens: 64000
|
||||||
|
},
|
||||||
|
'gemini-claude-opus-4-5-thinking': {
|
||||||
|
thinking: { min: 1024, max: 200000, zeroAllowed: false, dynamicAllowed: true },
|
||||||
|
maxCompletionTokens: 64000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAntigravityModelInput(model, defaultModel = DEFAULT_ANTIGRAVITY_MODEL) {
|
||||||
|
if (!model) {
|
||||||
|
return defaultModel
|
||||||
|
}
|
||||||
|
return model.startsWith('models/') ? model.slice('models/'.length) : model
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityModelAlias(modelName) {
|
||||||
|
const normalized = normalizeAntigravityModelInput(modelName)
|
||||||
|
if (Object.prototype.hasOwnProperty.call(UPSTREAM_TO_ALIAS, normalized)) {
|
||||||
|
return UPSTREAM_TO_ALIAS[normalized]
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityModelMetadata(modelName) {
|
||||||
|
const normalized = normalizeAntigravityModelInput(modelName)
|
||||||
|
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, normalized)) {
|
||||||
|
return ANTIGRAVITY_MODEL_METADATA[normalized]
|
||||||
|
}
|
||||||
|
if (normalized.startsWith('claude-')) {
|
||||||
|
const prefixed = `gemini-${normalized}`
|
||||||
|
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, prefixed)) {
|
||||||
|
return ANTIGRAVITY_MODEL_METADATA[prefixed]
|
||||||
|
}
|
||||||
|
const thinkingAlias = `${prefixed}-thinking`
|
||||||
|
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, thinkingAlias)) {
|
||||||
|
return ANTIGRAVITY_MODEL_METADATA[thinkingAlias]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAntigravityUpstreamModel(model) {
|
||||||
|
const normalized = normalizeAntigravityModelInput(model)
|
||||||
|
let upstream = Object.prototype.hasOwnProperty.call(ALIAS_TO_UPSTREAM, normalized)
|
||||||
|
? ALIAS_TO_UPSTREAM[normalized]
|
||||||
|
: normalized
|
||||||
|
|
||||||
|
if (upstream.startsWith('gemini-claude-')) {
|
||||||
|
upstream = upstream.replace(/^gemini-/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = {
|
||||||
|
// Opus:上游更常见的是 thinking 变体(CLIProxyAPI 也按此处理)
|
||||||
|
'claude-opus-4-5': 'claude-opus-4-5-thinking',
|
||||||
|
// Gemini thinking 变体回退
|
||||||
|
'gemini-2.5-flash-thinking': 'gemini-2.5-flash'
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping[upstream] || upstream
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeAntigravityModelInput,
|
||||||
|
getAntigravityModelAlias,
|
||||||
|
getAntigravityModelMetadata,
|
||||||
|
mapAntigravityUpstreamModel
|
||||||
|
}
|
||||||
121
src/utils/antigravityUpstreamDump.js
Normal file
121
src/utils/antigravityUpstreamDump.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
const path = require('path')
|
||||||
|
const logger = require('./logger')
|
||||||
|
const { getProjectRoot } = require('./projectPaths')
|
||||||
|
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||||
|
|
||||||
|
const UPSTREAM_REQUEST_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP'
|
||||||
|
const UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES'
|
||||||
|
const UPSTREAM_REQUEST_DUMP_FILENAME = 'antigravity-upstream-requests-dump.jsonl'
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
const raw = process.env[UPSTREAM_REQUEST_DUMP_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const normalized = String(raw).trim().toLowerCase()
|
||||||
|
return normalized === '1' || normalized === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaxBytes() {
|
||||||
|
const raw = process.env[UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(value) {
|
||||||
|
if (!value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const s = String(value)
|
||||||
|
if (s.length <= 10) {
|
||||||
|
return '***'
|
||||||
|
}
|
||||||
|
return `${s.slice(0, 3)}...${s.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonStringify(payload, maxBytes) {
|
||||||
|
let json = ''
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(payload)
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'antigravity_upstream_dump_error',
|
||||||
|
error: 'JSON.stringify_failed',
|
||||||
|
message: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'antigravity_upstream_dump_truncated',
|
||||||
|
maxBytes,
|
||||||
|
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||||
|
partialJson: truncated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAntigravityUpstreamRequest(requestInfo) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), UPSTREAM_REQUEST_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'antigravity_upstream_request',
|
||||||
|
requestId: requestInfo?.requestId || null,
|
||||||
|
model: requestInfo?.model || null,
|
||||||
|
stream: Boolean(requestInfo?.stream),
|
||||||
|
url: requestInfo?.url || null,
|
||||||
|
baseUrl: requestInfo?.baseUrl || null,
|
||||||
|
params: requestInfo?.params || null,
|
||||||
|
headers: requestInfo?.headers
|
||||||
|
? {
|
||||||
|
Host: requestInfo.headers.Host || requestInfo.headers.host || null,
|
||||||
|
'User-Agent':
|
||||||
|
requestInfo.headers['User-Agent'] || requestInfo.headers['user-agent'] || null,
|
||||||
|
Authorization: (() => {
|
||||||
|
const raw = requestInfo.headers.Authorization || requestInfo.headers.authorization
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const value = String(raw)
|
||||||
|
const m = value.match(/^Bearer\\s+(.+)$/i)
|
||||||
|
const token = m ? m[1] : value
|
||||||
|
return `Bearer ${redact(token)}`
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
envelope: requestInfo?.envelope || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
try {
|
||||||
|
await safeRotatingAppend(filename, line)
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to dump Antigravity upstream request', {
|
||||||
|
filename,
|
||||||
|
requestId: requestInfo?.requestId || null,
|
||||||
|
error: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dumpAntigravityUpstreamRequest,
|
||||||
|
UPSTREAM_REQUEST_DUMP_ENV,
|
||||||
|
UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV,
|
||||||
|
UPSTREAM_REQUEST_DUMP_FILENAME
|
||||||
|
}
|
||||||
175
src/utils/antigravityUpstreamResponseDump.js
Normal file
175
src/utils/antigravityUpstreamResponseDump.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
const path = require('path')
|
||||||
|
const logger = require('./logger')
|
||||||
|
const { getProjectRoot } = require('./projectPaths')
|
||||||
|
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||||
|
|
||||||
|
const UPSTREAM_RESPONSE_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP'
|
||||||
|
const UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP_MAX_BYTES'
|
||||||
|
const UPSTREAM_RESPONSE_DUMP_FILENAME = 'antigravity-upstream-responses-dump.jsonl'
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
const raw = process.env[UPSTREAM_RESPONSE_DUMP_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const normalized = String(raw).trim().toLowerCase()
|
||||||
|
return normalized === '1' || normalized === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaxBytes() {
|
||||||
|
const raw = process.env[UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonStringify(payload, maxBytes) {
|
||||||
|
let json = ''
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(payload)
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'antigravity_upstream_response_dump_error',
|
||||||
|
error: 'JSON.stringify_failed',
|
||||||
|
message: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'antigravity_upstream_response_dump_truncated',
|
||||||
|
maxBytes,
|
||||||
|
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||||
|
partialJson: truncated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 Antigravity 上游 API 的响应
|
||||||
|
* @param {Object} responseInfo - 响应信息
|
||||||
|
* @param {string} responseInfo.requestId - 请求 ID
|
||||||
|
* @param {string} responseInfo.model - 模型名称
|
||||||
|
* @param {number} responseInfo.statusCode - HTTP 状态码
|
||||||
|
* @param {string} responseInfo.statusText - HTTP 状态文本
|
||||||
|
* @param {Object} responseInfo.headers - 响应头
|
||||||
|
* @param {string} responseInfo.responseType - 响应类型 (stream/non-stream/error)
|
||||||
|
* @param {Object} responseInfo.summary - 响应摘要
|
||||||
|
* @param {Object} responseInfo.error - 错误信息(如果有)
|
||||||
|
*/
|
||||||
|
async function dumpAntigravityUpstreamResponse(responseInfo) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'antigravity_upstream_response',
|
||||||
|
requestId: responseInfo?.requestId || null,
|
||||||
|
model: responseInfo?.model || null,
|
||||||
|
statusCode: responseInfo?.statusCode || null,
|
||||||
|
statusText: responseInfo?.statusText || null,
|
||||||
|
responseType: responseInfo?.responseType || null,
|
||||||
|
headers: responseInfo?.headers || null,
|
||||||
|
summary: responseInfo?.summary || null,
|
||||||
|
error: responseInfo?.error || null,
|
||||||
|
rawData: responseInfo?.rawData || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
try {
|
||||||
|
await safeRotatingAppend(filename, line)
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to dump Antigravity upstream response', {
|
||||||
|
filename,
|
||||||
|
requestId: responseInfo?.requestId || null,
|
||||||
|
error: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 SSE 流中的每个事件(用于详细调试)
|
||||||
|
*/
|
||||||
|
async function dumpAntigravityStreamEvent(eventInfo) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'antigravity_stream_event',
|
||||||
|
requestId: eventInfo?.requestId || null,
|
||||||
|
eventIndex: eventInfo?.eventIndex || null,
|
||||||
|
eventType: eventInfo?.eventType || null,
|
||||||
|
data: eventInfo?.data || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
try {
|
||||||
|
await safeRotatingAppend(filename, line)
|
||||||
|
} catch (e) {
|
||||||
|
// 静默处理,避免日志过多
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录流式响应的最终摘要
|
||||||
|
*/
|
||||||
|
async function dumpAntigravityStreamSummary(summaryInfo) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'antigravity_stream_summary',
|
||||||
|
requestId: summaryInfo?.requestId || null,
|
||||||
|
model: summaryInfo?.model || null,
|
||||||
|
totalEvents: summaryInfo?.totalEvents || 0,
|
||||||
|
finishReason: summaryInfo?.finishReason || null,
|
||||||
|
hasThinking: summaryInfo?.hasThinking || false,
|
||||||
|
hasToolCalls: summaryInfo?.hasToolCalls || false,
|
||||||
|
toolCallNames: summaryInfo?.toolCallNames || [],
|
||||||
|
usage: summaryInfo?.usage || null,
|
||||||
|
error: summaryInfo?.error || null,
|
||||||
|
textPreview: summaryInfo?.textPreview || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
try {
|
||||||
|
await safeRotatingAppend(filename, line)
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to dump Antigravity stream summary', {
|
||||||
|
filename,
|
||||||
|
requestId: summaryInfo?.requestId || null,
|
||||||
|
error: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dumpAntigravityUpstreamResponse,
|
||||||
|
dumpAntigravityStreamEvent,
|
||||||
|
dumpAntigravityStreamSummary,
|
||||||
|
UPSTREAM_RESPONSE_DUMP_ENV,
|
||||||
|
UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV,
|
||||||
|
UPSTREAM_RESPONSE_DUMP_FILENAME
|
||||||
|
}
|
||||||
@@ -79,11 +79,21 @@ const PROMPT_DEFINITIONS = {
|
|||||||
title: 'Claude Code Compact System Prompt Agent SDK2',
|
title: 'Claude Code Compact System Prompt Agent SDK2',
|
||||||
text: "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."
|
text: "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."
|
||||||
},
|
},
|
||||||
|
claudeOtherSystemPrompt5: {
|
||||||
|
category: 'system',
|
||||||
|
title: 'Claude CLI Billing Header',
|
||||||
|
text: 'x-anthropic-billing-header: cc_version=2.1.15.c5a; cc_entrypoint=cli'
|
||||||
|
},
|
||||||
claudeOtherSystemPromptCompact: {
|
claudeOtherSystemPromptCompact: {
|
||||||
category: 'system',
|
category: 'system',
|
||||||
title: 'Claude Code Compact System Prompt',
|
title: 'Claude Code Compact System Prompt',
|
||||||
text: 'You are a helpful AI assistant tasked with summarizing conversations.'
|
text: 'You are a helpful AI assistant tasked with summarizing conversations.'
|
||||||
},
|
},
|
||||||
|
exploreAgentSystemPrompt: {
|
||||||
|
category: 'system',
|
||||||
|
title: 'Claude Code Explore Agent System Prompt',
|
||||||
|
text: "You are a file search specialist for Claude Code, Anthropic's official CLI for Claude."
|
||||||
|
},
|
||||||
outputStyleInsightsPrompt: {
|
outputStyleInsightsPrompt: {
|
||||||
category: 'output_style',
|
category: 'output_style',
|
||||||
title: 'Output Style Insights Addendum',
|
title: 'Output Style Insights Addendum',
|
||||||
|
|||||||
@@ -55,16 +55,69 @@ function sanitizeUpstreamError(errorData) {
|
|||||||
return errorData
|
return errorData
|
||||||
}
|
}
|
||||||
|
|
||||||
// 深拷贝避免修改原始对象
|
// AxiosError / Error:返回摘要,避免泄露请求体/headers/token 等敏感信息
|
||||||
const sanitized = JSON.parse(JSON.stringify(errorData))
|
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) => {
|
const sanitizeObject = (obj) => {
|
||||||
if (!obj || typeof obj !== 'object') {
|
if (!obj || typeof obj !== 'object') {
|
||||||
return obj
|
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) {
|
for (const key in obj) {
|
||||||
|
if (shouldRedactKey(key)) {
|
||||||
|
obj[key] = '[REDACTED]'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 清理所有字符串字段,不仅仅是 message
|
// 清理所有字符串字段,不仅仅是 message
|
||||||
if (typeof obj[key] === 'string') {
|
if (typeof obj[key] === 'string') {
|
||||||
obj[key] = sanitizeErrorMessage(obj[key])
|
obj[key] = sanitizeErrorMessage(obj[key])
|
||||||
@@ -76,7 +129,9 @@ function sanitizeUpstreamError(errorData) {
|
|||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitizeObject(sanitized)
|
// 尽量不修改原对象:浅拷贝后递归清理
|
||||||
|
const clone = Array.isArray(errorData) ? [...errorData] : { ...errorData }
|
||||||
|
return sanitizeObject(clone)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
46
src/utils/featureFlags.js
Normal file
46
src/utils/featureFlags.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
let config = {}
|
||||||
|
try {
|
||||||
|
// config/config.js 可能在某些环境不存在(例如仅拷贝了 config.example.js)
|
||||||
|
// 为保证可运行,这里做容错处理
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
config = require('../../config/config')
|
||||||
|
} catch (error) {
|
||||||
|
config = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseBooleanEnv = (value) => {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许执行"余额脚本"(安全开关)
|
||||||
|
* ⚠️ 安全警告:vm模块非安全沙箱,默认禁用。如需启用请显式设置 BALANCE_SCRIPT_ENABLED=true
|
||||||
|
* 仅在完全信任管理员且了解RCE风险时才启用此功能
|
||||||
|
*/
|
||||||
|
const isBalanceScriptEnabled = () => {
|
||||||
|
if (
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED !== undefined &&
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED !== ''
|
||||||
|
) {
|
||||||
|
return parseBooleanEnv(process.env.BALANCE_SCRIPT_ENABLED)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromConfig =
|
||||||
|
config?.accountBalance?.enableBalanceScript ??
|
||||||
|
config?.features?.balanceScriptEnabled ??
|
||||||
|
config?.security?.enableBalanceScript
|
||||||
|
|
||||||
|
// 默认禁用,需显式启用
|
||||||
|
return typeof fromConfig === 'boolean' ? fromConfig : false
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isBalanceScriptEnabled
|
||||||
|
}
|
||||||
265
src/utils/geminiSchemaCleaner.js
Normal file
265
src/utils/geminiSchemaCleaner.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
function appendHint(description, hint) {
|
||||||
|
if (!hint) {
|
||||||
|
return description || ''
|
||||||
|
}
|
||||||
|
if (!description) {
|
||||||
|
return hint
|
||||||
|
}
|
||||||
|
return `${description} (${hint})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefHint(refValue) {
|
||||||
|
const ref = String(refValue || '')
|
||||||
|
if (!ref) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const idx = ref.lastIndexOf('/')
|
||||||
|
const name = idx >= 0 ? ref.slice(idx + 1) : ref
|
||||||
|
return name ? `See: ${name}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(typeValue) {
|
||||||
|
if (typeof typeValue === 'string' && typeValue) {
|
||||||
|
return { type: typeValue, hint: '' }
|
||||||
|
}
|
||||||
|
if (!Array.isArray(typeValue) || typeValue.length === 0) {
|
||||||
|
return { type: '', hint: '' }
|
||||||
|
}
|
||||||
|
const raw = typeValue.map((t) => (t === null || t === undefined ? '' : String(t))).filter(Boolean)
|
||||||
|
const hasNull = raw.includes('null')
|
||||||
|
const nonNull = raw.filter((t) => t !== 'null')
|
||||||
|
const primary = nonNull[0] || 'string'
|
||||||
|
const hintParts = []
|
||||||
|
if (nonNull.length > 1) {
|
||||||
|
hintParts.push(`Accepts: ${nonNull.join(' | ')}`)
|
||||||
|
}
|
||||||
|
if (hasNull) {
|
||||||
|
hintParts.push('nullable')
|
||||||
|
}
|
||||||
|
return { type: primary, hint: hintParts.join('; ') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONSTRAINT_KEYS = [
|
||||||
|
'minLength',
|
||||||
|
'maxLength',
|
||||||
|
'exclusiveMinimum',
|
||||||
|
'exclusiveMaximum',
|
||||||
|
'pattern',
|
||||||
|
'minItems',
|
||||||
|
'maxItems'
|
||||||
|
]
|
||||||
|
|
||||||
|
function scoreSchema(schema) {
|
||||||
|
if (!schema || typeof schema !== 'object') {
|
||||||
|
return { score: 0, type: '' }
|
||||||
|
}
|
||||||
|
const t = typeof schema.type === 'string' ? schema.type : ''
|
||||||
|
if (t === 'object' || (schema.properties && typeof schema.properties === 'object')) {
|
||||||
|
return { score: 3, type: t || 'object' }
|
||||||
|
}
|
||||||
|
if (t === 'array' || schema.items) {
|
||||||
|
return { score: 2, type: t || 'array' }
|
||||||
|
}
|
||||||
|
if (t && t !== 'null') {
|
||||||
|
return { score: 1, type: t }
|
||||||
|
}
|
||||||
|
return { score: 0, type: t || 'null' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestFromAlternatives(alternatives) {
|
||||||
|
let bestIndex = 0
|
||||||
|
let bestScore = -1
|
||||||
|
const types = []
|
||||||
|
for (let i = 0; i < alternatives.length; i += 1) {
|
||||||
|
const alt = alternatives[i]
|
||||||
|
const { score, type } = scoreSchema(alt)
|
||||||
|
if (type) {
|
||||||
|
types.push(type)
|
||||||
|
}
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { best: alternatives[bestIndex], types: Array.from(new Set(types)).filter(Boolean) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanJsonSchemaForGemini(schema) {
|
||||||
|
if (schema === null || schema === undefined) {
|
||||||
|
return { type: 'object', properties: {} }
|
||||||
|
}
|
||||||
|
if (typeof schema !== 'object') {
|
||||||
|
return { type: 'object', properties: {} }
|
||||||
|
}
|
||||||
|
if (Array.isArray(schema)) {
|
||||||
|
return { type: 'object', properties: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
// $ref:Gemini/Antigravity 不支持,转换为 hint
|
||||||
|
if (typeof schema.$ref === 'string' && schema.$ref) {
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
description: appendHint(schema.description || '', getRefHint(schema.$ref)),
|
||||||
|
properties: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// anyOf / oneOf:选择最可能的 schema,保留类型提示
|
||||||
|
const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : null
|
||||||
|
const oneOf = Array.isArray(schema.oneOf) ? schema.oneOf : null
|
||||||
|
const alts = anyOf && anyOf.length ? anyOf : oneOf && oneOf.length ? oneOf : null
|
||||||
|
if (alts) {
|
||||||
|
const { best, types } = pickBestFromAlternatives(alts)
|
||||||
|
const cleaned = cleanJsonSchemaForGemini(best)
|
||||||
|
const mergedDescription = appendHint(cleaned.description || '', schema.description || '')
|
||||||
|
const typeHint = types.length > 1 ? `Accepts: ${types.join(' || ')}` : ''
|
||||||
|
return {
|
||||||
|
...cleaned,
|
||||||
|
description: appendHint(mergedDescription, typeHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allOf:合并 properties/required
|
||||||
|
if (Array.isArray(schema.allOf) && schema.allOf.length) {
|
||||||
|
const merged = {}
|
||||||
|
let mergedDesc = schema.description || ''
|
||||||
|
const mergedReq = new Set()
|
||||||
|
const mergedProps = {}
|
||||||
|
for (const item of schema.allOf) {
|
||||||
|
const cleaned = cleanJsonSchemaForGemini(item)
|
||||||
|
if (cleaned.description) {
|
||||||
|
mergedDesc = appendHint(mergedDesc, cleaned.description)
|
||||||
|
}
|
||||||
|
if (Array.isArray(cleaned.required)) {
|
||||||
|
for (const r of cleaned.required) {
|
||||||
|
if (typeof r === 'string' && r) {
|
||||||
|
mergedReq.add(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleaned.properties && typeof cleaned.properties === 'object') {
|
||||||
|
Object.assign(mergedProps, cleaned.properties)
|
||||||
|
}
|
||||||
|
if (cleaned.type && !merged.type) {
|
||||||
|
merged.type = cleaned.type
|
||||||
|
}
|
||||||
|
if (cleaned.items && !merged.items) {
|
||||||
|
merged.items = cleaned.items
|
||||||
|
}
|
||||||
|
if (Array.isArray(cleaned.enum) && !merged.enum) {
|
||||||
|
merged.enum = cleaned.enum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(mergedProps).length) {
|
||||||
|
merged.type = merged.type || 'object'
|
||||||
|
merged.properties = mergedProps
|
||||||
|
const req = Array.from(mergedReq).filter((r) => mergedProps[r])
|
||||||
|
if (req.length) {
|
||||||
|
merged.required = req
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mergedDesc) {
|
||||||
|
merged.description = mergedDesc
|
||||||
|
}
|
||||||
|
return cleanJsonSchemaForGemini(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {}
|
||||||
|
const constraintHints = []
|
||||||
|
|
||||||
|
// description
|
||||||
|
if (typeof schema.description === 'string') {
|
||||||
|
result.description = schema.description
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of CONSTRAINT_KEYS) {
|
||||||
|
const value = schema[key]
|
||||||
|
if (value === undefined || value === null || typeof value === 'object') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
constraintHints.push(`${key}: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// const -> enum
|
||||||
|
if (schema.const !== undefined && !Array.isArray(schema.enum)) {
|
||||||
|
result.enum = [schema.const]
|
||||||
|
}
|
||||||
|
|
||||||
|
// enum
|
||||||
|
if (Array.isArray(schema.enum)) {
|
||||||
|
const en = schema.enum.filter(
|
||||||
|
(v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
|
||||||
|
)
|
||||||
|
if (en.length) {
|
||||||
|
result.enum = en
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// type(flatten 数组 type)
|
||||||
|
const { type: normalizedType, hint: typeHint } = normalizeType(schema.type)
|
||||||
|
if (normalizedType) {
|
||||||
|
result.type = normalizedType
|
||||||
|
}
|
||||||
|
if (typeHint) {
|
||||||
|
result.description = appendHint(result.description || '', typeHint)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.enum && result.enum.length > 1 && result.enum.length <= 10) {
|
||||||
|
const list = result.enum.map((item) => String(item)).join(', ')
|
||||||
|
result.description = appendHint(result.description || '', `Allowed: ${list}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (constraintHints.length) {
|
||||||
|
result.description = appendHint(result.description || '', constraintHints.join(', '))
|
||||||
|
}
|
||||||
|
|
||||||
|
// additionalProperties:Gemini/Antigravity 不接受布尔值,直接删除并用 hint 记录
|
||||||
|
if (schema.additionalProperties === false) {
|
||||||
|
result.description = appendHint(result.description || '', 'No extra properties allowed')
|
||||||
|
}
|
||||||
|
|
||||||
|
// properties
|
||||||
|
if (
|
||||||
|
schema.properties &&
|
||||||
|
typeof schema.properties === 'object' &&
|
||||||
|
!Array.isArray(schema.properties)
|
||||||
|
) {
|
||||||
|
const props = {}
|
||||||
|
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
||||||
|
props[name] = cleanJsonSchemaForGemini(propSchema)
|
||||||
|
}
|
||||||
|
result.type = result.type || 'object'
|
||||||
|
result.properties = props
|
||||||
|
}
|
||||||
|
|
||||||
|
// items
|
||||||
|
if (schema.items !== undefined) {
|
||||||
|
result.type = result.type || 'array'
|
||||||
|
result.items = cleanJsonSchemaForGemini(schema.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// required(最后再清理无效字段)
|
||||||
|
if (Array.isArray(schema.required) && result.properties) {
|
||||||
|
const req = schema.required.filter(
|
||||||
|
(r) =>
|
||||||
|
typeof r === 'string' && r && Object.prototype.hasOwnProperty.call(result.properties, r)
|
||||||
|
)
|
||||||
|
if (req.length) {
|
||||||
|
result.required = req
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只保留 Gemini 兼容字段:其他($schema/$id/$defs/definitions/format/constraints/pattern...)一律丢弃
|
||||||
|
|
||||||
|
if (!result.type) {
|
||||||
|
result.type = result.properties ? 'object' : result.items ? 'array' : 'object'
|
||||||
|
}
|
||||||
|
if (result.type === 'object' && !result.properties) {
|
||||||
|
result.properties = {}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cleanJsonSchemaForGemini
|
||||||
|
}
|
||||||
@@ -52,50 +52,38 @@ function filterForOpenAI(headers) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 为 Claude/Anthropic API 过滤 headers
|
* 为 Claude/Anthropic API 过滤 headers
|
||||||
* 在原有逻辑基础上添加 CDN headers 到敏感列表
|
* 使用白名单模式,只允许指定的 headers 通过
|
||||||
*/
|
*/
|
||||||
function filterForClaude(headers) {
|
function filterForClaude(headers) {
|
||||||
const sensitiveHeaders = [
|
// 白名单模式:只允许以下 headers
|
||||||
'content-type',
|
const allowedHeaders = [
|
||||||
'user-agent',
|
|
||||||
'x-api-key',
|
|
||||||
'authorization',
|
|
||||||
'x-authorization',
|
|
||||||
'host',
|
|
||||||
'content-length',
|
|
||||||
'connection',
|
|
||||||
'proxy-authorization',
|
|
||||||
'content-encoding',
|
|
||||||
'transfer-encoding',
|
|
||||||
...cdnHeaders // 添加 CDN headers
|
|
||||||
]
|
|
||||||
|
|
||||||
const browserHeaders = [
|
|
||||||
'origin',
|
|
||||||
'referer',
|
|
||||||
'sec-fetch-mode',
|
|
||||||
'sec-fetch-site',
|
|
||||||
'sec-fetch-dest',
|
|
||||||
'sec-ch-ua',
|
|
||||||
'sec-ch-ua-mobile',
|
|
||||||
'sec-ch-ua-platform',
|
|
||||||
'accept-language',
|
|
||||||
'accept-encoding',
|
|
||||||
'accept',
|
'accept',
|
||||||
'cache-control',
|
'x-stainless-retry-count',
|
||||||
'pragma',
|
'x-stainless-timeout',
|
||||||
'anthropic-dangerous-direct-browser-access'
|
'x-stainless-lang',
|
||||||
|
'x-stainless-package-version',
|
||||||
|
'x-stainless-os',
|
||||||
|
'x-stainless-arch',
|
||||||
|
'x-stainless-runtime',
|
||||||
|
'x-stainless-runtime-version',
|
||||||
|
'x-stainless-helper-method',
|
||||||
|
'anthropic-dangerous-direct-browser-access',
|
||||||
|
'anthropic-version',
|
||||||
|
'x-app',
|
||||||
|
'anthropic-beta',
|
||||||
|
'accept-language',
|
||||||
|
'sec-fetch-mode',
|
||||||
|
'accept-encoding',
|
||||||
|
'user-agent',
|
||||||
|
'content-type',
|
||||||
|
'connection'
|
||||||
]
|
]
|
||||||
|
|
||||||
const allowedHeaders = ['x-request-id', 'anthropic-version', 'anthropic-beta']
|
|
||||||
|
|
||||||
const filtered = {}
|
const filtered = {}
|
||||||
Object.keys(headers || {}).forEach((key) => {
|
Object.keys(headers || {}).forEach((key) => {
|
||||||
const lowerKey = key.toLowerCase()
|
const lowerKey = key.toLowerCase()
|
||||||
if (allowedHeaders.includes(lowerKey)) {
|
if (allowedHeaders.includes(lowerKey)) {
|
||||||
filtered[key] = headers[key]
|
filtered[key] = headers[key]
|
||||||
} else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
|
|
||||||
filtered[key] = headers[key]
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ const createLogFormat = (colorize = false) => {
|
|||||||
|
|
||||||
const logFormat = createLogFormat(false)
|
const logFormat = createLogFormat(false)
|
||||||
const consoleFormat = createLogFormat(true)
|
const consoleFormat = createLogFormat(true)
|
||||||
|
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID
|
||||||
|
|
||||||
// 📁 确保日志目录存在并设置权限
|
// 📁 确保日志目录存在并设置权限
|
||||||
if (!fs.existsSync(config.logging.dirname)) {
|
if (!fs.existsSync(config.logging.dirname)) {
|
||||||
@@ -159,7 +160,8 @@ const createRotateTransport = (filename, level = null) => {
|
|||||||
transport.level = level
|
transport.level = level
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听轮转事件
|
// 监听轮转事件(测试环境关闭以避免 Jest 退出后输出)
|
||||||
|
if (!isTestEnv) {
|
||||||
transport.on('rotate', (oldFilename, newFilename) => {
|
transport.on('rotate', (oldFilename, newFilename) => {
|
||||||
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
|
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
|
||||||
})
|
})
|
||||||
@@ -171,6 +173,7 @@ const createRotateTransport = (filename, level = null) => {
|
|||||||
transport.on('archive', (zipFilename) => {
|
transport.on('archive', (zipFilename) => {
|
||||||
console.log(`🗜️ Log archived: ${zipFilename}`)
|
console.log(`🗜️ Log archived: ${zipFilename}`)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return transport
|
return transport
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
* Supports parsing model strings like "ccr,model_name" to extract vendor type and base model.
|
* 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
|
* Parse vendor-prefixed model string
|
||||||
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
|
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
|
||||||
@@ -19,18 +23,23 @@ function parseVendorPrefixedModel(modelStr) {
|
|||||||
const trimmed = modelStr.trim()
|
const trimmed = modelStr.trim()
|
||||||
const lowerTrimmed = trimmed.toLowerCase()
|
const lowerTrimmed = trimmed.toLowerCase()
|
||||||
|
|
||||||
// Check for ccr prefix (case insensitive)
|
for (const vendorPrefix of SUPPORTED_VENDOR_PREFIXES) {
|
||||||
if (lowerTrimmed.startsWith('ccr,')) {
|
if (!lowerTrimmed.startsWith(`${vendorPrefix},`)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const parts = trimmed.split(',')
|
const parts = trimmed.split(',')
|
||||||
if (parts.length >= 2) {
|
if (parts.length < 2) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
|
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
|
||||||
const baseModel = parts.slice(1).join(',').trim()
|
const baseModel = parts.slice(1).join(',').trim()
|
||||||
return {
|
return {
|
||||||
vendor: 'ccr',
|
vendor: vendorPrefix,
|
||||||
baseModel
|
baseModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// No recognized vendor prefix found
|
// No recognized vendor prefix found
|
||||||
return {
|
return {
|
||||||
@@ -179,10 +188,54 @@ function isOpus45OrNewer(modelName) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断某个 model 名称是否属于 Anthropic Claude 系列模型。
|
||||||
|
*
|
||||||
|
* 用于 API Key 维度的限额/统计(Claude 周费用)。这里刻意覆盖以下命名:
|
||||||
|
* - 标准 Anthropic 模型:claude-*,包括 claude-3-opus、claude-sonnet-*、claude-haiku-* 等
|
||||||
|
* - Bedrock 模型:{region}.anthropic.claude-... / anthropic.claude-...
|
||||||
|
* - 少数情况下 model 字段可能只包含家族关键词(sonnet/haiku/opus),也视为 Claude 系列
|
||||||
|
*
|
||||||
|
* 注意:会先去掉支持的 vendor 前缀(例如 "ccr,")。
|
||||||
|
*/
|
||||||
|
function isClaudeFamilyModel(modelName) {
|
||||||
|
if (!modelName || typeof modelName !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const { baseModel } = parseVendorPrefixedModel(modelName)
|
||||||
|
const m = (baseModel || '').trim().toLowerCase()
|
||||||
|
if (!m) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock 模型格式
|
||||||
|
if (
|
||||||
|
m.includes('.anthropic.claude-') ||
|
||||||
|
m.startsWith('anthropic.claude-') ||
|
||||||
|
m.includes('.claude-')
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标准 Anthropic 模型 ID
|
||||||
|
if (m.startsWith('claude-') || m.includes('claude-')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:某些下游链路里 model 字段可能不带 "claude-" 前缀,但仍包含家族关键词。
|
||||||
|
if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
parseVendorPrefixedModel,
|
parseVendorPrefixedModel,
|
||||||
hasVendorPrefix,
|
hasVendorPrefix,
|
||||||
getEffectiveModel,
|
getEffectiveModel,
|
||||||
getVendorType,
|
getVendorType,
|
||||||
isOpus45OrNewer
|
isOpus45OrNewer,
|
||||||
|
isClaudeFamilyModel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ const OAUTH_CONFIG = {
|
|||||||
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
|
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
|
||||||
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
|
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
|
||||||
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
||||||
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback',
|
REDIRECT_URI: 'https://platform.claude.com/oauth/code/callback',
|
||||||
SCOPES: 'org:create_api_key user:profile user:inference',
|
SCOPES: 'org:create_api_key user:profile user:inference user:sessions:claude_code',
|
||||||
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ function generateState() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机的 code verifier(PKCE)
|
* 生成随机的 code verifier(PKCE)
|
||||||
|
* 符合 RFC 7636 标准:32字节随机数 → base64url编码 → 43字符
|
||||||
* @returns {string} base64url 编码的随机字符串
|
* @returns {string} base64url 编码的随机字符串
|
||||||
*/
|
*/
|
||||||
function generateCodeVerifier() {
|
function generateCodeVerifier() {
|
||||||
|
|||||||
10
src/utils/projectPaths.js
Normal file
10
src/utils/projectPaths.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// 该文件位于 src/utils 下,向上两级即项目根目录。
|
||||||
|
function getProjectRoot() {
|
||||||
|
return path.resolve(__dirname, '..', '..')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getProjectRoot
|
||||||
|
}
|
||||||
88
src/utils/safeRotatingAppend.js
Normal file
88
src/utils/safeRotatingAppend.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* 安全 JSONL 追加工具(带文件大小限制与自动轮转)
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* 用于所有调试 Dump 模块,避免日志文件无限增长导致 I/O 拥塞。
|
||||||
|
*
|
||||||
|
* 策略:
|
||||||
|
* - 每次写入前检查目标文件大小
|
||||||
|
* - 超过阈值时,将现有文件重命名为 .bak(覆盖旧 .bak)
|
||||||
|
* - 然后写入新文件
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs/promises')
|
||||||
|
const logger = require('./logger')
|
||||||
|
|
||||||
|
// 默认文件大小上限:10MB
|
||||||
|
const DEFAULT_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
|
||||||
|
const MAX_FILE_SIZE_ENV = 'DUMP_MAX_FILE_SIZE_BYTES'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件大小上限(可通过环境变量覆盖)
|
||||||
|
*/
|
||||||
|
function getMaxFileSize() {
|
||||||
|
const raw = process.env[MAX_FILE_SIZE_ENV]
|
||||||
|
if (raw) {
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_MAX_FILE_SIZE_BYTES
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件大小,文件不存在时返回 0
|
||||||
|
*/
|
||||||
|
async function getFileSize(filepath) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(filepath)
|
||||||
|
return stat.size
|
||||||
|
} catch (e) {
|
||||||
|
// 文件不存在或无法读取
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全追加写入 JSONL 文件,支持自动轮转
|
||||||
|
*
|
||||||
|
* @param {string} filepath - 目标文件绝对路径
|
||||||
|
* @param {string} line - 要写入的单行(应以 \n 结尾)
|
||||||
|
* @param {Object} options - 可选配置
|
||||||
|
* @param {number} options.maxFileSize - 文件大小上限(字节),默认从环境变量或 10MB
|
||||||
|
*/
|
||||||
|
async function safeRotatingAppend(filepath, line, options = {}) {
|
||||||
|
const maxFileSize = options.maxFileSize || getMaxFileSize()
|
||||||
|
|
||||||
|
const currentSize = await getFileSize(filepath)
|
||||||
|
|
||||||
|
// 如果当前文件已达到或超过阈值,轮转
|
||||||
|
if (currentSize >= maxFileSize) {
|
||||||
|
const backupPath = `${filepath}.bak`
|
||||||
|
try {
|
||||||
|
// 先删除旧备份(如果存在)
|
||||||
|
await fs.unlink(backupPath).catch(() => {})
|
||||||
|
// 重命名当前文件为备份
|
||||||
|
await fs.rename(filepath, backupPath)
|
||||||
|
} catch (renameErr) {
|
||||||
|
// 轮转失败时记录警告日志,继续写入原文件
|
||||||
|
logger.warn('⚠️ Log rotation failed, continuing to write to original file', {
|
||||||
|
filepath,
|
||||||
|
backupPath,
|
||||||
|
error: renameErr?.message || String(renameErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加写入
|
||||||
|
await fs.appendFile(filepath, line, { encoding: 'utf8' })
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
safeRotatingAppend,
|
||||||
|
getMaxFileSize,
|
||||||
|
MAX_FILE_SIZE_ENV,
|
||||||
|
DEFAULT_MAX_FILE_SIZE_BYTES
|
||||||
|
}
|
||||||
183
src/utils/signatureCache.js
Normal file
183
src/utils/signatureCache.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Signature Cache - 签名缓存模块
|
||||||
|
*
|
||||||
|
* 用于缓存 Antigravity thinking block 的 thoughtSignature。
|
||||||
|
* Claude Code 客户端可能剥离非标准字段,导致多轮对话时签名丢失。
|
||||||
|
* 此模块按 sessionId + thinkingText 存储签名,便于后续请求恢复。
|
||||||
|
*
|
||||||
|
* 参考实现:
|
||||||
|
* - CLIProxyAPI: internal/cache/signature_cache.go
|
||||||
|
* - antigravity-claude-proxy: src/format/signature-cache.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const logger = require('./logger')
|
||||||
|
|
||||||
|
// 配置常量
|
||||||
|
const SIGNATURE_CACHE_TTL_MS = 60 * 60 * 1000 // 1 小时(同 CLIProxyAPI)
|
||||||
|
const MAX_ENTRIES_PER_SESSION = 100 // 每会话最大缓存条目
|
||||||
|
const MIN_SIGNATURE_LENGTH = 50 // 最小有效签名长度
|
||||||
|
const TEXT_HASH_LENGTH = 16 // 文本哈希长度(SHA256 前 16 位)
|
||||||
|
|
||||||
|
// 主缓存:sessionId -> Map<textHash, { signature, timestamp }>
|
||||||
|
const signatureCache = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成文本内容的稳定哈希值
|
||||||
|
* @param {string} text - 待哈希的文本
|
||||||
|
* @returns {string} 16 字符的十六进制哈希
|
||||||
|
*/
|
||||||
|
function hashText(text) {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const hash = crypto.createHash('sha256').update(text).digest('hex')
|
||||||
|
return hash.slice(0, TEXT_HASH_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建会话缓存
|
||||||
|
* @param {string} sessionId - 会话 ID
|
||||||
|
* @returns {Map} 会话的签名缓存 Map
|
||||||
|
*/
|
||||||
|
function getOrCreateSessionCache(sessionId) {
|
||||||
|
if (!signatureCache.has(sessionId)) {
|
||||||
|
signatureCache.set(sessionId, new Map())
|
||||||
|
}
|
||||||
|
return signatureCache.get(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查签名是否有效
|
||||||
|
* @param {string} signature - 待检查的签名
|
||||||
|
* @returns {boolean} 签名是否有效
|
||||||
|
*/
|
||||||
|
function isValidSignature(signature) {
|
||||||
|
return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存 thinking 签名
|
||||||
|
* @param {string} sessionId - 会话 ID
|
||||||
|
* @param {string} thinkingText - thinking 内容文本
|
||||||
|
* @param {string} signature - thoughtSignature
|
||||||
|
*/
|
||||||
|
function cacheSignature(sessionId, thinkingText, signature) {
|
||||||
|
if (!sessionId || !thinkingText || !signature) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidSignature(signature)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCache = getOrCreateSessionCache(sessionId)
|
||||||
|
const textHash = hashText(thinkingText)
|
||||||
|
|
||||||
|
if (!textHash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 淘汰策略:超过限制时删除最老的 1/4 条目
|
||||||
|
if (sessionCache.size >= MAX_ENTRIES_PER_SESSION) {
|
||||||
|
const entries = Array.from(sessionCache.entries())
|
||||||
|
entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
|
||||||
|
const toRemove = Math.max(1, Math.floor(entries.length / 4))
|
||||||
|
for (let i = 0; i < toRemove; i++) {
|
||||||
|
sessionCache.delete(entries[i][0])
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`[SignatureCache] Evicted ${toRemove} old entries for session ${sessionId.slice(0, 8)}...`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCache.set(textHash, {
|
||||||
|
signature,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[SignatureCache] Cached signature for session ${sessionId.slice(0, 8)}..., hash ${textHash}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的签名
|
||||||
|
* @param {string} sessionId - 会话 ID
|
||||||
|
* @param {string} thinkingText - thinking 内容文本
|
||||||
|
* @returns {string|null} 缓存的签名,未找到或过期则返回 null
|
||||||
|
*/
|
||||||
|
function getCachedSignature(sessionId, thinkingText) {
|
||||||
|
if (!sessionId || !thinkingText) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCache = signatureCache.get(sessionId)
|
||||||
|
if (!sessionCache) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const textHash = hashText(thinkingText)
|
||||||
|
if (!textHash) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = sessionCache.get(textHash)
|
||||||
|
if (!entry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if (Date.now() - entry.timestamp > SIGNATURE_CACHE_TTL_MS) {
|
||||||
|
sessionCache.delete(textHash)
|
||||||
|
logger.debug(`[SignatureCache] Entry expired for hash ${textHash}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[SignatureCache] Cache hit for session ${sessionId.slice(0, 8)}..., hash ${textHash}`
|
||||||
|
)
|
||||||
|
return entry.signature
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除会话缓存
|
||||||
|
* @param {string} sessionId - 要清除的会话 ID,为空则清除全部
|
||||||
|
*/
|
||||||
|
function clearSignatureCache(sessionId = null) {
|
||||||
|
if (sessionId) {
|
||||||
|
signatureCache.delete(sessionId)
|
||||||
|
logger.debug(`[SignatureCache] Cleared cache for session ${sessionId.slice(0, 8)}...`)
|
||||||
|
} else {
|
||||||
|
signatureCache.clear()
|
||||||
|
logger.debug('[SignatureCache] Cleared all caches')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存统计信息(调试用)
|
||||||
|
* @returns {Object} { sessionCount, totalEntries }
|
||||||
|
*/
|
||||||
|
function getCacheStats() {
|
||||||
|
let totalEntries = 0
|
||||||
|
for (const sessionCache of signatureCache.values()) {
|
||||||
|
totalEntries += sessionCache.size
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sessionCount: signatureCache.size,
|
||||||
|
totalEntries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cacheSignature,
|
||||||
|
getCachedSignature,
|
||||||
|
clearSignatureCache,
|
||||||
|
getCacheStats,
|
||||||
|
isValidSignature,
|
||||||
|
// 内部函数导出(用于测试或扩展)
|
||||||
|
hashText,
|
||||||
|
MIN_SIGNATURE_LENGTH,
|
||||||
|
MAX_ENTRIES_PER_SESSION,
|
||||||
|
SIGNATURE_CACHE_TTL_MS
|
||||||
|
}
|
||||||
105
src/utils/statsHelper.js
Normal file
105
src/utils/statsHelper.js
Normal 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
36
src/utils/streamHelper.js
Normal 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
|
||||||
|
}
|
||||||
202
src/utils/warmupInterceptor.js
Normal file
202
src/utils/warmupInterceptor.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预热请求拦截器
|
||||||
|
* 检测并拦截低价值请求(标题生成、Warmup等),直接返回模拟响应
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测是否为预热请求
|
||||||
|
* @param {Object} body - 请求体
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isWarmupRequest(body) {
|
||||||
|
if (!body) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 messages
|
||||||
|
if (body.messages && Array.isArray(body.messages)) {
|
||||||
|
for (const msg of body.messages) {
|
||||||
|
// 处理 content 为数组的情况
|
||||||
|
if (Array.isArray(msg.content)) {
|
||||||
|
for (const content of msg.content) {
|
||||||
|
if (content.type === 'text' && typeof content.text === 'string') {
|
||||||
|
if (isTitleOrWarmupText(content.text)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 处理 content 为字符串的情况
|
||||||
|
if (typeof msg.content === 'string') {
|
||||||
|
if (isTitleOrWarmupText(msg.content)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 system prompt
|
||||||
|
if (body.system) {
|
||||||
|
const systemText = extractSystemText(body.system)
|
||||||
|
if (isTitleExtractionSystemPrompt(systemText)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文本是否为标题生成或Warmup请求
|
||||||
|
*/
|
||||||
|
function isTitleOrWarmupText(text) {
|
||||||
|
if (!text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
text.includes('Please write a 5-10 word title for the following conversation:') ||
|
||||||
|
text === 'Warmup'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查system prompt是否为标题提取类型
|
||||||
|
*/
|
||||||
|
function isTitleExtractionSystemPrompt(systemText) {
|
||||||
|
if (!systemText) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return systemText.includes(
|
||||||
|
'nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从system字段提取文本
|
||||||
|
*/
|
||||||
|
function extractSystemText(system) {
|
||||||
|
if (typeof system === 'string') {
|
||||||
|
return system
|
||||||
|
}
|
||||||
|
if (Array.isArray(system)) {
|
||||||
|
return system.map((s) => (typeof s === 'object' ? s.text || '' : String(s))).join('')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成模拟的非流式响应
|
||||||
|
* @param {string} model - 模型名称
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function buildMockWarmupResponse(model) {
|
||||||
|
return {
|
||||||
|
id: `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}`,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: 'New Conversation' }],
|
||||||
|
model: model || 'claude-3-5-sonnet-20241022',
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送模拟的流式响应
|
||||||
|
* @param {Object} res - Express response对象
|
||||||
|
* @param {string} model - 模型名称
|
||||||
|
*/
|
||||||
|
function sendMockWarmupStream(res, model) {
|
||||||
|
const effectiveModel = model || 'claude-3-5-sonnet-20241022'
|
||||||
|
const messageId = `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}`
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
event: 'message_start',
|
||||||
|
data: {
|
||||||
|
message: {
|
||||||
|
content: [],
|
||||||
|
id: messageId,
|
||||||
|
model: effectiveModel,
|
||||||
|
role: 'assistant',
|
||||||
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
type: 'message',
|
||||||
|
usage: { input_tokens: 10, output_tokens: 0 }
|
||||||
|
},
|
||||||
|
type: 'message_start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'content_block_start',
|
||||||
|
data: {
|
||||||
|
content_block: { text: '', type: 'text' },
|
||||||
|
index: 0,
|
||||||
|
type: 'content_block_start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'content_block_delta',
|
||||||
|
data: {
|
||||||
|
delta: { text: 'New', type: 'text_delta' },
|
||||||
|
index: 0,
|
||||||
|
type: 'content_block_delta'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'content_block_delta',
|
||||||
|
data: {
|
||||||
|
delta: { text: ' Conversation', type: 'text_delta' },
|
||||||
|
index: 0,
|
||||||
|
type: 'content_block_delta'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'content_block_stop',
|
||||||
|
data: { index: 0, type: 'content_block_stop' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'message_delta',
|
||||||
|
data: {
|
||||||
|
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||||
|
type: 'message_delta',
|
||||||
|
usage: { input_tokens: 10, output_tokens: 2 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'message_stop',
|
||||||
|
data: { type: 'message_stop' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
let index = 0
|
||||||
|
const sendNext = () => {
|
||||||
|
if (index >= events.length) {
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { event, data } = events[index]
|
||||||
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||||
|
index++
|
||||||
|
|
||||||
|
// 模拟网络延迟
|
||||||
|
setTimeout(sendNext, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isWarmupRequest,
|
||||||
|
buildMockWarmupResponse,
|
||||||
|
sendMockWarmupStream
|
||||||
|
}
|
||||||
@@ -62,10 +62,16 @@ class ClaudeCodeValidator {
|
|||||||
|
|
||||||
for (const entry of systemEntries) {
|
for (const entry of systemEntries) {
|
||||||
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
||||||
const { bestScore } = bestSimilarityByTemplates(rawText)
|
const { bestScore, templateId, maskedRaw } = bestSimilarityByTemplates(rawText)
|
||||||
if (bestScore < threshold) {
|
if (bestScore < threshold) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}, prompt=${rawText}`
|
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}`
|
||||||
|
)
|
||||||
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,8 +125,12 @@ class CodexCliValidator {
|
|||||||
const part1 = parts1[i] || 0
|
const part1 = parts1[i] || 0
|
||||||
const part2 = parts2[i] || 0
|
const part2 = parts2[i] || 0
|
||||||
|
|
||||||
if (part1 < part2) return -1
|
if (part1 < part2) {
|
||||||
if (part1 > part2) return 1
|
return -1
|
||||||
|
}
|
||||||
|
if (part1 > part2) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class GeminiCliValidator {
|
|||||||
// 2. 对于 /gemini 路径,检查是否包含 generateContent
|
// 2. 对于 /gemini 路径,检查是否包含 generateContent
|
||||||
if (path.includes('generateContent')) {
|
if (path.includes('generateContent')) {
|
||||||
// 包含 generateContent 的路径需要验证 User-Agent
|
// 包含 generateContent 的路径需要验证 User-Agent
|
||||||
const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i
|
const geminiCliPattern = /^GeminiCLI\/v?[\d.]+/i
|
||||||
if (!geminiCliPattern.test(userAgent)) {
|
if (!geminiCliPattern.test(userAgent)) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`
|
`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`
|
||||||
@@ -84,8 +84,12 @@ class GeminiCliValidator {
|
|||||||
const part1 = parts1[i] || 0
|
const part1 = parts1[i] || 0
|
||||||
const part2 = parts2[i] || 0
|
const part2 = parts2[i] || 0
|
||||||
|
|
||||||
if (part1 < part2) return -1
|
if (part1 < part2) {
|
||||||
if (part1 > part2) return 1
|
return -1
|
||||||
|
}
|
||||||
|
if (part1 > part2) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
218
tests/accountBalanceService.test.js
Normal file
218
tests/accountBalanceService.test.js
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
// Mock logger,避免测试输出污染控制台
|
||||||
|
jest.mock('../src/utils/logger', () => ({
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const accountBalanceServiceModule = require('../src/services/accountBalanceService')
|
||||||
|
|
||||||
|
const { AccountBalanceService } = accountBalanceServiceModule
|
||||||
|
|
||||||
|
describe('AccountBalanceService', () => {
|
||||||
|
const originalBalanceScriptEnabled = process.env.BALANCE_SCRIPT_ENABLED
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalBalanceScriptEnabled === undefined) {
|
||||||
|
delete process.env.BALANCE_SCRIPT_ENABLED
|
||||||
|
} else {
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED = originalBalanceScriptEnabled
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockLogger = {
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildMockRedis = () => ({
|
||||||
|
getLocalBalance: jest.fn().mockResolvedValue(null),
|
||||||
|
setLocalBalance: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getAccountBalance: jest.fn().mockResolvedValue(null),
|
||||||
|
setAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||||||
|
deleteAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getBalanceScriptConfig: jest.fn().mockResolvedValue(null),
|
||||||
|
getAccountUsageStats: jest.fn().mockResolvedValue({
|
||||||
|
total: { requests: 10 },
|
||||||
|
daily: { requests: 2, cost: 20 },
|
||||||
|
monthly: { requests: 5 }
|
||||||
|
}),
|
||||||
|
getDateInTimezone: (date) => new Date(date.getTime() + 8 * 3600 * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize platform aliases', () => {
|
||||||
|
const service = new AccountBalanceService({ redis: buildMockRedis(), logger: mockLogger })
|
||||||
|
expect(service.normalizePlatform('claude-official')).toBe('claude')
|
||||||
|
expect(service.normalizePlatform('azure-openai')).toBe('azure_openai')
|
||||||
|
expect(service.normalizePlatform('gemini-api')).toBe('gemini-api')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should build local quota/balance from dailyQuota and local dailyCost', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(30)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(123.45)
|
||||||
|
|
||||||
|
const account = { id: 'acct-1', name: 'A', dailyQuota: '100', quotaResetTime: '00:00' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'claude-console', {
|
||||||
|
queryApi: false,
|
||||||
|
useCache: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data.source).toBe('local')
|
||||||
|
expect(result.data.balance.amount).toBeCloseTo(80, 6)
|
||||||
|
expect(result.data.quota.percentage).toBeCloseTo(20, 6)
|
||||||
|
expect(result.data.statistics.totalCost).toBeCloseTo(123.45, 6)
|
||||||
|
expect(mockRedis.setLocalBalance).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use cached balance when account has no dailyQuota', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
mockRedis.getAccountBalance.mockResolvedValue({
|
||||||
|
status: 'success',
|
||||||
|
balance: 12.34,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
errorMessage: '',
|
||||||
|
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||||
|
ttlSeconds: 120
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
const account = { id: 'acct-2', name: 'B' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: false,
|
||||||
|
useCache: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.data.source).toBe('cache')
|
||||||
|
expect(result.data.balance.amount).toBeCloseTo(12.34, 6)
|
||||||
|
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not cache provider errors and fallback to local when queryApi=true', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
service.registerProvider('openai', {
|
||||||
|
queryBalance: () => {
|
||||||
|
throw new Error('boom')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const account = { id: 'acct-3', name: 'C' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockRedis.setAccountBalance).not.toHaveBeenCalled()
|
||||||
|
expect(result.data.source).toBe('local')
|
||||||
|
expect(result.data.status).toBe('error')
|
||||||
|
expect(result.data.error).toBe('boom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore script config when balance script is disabled', async () => {
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED = 'false'
|
||||||
|
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
mockRedis.getBalanceScriptConfig.mockResolvedValue({
|
||||||
|
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 1, currency: 'USD' }) }
|
||||||
|
service.registerProvider('openai', provider)
|
||||||
|
|
||||||
|
const scriptSpy = jest.spyOn(service, '_getBalanceFromScript')
|
||||||
|
|
||||||
|
const account = { id: 'acct-script-off', name: 'S' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(provider.queryBalance).toHaveBeenCalled()
|
||||||
|
expect(scriptSpy).not.toHaveBeenCalled()
|
||||||
|
expect(result.data.source).toBe('api')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prefer script when configured and enabled', async () => {
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED = 'true'
|
||||||
|
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
mockRedis.getBalanceScriptConfig.mockResolvedValue({
|
||||||
|
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 2, currency: 'USD' }) }
|
||||||
|
service.registerProvider('openai', provider)
|
||||||
|
|
||||||
|
jest.spyOn(service, '_getBalanceFromScript').mockResolvedValue({
|
||||||
|
status: 'success',
|
||||||
|
balance: 3,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'script',
|
||||||
|
rawData: { ok: true },
|
||||||
|
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||||
|
errorMessage: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const account = { id: 'acct-script-on', name: 'T' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(provider.queryBalance).not.toHaveBeenCalled()
|
||||||
|
expect(result.data.source).toBe('api')
|
||||||
|
expect(result.data.balance.amount).toBeCloseTo(3, 6)
|
||||||
|
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should count low balance once per account in summary', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
|
||||||
|
service.getSupportedPlatforms = () => ['claude-console']
|
||||||
|
service.getAllAccountsByPlatform = async () => [{ id: 'acct-4', name: 'D' }]
|
||||||
|
service._getAccountBalanceForAccount = async () => ({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId: 'acct-4',
|
||||||
|
platform: 'claude-console',
|
||||||
|
balance: { amount: 5, currency: 'USD', formattedAmount: '$5.00' },
|
||||||
|
quota: { percentage: 95 },
|
||||||
|
statistics: { totalCost: 1 },
|
||||||
|
source: 'local',
|
||||||
|
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||||
|
cacheExpiresAt: null,
|
||||||
|
status: 'success',
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = await service.getBalanceSummary()
|
||||||
|
expect(summary.lowBalanceCount).toBe(1)
|
||||||
|
expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
860
tests/concurrencyQueue.integration.test.js
Normal file
860
tests/concurrencyQueue.integration.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
278
tests/concurrencyQueue.test.js
Normal file
278
tests/concurrencyQueue.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
434
tests/userMessageQueue.test.js
Normal file
434
tests/userMessageQueue.test.js
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
/**
|
||||||
|
* 用户消息队列服务测试
|
||||||
|
* 测试消息类型检测、队列串行行为、延迟间隔、超时处理和功能开关
|
||||||
|
*/
|
||||||
|
|
||||||
|
const redis = require('../src/models/redis')
|
||||||
|
const userMessageQueueService = require('../src/services/userMessageQueueService')
|
||||||
|
|
||||||
|
describe('UserMessageQueueService', () => {
|
||||||
|
describe('isUserMessageRequest', () => {
|
||||||
|
it('should return true when last message role is user', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'Hello' },
|
||||||
|
{ role: 'assistant', content: 'Hi there' },
|
||||||
|
{ role: 'user', content: 'How are you?' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when last message role is assistant', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'Hello' },
|
||||||
|
{ role: 'assistant', content: 'Hi there' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when last message contains tool_result', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'Hello' },
|
||||||
|
{ role: 'assistant', content: 'Let me check that' },
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'test-id',
|
||||||
|
content: 'Tool result'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// tool_result 消息虽然 role 是 user,但不是真正的用户消息
|
||||||
|
// 应该返回 false,不进入用户消息队列
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when last message contains multiple tool_results', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'Run multiple tools' },
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-1',
|
||||||
|
content: 'Result 1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-2',
|
||||||
|
content: 'Result 2'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true when user message has array content with text type', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Hello, this is a user message'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true when user message has mixed text and image content', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'What is in this image?'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
source: { type: 'base64', media_type: 'image/png', data: '...' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when messages is empty', () => {
|
||||||
|
const requestBody = { messages: [] }
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when messages is not an array', () => {
|
||||||
|
const requestBody = { messages: 'not an array' }
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when messages is undefined', () => {
|
||||||
|
const requestBody = {}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when requestBody is null', () => {
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when requestBody is undefined', () => {
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when last message has no role', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [{ content: 'Hello' }]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle single user message', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [{ role: 'user', content: 'Hello' }]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle single assistant message', () => {
|
||||||
|
const requestBody = {
|
||||||
|
messages: [{ role: 'assistant', content: 'Hello' }]
|
||||||
|
}
|
||||||
|
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getConfig', () => {
|
||||||
|
it('should return config with expected properties', async () => {
|
||||||
|
const config = await userMessageQueueService.getConfig()
|
||||||
|
expect(config).toHaveProperty('enabled')
|
||||||
|
expect(config).toHaveProperty('delayMs')
|
||||||
|
expect(config).toHaveProperty('timeoutMs')
|
||||||
|
expect(config).toHaveProperty('lockTtlMs')
|
||||||
|
expect(typeof config.enabled).toBe('boolean')
|
||||||
|
expect(typeof config.delayMs).toBe('number')
|
||||||
|
expect(typeof config.timeoutMs).toBe('number')
|
||||||
|
expect(typeof config.lockTtlMs).toBe('number')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isEnabled', () => {
|
||||||
|
it('should return boolean', async () => {
|
||||||
|
const enabled = await userMessageQueueService.isEnabled()
|
||||||
|
expect(typeof enabled).toBe('boolean')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('acquireQueueLock', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should acquire lock immediately when no lock exists', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: true,
|
||||||
|
waitMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(result.requestId).toBe('req-1')
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip lock acquisition when queue disabled', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: false,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
const acquireSpy = jest.spyOn(redis, 'acquireUserMessageLock')
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(result.skipped).toBe(true)
|
||||||
|
expect(acquireSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate requestId when not provided', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: true,
|
||||||
|
waitMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(result.requestId).toBeDefined()
|
||||||
|
expect(result.requestId.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wait and retry when lock is held by another request', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
let callCount = 0
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
|
||||||
|
callCount++
|
||||||
|
if (callCount < 3) {
|
||||||
|
return { acquired: false, waitMs: -1 } // lock held
|
||||||
|
}
|
||||||
|
return { acquired: true, waitMs: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock sleep to speed up test
|
||||||
|
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(callCount).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect delay when previous request just completed', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
let callCount = 0
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
|
||||||
|
callCount++
|
||||||
|
if (callCount === 1) {
|
||||||
|
return { acquired: false, waitMs: 150 } // need to wait 150ms for delay
|
||||||
|
}
|
||||||
|
return { acquired: true, waitMs: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const sleepSpy = jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(sleepSpy).toHaveBeenCalledWith(150) // Should wait for delay
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should timeout and return error when wait exceeds timeout', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 100, // very short timeout
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: false,
|
||||||
|
waitMs: -1 // always held
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use real timers for timeout test but mock sleep to be instant
|
||||||
|
jest.spyOn(userMessageQueueService, '_sleep').mockImplementation(async () => {
|
||||||
|
// Simulate time passing
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60))
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1', 100)
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(false)
|
||||||
|
expect(result.error).toBe('queue_timeout')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('releaseQueueLock', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should release lock successfully when holding the lock', async () => {
|
||||||
|
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(true)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(redis.releaseUserMessageLock).toHaveBeenCalledWith('acct-1', 'req-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when not holding the lock', async () => {
|
||||||
|
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(false)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when accountId is missing', async () => {
|
||||||
|
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock(null, 'req-1')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(releaseSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when requestId is missing', async () => {
|
||||||
|
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock('acct-1', null)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(releaseSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('queue serialization behavior', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow different accounts to acquire locks simultaneously', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: true,
|
||||||
|
waitMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const [result1, result2] = await Promise.all([
|
||||||
|
userMessageQueueService.acquireQueueLock('acct-1', 'req-1'),
|
||||||
|
userMessageQueueService.acquireQueueLock('acct-2', 'req-2')
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result1.acquired).toBe(true)
|
||||||
|
expect(result2.acquired).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should serialize requests for same account', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 50,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
const lockState = { held: false, holderId: null }
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(redis, 'acquireUserMessageLock')
|
||||||
|
.mockImplementation(async (accountId, requestId) => {
|
||||||
|
if (!lockState.held) {
|
||||||
|
lockState.held = true
|
||||||
|
lockState.holderId = requestId
|
||||||
|
return { acquired: true, waitMs: 0 }
|
||||||
|
}
|
||||||
|
return { acquired: false, waitMs: -1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(redis, 'releaseUserMessageLock')
|
||||||
|
.mockImplementation(async (accountId, requestId) => {
|
||||||
|
if (lockState.holderId === requestId) {
|
||||||
|
lockState.held = false
|
||||||
|
lockState.holderId = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
// First request acquires lock
|
||||||
|
const result1 = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
expect(result1.acquired).toBe(true)
|
||||||
|
|
||||||
|
// Second request should fail to acquire (lock held)
|
||||||
|
const acquirePromise = userMessageQueueService.acquireQueueLock('acct-1', 'req-2', 200)
|
||||||
|
|
||||||
|
// Release first lock
|
||||||
|
await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
// Now second request should acquire
|
||||||
|
const result2 = await acquirePromise
|
||||||
|
expect(result2.acquired).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
15
web/admin-spa/package-lock.json
generated
15
web/admin-spa/package-lock.json
generated
@@ -1157,7 +1157,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
@@ -1352,7 +1351,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1589,7 +1587,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -3063,15 +3060,13 @@
|
|||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash-unified": {
|
"node_modules/lodash-unified": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -3623,7 +3618,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3770,7 +3764,6 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -4035,7 +4028,6 @@
|
|||||||
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -4533,7 +4525,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4924,7 +4915,6 @@
|
|||||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -5125,7 +5115,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
||||||
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.18",
|
"@vue/compiler-dom": "3.5.18",
|
||||||
"@vue/compiler-sfc": "3.5.18",
|
"@vue/compiler-sfc": "3.5.18",
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:append-to-body="true"
|
||||||
|
class="balance-script-dialog"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
:model-value="show"
|
||||||
|
:title="`配置余额脚本 - ${account?.name || ''}`"
|
||||||
|
top="5vh"
|
||||||
|
width="720px"
|
||||||
|
@close="emitClose"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
|
||||||
|
<input v-model="form.apiKey" class="input-text" placeholder="access token / key" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>请求地址(baseUrl)</label
|
||||||
|
>
|
||||||
|
<input v-model="form.baseUrl" class="input-text" placeholder="https://api.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">Token(可选)</label>
|
||||||
|
<input v-model="form.token" class="input-text" placeholder="Bearer token" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>额外参数 (extra / userId)</label
|
||||||
|
>
|
||||||
|
<input v-model="form.extra" class="input-text" placeholder="用户ID等" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">超时时间(秒)</label>
|
||||||
|
<input v-model.number="form.timeoutSeconds" class="input-text" min="1" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>自动查询间隔(分钟)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="form.autoIntervalMinutes"
|
||||||
|
class="input-text"
|
||||||
|
min="0"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">0 表示仅手动刷新</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 md:col-span-2">
|
||||||
|
可用变量:{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}apiKey{{ '}'
|
||||||
|
}}{{ '}' }}、{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}accountId{{ '}'
|
||||||
|
}}{{ '}' }}、{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}extra{{ '}'
|
||||||
|
}}{{ '}' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">提取器代码</div>
|
||||||
|
<button
|
||||||
|
class="rounded bg-gray-200 px-2 py-1 text-xs dark:bg-gray-700"
|
||||||
|
@click="applyPreset"
|
||||||
|
>
|
||||||
|
使用示例
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="form.scriptBody"
|
||||||
|
class="min-h-[260px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
extractor 可返回:isValid、invalidMessage、remaining、unit、planName、total、used、extra
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="testResult" class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-gray-800/60">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold">测试结果</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'rounded px-2 py-0.5 text-xs',
|
||||||
|
testResult.mapped?.status === 'success'
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||||
|
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ testResult.mapped?.status || 'unknown' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
|
||||||
|
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
|
||||||
|
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
|
||||||
|
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
|
||||||
|
错误: {{ testResult.mapped.errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer">查看 extractor 输出</summary>
|
||||||
|
<pre class="mt-1 whitespace-pre-wrap break-all">{{
|
||||||
|
formatJson(testResult.extracted)
|
||||||
|
}}</pre>
|
||||||
|
</details>
|
||||||
|
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer">查看原始响应</summary>
|
||||||
|
<pre class="mt-1 whitespace-pre-wrap break-all">{{
|
||||||
|
formatJson(testResult.response)
|
||||||
|
}}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<el-button :loading="testing" @click="testScript">测试脚本</el-button>
|
||||||
|
<el-button :loading="saving" type="primary" @click="saveConfig">保存配置</el-button>
|
||||||
|
<el-button @click="emitClose">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
account: { type: Object, default: () => ({}) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'saved'])
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const testResult = ref(null)
|
||||||
|
|
||||||
|
const presetScript = `({
|
||||||
|
request: {
|
||||||
|
url: "{{baseUrl}}/api/user/self",
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer {{apiKey}}",
|
||||||
|
"New-Api-User": "{{extra}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extractor: function (response) {
|
||||||
|
if (response && response.success && response.data) {
|
||||||
|
const quota = response.data.quota || 0;
|
||||||
|
const used = response.data.used_quota || 0;
|
||||||
|
return {
|
||||||
|
planName: response.data.group || "默认套餐",
|
||||||
|
remaining: quota / 500000,
|
||||||
|
used: used / 500000,
|
||||||
|
total: (quota + used) / 500000,
|
||||||
|
unit: "USD"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
invalidMessage: (response && response.message) || "查询失败"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})`
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
baseUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
token: '',
|
||||||
|
extra: '',
|
||||||
|
timeoutSeconds: 10,
|
||||||
|
autoIntervalMinutes: 0,
|
||||||
|
scriptBody: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildDefaultForm = () => ({
|
||||||
|
baseUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
token: '',
|
||||||
|
extra: '',
|
||||||
|
timeoutSeconds: 10,
|
||||||
|
autoIntervalMinutes: 0,
|
||||||
|
// 默认给出示例脚本,字段保持清空,避免“上一个账户的配置污染当前账户”
|
||||||
|
scriptBody: presetScript
|
||||||
|
})
|
||||||
|
|
||||||
|
const emitClose = () => emit('close')
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(form, buildDefaultForm())
|
||||||
|
testResult.value = null
|
||||||
|
saving.value = false
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
if (!props.account?.id || !props.account?.platform) return
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`
|
||||||
|
)
|
||||||
|
if (res?.success && res.data) {
|
||||||
|
Object.assign(form, res.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('加载脚本配置失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
if (!props.account?.id || !props.account?.platform) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await apiClient.put(
|
||||||
|
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`,
|
||||||
|
{ ...form }
|
||||||
|
)
|
||||||
|
showToast('已保存', 'success')
|
||||||
|
emit('saved')
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '保存失败', 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testScript = async () => {
|
||||||
|
if (!props.account?.id || !props.account?.platform) return
|
||||||
|
testing.value = true
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(
|
||||||
|
`/admin/accounts/${props.account.id}/balance/script/test?platform=${props.account.platform}`,
|
||||||
|
{ ...form }
|
||||||
|
)
|
||||||
|
if (res?.success) {
|
||||||
|
testResult.value = res.data
|
||||||
|
showToast('测试完成', 'success')
|
||||||
|
} else {
|
||||||
|
showToast(res?.error || '测试失败', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '测试失败', 'error')
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPreset = () => {
|
||||||
|
form.scriptBody = presetScript
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayAmount = (val) => {
|
||||||
|
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
|
||||||
|
return Number(val).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatJson = (data) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data, null, 2)
|
||||||
|
} catch (error) {
|
||||||
|
return String(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
resetForm()
|
||||||
|
loadConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.balance-script-dialog) {
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.balance-script-dialog .el-dialog__body) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.balance-script-dialog .el-dialog__footer) {
|
||||||
|
border-top: 1px solid rgba(229, 231, 235, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-text {
|
||||||
|
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -477,6 +477,36 @@
|
|||||||
<i class="fas fa-check text-xs text-white"></i>
|
<i class="fas fa-check text-xs text-white"></i>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</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
|
<label
|
||||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||||
@@ -772,7 +802,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gemini 项目 ID 字段 -->
|
<!-- 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"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>项目 ID (可选)</label
|
>项目 ID (可选)</label
|
||||||
>
|
>
|
||||||
@@ -822,41 +852,194 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bedrock 特定字段 -->
|
<!-- Bedrock 特定字段 -->
|
||||||
<div v-if="form.platform === 'bedrock' && !isEdit" class="space-y-4">
|
<div v-if="form.platform === 'bedrock'" class="space-y-4">
|
||||||
|
<!-- 凭证类型选择器 -->
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>AWS 访问密钥 ID *</label
|
>凭证类型 *</label
|
||||||
|
>
|
||||||
|
<div v-if="!isEdit" class="flex gap-4">
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.credentialType"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="radio"
|
||||||
|
value="access_key"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>AWS Access Key(访问密钥)</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.credentialType"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="radio"
|
||||||
|
value="bearer_token"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>Bearer Token(长期令牌)</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex gap-4">
|
||||||
|
<label class="flex items-center opacity-60">
|
||||||
|
<input
|
||||||
|
v-model="form.credentialType"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
disabled
|
||||||
|
type="radio"
|
||||||
|
value="access_key"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>AWS Access Key(访问密钥)</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center opacity-60">
|
||||||
|
<input
|
||||||
|
v-model="form.credentialType"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
disabled
|
||||||
|
type="radio"
|
||||||
|
value="bearer_token"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>Bearer Token(长期令牌)</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
|
||||||
|
<div class="text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
<p v-if="form.credentialType === 'access_key'" class="font-medium">
|
||||||
|
使用 AWS Access Key ID 和 Secret Access Key 进行身份验证(支持临时凭证)
|
||||||
|
</p>
|
||||||
|
<p v-else class="font-medium">
|
||||||
|
使用 AWS Bedrock API Keys 生成的 Bearer Token
|
||||||
|
进行身份验证,更简单、权限范围更小
|
||||||
|
</p>
|
||||||
|
<p v-if="isEdit" class="mt-1 text-xs italic">
|
||||||
|
💡 编辑模式下凭证类型不可更改,如需切换类型请重新创建账户
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AWS Access Key 字段(仅在 access_key 模式下显示)-->
|
||||||
|
<div v-if="form.credentialType === 'access_key'">
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>AWS 访问密钥 ID {{ isEdit ? '' : '*' }}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.accessKeyId"
|
v-model="form.accessKeyId"
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
:class="{ 'border-red-500': errors.accessKeyId }"
|
:class="{ 'border-red-500': errors.accessKeyId }"
|
||||||
placeholder="请输入 AWS Access Key ID"
|
:placeholder="isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Access Key ID'"
|
||||||
required
|
:required="!isEdit"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
|
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
|
||||||
{{ errors.accessKeyId }}
|
{{ errors.accessKeyId }}
|
||||||
</p>
|
</p>
|
||||||
|
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
💡 编辑模式下,留空则保持原有 Access Key ID 不变
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>AWS 秘密访问密钥 *</label
|
>AWS 秘密访问密钥 {{ isEdit ? '' : '*' }}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.secretAccessKey"
|
v-model="form.secretAccessKey"
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
:class="{ 'border-red-500': errors.secretAccessKey }"
|
:class="{ 'border-red-500': errors.secretAccessKey }"
|
||||||
placeholder="请输入 AWS Secret Access Key"
|
:placeholder="
|
||||||
required
|
isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Secret Access Key'
|
||||||
|
"
|
||||||
|
:required="!isEdit"
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
|
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
|
||||||
{{ errors.secretAccessKey }}
|
{{ errors.secretAccessKey }}
|
||||||
</p>
|
</p>
|
||||||
|
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
💡 编辑模式下,留空则保持原有 Secret Access Key 不变
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>会话令牌 (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.sessionToken"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
:placeholder="
|
||||||
|
isEdit
|
||||||
|
? '留空则保持原有 Session Token 不变'
|
||||||
|
: '如果使用临时凭证,请输入会话令牌'
|
||||||
|
"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
仅在使用临时 AWS 凭证时需要填写
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bearer Token 字段(仅在 bearer_token 模式下显示)-->
|
||||||
|
<div v-if="form.credentialType === 'bearer_token'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>Bearer Token {{ isEdit ? '' : '*' }}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.bearerToken"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
:class="{ 'border-red-500': errors.bearerToken }"
|
||||||
|
:placeholder="
|
||||||
|
isEdit ? '留空则保持原有 Bearer Token 不变' : '请输入 AWS Bearer Token'
|
||||||
|
"
|
||||||
|
:required="!isEdit"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<p v-if="errors.bearerToken" class="mt-1 text-xs text-red-500">
|
||||||
|
{{ errors.bearerToken }}
|
||||||
|
</p>
|
||||||
|
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
💡 编辑模式下,留空则保持原有 Bearer Token 不变
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="mt-2 rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-700 dark:bg-green-900/30"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<i class="fas fa-key mt-0.5 text-green-600 dark:text-green-400" />
|
||||||
|
<div class="text-xs text-green-700 dark:text-green-300">
|
||||||
|
<p class="mb-1 font-medium">Bearer Token 说明:</p>
|
||||||
|
<ul class="list-inside list-disc space-y-1 text-xs">
|
||||||
|
<li>输入 AWS Bedrock API Keys 生成的 Bearer Token</li>
|
||||||
|
<li>Bearer Token 仅限 Bedrock 服务访问,权限范围更小</li>
|
||||||
|
<li>相比 Access Key 更简单,无需 Secret Key</li>
|
||||||
|
<li>
|
||||||
|
参考:<a
|
||||||
|
class="text-green-600 underline dark:text-green-400"
|
||||||
|
href="https://aws.amazon.com/cn/blogs/machine-learning/accelerate-ai-development-with-amazon-bedrock-api-keys/"
|
||||||
|
target="_blank"
|
||||||
|
>AWS 官方文档</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AWS 区域(两种凭证类型都需要)-->
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>AWS 区域 *</label
|
>AWS 区域 *</label
|
||||||
@@ -872,10 +1055,12 @@
|
|||||||
<p v-if="errors.region" class="mt-1 text-xs text-red-500">
|
<p v-if="errors.region" class="mt-1 text-xs text-red-500">
|
||||||
{{ errors.region }}
|
{{ errors.region }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
<div
|
||||||
|
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
|
||||||
|
>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<i class="fas fa-info-circle mt-0.5 text-blue-600" />
|
<i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
|
||||||
<div class="text-xs text-blue-700">
|
<div class="text-xs text-blue-700 dark:text-blue-300">
|
||||||
<p class="mb-1 font-medium">常用 AWS 区域参考:</p>
|
<p class="mb-1 font-medium">常用 AWS 区域参考:</p>
|
||||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||||
<span>• us-east-1 (美国东部)</span>
|
<span>• us-east-1 (美国东部)</span>
|
||||||
@@ -885,26 +1070,13 @@
|
|||||||
<span>• ap-northeast-1 (东京)</span>
|
<span>• ap-northeast-1 (东京)</span>
|
||||||
<span>• eu-central-1 (法兰克福)</span>
|
<span>• eu-central-1 (法兰克福)</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-blue-600">💡 请输入完整的区域代码,如 us-east-1</p>
|
<p class="mt-2 text-blue-600 dark:text-blue-400">
|
||||||
</div>
|
💡 请输入完整的区域代码,如 us-east-1
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
|
||||||
>会话令牌 (可选)</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.sessionToken"
|
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
|
||||||
placeholder="如果使用临时凭证,请输入会话令牌"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
仅在使用临时 AWS 凭证时需要填写
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
@@ -1320,10 +1492,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"
|
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"
|
type="button"
|
||||||
@click="
|
@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>
|
||||||
<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"
|
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 +1506,6 @@
|
|||||||
>
|
>
|
||||||
+ Sonnet 4.5
|
+ Sonnet 4.5
|
||||||
</button>
|
</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
|
<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"
|
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"
|
type="button"
|
||||||
@@ -1650,6 +1804,47 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude 账户级串行队列开关 -->
|
||||||
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.serialQueueEnabled"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div class="ml-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
启用账户级串行队列
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 拦截预热请求开关(Claude 和 Claude Console) -->
|
||||||
|
<div
|
||||||
|
v-if="form.platform === 'claude' || form.platform === 'claude-console'"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.interceptWarmup"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div class="ml-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
拦截预热请求
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
启用后,对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Claude User-Agent 版本配置 -->
|
<!-- Claude User-Agent 版本配置 -->
|
||||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
<label class="flex items-start">
|
<label class="flex items-start">
|
||||||
@@ -1801,7 +1996,7 @@
|
|||||||
Token,建议也一并填写以支持自动刷新。
|
Token,建议也一并填写以支持自动刷新。
|
||||||
</p>
|
</p>
|
||||||
<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"
|
class="mb-2 text-sm text-blue-800 dark:text-blue-300"
|
||||||
>
|
>
|
||||||
请输入有效的 Gemini Access Token。如果您有 Refresh
|
请输入有效的 Gemini Access Token。如果您有 Refresh
|
||||||
@@ -1838,12 +2033,14 @@
|
|||||||
文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。
|
文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。
|
||||||
</p>
|
</p>
|
||||||
<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"
|
class="text-xs text-blue-800 dark:text-blue-300"
|
||||||
>
|
>
|
||||||
请从已登录 Gemini CLI 的机器上获取
|
请从已登录 Gemini CLI 的机器上获取
|
||||||
<code class="rounded bg-blue-100 px-1 py-0.5 font-mono dark:bg-blue-900/50"
|
<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>
|
</p>
|
||||||
@@ -2568,7 +2765,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gemini 项目 ID 字段 -->
|
<!-- 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"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>项目 ID (可选)</label
|
>项目 ID (可选)</label
|
||||||
>
|
>
|
||||||
@@ -2633,6 +2830,44 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude 账户级串行队列开关(编辑模式) -->
|
||||||
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.serialQueueEnabled"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div class="ml-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
启用账户级串行队列
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 拦截预热请求开关(Claude 和 Claude Console 编辑模式) -->
|
||||||
|
<div v-if="form.platform === 'claude' || form.platform === 'claude-console'" class="mt-4">
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.interceptWarmup"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div class="ml-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
拦截预热请求
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
启用后,对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Claude User-Agent 版本配置(编辑模式) -->
|
<!-- Claude User-Agent 版本配置(编辑模式) -->
|
||||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
<label class="flex items-start">
|
<label class="flex items-start">
|
||||||
@@ -3819,7 +4054,7 @@ const determinePlatformGroup = (platform) => {
|
|||||||
return 'claude'
|
return 'claude'
|
||||||
} else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) {
|
} else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) {
|
||||||
return 'openai'
|
return 'openai'
|
||||||
} else if (['gemini', 'gemini-api'].includes(platform)) {
|
} else if (['gemini', 'gemini-antigravity', 'gemini-api'].includes(platform)) {
|
||||||
return 'gemini'
|
return 'gemini'
|
||||||
} else if (platform === 'droid') {
|
} else if (platform === 'droid') {
|
||||||
return 'droid'
|
return 'droid'
|
||||||
@@ -3954,7 +4189,8 @@ const form = ref({
|
|||||||
platform: props.account?.platform || 'claude',
|
platform: props.account?.platform || 'claude',
|
||||||
addType: (() => {
|
addType: (() => {
|
||||||
const platform = props.account?.platform || 'claude'
|
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'
|
if (platform === 'claude') return 'oauth'
|
||||||
return 'manual'
|
return 'manual'
|
||||||
})(),
|
})(),
|
||||||
@@ -3967,6 +4203,9 @@ const form = ref({
|
|||||||
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
||||||
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
|
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
|
||||||
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
|
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
|
||||||
|
serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关
|
||||||
|
interceptWarmup:
|
||||||
|
props.account?.interceptWarmup === true || props.account?.interceptWarmup === 'true', // 拦截预热请求
|
||||||
groupId: '',
|
groupId: '',
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
projectId: props.account?.projectId || '',
|
projectId: props.account?.projectId || '',
|
||||||
@@ -4008,10 +4247,12 @@ const form = ref({
|
|||||||
// 并发控制字段
|
// 并发控制字段
|
||||||
maxConcurrentTasks: props.account?.maxConcurrentTasks || 0,
|
maxConcurrentTasks: props.account?.maxConcurrentTasks || 0,
|
||||||
// Bedrock 特定字段
|
// Bedrock 特定字段
|
||||||
|
credentialType: props.account?.credentialType || 'access_key', // 'access_key' 或 'bearer_token'
|
||||||
accessKeyId: props.account?.accessKeyId || '',
|
accessKeyId: props.account?.accessKeyId || '',
|
||||||
secretAccessKey: props.account?.secretAccessKey || '',
|
secretAccessKey: props.account?.secretAccessKey || '',
|
||||||
region: props.account?.region || '',
|
region: props.account?.region || '',
|
||||||
sessionToken: props.account?.sessionToken || '',
|
sessionToken: props.account?.sessionToken || '',
|
||||||
|
bearerToken: props.account?.bearerToken || '', // Bearer Token 字段
|
||||||
defaultModel: props.account?.defaultModel || '',
|
defaultModel: props.account?.defaultModel || '',
|
||||||
smallFastModel: props.account?.smallFastModel || '',
|
smallFastModel: props.account?.smallFastModel || '',
|
||||||
// Azure OpenAI 特定字段
|
// Azure OpenAI 特定字段
|
||||||
@@ -4174,6 +4415,7 @@ const errors = ref({
|
|||||||
accessKeyId: '',
|
accessKeyId: '',
|
||||||
secretAccessKey: '',
|
secretAccessKey: '',
|
||||||
region: '',
|
region: '',
|
||||||
|
bearerToken: '',
|
||||||
azureEndpoint: '',
|
azureEndpoint: '',
|
||||||
deploymentName: ''
|
deploymentName: ''
|
||||||
})
|
})
|
||||||
@@ -4293,7 +4535,7 @@ const selectPlatformGroup = (group) => {
|
|||||||
} else if (group === 'openai') {
|
} else if (group === 'openai') {
|
||||||
form.value.platform = 'openai'
|
form.value.platform = 'openai'
|
||||||
} else if (group === 'gemini') {
|
} else if (group === 'gemini') {
|
||||||
form.value.platform = 'gemini'
|
form.value.platform = 'gemini' // Default to Gemini CLI, user can select Antigravity
|
||||||
} else if (group === 'droid') {
|
} else if (group === 'droid') {
|
||||||
form.value.platform = 'droid'
|
form.value.platform = 'droid'
|
||||||
}
|
}
|
||||||
@@ -4330,7 +4572,11 @@ const nextStep = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 对于Gemini账户,检查项目 ID
|
// 对于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() === '') {
|
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||||
// 使用自定义确认弹窗
|
// 使用自定义确认弹窗
|
||||||
const confirmed = await showConfirm(
|
const confirmed = await showConfirm(
|
||||||
@@ -4553,9 +4799,11 @@ const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
|
|||||||
claudeAiOauth: claudeOauthPayload,
|
claudeAiOauth: claudeOauthPayload,
|
||||||
priority: form.value.priority || 50,
|
priority: form.value.priority || 50,
|
||||||
autoStopOnWarning: form.value.autoStopOnWarning || false,
|
autoStopOnWarning: form.value.autoStopOnWarning || false,
|
||||||
|
interceptWarmup: form.value.interceptWarmup || false,
|
||||||
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
|
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
|
||||||
useUnifiedClientId: form.value.useUnifiedClientId || false,
|
useUnifiedClientId: form.value.useUnifiedClientId || false,
|
||||||
unifiedClientId: clientId,
|
unifiedClientId: clientId,
|
||||||
|
maxConcurrency: form.value.serialQueueEnabled ? 1 : 0,
|
||||||
subscriptionInfo: {
|
subscriptionInfo: {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
hasClaudeMax: form.value.subscriptionType === 'claude_max',
|
hasClaudeMax: form.value.subscriptionType === 'claude_max',
|
||||||
@@ -4693,6 +4941,7 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
|
|||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
|
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||||
// 添加订阅类型信息
|
// 添加订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -4700,9 +4949,14 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
|
|||||||
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
||||||
manuallySet: true // 标记为手动设置
|
manuallySet: true // 标记为手动设置
|
||||||
}
|
}
|
||||||
} else if (currentPlatform === 'gemini') {
|
} else if (currentPlatform === 'gemini' || currentPlatform === 'gemini-antigravity') {
|
||||||
// Gemini使用geminiOauth字段
|
// Gemini/Antigravity使用geminiOauth字段
|
||||||
data.geminiOauth = tokenInfo.tokens || tokenInfo
|
data.geminiOauth = tokenInfo.tokens || tokenInfo
|
||||||
|
// 根据 platform 设置 oauthProvider
|
||||||
|
data.oauthProvider =
|
||||||
|
currentPlatform === 'gemini-antigravity'
|
||||||
|
? 'antigravity'
|
||||||
|
: tokenInfo.oauthProvider || 'gemini-cli'
|
||||||
if (form.value.projectId) {
|
if (form.value.projectId) {
|
||||||
data.projectId = form.value.projectId
|
data.projectId = form.value.projectId
|
||||||
}
|
}
|
||||||
@@ -4874,7 +5128,10 @@ const createAccount = async () => {
|
|||||||
hasError = true
|
hasError = true
|
||||||
}
|
}
|
||||||
} else if (form.value.platform === 'bedrock') {
|
} else if (form.value.platform === 'bedrock') {
|
||||||
// Bedrock 验证
|
// Bedrock 验证 - 根据凭证类型进行不同验证
|
||||||
|
if (form.value.credentialType === 'access_key') {
|
||||||
|
// Access Key 模式:创建时必填,编辑时可选(留空则保持原有凭证)
|
||||||
|
if (!isEdit.value) {
|
||||||
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
|
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
|
||||||
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
|
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
|
||||||
hasError = true
|
hasError = true
|
||||||
@@ -4883,6 +5140,16 @@ const createAccount = async () => {
|
|||||||
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
|
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
|
||||||
hasError = true
|
hasError = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else if (form.value.credentialType === 'bearer_token') {
|
||||||
|
// Bearer Token 模式:创建时必填,编辑时可选(留空则保持原有凭证)
|
||||||
|
if (!isEdit.value) {
|
||||||
|
if (!form.value.bearerToken || form.value.bearerToken.trim() === '') {
|
||||||
|
errors.value.bearerToken = '请填写 Bearer Token'
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!form.value.region || form.value.region.trim() === '') {
|
if (!form.value.region || form.value.region.trim() === '') {
|
||||||
errors.value.region = '请选择 AWS 区域'
|
errors.value.region = '请选择 AWS 区域'
|
||||||
hasError = true
|
hasError = true
|
||||||
@@ -5016,6 +5283,7 @@ const createAccount = async () => {
|
|||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
|
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||||
// 添加订阅类型信息
|
// 添加订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -5107,6 +5375,7 @@ const createAccount = async () => {
|
|||||||
// 上游错误处理(仅 Claude Console)
|
// 上游错误处理(仅 Claude Console)
|
||||||
if (form.value.platform === 'claude-console') {
|
if (form.value.platform === 'claude-console') {
|
||||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||||
|
data.interceptWarmup = !!form.value.interceptWarmup
|
||||||
}
|
}
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
@@ -5122,6 +5391,10 @@ const createAccount = async () => {
|
|||||||
data.rateLimitDuration = 60 // 默认值60,不从用户输入获取
|
data.rateLimitDuration = 60 // 默认值60,不从用户输入获取
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
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') {
|
} else if (form.value.platform === 'gemini-api') {
|
||||||
// Gemini API 账户特定数据
|
// Gemini API 账户特定数据
|
||||||
data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com'
|
data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com'
|
||||||
@@ -5131,12 +5404,21 @@ const createAccount = async () => {
|
|||||||
? form.value.supportedModels
|
? form.value.supportedModels
|
||||||
: []
|
: []
|
||||||
} else if (form.value.platform === 'bedrock') {
|
} else if (form.value.platform === 'bedrock') {
|
||||||
// Bedrock 账户特定数据 - 构造 awsCredentials 对象
|
// Bedrock 账户特定数据
|
||||||
|
data.credentialType = form.value.credentialType || 'access_key'
|
||||||
|
|
||||||
|
// 根据凭证类型构造不同的凭证对象
|
||||||
|
if (form.value.credentialType === 'access_key') {
|
||||||
data.awsCredentials = {
|
data.awsCredentials = {
|
||||||
accessKeyId: form.value.accessKeyId,
|
accessKeyId: form.value.accessKeyId,
|
||||||
secretAccessKey: form.value.secretAccessKey,
|
secretAccessKey: form.value.secretAccessKey,
|
||||||
sessionToken: form.value.sessionToken || null
|
sessionToken: form.value.sessionToken || null
|
||||||
}
|
}
|
||||||
|
} else if (form.value.credentialType === 'bearer_token') {
|
||||||
|
// Bearer Token 模式:必须传递 Bearer Token
|
||||||
|
data.bearerToken = form.value.bearerToken
|
||||||
|
}
|
||||||
|
|
||||||
data.region = form.value.region
|
data.region = form.value.region
|
||||||
data.defaultModel = form.value.defaultModel || null
|
data.defaultModel = form.value.defaultModel || null
|
||||||
data.smallFastModel = form.value.smallFastModel || null
|
data.smallFastModel = form.value.smallFastModel || null
|
||||||
@@ -5173,7 +5455,7 @@ const createAccount = async () => {
|
|||||||
result = await accountsStore.createOpenAIAccount(data)
|
result = await accountsStore.createOpenAIAccount(data)
|
||||||
} else if (form.value.platform === 'azure_openai') {
|
} else if (form.value.platform === 'azure_openai') {
|
||||||
result = await accountsStore.createAzureOpenAIAccount(data)
|
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)
|
result = await accountsStore.createGeminiAccount(data)
|
||||||
} else if (form.value.platform === 'gemini-api') {
|
} else if (form.value.platform === 'gemini-api') {
|
||||||
result = await accountsStore.createGeminiApiAccount(data)
|
result = await accountsStore.createGeminiApiAccount(data)
|
||||||
@@ -5403,9 +5685,11 @@ const updateAccount = async () => {
|
|||||||
|
|
||||||
data.priority = form.value.priority || 50
|
data.priority = form.value.priority || 50
|
||||||
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
||||||
|
data.interceptWarmup = form.value.interceptWarmup || false
|
||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
|
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||||
// 更新订阅类型信息
|
// 更新订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -5438,6 +5722,8 @@ const updateAccount = async () => {
|
|||||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||||
// 上游错误处理
|
// 上游错误处理
|
||||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||||
|
// 拦截预热请求
|
||||||
|
data.interceptWarmup = !!form.value.interceptWarmup
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||||
@@ -5460,6 +5746,13 @@ const updateAccount = async () => {
|
|||||||
|
|
||||||
// Bedrock 特定更新
|
// Bedrock 特定更新
|
||||||
if (props.account.platform === 'bedrock') {
|
if (props.account.platform === 'bedrock') {
|
||||||
|
// 更新凭证类型
|
||||||
|
if (form.value.credentialType) {
|
||||||
|
data.credentialType = form.value.credentialType
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据凭证类型更新凭证
|
||||||
|
if (form.value.credentialType === 'access_key') {
|
||||||
// 只有当有凭证变更时才构造 awsCredentials 对象
|
// 只有当有凭证变更时才构造 awsCredentials 对象
|
||||||
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
|
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
|
||||||
data.awsCredentials = {}
|
data.awsCredentials = {}
|
||||||
@@ -5473,6 +5766,13 @@ const updateAccount = async () => {
|
|||||||
data.awsCredentials.sessionToken = form.value.sessionToken || null
|
data.awsCredentials.sessionToken = form.value.sessionToken || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (form.value.credentialType === 'bearer_token') {
|
||||||
|
// Bearer Token 模式:更新 Bearer Token(编辑时可选,留空则保留原有凭证)
|
||||||
|
if (form.value.bearerToken && form.value.bearerToken.trim()) {
|
||||||
|
data.bearerToken = form.value.bearerToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (form.value.region) {
|
if (form.value.region) {
|
||||||
data.region = form.value.region
|
data.region = form.value.region
|
||||||
}
|
}
|
||||||
@@ -6006,9 +6306,12 @@ watch(
|
|||||||
accountType: newAccount.accountType || 'shared',
|
accountType: newAccount.accountType || 'shared',
|
||||||
subscriptionType: subscriptionType,
|
subscriptionType: subscriptionType,
|
||||||
autoStopOnWarning: newAccount.autoStopOnWarning || false,
|
autoStopOnWarning: newAccount.autoStopOnWarning || false,
|
||||||
|
interceptWarmup:
|
||||||
|
newAccount.interceptWarmup === true || newAccount.interceptWarmup === 'true',
|
||||||
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
|
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
|
||||||
useUnifiedClientId: newAccount.useUnifiedClientId || false,
|
useUnifiedClientId: newAccount.useUnifiedClientId || false,
|
||||||
unifiedClientId: newAccount.unifiedClientId || '',
|
unifiedClientId: newAccount.unifiedClientId || '',
|
||||||
|
serialQueueEnabled: (newAccount.maxConcurrency || 0) > 0,
|
||||||
groupId: groupId,
|
groupId: groupId,
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
projectId: newAccount.projectId || '',
|
projectId: newAccount.projectId || '',
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0" @click="handleClose" />
|
||||||
|
<div
|
||||||
|
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
|
||||||
|
>
|
||||||
|
<!-- 顶部栏 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-500 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-clock" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">定时测试配置</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ account?.name || '未知账户' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<div class="px-5 py-4">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2 text-blue-500" />
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">加载配置中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 启用开关 -->
|
||||||
|
<div class="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-700 dark:text-gray-300">启用定时测试</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">按计划自动测试账户连通性</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'relative h-6 w-11 rounded-full transition-colors duration-200',
|
||||||
|
config.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
]"
|
||||||
|
@click="config.enabled = !config.enabled"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
|
||||||
|
config.enabled ? 'left-5' : 'left-0.5'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron 表达式配置 -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Cron 表达式
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="config.cronExpression"
|
||||||
|
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
placeholder="0 8 * * *"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
格式: 分 时 日 月 周 (例: "0 8 * * *" = 每天8:00)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快捷选项 -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
快捷设置
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in cronPresets"
|
||||||
|
:key="preset.value"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
|
||||||
|
config.cronExpression === preset.value
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
|
||||||
|
!config.enabled && 'cursor-not-allowed opacity-50'
|
||||||
|
]"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
@click="config.cronExpression = preset.value"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 测试模型选择 -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
测试模型
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="config.model"
|
||||||
|
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
placeholder="claude-sonnet-4-5-20250929"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="modelOption in modelOptions"
|
||||||
|
:key="modelOption.value"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
|
||||||
|
config.model === modelOption.value
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
|
||||||
|
!config.enabled && 'cursor-not-allowed opacity-50'
|
||||||
|
]"
|
||||||
|
:disabled="!config.enabled"
|
||||||
|
@click="config.model = modelOption.value"
|
||||||
|
>
|
||||||
|
{{ modelOption.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 测试历史 -->
|
||||||
|
<div v-if="testHistory.length > 0" class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
最近测试记录
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="max-h-40 space-y-2 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(record, index) in testHistory"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
:class="[
|
||||||
|
'fas',
|
||||||
|
record.success
|
||||||
|
? 'fa-check-circle text-green-500'
|
||||||
|
: 'fa-times-circle text-red-500'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">
|
||||||
|
{{ formatTimestamp(record.timestamp) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="record.latencyMs" class="text-gray-500 dark:text-gray-500">
|
||||||
|
{{ record.latencyMs }}ms
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="record.error"
|
||||||
|
class="max-w-[150px] truncate text-red-500"
|
||||||
|
:title="record.error"
|
||||||
|
>
|
||||||
|
{{ record.error }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无历史记录 -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-history mb-2 text-2xl text-gray-300 dark:text-gray-600" />
|
||||||
|
<p>暂无测试记录</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作栏 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
|
||||||
|
saving
|
||||||
|
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
|
||||||
|
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
|
||||||
|
]"
|
||||||
|
:disabled="saving || loading"
|
||||||
|
@click="saveConfig"
|
||||||
|
>
|
||||||
|
<i :class="['fas', saving ? 'fa-spinner fa-spin' : 'fa-save']" />
|
||||||
|
{{ saving ? '保存中...' : '保存配置' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { API_PREFIX } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'saved'])
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const config = ref({
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: '0 8 * * *',
|
||||||
|
model: 'claude-sonnet-4-5-20250929'
|
||||||
|
})
|
||||||
|
const testHistory = ref([])
|
||||||
|
|
||||||
|
// Cron 预设选项
|
||||||
|
const cronPresets = [
|
||||||
|
{ label: '每天 8:00', value: '0 8 * * *' },
|
||||||
|
{ label: '每天 12:00', value: '0 12 * * *' },
|
||||||
|
{ label: '每天 18:00', value: '0 18 * * *' },
|
||||||
|
{ label: '每6小时', value: '0 */6 * * *' },
|
||||||
|
{ label: '每12小时', value: '0 */12 * * *' },
|
||||||
|
{ label: '工作日 9:00', value: '0 9 * * 1-5' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 模型选项
|
||||||
|
const modelOptions = [
|
||||||
|
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
|
||||||
|
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
|
||||||
|
{ label: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 格式化时间戳
|
||||||
|
function formatTimestamp(timestamp) {
|
||||||
|
if (!timestamp) return '未知'
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
async function loadConfig() {
|
||||||
|
if (!props.account) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const authToken = localStorage.getItem('authToken')
|
||||||
|
const platform = props.account.platform
|
||||||
|
|
||||||
|
// 根据平台获取配置端点
|
||||||
|
let endpoint = ''
|
||||||
|
if (platform === 'claude') {
|
||||||
|
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
|
||||||
|
} else {
|
||||||
|
// 其他平台暂不支持
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const configRes = await fetch(endpoint, {
|
||||||
|
headers: {
|
||||||
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (configRes.ok) {
|
||||||
|
const data = await configRes.json()
|
||||||
|
if (data.success && data.data?.config) {
|
||||||
|
config.value = {
|
||||||
|
enabled: data.data.config.enabled || false,
|
||||||
|
cronExpression: data.data.config.cronExpression || '0 8 * * *',
|
||||||
|
model: data.data.config.model || 'claude-sonnet-4-5-20250929'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取测试历史
|
||||||
|
const historyEndpoint = endpoint.replace('/test-config', '/test-history')
|
||||||
|
const historyRes = await fetch(historyEndpoint, {
|
||||||
|
headers: {
|
||||||
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (historyRes.ok) {
|
||||||
|
const historyData = await historyRes.json()
|
||||||
|
if (historyData.success && historyData.data?.history) {
|
||||||
|
testHistory.value = historyData.data.history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('加载配置失败: ' + err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
async function saveConfig() {
|
||||||
|
if (!props.account) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const authToken = localStorage.getItem('authToken')
|
||||||
|
const platform = props.account.platform
|
||||||
|
|
||||||
|
let endpoint = ''
|
||||||
|
if (platform === 'claude') {
|
||||||
|
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
|
||||||
|
} else {
|
||||||
|
saving.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: config.value.enabled,
|
||||||
|
cronExpression: config.value.cronExpression,
|
||||||
|
model: config.value.model
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('配置已保存', 'success')
|
||||||
|
emit('saved')
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
const errorData = await res.json().catch(() => ({}))
|
||||||
|
showToast(errorData.message || '保存失败', 'error')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('保存失败: ' + err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭模态框
|
||||||
|
function handleClose() {
|
||||||
|
if (saving.value) return
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 show 变化,加载配置
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
config.value = {
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: '0 8 * * *',
|
||||||
|
model: 'claude-sonnet-4-5-20250929'
|
||||||
|
}
|
||||||
|
testHistory.value = []
|
||||||
|
loadConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -68,6 +68,22 @@
|
|||||||
{{ platformLabel }}
|
{{ platformLabel }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Bedrock 账号类型 -->
|
||||||
|
<div
|
||||||
|
v-if="props.account?.platform === 'bedrock'"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">账号类型</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
credentialTypeBadgeClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i :class="credentialTypeIcon" />
|
||||||
|
{{ credentialTypeLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
|
||||||
@@ -209,13 +225,15 @@ const platformLabel = computed(() => {
|
|||||||
const platform = props.account.platform
|
const platform = props.account.platform
|
||||||
if (platform === 'claude') return 'Claude OAuth'
|
if (platform === 'claude') return 'Claude OAuth'
|
||||||
if (platform === 'claude-console') return 'Claude Console'
|
if (platform === 'claude-console') return 'Claude Console'
|
||||||
|
if (platform === 'bedrock') return 'AWS Bedrock'
|
||||||
return platform
|
return platform
|
||||||
})
|
})
|
||||||
|
|
||||||
const platformIcon = computed(() => {
|
const platformIcon = computed(() => {
|
||||||
if (!props.account) return 'fas fa-question'
|
if (!props.account) return 'fas fa-question'
|
||||||
const platform = props.account.platform
|
const platform = props.account.platform
|
||||||
if (platform === 'claude' || platform === 'claude-console') return 'fas fa-brain'
|
if (platform === 'claude' || platform === 'claude-console' || platform === 'bedrock')
|
||||||
|
return 'fas fa-brain'
|
||||||
return 'fas fa-robot'
|
return 'fas fa-robot'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -228,6 +246,39 @@ const platformBadgeClass = computed(() => {
|
|||||||
if (platform === 'claude-console') {
|
if (platform === 'claude-console') {
|
||||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
|
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
|
||||||
}
|
}
|
||||||
|
if (platform === 'bedrock') {
|
||||||
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
|
||||||
|
}
|
||||||
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bedrock 账号类型相关
|
||||||
|
const credentialTypeLabel = computed(() => {
|
||||||
|
if (!props.account || props.account.platform !== 'bedrock') return ''
|
||||||
|
const credentialType = props.account.credentialType
|
||||||
|
if (credentialType === 'access_key') return 'Access Key'
|
||||||
|
if (credentialType === 'bearer_token') return 'Bearer Token'
|
||||||
|
return 'Unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
const credentialTypeIcon = computed(() => {
|
||||||
|
if (!props.account || props.account.platform !== 'bedrock') return ''
|
||||||
|
const credentialType = props.account.credentialType
|
||||||
|
if (credentialType === 'access_key') return 'fas fa-key'
|
||||||
|
if (credentialType === 'bearer_token') return 'fas fa-ticket'
|
||||||
|
return 'fas fa-question'
|
||||||
|
})
|
||||||
|
|
||||||
|
const credentialTypeBadgeClass = computed(() => {
|
||||||
|
if (!props.account || props.account.platform !== 'bedrock')
|
||||||
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
const credentialType = props.account.credentialType
|
||||||
|
if (credentialType === 'access_key') {
|
||||||
|
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||||
|
}
|
||||||
|
if (credentialType === 'bearer_token') {
|
||||||
|
return 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
|
||||||
|
}
|
||||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -346,6 +397,9 @@ function getTestEndpoint() {
|
|||||||
if (platform === 'claude-console') {
|
if (platform === 'claude-console') {
|
||||||
return `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`
|
return `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`
|
||||||
}
|
}
|
||||||
|
if (platform === 'bedrock') {
|
||||||
|
return `${API_PREFIX}/admin/bedrock-accounts/${props.account.id}/test`
|
||||||
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +523,7 @@ function handleClose() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听show变化,重置状态
|
// 监听show变化,重置状态并设置测试模型
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
@@ -478,6 +532,21 @@ watch(
|
|||||||
responseText.value = ''
|
responseText.value = ''
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
testDuration.value = 0
|
testDuration.value = 0
|
||||||
|
|
||||||
|
// 根据平台和账号类型设置测试模型
|
||||||
|
if (props.account?.platform === 'bedrock') {
|
||||||
|
const credentialType = props.account.credentialType
|
||||||
|
if (credentialType === 'bearer_token') {
|
||||||
|
// Bearer Token 模式使用 Sonnet 4.5
|
||||||
|
testModel.value = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
|
||||||
|
} else {
|
||||||
|
// Access Key 模式使用 Haiku(更快更便宜)
|
||||||
|
testModel.value = 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 其他平台使用默认模型
|
||||||
|
testModel.value = 'claude-sonnet-4-5-20250929'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user