mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
810fe9fe90 | ||
|
|
141b07db78 | ||
|
|
1dad810d15 | ||
|
|
4723328be4 | ||
|
|
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 |
40
.env.example
40
.env.example
@@ -53,20 +53,38 @@ CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-20
|
||||
# - /antigravity/api -> Antigravity OAuth
|
||||
# - /gemini-cli/api -> Gemini CLI OAuth
|
||||
|
||||
# (可选)Claude Code 调试 Dump:会在项目根目录写入 jsonl 文件,便于排查 tools/schema/回包问题
|
||||
# - anthropic-requests-dump.jsonl
|
||||
# - anthropic-responses-dump.jsonl
|
||||
# - anthropic-tools-dump.jsonl
|
||||
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
|
||||
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
|
||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
|
||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
|
||||
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
|
||||
# ============================================================================
|
||||
# 🐛 调试 Dump 配置(可选)
|
||||
# ============================================================================
|
||||
# 以下开启后会在项目根目录写入 .jsonl 调试文件,便于排查问题。
|
||||
# ⚠️ 生产环境建议关闭,避免磁盘占用。
|
||||
#
|
||||
# (可选)Antigravity 上游请求 Dump:会在项目根目录写入 jsonl 文件,便于核对最终发往上游的 payload(含 tools/schema 清洗后的结果)
|
||||
# - antigravity-upstream-requests-dump.jsonl
|
||||
# 📄 输出文件列表:
|
||||
# - anthropic-requests-dump.jsonl (客户端请求)
|
||||
# - anthropic-responses-dump.jsonl (返回给客户端的响应)
|
||||
# - anthropic-tools-dump.jsonl (工具定义快照)
|
||||
# - antigravity-upstream-requests-dump.jsonl (发往上游的请求)
|
||||
# - antigravity-upstream-responses-dump.jsonl (上游 SSE 响应)
|
||||
#
|
||||
# 📌 开关配置:
|
||||
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
|
||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
|
||||
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
|
||||
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
|
||||
# ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true
|
||||
#
|
||||
# 📏 单条记录大小上限(字节),默认 2MB:
|
||||
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
|
||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
|
||||
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
|
||||
#
|
||||
# 📦 整个 Dump 文件大小上限(字节),超过后自动轮转为 .bak 文件,默认 10MB:
|
||||
# DUMP_MAX_FILE_SIZE_BYTES=10485760
|
||||
#
|
||||
# 🔧 工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务
|
||||
# (仅 /antigravity/api 分流生效)
|
||||
# ANTHROPIC_TOOL_ERROR_CONTINUE=true
|
||||
|
||||
|
||||
# 🚫 529错误处理配置
|
||||
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
||||
|
||||
30
README.md
30
README.md
@@ -1,9 +1,9 @@
|
||||
# Claude Relay Service
|
||||
|
||||
> [!CAUTION]
|
||||
> **安全更新通知**:v1.1.240 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
|
||||
> **安全更新通知**:v1.1.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
|
||||
>
|
||||
> **请立即更新到 v1.1.241+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||
> **请立即更新到 v1.1.249+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -394,29 +394,32 @@ docker-compose.yml 已包含:
|
||||
|
||||
**Claude Code 设置环境变量:**
|
||||
|
||||
默认使用标准 Claude 账号池(Claude/Console/Bedrock/CCR):
|
||||
|
||||
**使用标准 Claude 账号池**
|
||||
|
||||
默认使用标准 Claude 账号池:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||
```
|
||||
|
||||
如果希望 Claude Code 通过 Anthropic 协议直接使用 Gemini OAuth 账号池(路径分流,不需要在模型名里加前缀):
|
||||
**使用 Antigravity 账户池**
|
||||
|
||||
Antigravity OAuth(支持 `claude-opus-4-5` 等 Antigravity 模型):
|
||||
适用于通过 Antigravity 渠道使用 Claude 模型(如 `claude-opus-4-5` 等)。
|
||||
|
||||
```bash
|
||||
# 1. 设置 Base URL 为 Antigravity 专用路径
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥(permissions 需要是 all 或 gemini)"
|
||||
|
||||
# 2. 设置 API Key(在后台创建,权限需包含 'all' 或 'gemini')
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||
|
||||
# 3. 指定模型名称(直接使用短名,无需前缀!)
|
||||
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||
```
|
||||
|
||||
Gemini CLI OAuth(使用 Gemini 模型):
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥(permissions 需要是 all 或 gemini)"
|
||||
export ANTHROPIC_MODEL="gemini-2.5-pro"
|
||||
# 4. 启动
|
||||
claude
|
||||
```
|
||||
|
||||
**VSCode Claude 插件配置:**
|
||||
@@ -622,6 +625,7 @@ gpt-5 # Codex使用固定模型ID
|
||||
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
||||
- 根据不同的路由前缀自动识别账号类型
|
||||
- `/claude/` - 使用Claude账号池
|
||||
- `/antigravity/api/` - 使用Antigravity账号池(推荐用于Claude Code)
|
||||
- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用)
|
||||
- `/gemini/` - 使用Gemini账号池
|
||||
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
||||
|
||||
24
README_EN.md
24
README_EN.md
@@ -1,9 +1,9 @@
|
||||
# Claude Relay Service
|
||||
|
||||
> [!CAUTION]
|
||||
> **Security Update**: v1.1.240 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
|
||||
> **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.241+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||
> **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">
|
||||
|
||||
@@ -243,31 +243,13 @@ Now you can replace the official API with your own service:
|
||||
|
||||
**Claude Code Set Environment Variables:**
|
||||
|
||||
Default uses standard Claude account pool (Claude/Console/Bedrock/CCR):
|
||||
Default uses standard Claude account pool:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain
|
||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
|
||||
```
|
||||
|
||||
If you want Claude Code to use Gemini OAuth accounts via the Anthropic protocol (path-based routing, no vendor prefix in `model`):
|
||||
|
||||
Antigravity OAuth (supports `claude-opus-4-5` and other Antigravity models):
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
|
||||
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||
```
|
||||
|
||||
Gemini CLI OAuth (Gemini models):
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
|
||||
export ANTHROPIC_MODEL="gemini-2.5-pro"
|
||||
```
|
||||
|
||||
**VSCode Claude Plugin Configuration:**
|
||||
|
||||
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
|
||||
|
||||
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.
|
||||
@@ -179,7 +179,7 @@ class Application {
|
||||
// 🔧 基础中间件
|
||||
this.app.use(
|
||||
express.json({
|
||||
limit: '10mb',
|
||||
limit: '100mb',
|
||||
verify: (req, res, buf, encoding) => {
|
||||
// 验证JSON格式
|
||||
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
||||
@@ -188,7 +188,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)
|
||||
|
||||
// 🎯 信任代理
|
||||
|
||||
@@ -862,7 +862,7 @@ async function handleKeyInfo(req, res) {
|
||||
res.json({
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: keyData.permissions,
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_remaining:
|
||||
|
||||
@@ -1434,7 +1434,6 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
|
||||
// 设置管理员信息(只包含必要信息)
|
||||
req.admin = {
|
||||
id: adminSession.adminId || 'admin',
|
||||
username: adminSession.username,
|
||||
sessionId: token,
|
||||
loginTime: adminSession.loginTime
|
||||
@@ -1567,8 +1566,15 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
||||
try {
|
||||
const adminSession = await redis.getSession(adminToken)
|
||||
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 = {
|
||||
id: adminSession.adminId || 'admin',
|
||||
username: adminSession.username,
|
||||
sessionId: adminToken,
|
||||
loginTime: adminSession.loginTime
|
||||
@@ -1579,6 +1585,7 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
||||
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
return next()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Admin authentication failed, trying user authentication:', error.message)
|
||||
}
|
||||
@@ -2043,7 +2050,7 @@ const globalRateLimit = async (req, res, next) =>
|
||||
|
||||
// 📊 请求大小限制中间件
|
||||
const requestSizeLimit = (req, res, next) => {
|
||||
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '60', 10)
|
||||
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '100', 10)
|
||||
const maxSize = MAX_SIZE_MB * 1024 * 1024
|
||||
const contentLength = parseInt(req.headers['content-length'] || '0')
|
||||
|
||||
@@ -2052,7 +2059,7 @@ const requestSizeLimit = (req, res, next) => {
|
||||
return res.status(413).json({
|
||||
error: 'Payload Too Large',
|
||||
message: 'Request body size exceeds limit',
|
||||
limit: '10MB'
|
||||
limit: `${MAX_SIZE_MB}MB`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -122,12 +122,18 @@ async function handleMessagesRequest(req, res) {
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
|
||||
// Claude 服务权限校验,阻止未授权的 Key
|
||||
if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) {
|
||||
const forcedVendor = req._anthropicVendor || null
|
||||
const requiredService =
|
||||
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
|
||||
|
||||
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: '此 API Key 无权访问 Claude 服务'
|
||||
message:
|
||||
requiredService === 'gemini'
|
||||
? '此 API Key 无权访问 Gemini 服务'
|
||||
: '此 API Key 无权访问 Claude 服务'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -176,7 +182,6 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
const forcedVendor = req._anthropicVendor || null
|
||||
logger.api('📥 /v1/messages request received', {
|
||||
model: req.body.model || null,
|
||||
forcedVendor,
|
||||
@@ -192,34 +197,10 @@ async function handleMessagesRequest(req, res) {
|
||||
|
||||
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
||||
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
|
||||
const permissions = req.apiKey?.permissions || 'all'
|
||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: '此 API Key 无权访问 Gemini 服务'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const baseModel = (req.body.model || '').trim()
|
||||
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
|
||||
}
|
||||
|
||||
// Claude 服务权限校验,阻止未授权的 Key(默认路径保持不变)
|
||||
if (
|
||||
req.apiKey.permissions &&
|
||||
req.apiKey.permissions !== 'all' &&
|
||||
req.apiKey.permissions !== 'claude'
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: '此 API Key 无权访问 Claude 服务'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否为流式请求
|
||||
const isStream = req.body.stream === true
|
||||
|
||||
@@ -1250,8 +1231,7 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
//(通过 v1internal:fetchAvailableModels),避免依赖静态 modelService 列表。
|
||||
const forcedVendor = req._anthropicVendor || null
|
||||
if (forcedVendor === 'antigravity') {
|
||||
const permissions = req.apiKey?.permissions || 'all'
|
||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
||||
if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
@@ -1444,34 +1424,25 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
|
||||
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
||||
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
||||
const forcedVendor = req._anthropicVendor || null
|
||||
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
|
||||
const permissions = req.apiKey?.permissions || 'all'
|
||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
||||
const requiredService =
|
||||
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
|
||||
|
||||
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: 'This API key does not have permission to access Gemini'
|
||||
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 })
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (
|
||||
req.apiKey.permissions &&
|
||||
req.apiKey.permissions !== 'all' &&
|
||||
req.apiKey.permissions !== 'claude'
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: 'This API key does not have permission to access Claude'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 🔗 会话绑定验证(与 messages 端点保持一致)
|
||||
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||
const sessionValidation = await claudeRelayConfigService.validateNewSession(
|
||||
|
||||
@@ -155,7 +155,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: keyData.permissions,
|
||||
// 添加激活相关字段
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
|
||||
@@ -8,6 +8,7 @@ const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const claudeRelayService = require('../services/claudeRelayService')
|
||||
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||
const openaiToClaude = require('../services/openaiToClaude')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||
@@ -19,8 +20,7 @@ const { getEffectiveModel } = require('../utils/modelHelper')
|
||||
|
||||
// 🔧 辅助函数:检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||
const permissions = apiKeyData.permissions || 'all'
|
||||
return permissions === 'all' || permissions === requiredPermission
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||
}
|
||||
|
||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||
@@ -235,7 +235,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { accountId } = accountSelection
|
||||
const { accountId, accountType } = accountSelection
|
||||
|
||||
// 获取该账号存储的 Claude Code headers
|
||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||
@@ -265,13 +265,9 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
}
|
||||
})
|
||||
|
||||
// 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
(usage) => {
|
||||
// 使用转换后的响应流 (根据账户类型选择转发服务)
|
||||
// 创建 usage 回调函数
|
||||
const usageCallback = (usage) => {
|
||||
// 记录使用统计
|
||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||
const model = usage.model || claudeRequest.model
|
||||
@@ -288,7 +284,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
apiKeyData.id,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
accountType
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
@@ -303,27 +300,62 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
cacheReadTokens
|
||||
},
|
||||
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)}`
|
||||
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:
|
||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 非流式请求
|
||||
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
|
||||
|
||||
// 发送请求到 Claude (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
||||
const claudeResponse = await claudeRelayService.relayRequest(
|
||||
// 根据账户类型选择转发服务
|
||||
let claudeResponse
|
||||
if (accountType === 'claude-console') {
|
||||
// Claude Console 账户使用 Console 转发服务
|
||||
claudeResponse = await claudeConsoleRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
accountId
|
||||
)
|
||||
} else {
|
||||
// Claude Official 账户使用标准转发服务
|
||||
claudeResponse = await claudeRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
@@ -331,6 +363,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
claudeCodeHeaders,
|
||||
{ betaHeader: 'oauth-2025-04-20' }
|
||||
)
|
||||
}
|
||||
|
||||
// 解析 Claude 响应
|
||||
let claudeData
|
||||
@@ -376,7 +409,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
apiKeyData.id,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
claudeRequest.model,
|
||||
accountId
|
||||
accountId,
|
||||
accountType
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
@@ -391,7 +425,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
cacheReadTokens
|
||||
},
|
||||
claudeRequest.model,
|
||||
'openai-claude-non-stream'
|
||||
`openai-${accountType}-non-stream`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -402,8 +436,19 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
|
||||
} catch (error) {
|
||||
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||
if (error.message === 'Client disconnected') {
|
||||
logger.info('🔌 OpenAI-Claude stream ended: Client disconnected')
|
||||
} else {
|
||||
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
|
||||
res.status(status).json({
|
||||
error: {
|
||||
@@ -412,6 +457,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
code: 'internal_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
|
||||
@@ -673,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 格式的错误响应
|
||||
const status = error.status || 500
|
||||
const errorResponse = {
|
||||
@@ -682,8 +688,9 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
code: 'internal_error'
|
||||
}
|
||||
}
|
||||
|
||||
res.status(status).json(errorResponse)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
@@ -693,8 +700,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
// OpenAI 兼容的模型列表端点
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
// 获取可用模型列表的共享处理器
|
||||
async function handleGetModels(req, res) {
|
||||
try {
|
||||
const apiKeyData = req.apiKey
|
||||
|
||||
@@ -782,8 +789,13 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
// OpenAI 兼容的模型列表端点 (带 v1 版)
|
||||
router.get('/v1/models', authenticateApiKey, handleGetModels)
|
||||
|
||||
// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载)
|
||||
router.get('/models', authenticateApiKey, handleGetModels)
|
||||
|
||||
// OpenAI 兼容的模型详情端点
|
||||
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||
|
||||
@@ -904,7 +904,7 @@ router.get('/key-info', authenticateApiKey, async (req, res) => {
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: keyData.permissions,
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_remaining:
|
||||
|
||||
@@ -46,11 +46,11 @@ async function routeToBackend(req, res, requestedModel) {
|
||||
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
|
||||
|
||||
// 检查权限
|
||||
const permissions = req.apiKey.permissions || 'all'
|
||||
const { permissions } = req.apiKey
|
||||
|
||||
if (backend === 'claude') {
|
||||
// Claude 后端:通过 OpenAI 兼容层
|
||||
if (permissions !== 'all' && permissions !== 'claude') {
|
||||
if (!apiKeyService.hasPermission(permissions, 'claude')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Claude',
|
||||
@@ -62,7 +62,7 @@ async function routeToBackend(req, res, requestedModel) {
|
||||
await handleChatCompletion(req, res, req.apiKey)
|
||||
} else if (backend === 'openai') {
|
||||
// OpenAI 后端
|
||||
if (permissions !== 'all' && permissions !== 'openai') {
|
||||
if (!apiKeyService.hasPermission(permissions, 'openai')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access OpenAI',
|
||||
|
||||
@@ -270,7 +270,7 @@ class AccountBalanceService {
|
||||
}
|
||||
|
||||
async _getAccountBalanceForAccount(account, platform, options = {}) {
|
||||
const queryApi = this._parseBoolean(options.queryApi) || false
|
||||
const queryMode = this._parseQueryMode(options.queryApi)
|
||||
const useCache = options.useCache !== false
|
||||
|
||||
const accountId = account?.id
|
||||
@@ -297,8 +297,14 @@ class AccountBalanceService {
|
||||
|
||||
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
|
||||
|
||||
// 非强制查询:优先读缓存
|
||||
if (!queryApi) {
|
||||
// 安全限制: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') {
|
||||
@@ -321,6 +327,7 @@ class AccountBalanceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveQueryMode === 'local') {
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: 'success',
|
||||
@@ -338,6 +345,7 @@ class AccountBalanceService {
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计
|
||||
let providerResult
|
||||
@@ -723,6 +731,14 @@ class AccountBalanceService {
|
||||
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 : []
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,8 @@ function getAntigravityHeaders(accessToken, baseUrl) {
|
||||
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Encoding': 'gzip'
|
||||
'Accept-Encoding': 'gzip',
|
||||
requestType: 'agent'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +305,11 @@ async function request({
|
||||
}
|
||||
|
||||
const isRetryable = (error) => {
|
||||
// 处理网络层面的连接重置或超时(常见于长请求被中间节点切断)
|
||||
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
|
||||
return true
|
||||
}
|
||||
|
||||
const status = error?.response?.status
|
||||
if (status === 429) {
|
||||
return true
|
||||
@@ -429,7 +435,37 @@ async function request({
|
||||
const status = error?.response?.status
|
||||
if (status === 429 && !retriedAfterDelay && !signal?.aborted) {
|
||||
const data = error?.response?.data
|
||||
const msg = typeof data === 'string' ? data : JSON.stringify(data || '')
|
||||
|
||||
// 安全地将 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')
|
||||
|
||||
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
|
||||
@@ -2,6 +2,7 @@ const ClaudeBalanceProvider = require('./claudeBalanceProvider')
|
||||
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
|
||||
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
|
||||
const GenericBalanceProvider = require('./genericBalanceProvider')
|
||||
const GeminiBalanceProvider = require('./geminiBalanceProvider')
|
||||
|
||||
function registerAllProviders(balanceService) {
|
||||
// Claude
|
||||
@@ -14,7 +15,7 @@ function registerAllProviders(balanceService) {
|
||||
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
|
||||
|
||||
// 其他平台(降级)
|
||||
balanceService.registerProvider('gemini', new GenericBalanceProvider('gemini'))
|
||||
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'))
|
||||
|
||||
@@ -2,6 +2,50 @@ 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){...} })
|
||||
@@ -55,6 +99,11 @@ class BalanceScriptService {
|
||||
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 必须是函数')
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@ class RateLimitCleanupService {
|
||||
const results = {
|
||||
openai: { checked: 0, cleared: 0, errors: [] },
|
||||
claude: { checked: 0, cleared: 0, errors: [] },
|
||||
claudeConsole: { checked: 0, cleared: 0, errors: [] }
|
||||
claudeConsole: { checked: 0, cleared: 0, errors: [] },
|
||||
tokenRefresh: { checked: 0, refreshed: 0, errors: [] }
|
||||
}
|
||||
|
||||
// 清理 OpenAI 账号
|
||||
@@ -84,21 +85,29 @@ class RateLimitCleanupService {
|
||||
// 清理 Claude Console 账号
|
||||
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
||||
|
||||
// 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
|
||||
await this.proactiveRefreshClaudeTokens(results.tokenRefresh)
|
||||
|
||||
const totalChecked =
|
||||
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
||||
const totalCleared =
|
||||
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
if (totalCleared > 0) {
|
||||
if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) {
|
||||
logger.info(
|
||||
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
|
||||
`✅ Rate limit cleanup completed: ${totalCleared}/${totalChecked} accounts cleared, ${results.tokenRefresh.refreshed} tokens refreshed (${duration}ms)`
|
||||
)
|
||||
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
||||
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
||||
logger.info(
|
||||
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
||||
)
|
||||
if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) {
|
||||
logger.info(
|
||||
` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed`
|
||||
)
|
||||
}
|
||||
|
||||
// 发送 webhook 恢复通知
|
||||
if (this.clearedAccounts.length > 0) {
|
||||
@@ -114,7 +123,8 @@ class RateLimitCleanupService {
|
||||
const allErrors = [
|
||||
...results.openai.errors,
|
||||
...results.claude.errors,
|
||||
...results.claudeConsole.errors
|
||||
...results.claudeConsole.errors,
|
||||
...results.tokenRefresh.errors
|
||||
]
|
||||
if (allErrors.length > 0) {
|
||||
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
||||
@@ -348,6 +358,75 @@ class RateLimitCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动刷新 Claude 账户 Token(防止等待重置期间 Token 过期)
|
||||
* 仅对因限流/配额限制而等待重置的账户执行刷新:
|
||||
* - 429 限流账户(rateLimitAutoStopped=true)
|
||||
* - 5小时限制自动停止账户(fiveHourAutoStopped=true)
|
||||
* 不处理错误状态账户(error/temp_error)
|
||||
*/
|
||||
async proactiveRefreshClaudeTokens(result) {
|
||||
try {
|
||||
const redis = require('../models/redis')
|
||||
const accounts = await redis.getAllClaudeAccounts()
|
||||
const now = Date.now()
|
||||
const refreshAheadMs = 30 * 60 * 1000 // 提前30分钟刷新
|
||||
const recentRefreshMs = 5 * 60 * 1000 // 5分钟内刷新过则跳过
|
||||
|
||||
for (const account of accounts) {
|
||||
// 1. 必须激活
|
||||
if (account.isActive !== 'true') {
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. 必须有 refreshToken
|
||||
if (!account.refreshToken) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. 【优化】仅处理因限流/配额限制而等待重置的账户
|
||||
// 正常调度的账户会在请求时自动刷新,无需主动刷新
|
||||
// 错误状态账户的 Token 可能已失效,刷新也会失败
|
||||
const isWaitingForReset =
|
||||
account.rateLimitAutoStopped === 'true' || // 429 限流
|
||||
account.fiveHourAutoStopped === 'true' // 5小时限制自动停止
|
||||
if (!isWaitingForReset) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 4. 【优化】如果最近 5 分钟内已刷新,跳过(避免重复刷新)
|
||||
const lastRefreshAt = account.lastRefreshAt ? new Date(account.lastRefreshAt).getTime() : 0
|
||||
if (now - lastRefreshAt < recentRefreshMs) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 5. 检查 Token 是否即将过期(30分钟内)
|
||||
const expiresAt = parseInt(account.expiresAt)
|
||||
if (expiresAt && now < expiresAt - refreshAheadMs) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 符合条件,执行刷新
|
||||
result.checked++
|
||||
try {
|
||||
await claudeAccountService.refreshAccountToken(account.id)
|
||||
result.refreshed++
|
||||
logger.info(`🔄 Proactively refreshed token: ${account.name} (${account.id})`)
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
error: error.message
|
||||
})
|
||||
logger.warn(`⚠️ Proactive refresh failed for ${account.name}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to proactively refresh Claude tokens:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发一次清理(供 API 或 CLI 调用)
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require('fs/promises')
|
||||
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'
|
||||
@@ -108,7 +108,7 @@ async function dumpAnthropicMessagesRequest(req, meta = {}) {
|
||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||
|
||||
try {
|
||||
await fs.appendFile(filename, line, { encoding: 'utf8' })
|
||||
await safeRotatingAppend(filename, line)
|
||||
} catch (e) {
|
||||
logger.warn('Failed to dump Anthropic request', {
|
||||
filename,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require('fs/promises')
|
||||
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'
|
||||
@@ -89,7 +89,7 @@ async function dumpAnthropicResponse(req, responseInfo, meta = {}) {
|
||||
|
||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||
try {
|
||||
await fs.appendFile(filename, line, { encoding: 'utf8' })
|
||||
await safeRotatingAppend(filename, line)
|
||||
} catch (e) {
|
||||
logger.warn('Failed to dump Anthropic response', {
|
||||
filename,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require('fs/promises')
|
||||
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'
|
||||
@@ -103,7 +103,7 @@ async function dumpAntigravityUpstreamRequest(requestInfo) {
|
||||
|
||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||
try {
|
||||
await fs.appendFile(filename, line, { encoding: 'utf8' })
|
||||
await safeRotatingAppend(filename, line)
|
||||
} catch (e) {
|
||||
logger.warn('Failed to dump Antigravity upstream request', {
|
||||
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
|
||||
}
|
||||
@@ -20,8 +20,9 @@ const parseBooleanEnv = (value) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否允许执行“余额脚本”(安全开关)
|
||||
* 默认开启,便于保持现有行为;如需禁用请显式设置 BALANCE_SCRIPT_ENABLED=false(环境变量优先)
|
||||
* 是否允许执行"余额脚本"(安全开关)
|
||||
* ⚠️ 安全警告:vm模块非安全沙箱,默认禁用。如需启用请显式设置 BALANCE_SCRIPT_ENABLED=true
|
||||
* 仅在完全信任管理员且了解RCE风险时才启用此功能
|
||||
*/
|
||||
const isBalanceScriptEnabled = () => {
|
||||
if (
|
||||
@@ -36,7 +37,8 @@ const isBalanceScriptEnabled = () => {
|
||||
config?.features?.balanceScriptEnabled ??
|
||||
config?.security?.enableBalanceScript
|
||||
|
||||
return typeof fromConfig === 'boolean' ? fromConfig : true
|
||||
// 默认禁用,需显式启用
|
||||
return typeof fromConfig === 'boolean' ? fromConfig : false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -52,10 +52,51 @@
|
||||
</div>
|
||||
|
||||
<!-- 配额(如适用) -->
|
||||
<div v-if="quotaInfo" class="space-y-1">
|
||||
<div v-if="quotaInfo && isAntigravityQuota" class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>已用: {{ formatNumber(quotaInfo.used) }}</span>
|
||||
<span>剩余: {{ formatNumber(quotaInfo.remaining) }}</span>
|
||||
<span>剩余</span>
|
||||
<span>{{ formatQuotaNumber(quotaInfo.remaining) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="row in antigravityRows"
|
||||
:key="row.category"
|
||||
class="flex items-center gap-2 rounded-md bg-gray-50 px-2 py-1.5 dark:bg-gray-700/60"
|
||||
>
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :class="row.dotClass"></span>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-xs font-medium text-gray-800 dark:text-gray-100"
|
||||
:title="row.category"
|
||||
>
|
||||
{{ row.category }}
|
||||
</span>
|
||||
|
||||
<div class="flex w-[94px] flex-col gap-0.5">
|
||||
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-600">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="row.barClass"
|
||||
:style="{ width: `${row.remainingPercent ?? 0}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
<span>{{ row.remainingText }}</span>
|
||||
<span v-if="row.resetAt" class="text-gray-400 dark:text-gray-400">{{
|
||||
formatResetTime(row.resetAt)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="quotaInfo" class="space-y-1">
|
||||
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>已用: {{ formatQuotaNumber(quotaInfo.used) }}</span>
|
||||
<span>剩余: {{ formatQuotaNumber(quotaInfo.remaining) }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
@@ -100,7 +141,8 @@ const props = defineProps({
|
||||
platform: { type: String, required: true },
|
||||
initialBalance: { type: Object, default: null },
|
||||
hideRefresh: { type: Boolean, default: false },
|
||||
autoLoad: { type: Boolean, default: true }
|
||||
autoLoad: { type: Boolean, default: true },
|
||||
queryMode: { type: String, default: 'local' } // local | auto | api
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refreshed', 'error'])
|
||||
@@ -136,6 +178,43 @@ const quotaInfo = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const isAntigravityQuota = computed(() => {
|
||||
return balanceData.value?.quota?.type === 'antigravity'
|
||||
})
|
||||
|
||||
const antigravityRows = computed(() => {
|
||||
if (!isAntigravityQuota.value) return []
|
||||
|
||||
const buckets = balanceData.value?.quota?.buckets
|
||||
const list = Array.isArray(buckets) ? buckets : []
|
||||
const map = new Map(list.map((b) => [b?.category, b]))
|
||||
|
||||
const order = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
|
||||
const styles = {
|
||||
'Gemini Pro': { dotClass: 'bg-blue-500', barClass: 'bg-blue-500 dark:bg-blue-400' },
|
||||
Claude: { dotClass: 'bg-purple-500', barClass: 'bg-purple-500 dark:bg-purple-400' },
|
||||
'Gemini Flash': { dotClass: 'bg-cyan-500', barClass: 'bg-cyan-500 dark:bg-cyan-400' },
|
||||
'Gemini Image': { dotClass: 'bg-emerald-500', barClass: 'bg-emerald-500 dark:bg-emerald-400' }
|
||||
}
|
||||
|
||||
return order.map((category) => {
|
||||
const raw = map.get(category) || null
|
||||
const remaining = raw?.remaining
|
||||
const remainingPercent = Number.isFinite(Number(remaining))
|
||||
? Math.max(0, Math.min(100, Number(remaining)))
|
||||
: null
|
||||
|
||||
return {
|
||||
category,
|
||||
remainingPercent,
|
||||
remainingText: remainingPercent === null ? '—' : `${Math.round(remainingPercent)}%`,
|
||||
resetAt: raw?.resetAt || null,
|
||||
dotClass: styles[category]?.dotClass || 'bg-gray-400',
|
||||
barClass: styles[category]?.barClass || 'bg-gray-400'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const quotaBarClass = computed(() => {
|
||||
const percentage = quotaInfo.value?.percentage || 0
|
||||
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
|
||||
@@ -144,7 +223,12 @@ const quotaBarClass = computed(() => {
|
||||
})
|
||||
|
||||
const canRefresh = computed(() => {
|
||||
// 仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
|
||||
// antigravity 配额:允许直接触发 Provider 刷新(无需脚本)
|
||||
if (props.queryMode === 'api' || props.queryMode === 'auto') {
|
||||
return true
|
||||
}
|
||||
|
||||
// 其他平台:仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
|
||||
const data = balanceData.value
|
||||
if (!data) return false
|
||||
if (data.scriptEnabled === false) return false
|
||||
@@ -159,6 +243,9 @@ const refreshTitle = computed(() => {
|
||||
}
|
||||
return '请先配置余额脚本'
|
||||
}
|
||||
if (isAntigravityQuota.value) {
|
||||
return '刷新配额(调用 Antigravity API)'
|
||||
}
|
||||
return '刷新余额(调用脚本配置的余额 API)'
|
||||
})
|
||||
|
||||
@@ -179,7 +266,10 @@ const load = async () => {
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/accounts/${props.accountId}/balance`, {
|
||||
params: { platform: props.platform, queryApi: false }
|
||||
params: {
|
||||
platform: props.platform,
|
||||
queryApi: props.queryMode === 'api' ? true : props.queryMode === 'auto' ? 'auto' : false
|
||||
}
|
||||
})
|
||||
if (response?.success) {
|
||||
balanceData.value = response.data
|
||||
@@ -231,6 +321,16 @@ const formatNumber = (num) => {
|
||||
return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const formatQuotaNumber = (num) => {
|
||||
if (num === Infinity) return '∞'
|
||||
const value = Number(num)
|
||||
if (!Number.isFinite(value)) return 'N/A'
|
||||
if (isAntigravityQuota.value) {
|
||||
return `${Math.round(value)}%`
|
||||
}
|
||||
return formatNumber(value)
|
||||
}
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
const value = Number(amount)
|
||||
if (!Number.isFinite(value)) return '$0.00'
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Gemini OAuth流程 -->
|
||||
<div v-else-if="platform === 'gemini'">
|
||||
<div v-else-if="platform === 'gemini' || platform === 'gemini-antigravity'">
|
||||
<div
|
||||
class="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-700 dark:bg-green-900/30"
|
||||
>
|
||||
|
||||
@@ -1233,13 +1233,28 @@ onMounted(async () => {
|
||||
form.totalCostLimit = props.apiKey.totalCostLimit || ''
|
||||
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
||||
// 处理权限数据,兼容旧格式(字符串)和新格式(数组)
|
||||
const perms = props.apiKey.permissions
|
||||
// 有效的权限值
|
||||
const VALID_PERMS = ['claude', 'gemini', 'openai', 'droid']
|
||||
let perms = props.apiKey.permissions
|
||||
// 如果是字符串,尝试 JSON.parse(Redis 可能返回 "[]" 或 "[\"gemini\"]")
|
||||
if (typeof perms === 'string') {
|
||||
if (perms === 'all' || perms === '') {
|
||||
perms = []
|
||||
} else if (perms.startsWith('[')) {
|
||||
try {
|
||||
perms = JSON.parse(perms)
|
||||
} catch {
|
||||
perms = VALID_PERMS.includes(perms) ? [perms] : []
|
||||
}
|
||||
} else if (VALID_PERMS.includes(perms)) {
|
||||
perms = [perms]
|
||||
} else {
|
||||
perms = []
|
||||
}
|
||||
}
|
||||
if (Array.isArray(perms)) {
|
||||
form.permissions = perms
|
||||
} else if (perms === 'all' || !perms) {
|
||||
form.permissions = []
|
||||
} else if (typeof perms === 'string') {
|
||||
form.permissions = [perms]
|
||||
// 过滤掉无效值(如 "[]")
|
||||
form.permissions = perms.filter((p) => VALID_PERMS.includes(p))
|
||||
} else {
|
||||
form.permissions = []
|
||||
}
|
||||
|
||||
@@ -797,11 +797,19 @@
|
||||
:account-id="account.id"
|
||||
:initial-balance="account.balanceInfo"
|
||||
:platform="account.platform"
|
||||
:query-mode="
|
||||
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
|
||||
? 'auto'
|
||||
: 'local'
|
||||
"
|
||||
@error="(error) => handleBalanceError(account.id, error)"
|
||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||
/>
|
||||
<div class="mt-1 text-xs">
|
||||
<button
|
||||
v-if="
|
||||
!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')
|
||||
"
|
||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||
@click="openBalanceScriptModal(account)"
|
||||
>
|
||||
@@ -1476,11 +1484,17 @@
|
||||
:account-id="account.id"
|
||||
:initial-balance="account.balanceInfo"
|
||||
:platform="account.platform"
|
||||
:query-mode="
|
||||
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
|
||||
? 'auto'
|
||||
: 'local'
|
||||
"
|
||||
@error="(error) => handleBalanceError(account.id, error)"
|
||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||
/>
|
||||
<div class="mt-1 text-xs">
|
||||
<button
|
||||
v-if="!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')"
|
||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||
@click="openBalanceScriptModal(account)"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user